sinuous
Version:
🧬 Small, fast, reactive render engine
499 lines (449 loc) • 15.3 kB
JavaScript
;
/**
* 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;