wc-compiler
Version:
Experimental native Web Components compiler.
415 lines (356 loc) • 14.5 kB
JavaScript
// https://nodejs.org/api/esm.html#esm_loaders
import * as acorn from 'acorn';
import * as walk from 'acorn-walk';
import { generate } from 'astring';
import fs from 'fs';
// ideally we can eventually adopt an ESM compatible version of this plugin
// https://github.com/acornjs/acorn-jsx/issues/112
// @ts-ignore
// but it does have a default export???
import jsx from '@projectevergreen/acorn-jsx-esm';
import { parse, parseFragment, serialize } from 'parse5';
import { transform } from 'sucrase';
const jsxRegex = /\.(jsx)$/;
// TODO same hack as definitions
// https://github.com/ProjectEvergreen/wcc/discussions/74
let string;
// TODO move to a util
// https://github.com/ProjectEvergreen/wcc/discussions/74
function getParse(html) {
return html.indexOf('<html>') >= 0 || html.indexOf('<body>') >= 0 || html.indexOf('<head>') >= 0
? parse
: parseFragment;
}
export function getParser(moduleURL) {
const isJSX = moduleURL.pathname.split('.').pop() === 'jsx';
if (!isJSX) {
return;
}
return {
parser: acorn.Parser.extend(jsx()),
config: {
// https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171
...walk.base,
JSXElement: () => {}
}
};
}
// replace all instances of __this__ marker with relative reference to the custom element parent node
function applyDomDepthSubstitutions(tree, currentDepth = 1, hasShadowRoot = false) {
try {
for (const node of tree.childNodes) {
const attrs = node.attrs;
// check for attributes
// and swap out __this__ with depthful parentElement chain
if (attrs && attrs.length > 0) {
for (const attr in attrs) {
const { value } = attrs[attr];
if (value.indexOf('__this__.') >= 0) {
const root = hasShadowRoot ? '.getRootNode().host' : `${'.parentElement'.repeat(currentDepth)}`;
node.attrs[attr].value = value.replace(/__this__/g, `this${root}`);
}
}
}
if (node.childNodes && node.childNodes.length > 0) {
applyDomDepthSubstitutions(node, currentDepth + 1, hasShadowRoot);
}
}
} catch (e) {
console.error(e);
}
return tree;
}
function parseJsxElement(element, moduleContents = '') {
try {
const { type } = element;
if (type === 'JSXElement') {
const { openingElement } = element;
const { attributes } = openingElement;
const tagName = openingElement.name.name;
string += `<${tagName}`;
for (const attribute of attributes) {
const { name } = attribute.name;
// handle events
if (name.startsWith('on')) {
const { value } = attribute;
const { expression } = value;
// onclick={this.increment}
if (value.type === 'JSXExpressionContainer') {
if (expression.type === 'MemberExpression') {
if (expression.object.type === 'ThisExpression') {
if (expression.property.type === 'Identifier') {
// we leave markers for `this` so we can replace it later while also NOT accidentally replacing
// legitimate uses of this that might be actual content / markup of the custom element
string += ` ${name}="__this__.${expression.property.name}()"`;
}
}
}
// onclick={() => this.deleteUser(user.id)}
// TODO onclick={(e) => { this.deleteUser(user.id) }}
// TODO onclick={(e) => { this.deleteUser(user.id) && this.logAction(user.id) }}
// https://github.com/ProjectEvergreen/wcc/issues/88
if (expression.type === 'ArrowFunctionExpression') {
if (expression.body && expression.body.type === 'CallExpression') {
const { start, end } = expression;
string += ` ${name}="${moduleContents.slice(start, end).replace(/this./g, '__this__.').replace('() => ', '')}"`;
}
}
if (expression.type === 'AssignmentExpression') {
const { left, right } = expression;
if (left.object.type === 'ThisExpression') {
if (left.property.type === 'Identifier') {
// very naive (fine grained?) reactivity
string += ` ${name}="__this__.${left.property.name}${expression.operator}${right.raw}; __this__.render();"`;
}
}
}
}
} else if (attribute.name.type === 'JSXIdentifier') {
// TODO is there any difference between an attribute for an event handler vs a normal attribute?
// Can all these be parsed using one function>
if (attribute.value) {
if (attribute.value.type === 'Literal') {
// xxx="yyy" >
string += ` ${name}="${attribute.value.value}"`;
} else if (attribute.value.type === 'JSXExpressionContainer') {
// xxx={allTodos.length} >
const { value } = attribute;
const { expression } = value;
if (expression.type === 'Identifier') {
string += ` ${name}=$\{${expression.name}}`;
}
if (expression.type === 'MemberExpression') {
if (expression.object.type === 'Identifier') {
if (expression.property.type === 'Identifier') {
string += ` ${name}=$\{${expression.object.name}.${expression.property.name}}`;
}
}
}
}
} else {
// xxx >
string += ` ${name}`;
}
}
}
string += openingElement.selfClosing ? '/>' : '>';
if (element.children.length > 0) {
element.children.forEach(child => parseJsxElement(child, moduleContents));
}
string += `</${tagName}>`;
}
if (type === 'JSXText') {
string += element.raw;
}
if (type === 'JSXExpressionContainer') {
const { type } = element.expression;
if (type === 'Identifier') {
// You have {count} TODOs left to complete
string += `$\{${element.expression.name}}`;
} else if (type === 'MemberExpression') {
const { object } = element.expression.object;
// You have {this.todos.length} Todos left to complete
// https://github.com/ProjectEvergreen/wcc/issues/88
if (object && object.type === 'ThisExpression') {
// TODO ReferenceError: __this__ is not defined
// string += `\$\{__this__.${element.expression.object.property.name}.${element.expression.property.name}\}`;
} else {
// const { todos } = this;
// ....
// You have {todos.length} Todos left to complete
string += `$\{${element.expression.object.name}.${element.expression.property.name}}`;
}
}
}
} catch (e) {
console.error(e);
}
return string;
}
// TODO handle if / else statements
// https://github.com/ProjectEvergreen/wcc/issues/88
function findThisReferences(context, statement) {
const references = [];
const isRenderFunctionContext = context === 'render';
const { expression, type } = statement;
const isConstructorThisAssignment = context === 'constructor'
&& type === 'ExpressionStatement'
&& expression.type === 'AssignmentExpression'
&& expression.left.object.type === 'ThisExpression';
if (isConstructorThisAssignment) {
// this.name = 'something'; // constructor
references.push(expression.left.property.name);
} else if (isRenderFunctionContext && type === 'VariableDeclaration') {
statement.declarations.forEach(declaration => {
const { init, id } = declaration;
if (init.object && init.object.type === 'ThisExpression') {
// const { description } = this.todo;
references.push(init.property.name);
} else if (init.type === 'ThisExpression' && id && id.properties) {
// const { description } = this.todo;
id.properties.forEach((property) => {
references.push(property.key.name);
});
}
});
}
return references;
}
export function parseJsx(moduleURL) {
const moduleContents = fs.readFileSync(moduleURL, 'utf-8');
const result = transform(moduleContents, {
transforms: ['typescript', 'jsx'],
jsxRuntime: 'preserve'
});
// would be nice if we could do this instead, so we could know ahead of time
// const { inferredObservability } = await import(moduleURL);
// however, this requires making parseJsx async, but WCC acorn walking is done sync
const hasOwnObservedAttributes = undefined;
let inferredObservability = false;
let observedAttributes = [];
let tree = acorn.Parser.extend(jsx()).parse(result.code, {
ecmaVersion: 'latest',
sourceType: 'module'
});
string = '';
walk.simple(tree, {
ClassDeclaration(node) {
// @ts-ignore
if (node.superClass.name === 'HTMLElement') {
const hasShadowRoot = moduleContents.slice(node.body.start, node.body.end).indexOf('this.attachShadow(') > 0;
for (const n1 of node.body.body) {
if (n1.type === 'MethodDefinition') {
// @ts-ignore
const nodeName = n1.key.name;
if (nodeName === 'render') {
for (const n2 in n1.value.body.body) {
const n = n1.value.body.body[n2];
if (n.type === 'VariableDeclaration') {
observedAttributes = [
...observedAttributes,
...findThisReferences('render', n)
];
// @ts-ignore
} else if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') {
const html = parseJsxElement(n.argument, moduleContents);
const elementTree = getParse(html)(html);
const elementRoot = hasShadowRoot ? 'this.shadowRoot' : 'this';
applyDomDepthSubstitutions(elementTree, undefined, hasShadowRoot);
const serializedHtml = serialize(elementTree);
// we have to Shadow DOM use cases here
// 1. No shadowRoot, so we attachShadow and append the template
// 2. If there is root from the attachShadow signal, so we just need to inject innerHTML, say in an htmx
// could / should we do something else instead of .innerHTML
// https://github.com/ProjectEvergreen/wcc/issues/138
const renderHandler = hasShadowRoot
? `
const template = document.createElement('template');
template.innerHTML = \`${serializedHtml}\`;
if(!${elementRoot}) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
} else {
this.shadowRoot.innerHTML = template.innerHTML;
}
`
: `${elementRoot}.innerHTML = \`${serializedHtml}\`;`;
const transformed = acorn.parse(renderHandler, {
ecmaVersion: 'latest',
sourceType: 'module'
});
// @ts-ignore
n1.value.body.body[n2] = transformed;
}
}
}
}
}
}
},
ExportNamedDeclaration(node) {
const { declaration } = node;
if (declaration && declaration.type === 'VariableDeclaration' && declaration.kind === 'const' && declaration.declarations.length === 1) {
// @ts-ignore
if (declaration.declarations[0].id.name === 'inferredObservability') {
// @ts-ignore
inferredObservability = Boolean(node.declaration.declarations[0].init.raw);
}
}
}
}, {
// https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171
...walk.base,
// @ts-ignore
JSXElement: () => {}
});
// TODO - signals: use constructor, render, HTML attributes? some, none, or all?
if (inferredObservability && observedAttributes.length > 0 && !hasOwnObservedAttributes) {
let insertPoint;
for (const line of tree.body) {
// test for class MyComponent vs export default class MyComponent
// @ts-ignore
if (line.type === 'ClassDeclaration' || (line.declaration && line.declaration.type) === 'ClassDeclaration') {
// @ts-ignore
insertPoint = line.declaration.body.start + 1;
}
}
let newModuleContents = generate(tree);
// TODO better way to determine value type?
newModuleContents = `${newModuleContents.slice(0, insertPoint)}
static get observedAttributes() {
return [${[...observedAttributes].map(attr => `'${attr}'`).join(',')}]
}
attributeChangedCallback(name, oldValue, newValue) {
function getValue(value) {
return value.charAt(0) === '{' || value.charAt(0) === '['
? JSON.parse(value)
: !isNaN(value)
? parseInt(value, 10)
: value === 'true' || value === 'false'
? value === 'true' ? true : false
: value;
}
if (newValue !== oldValue) {
switch(name) {
${observedAttributes.map((attr) => {
return `
case '${attr}':
this.${attr} = getValue(newValue);
break;
`;
}).join('\n')}
}
this.render();
}
}
${newModuleContents.slice(insertPoint)}
`;
tree = acorn.Parser.extend(jsx()).parse(newModuleContents, {
ecmaVersion: 'latest',
sourceType: 'module'
});
}
return tree;
}
// --------------
export function resolve(specifier, context, defaultResolve) {
const { parentURL } = context;
if (jsxRegex.test(specifier)) {
return {
url: new URL(specifier, parentURL).href,
shortCircuit: true
};
}
return defaultResolve(specifier, context, defaultResolve);
}
export async function load(url, context, defaultLoad) {
if (jsxRegex.test(url)) {
const jsFromJsx = parseJsx(new URL(url));
return {
format: 'module',
source: generate(jsFromJsx),
shortCircuit: true
};
}
return defaultLoad(url, context, defaultLoad);
}