UNPKG

sinuous

Version:

🧬 Small, fast, reactive render engine

499 lines (449 loc) • 15.3 kB
'use strict'; /** * Copyright 2018 Google Inc. All Rights Reserved. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const MODE_SLASH = 0; const MODE_TEXT = 1; const MODE_WHITESPACE = 2; const MODE_TAGNAME = 3; const MODE_COMMENT = 4; const MODE_PROP_SET = 5; const MODE_PROP_APPEND = 6; const TAG_SET = 1; const CHILD_APPEND = 0; const CHILD_RECURSE = 2; const PROPS_ASSIGN = 3; const PROP_SET = MODE_PROP_SET; const PROP_APPEND = MODE_PROP_APPEND; // Turn a result of a build(...) call into a tree that is more // convenient to analyze and transform (e.g. Babel plugins). // For example: // treeify( // build'<div href="1${a}" ...${b}><${x} /></div>`, // [X, Y, Z] // ) // returns: // { // tag: 'div', // props: [ { href: ["1", X] }, Y ], // children: [ { tag: Z, props: [], children: [] } ] // } const treeify = (built, fields) => { const _treeify = built => { let tag = ''; let currentProps = null; const props = []; const children = []; for (let i = 1; i < built.length; i++) { const field = built[i++]; const value = typeof field === 'number' ? fields[field - 1] : field; if (built[i] === TAG_SET) { tag = value; } else if (built[i] === PROPS_ASSIGN) { props.push(value); currentProps = null; } else if (built[i] === PROP_SET) { if (!currentProps) { currentProps = Object.create(null); props.push(currentProps); } currentProps[built[++i]] = [value]; } else if (built[i] === PROP_APPEND) { currentProps[built[++i]].push(value); } else if (built[i] === CHILD_RECURSE) { children.push(_treeify(value)); } else if (built[i] === CHILD_APPEND) { children.push(value); } } return { tag, props, children }; }; const { children } = _treeify(built); return children.length > 1 ? children : children[0]; }; const build = function(statics) { let mode = MODE_TEXT; let buffer = ''; let quote = ''; let current = [0]; let char, propName; const commit = field => { if (mode === MODE_TEXT && (field || (buffer = buffer.replace(/^\s*\n\s*|\s*\n\s*$/g,'')))) { { current.push(field || buffer, CHILD_APPEND); } } else if (mode === MODE_TAGNAME && (field || buffer)) { { current.push(field || buffer, TAG_SET); } mode = MODE_WHITESPACE; } else if (mode === MODE_WHITESPACE && buffer === '...' && field) { { current.push(field, PROPS_ASSIGN); } } else if (mode === MODE_WHITESPACE && buffer && !field) { { current.push(true, PROP_SET, buffer); } } else if (mode >= MODE_PROP_SET) { { if (buffer || (!field && mode === MODE_PROP_SET)) { current.push(buffer, mode, propName); mode = MODE_PROP_APPEND; } if (field) { current.push(field, mode, propName); mode = MODE_PROP_APPEND; } } } buffer = ''; }; for (let i=0; i<statics.length; i++) { if (i) { if (mode === MODE_TEXT) { commit(); } commit(i); } for (let j=0; j<statics[i].length;j++) { char = statics[i][j]; if (mode === MODE_TEXT) { if (char === '<') { // commit buffer commit(); { current = [current]; } mode = MODE_TAGNAME; } else { buffer += char; } } else if (mode === MODE_COMMENT) { // Ignore everything until the last three characters are '-', '-' and '>' if (buffer === '--' && char === '>') { mode = MODE_TEXT; buffer = ''; } else { buffer = char + buffer[0]; } } else if (quote) { if (char === quote) { quote = ''; } else { buffer += char; } } else if (char === '"' || char === "'") { quote = char; } else if (char === '>') { commit(); mode = MODE_TEXT; } else if (!mode) ; else if (char === '=') { mode = MODE_PROP_SET; propName = buffer; buffer = ''; } else if (char === '/' && (mode < MODE_PROP_SET || statics[i][j+1] === '>')) { commit(); if (mode === MODE_TAGNAME) { current = current[0]; } mode = current; { (current = current[0]).push(mode, CHILD_RECURSE); } mode = MODE_SLASH; } else if (char === ' ' || char === '\t' || char === '\n' || char === '\r') { // <a disabled> commit(); mode = MODE_WHITESPACE; } else { buffer += char; } if (mode === MODE_TAGNAME && buffer === '!--') { mode = MODE_COMMENT; current = current[0]; } } } commit(); return current; }; /** * @param {{ types: import('@babel/types') }} babel * @param {object} options * @param {string | false} [options.pragma=h] JSX/hyperscript pragma. * @param {string} [options.tag=html] The tagged template "tag" function name to process. * @param {string | boolean | object} [options.import=false] Import the tag automatically * @param {boolean} [options.monomorphic=false] Output monomorphic inline objects instead of using String literals. * @param {boolean} [options.useBuiltIns=false] Use the native Object.assign instead of trying to polyfill it. * @param {boolean} [options.useNativeSpread=false] Use the native { ...a, ...b } syntax for prop spreads. * @param {boolean} [options.variableArity=true] If `false`, always passes exactly 3 arguments to the pragma function. * @param {boolean} [options.wrapExpression=''] If set wraps the generated expression with a function passing the same arguments the tagged template would receive. */ function htmBabelPlugin({ types: t }, options = {}) { const pragmaString = options.pragma === false ? false : options.pragma || 'h'; const pragma = pragmaString === false ? false : dottedIdentifier(pragmaString); const useBuiltIns = options.useBuiltIns; const useNativeSpread = options.useNativeSpread; const inlineVNodes = options.monomorphic || pragma === false; const importDeclaration = pragmaImport(options.import || false); const wrapExpression = options.wrapExpression; let fields; function pragmaImport(imp) { if (pragmaString === false || imp === false) { return null; } const pragmaRoot = t.identifier(pragmaString.split('.')[0]); // eslint-disable-next-line const { module, export: export_ } = typeof imp !== 'string' ? imp : { module: imp, export: null }; let specifier; if (export_ === '*') { specifier = t.importNamespaceSpecifier(pragmaRoot); } else if (export_ === 'default') { specifier = t.importDefaultSpecifier(pragmaRoot); } else { specifier = t.importSpecifier(pragmaRoot, export_ ? t.identifier(export_) : pragmaRoot); } return t.importDeclaration([specifier], t.stringLiteral(module)); } function dottedIdentifier(keypath) { const path = keypath.split('.'); let out; for (let i = 0; i < path.length; i++) { const ident = propertyName(path[i]); out = i === 0 ? ident : t.memberExpression(out, ident); } return out; } function patternStringToRegExp(str) { const parts = str.split('/').slice(1); const end = parts.pop() || ''; return new RegExp(parts.join('/'), end); } function propertyName(key) { if (t.isValidIdentifier(key)) { return t.identifier(key); } return t.stringLiteral(key); } function objectProperties(obj) { return Object.keys(obj).map(key => { const values = obj[key].map(valueOrNode => t.isNode(valueOrNode) ? maybeField(valueOrNode) : t.valueToNode(valueOrNode) ); let node = values[0]; if (values.length > 1) { if (!t.isStringLiteral(node)) { node = t.binaryExpression('+', t.stringLiteral(''), concatFunctionNode(node)); } values.slice(1).forEach(value => { node = t.binaryExpression('+', node, concatFunctionNode(value)); }); if (values.some(isFunctionLike)) { node = t.functionExpression(null, [], t.blockStatement([ t.returnStatement(node) ])); } } return t.objectProperty(propertyName(key), node); }); } function stringValue(str) { if (options.monomorphic) { return t.objectExpression([ t.objectProperty(propertyName('type'), t.numericLiteral(3)), t.objectProperty(propertyName('tag'), t.nullLiteral()), t.objectProperty(propertyName('props'), t.nullLiteral()), t.objectProperty(propertyName('children'), t.nullLiteral()), t.objectProperty(propertyName('text'), t.stringLiteral(str)) ]); } return t.stringLiteral(str); } function createVNode(tag, props, children) { // Never pass children=[[]]. if ( children.elements.length === 1 && t.isArrayExpression(children.elements[0]) && children.elements[0].elements.length === 0 ) { children = children.elements[0]; } if (inlineVNodes) { return t.objectExpression([ options.monomorphic && t.objectProperty(propertyName('type'), t.numericLiteral(1)), t.objectProperty(propertyName('tag'), tag), t.objectProperty(propertyName('props'), props), t.objectProperty(propertyName('children'), children), options.monomorphic && t.objectProperty(propertyName('text'), t.nullLiteral()) ].filter(Boolean)); } // Passing `{variableArity:false}` always produces `h(tag, props, children)` - where `children` is always an Array. // Otherwise, the default is `h(tag, props, ...children)`. if (options.variableArity !== false) { children = children.elements; } return t.callExpression(pragma, [tag, props].concat(children)); } function spreadNode(args, state) { if (!args || args.length === 0) { return t.nullLiteral(); } if (args.length > 0 && t.isNode(args[0])) { args.unshift({}); } // 'Object.assign(x)', can be collapsed to 'x'. if (args.length === 1) { return propsNode(args[0]); } // 'Object.assign({}, x)', can be collapsed to 'x'. if (args.length === 2 && !t.isNode(args[0]) && Object.keys(args[0]).length === 0) { return propsNode(args[1]); } if (useNativeSpread) { const properties = []; args.forEach(arg => { if (t.isNode(arg)) { properties.push(t.spreadElement(arg)); } else { properties.push(...objectProperties(arg)); } }); return t.objectExpression(properties); } const helper = useBuiltIns ? dottedIdentifier('Object.assign') : state.addHelper('extends'); return t.callExpression(helper, args.map(propsNode)); } function propsNode(props) { return t.isNode(props) ? maybeField(props) : t.objectExpression(objectProperties(props)); } function transform(node, state) { node = maybeField(node); if (t.isNode(node)) return node; if (typeof node === 'string') return stringValue(node); if (node === undefined) return t.identifier('undefined'); const { tag, props, children } = node; const isComponent = typeof tag !== 'string'; const newTag = isComponent ? tag : t.stringLiteral(tag); const newProps = spreadNode(props, state); const newChildren = t.arrayExpression((children || []) .map(child => transform(child, state)) .map(child => isComponent ? t.arrowFunctionExpression([], child) : child)); return createVNode(newTag, newProps, newChildren); } function maybeField(node) { if (fields.has(node)) { return fields.get(node); } return node; } function isFunctionLike(node) { return ( t.isIdentifier(node) || t.isFunctionExpression(node) || t.isArrowFunctionExpression(node) ); } function concatFunctionNode(node) { if (isFunctionLike(node)) { const typeofNode = t.unaryExpression('typeof', node); const isNodeFunction = t.binaryExpression('===', typeofNode, t.stringLiteral('function')); return t.conditionalExpression(isNodeFunction, t.callExpression(t.memberExpression(node, t.identifier('call')), [t.thisExpression()]), node); } return node; } // The tagged template tag function name we're looking for. // This is static because it's generally assigned via htm.bind(h), // which could be imported from elsewhere, making tracking impossible. const htmlName = options.tag || 'html'; return { name: 'htm', visitor: { Program: { exit(path, state) { if (state.get('hasHtm') && importDeclaration) { path.unshiftContainer('body', importDeclaration); } }, }, TaggedTemplateExpression(path, state) { fields = new Map(); const tag = path.node.tag.name; if (htmlName[0] === '/' ? patternStringToRegExp(htmlName).test(tag) : tag === htmlName) { const statics = path.node.quasi.quasis.map(e => e.value.raw); const exprs = path.node.quasi.expressions; let tree = treeify(build(statics), exprs); // Turn array expression in Array so it can be converted below // to a pragma call expression for fragments. if (t.isArrayExpression(tree)) { tree = tree.elements; } if (wrapExpression) { exprs.forEach(expr => { fields.set(expr, path.scope.generateUidIdentifier("field")); }); } let node = Array.isArray(tree) ? t.callExpression(pragma, [ t.arrayExpression(tree.map(root => transform(root, state))) ]) : t.isNode(tree) || typeof tree === 'string' ? t.callExpression(pragma, [ t.arrayExpression([transform(tree, state)]) ]) : transform(tree, state); if (wrapExpression) { let taggedArgs = Array.from(fields.values()); taggedArgs.unshift(path.scope.generateUidIdentifier("statics")); node = t.callExpression(dottedIdentifier(`${wrapExpression}.apply`), [ t.arrowFunctionExpression(taggedArgs, node), t.arrayExpression([ t.arrayExpression(statics.map(str => t.stringLiteral(str))), ...exprs ]) ]); } path.replaceWith(node); state.set('hasHtm', true); } } } }; } module.exports = htmBabelPlugin;