311 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			311 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /**
 | |
|  * This module is Node-only.
 | |
|  */
 | |
| import {
 | |
|   NodeTypes,
 | |
|   ElementNode,
 | |
|   TransformContext,
 | |
|   TemplateChildNode,
 | |
|   SimpleExpressionNode,
 | |
|   createCallExpression,
 | |
|   HoistTransform,
 | |
|   CREATE_STATIC,
 | |
|   ExpressionNode,
 | |
|   ElementTypes,
 | |
|   PlainElementNode,
 | |
|   JSChildNode,
 | |
|   TextCallNode
 | |
| } from '@vue/compiler-core'
 | |
| import {
 | |
|   isVoidTag,
 | |
|   isString,
 | |
|   isSymbol,
 | |
|   isKnownAttr,
 | |
|   escapeHtml,
 | |
|   toDisplayString,
 | |
|   normalizeClass,
 | |
|   normalizeStyle,
 | |
|   stringifyStyle
 | |
| } from '@vue/shared'
 | |
| 
 | |
| export const enum StringifyThresholds {
 | |
|   ELEMENT_WITH_BINDING_COUNT = 5,
 | |
|   NODE_COUNT = 20
 | |
| }
 | |
| 
 | |
| type StringiableNode = PlainElementNode | TextCallNode
 | |
| 
 | |
| /**
 | |
|  * Turn eligible hoisted static trees into stringied static nodes, e.g.
 | |
|  *
 | |
|  * ```js
 | |
|  * const _hoisted_1 = createStaticVNode(`<div class="foo">bar</div>`)
 | |
|  * ```
 | |
|  *
 | |
|  * A single static vnode can contain stringified content for **multiple**
 | |
|  * consecutive nodes (element and plain text), called a "chunk".
 | |
|  * `@vue/runtime-dom` will create the content via innerHTML in a hidden
 | |
|  * container element and insert all the nodes in place. The call must also
 | |
|  * provide the number of nodes contained in the chunk so that during hydration
 | |
|  * we can know how many nodes the static vnode should adopt.
 | |
|  *
 | |
|  * The optimization scans a children list that contains hoisted nodes, and
 | |
|  * tries to find the largest chunk of consecutive hoisted nodes before running
 | |
|  * into a non-hoisted node or the end of the list. A chunk is then converted
 | |
|  * into a single static vnode and replaces the hoisted expression of the first
 | |
|  * node in the chunk. Other nodes in the chunk are considered "merged" and
 | |
|  * therefore removed from both the hoist list and the children array.
 | |
|  *
 | |
|  * This optimization is only performed in Node.js.
 | |
|  */
 | |
| export const stringifyStatic: HoistTransform = (children, context) => {
 | |
|   let nc = 0 // current node count
 | |
|   let ec = 0 // current element with binding count
 | |
|   const currentChunk: StringiableNode[] = []
 | |
| 
 | |
|   const stringifyCurrentChunk = (currentIndex: number): number => {
 | |
|     if (
 | |
|       nc >= StringifyThresholds.NODE_COUNT ||
 | |
|       ec >= StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
 | |
|     ) {
 | |
|       // combine all currently eligible nodes into a single static vnode call
 | |
|       const staticCall = createCallExpression(context.helper(CREATE_STATIC), [
 | |
|         JSON.stringify(
 | |
|           currentChunk.map(node => stringifyNode(node, context)).join('')
 | |
|         ),
 | |
|         // the 2nd argument indicates the number of DOM nodes this static vnode
 | |
|         // will insert / hydrate
 | |
|         String(currentChunk.length)
 | |
|       ])
 | |
|       // replace the first node's hoisted expression with the static vnode call
 | |
|       replaceHoist(currentChunk[0], staticCall, context)
 | |
| 
 | |
|       if (currentChunk.length > 1) {
 | |
|         for (let i = 1; i < currentChunk.length; i++) {
 | |
|           // for the merged nodes, set their hoisted expression to null
 | |
|           replaceHoist(currentChunk[i], null, context)
 | |
|         }
 | |
| 
 | |
|         // also remove merged nodes from children
 | |
|         const deleteCount = currentChunk.length - 1
 | |
|         children.splice(currentIndex - currentChunk.length + 1, deleteCount)
 | |
|         return deleteCount
 | |
|       }
 | |
|     }
 | |
|     return 0
 | |
|   }
 | |
| 
 | |
|   let i = 0
 | |
|   for (; i < children.length; i++) {
 | |
|     const child = children[i]
 | |
|     const hoisted = getHoistedNode(child)
 | |
|     if (hoisted) {
 | |
|       // presence of hoisted means child must be a stringifiable node
 | |
|       const node = child as StringiableNode
 | |
|       const result = analyzeNode(node)
 | |
|       if (result) {
 | |
|         // node is stringifiable, record state
 | |
|         nc += result[0]
 | |
|         ec += result[1]
 | |
|         currentChunk.push(node)
 | |
|         continue
 | |
|       }
 | |
|     }
 | |
|     // we only reach here if we ran into a node that is not stringifiable
 | |
|     // check if currently analyzed nodes meet criteria for stringification.
 | |
|     // adjust iteration index
 | |
|     i -= stringifyCurrentChunk(i)
 | |
|     // reset state
 | |
|     nc = 0
 | |
|     ec = 0
 | |
|     currentChunk.length = 0
 | |
|   }
 | |
|   // in case the last node was also stringifiable
 | |
|   stringifyCurrentChunk(i)
 | |
| }
 | |
| 
 | |
| const getHoistedNode = (node: TemplateChildNode) =>
 | |
|   ((node.type === NodeTypes.ELEMENT && node.tagType === ElementTypes.ELEMENT) ||
 | |
|     node.type == NodeTypes.TEXT_CALL) &&
 | |
|   node.codegenNode &&
 | |
|   node.codegenNode.type === NodeTypes.SIMPLE_EXPRESSION &&
 | |
|   node.codegenNode.hoisted
 | |
| 
 | |
| const dataAriaRE = /^(data|aria)-/
 | |
| const isStringifiableAttr = (name: string) => {
 | |
|   return isKnownAttr(name) || dataAriaRE.test(name)
 | |
| }
 | |
| 
 | |
| const replaceHoist = (
 | |
|   node: StringiableNode,
 | |
|   replacement: JSChildNode | null,
 | |
|   context: TransformContext
 | |
| ) => {
 | |
|   const hoistToReplace = (node.codegenNode as SimpleExpressionNode).hoisted!
 | |
|   context.hoists[context.hoists.indexOf(hoistToReplace)] = replacement
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * for a hoisted node, analyze it and return:
 | |
|  * - false: bailed (contains runtime constant)
 | |
|  * - [nc, ec] where
 | |
|  *   - nc is the number of nodes inside
 | |
|  *   - ec is the number of element with bindings inside
 | |
|  */
 | |
| function analyzeNode(node: StringiableNode): [number, number] | false {
 | |
|   if (node.type === NodeTypes.TEXT_CALL) {
 | |
|     return [1, 0]
 | |
|   }
 | |
| 
 | |
|   let nc = 1 // node count
 | |
|   let ec = node.props.length > 0 ? 1 : 0 // element w/ binding count
 | |
|   let bailed = false
 | |
|   const bail = (): false => {
 | |
|     bailed = true
 | |
|     return false
 | |
|   }
 | |
| 
 | |
|   // TODO: check for cases where using innerHTML will result in different
 | |
|   // output compared to imperative node insertions.
 | |
|   // probably only need to check for most common case
 | |
|   // i.e. non-phrasing-content tags inside `<p>`
 | |
|   function walk(node: ElementNode): boolean {
 | |
|     for (let i = 0; i < node.props.length; i++) {
 | |
|       const p = node.props[i]
 | |
|       // bail on non-attr bindings
 | |
|       if (p.type === NodeTypes.ATTRIBUTE && !isStringifiableAttr(p.name)) {
 | |
|         return bail()
 | |
|       }
 | |
|       if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
 | |
|         // bail on non-attr bindings
 | |
|         if (
 | |
|           p.arg &&
 | |
|           (p.arg.type === NodeTypes.COMPOUND_EXPRESSION ||
 | |
|             (p.arg.isStatic && !isStringifiableAttr(p.arg.content)))
 | |
|         ) {
 | |
|           return bail()
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     for (let i = 0; i < node.children.length; i++) {
 | |
|       nc++
 | |
|       if (nc >= StringifyThresholds.NODE_COUNT) {
 | |
|         return true
 | |
|       }
 | |
|       const child = node.children[i]
 | |
|       if (child.type === NodeTypes.ELEMENT) {
 | |
|         if (child.props.length > 0) {
 | |
|           ec++
 | |
|           if (ec >= StringifyThresholds.ELEMENT_WITH_BINDING_COUNT) {
 | |
|             return true
 | |
|           }
 | |
|         }
 | |
|         walk(child)
 | |
|         if (bailed) {
 | |
|           return false
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     return true
 | |
|   }
 | |
| 
 | |
|   return walk(node) ? [nc, ec] : false
 | |
| }
 | |
| 
 | |
| function stringifyNode(
 | |
|   node: string | TemplateChildNode,
 | |
|   context: TransformContext
 | |
| ): string {
 | |
|   if (isString(node)) {
 | |
|     return node
 | |
|   }
 | |
|   if (isSymbol(node)) {
 | |
|     return ``
 | |
|   }
 | |
|   switch (node.type) {
 | |
|     case NodeTypes.ELEMENT:
 | |
|       return stringifyElement(node, context)
 | |
|     case NodeTypes.TEXT:
 | |
|       return escapeHtml(node.content)
 | |
|     case NodeTypes.COMMENT:
 | |
|       return `<!--${escapeHtml(node.content)}-->`
 | |
|     case NodeTypes.INTERPOLATION:
 | |
|       return escapeHtml(toDisplayString(evaluateConstant(node.content)))
 | |
|     case NodeTypes.COMPOUND_EXPRESSION:
 | |
|       return escapeHtml(evaluateConstant(node))
 | |
|     case NodeTypes.TEXT_CALL:
 | |
|       return stringifyNode(node.content, context)
 | |
|     default:
 | |
|       // static trees will not contain if/for nodes
 | |
|       return ''
 | |
|   }
 | |
| }
 | |
| 
 | |
| function stringifyElement(
 | |
|   node: ElementNode,
 | |
|   context: TransformContext
 | |
| ): string {
 | |
|   let res = `<${node.tag}`
 | |
|   for (let i = 0; i < node.props.length; i++) {
 | |
|     const p = node.props[i]
 | |
|     if (p.type === NodeTypes.ATTRIBUTE) {
 | |
|       res += ` ${p.name}`
 | |
|       if (p.value) {
 | |
|         res += `="${escapeHtml(p.value.content)}"`
 | |
|       }
 | |
|     } else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
 | |
|       // constant v-bind, e.g. :foo="1"
 | |
|       let evaluated = evaluateConstant(p.exp as SimpleExpressionNode)
 | |
|       const arg = p.arg && (p.arg as SimpleExpressionNode).content
 | |
|       if (arg === 'class') {
 | |
|         evaluated = normalizeClass(evaluated)
 | |
|       } else if (arg === 'style') {
 | |
|         evaluated = stringifyStyle(normalizeStyle(evaluated))
 | |
|       }
 | |
|       res += ` ${(p.arg as SimpleExpressionNode).content}="${escapeHtml(
 | |
|         evaluated
 | |
|       )}"`
 | |
|     }
 | |
|   }
 | |
|   if (context.scopeId) {
 | |
|     res += ` ${context.scopeId}`
 | |
|   }
 | |
|   res += `>`
 | |
|   for (let i = 0; i < node.children.length; i++) {
 | |
|     res += stringifyNode(node.children[i], context)
 | |
|   }
 | |
|   if (!isVoidTag(node.tag)) {
 | |
|     res += `</${node.tag}>`
 | |
|   }
 | |
|   return res
 | |
| }
 | |
| 
 | |
| // __UNSAFE__
 | |
| // Reason: eval.
 | |
| // It's technically safe to eval because only constant expressions are possible
 | |
| // here, e.g. `{{ 1 }}` or `{{ 'foo' }}`
 | |
| // in addition, constant exps bail on presence of parens so you can't even
 | |
| // run JSFuck in here. But we mark it unsafe for security review purposes.
 | |
| // (see compiler-core/src/transformExpressions)
 | |
| function evaluateConstant(exp: ExpressionNode): string {
 | |
|   if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
 | |
|     return new Function(`return ${exp.content}`)()
 | |
|   } else {
 | |
|     // compound
 | |
|     let res = ``
 | |
|     exp.children.forEach(c => {
 | |
|       if (isString(c) || isSymbol(c)) {
 | |
|         return
 | |
|       }
 | |
|       if (c.type === NodeTypes.TEXT) {
 | |
|         res += c.content
 | |
|       } else if (c.type === NodeTypes.INTERPOLATION) {
 | |
|         res += toDisplayString(evaluateConstant(c.content))
 | |
|       } else {
 | |
|         res += evaluateConstant(c)
 | |
|       }
 | |
|     })
 | |
|     return res
 | |
|   }
 | |
| }
 |