Last active
August 21, 2023 19:26
-
-
Save zerobias/70b3469a108aaf8e298795c51645b140 to your computer and use it in GitHub Desktop.
HTML to forest rollup plugin
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {parseFragment} from 'parse5' | |
import generate from '@babel/generator' | |
import {parse} from '@babel/parser' | |
import t from '@babel/types' | |
import traverse from '@babel/traverse' | |
import template from '@babel/template' | |
function addFactoryImport(path, defs) { | |
const programPath = path.find(path => path.isProgram()) | |
for (const {local, imported} of defs) { | |
const [newPath] = programPath.unshiftContainer( | |
'body', | |
t.importDeclaration( | |
[t.importSpecifier(t.identifier(local), t.identifier(imported))], | |
t.stringLiteral('forest') | |
) | |
) | |
newPath.get('specifiers').forEach(specifier => { | |
programPath.scope.registerBinding('module', specifier) | |
}) | |
} | |
} | |
function getQuasiValue(quasi) { | |
if (!t.isTemplateElement(quasi)) return | |
return quasi.value.cooked | |
} | |
function createTaggedCall(method, items, simplify = false) { | |
if (simplify) { | |
if (items.length === 3) { | |
const [open, content, close] = items | |
if ( | |
open.type === 'text' && | |
close.type === 'text' && | |
open.value.length === 0 && | |
close.value.length === 0 | |
) { | |
return content.value | |
} | |
} | |
if (items.length === 1) return t.stringLiteral(items[0].value) | |
} | |
const expressions = [] | |
const quasis = [] | |
for (const item of items) { | |
switch (item.type) { | |
case 'text': | |
quasis.push( | |
t.templateElement( | |
{raw: item.value}, | |
items.indexOf(item) === items.length - 1 | |
) | |
) | |
break | |
case 'child': | |
case 'spec': | |
expressions.push(item.value) | |
break | |
} | |
} | |
return t.taggedTemplateExpression( | |
t.identifier(method), | |
t.templateLiteral(quasis, expressions) | |
) | |
} | |
function objectField(field) { | |
return /^[a-zA-Z_$0-9]+$/gm.test(field) | |
? t.identifier(field) | |
: t.stringLiteral(field) | |
} | |
function unindent(text) { | |
function trimNewLinesOnly(text) { | |
while (/^\s*\n/.test(text)) text = text.replace(/^\s*\n/, '') | |
while (/\n\s*$/.test(text)) text = text.replace(/\n\s*$/, '') | |
return text | |
} | |
text = trimNewLinesOnly(text) | |
const rows = text.split(`\n`) | |
let leftIdent = -1 | |
for (const row of rows) { | |
if (row.length === 0) continue | |
const [space] = row.match(/^\s*/gm) | |
const size = space.replace(/\t/gm, () => ' ').length | |
if (leftIdent === -1) leftIdent = size | |
else leftIdent = Math.min(leftIdent, size) | |
} | |
if (leftIdent === -1) return text | |
return trimNewLinesOnly(rows.map(row => row.slice(leftIdent)).join(`\n`)) | |
} | |
function parseVars(value, varMap) { | |
value = unindent(String(value)) | |
const matches = [...value.matchAll(/FOREST_(\d+)_QUASI/gm)] | |
if (matches.length === 0) { | |
return [ | |
{ | |
type: 'text', | |
value | |
} | |
] | |
} | |
const items = [] | |
let lastIndex = 0 | |
for (let i = 0; i < matches.length; i++) { | |
const match = matches[i] | |
const startFrom = match.index | |
const fullMatch = match[0] | |
const variable = varMap[fullMatch] | |
const textBefore = value.slice(lastIndex, startFrom) | |
items.push({ | |
type: 'text', | |
value: textBefore | |
}) | |
items.push({ | |
type: variable.isSpec ? 'spec' : 'child', | |
value: variable.expr, | |
origExpr: variable.origExpr | |
}) | |
lastIndex = startFrom + fullMatch.length | |
} | |
const lastText = value.slice(lastIndex) | |
items.push({ | |
type: 'text', | |
value: lastText | |
}) | |
return items | |
} | |
function traverseHtml(node, ctx, varMap) { | |
switch (node.nodeName) { | |
case '#comment': | |
return | |
case '#document-fragment': { | |
node.childNodes.forEach(child => { | |
traverseHtml(child, ctx, varMap) | |
}) | |
break | |
} | |
case '#text': { | |
const parsed = parseVars(node.value, varMap) | |
let expression = [] | |
for (const item of parsed) { | |
if (item.type === 'spec') { | |
if (expression.length > 0) { | |
if ( | |
expression.length === 1 && | |
expression[0].type === 'text' && | |
/^\n+\s*$/.test(expression[0].value) | |
) { | |
expression = [] | |
} else { | |
ctx.items.push({ | |
type: 'expression', | |
value: createTaggedCall('textHtml', expression) | |
}) | |
} | |
} | |
expression = [] | |
ctx.items.push({ | |
type: 'expression', | |
value: item.value | |
}) | |
} else { | |
expression.push(item) | |
} | |
} | |
if (expression.length > 0) { | |
if ( | |
expression.length === 1 && | |
expression[0].type === 'text' && | |
(/^\n+\s*$/.test(expression[0].value) || | |
expression[0].value.length === 0) | |
) { | |
expression = [] | |
} else { | |
ctx.items.push({ | |
type: 'expression', | |
value: createTaggedCall('textHtml', expression) | |
}) | |
} | |
} | |
break | |
} | |
default: { | |
const childCtx = { | |
type: 'element', | |
tag: node.tagName, | |
attrs: [], | |
dataAttrs: [], | |
handlers: [], | |
items: [] | |
} | |
ctx.items.push(childCtx) | |
for (const attr of node.attrs) { | |
const parsed = parseVars(attr.value, varMap) | |
const value = createTaggedCall('valHtml', parsed, true) | |
if (attr.name.startsWith('data-')) { | |
const key = attr.name | |
.slice(5) | |
.replace(/-\w/gm, ([, char]) => char.toUpperCase()) | |
childCtx.dataAttrs.push({key, value}) | |
} else if (attr.name === 'handler') { | |
childCtx.handlers.push( | |
...parsed.filter(e => e.type === 'spec').map(e => e.origExpr) | |
) | |
} else { | |
childCtx.attrs.push({ | |
key: attr.name, | |
value | |
}) | |
} | |
} | |
node.childNodes.forEach(child => { | |
traverseHtml(child, childCtx, varMap) | |
}) | |
} | |
} | |
} | |
function traverseCtx(ctx, acc) { | |
switch (ctx.type) { | |
case 'seq': | |
for (const item of ctx.items) { | |
traverseCtx(item, acc) | |
} | |
break | |
case 'expression': | |
acc.push( | |
t.isBlockStatement(ctx.value) | |
? ctx.value | |
: t.expressionStatement(ctx.value) | |
) | |
break | |
case 'element': { | |
if ( | |
ctx.attrs.length === 0 && | |
ctx.dataAttrs.length === 0 && | |
ctx.items.length === 0 && | |
ctx.handlers.length === 0 | |
) { | |
acc.push( | |
t.expressionStatement( | |
t.callExpression(t.identifier('hHtml'), [t.stringLiteral(ctx.tag)]) | |
) | |
) | |
break | |
} | |
const configProperties = [] | |
const statements = [] | |
if (ctx.handlers.length > 0) { | |
configProperties.push( | |
t.objectProperty(t.identifier('handler'), ctx.handlers[0]) | |
) | |
/** dont used, as parse5 will skip attr duplicates */ | |
if (ctx.handlers.length > 1) { | |
ctx.handlers.slice(1).forEach(expr => { | |
statements.push( | |
t.expressionStatement( | |
t.callExpression(t.identifier('specHtml'), [ | |
t.objectExpression([ | |
t.objectProperty(t.identifier('handler'), expr) | |
]) | |
]) | |
) | |
) | |
}) | |
} | |
} | |
if (ctx.attrs.length > 0) { | |
configProperties.push( | |
t.objectProperty( | |
t.identifier('attr'), | |
t.objectExpression( | |
ctx.attrs.map(e => | |
t.objectProperty(objectField(e.key), e.value, false) | |
) | |
) | |
) | |
) | |
} | |
if (ctx.dataAttrs.length > 0) { | |
configProperties.push( | |
t.objectProperty( | |
t.identifier('data'), | |
t.objectExpression( | |
ctx.dataAttrs.map(e => | |
t.objectProperty(objectField(e.key), e.value, false) | |
) | |
) | |
) | |
) | |
} | |
if (ctx.items.length > 0 || statements.length > 0) { | |
for (const item of ctx.items) { | |
traverseCtx(item, statements) | |
} | |
configProperties.push( | |
t.objectMethod( | |
'method', | |
t.identifier('fn'), | |
[], | |
t.blockStatement(statements) | |
) | |
) | |
} | |
acc.push( | |
t.expressionStatement( | |
t.callExpression(t.identifier('hHtml'), [ | |
t.stringLiteral(ctx.tag), | |
t.objectExpression(configProperties) | |
]) | |
) | |
) | |
break | |
} | |
} | |
} | |
function traverseJsx(node, ctx, varMap) { | |
switch (node.type) { | |
case 'JSXText': { | |
const parsed = parseVars(node.value, varMap) | |
let expression = [] | |
console.log('unsupported jsx JSXText') | |
break | |
} | |
case 'JSXExpressionContainer': { | |
console.log('unsupported jsx JSXExpressionContainer') | |
break | |
} | |
case 'JSXSpreadChild': { | |
console.log('unsupported jsx JSXSpreadChild') | |
break | |
} | |
case 'JSXElement': { | |
console.log('unsupported jsx JSXElement') | |
break | |
} | |
case 'JSXFragment': { | |
console.log('unsupported jsx JSXFragment') | |
break | |
} | |
default: { | |
console.log('unsupported jsx default', node.type) | |
break | |
} | |
} | |
} | |
export function htmlToForest() { | |
const root = process.cwd() | |
return { | |
name: 'html-to-forest', | |
transform(code, id) { | |
id = String(id) | |
if (!id.startsWith(root) || (!id.endsWith('.ts') && !id.endsWith('.tsx'))) | |
return null | |
if (!code.includes('html`')) return null | |
const ast = parse(code, { | |
sourceType: 'module', | |
plugins: ['jsx', 'typescript'] | |
}) | |
let isHtmlUsed = false | |
let isHImported = false | |
traverse(ast, { | |
JSXFragment(path) { | |
const childs = [] | |
path.node.children.forEach((child, idx) => { | |
switch (child.type) { | |
case 'JSXText': { | |
child.value | |
console.log('unsupported jsx JSXText') | |
break | |
} | |
case 'JSXExpressionContainer': { | |
console.log('unsupported jsx JSXExpressionContainer') | |
break | |
} | |
case 'JSXSpreadChild': { | |
console.log('unsupported jsx JSXSpreadChild') | |
break | |
} | |
case 'JSXElement': { | |
switch (child.openingElement.name.type) { | |
case 'JSXIdentifier': { | |
const childCtx = { | |
type: 'element', | |
tag: child.openingElement.name.name, | |
attrs: [], | |
dataAttrs: [], | |
handlers: [], | |
items: [] | |
} | |
console.log('unsupported jsx JSXIdentifier') | |
break | |
} | |
case 'JSXMemberExpression': { | |
console.log('unsupported jsx JSXMemberExpression') | |
break | |
} | |
case 'JSXNamespacedName': { | |
console.log('unsupported jsx JSXNamespacedName') | |
break | |
} | |
} | |
console.log('unsupported jsx JSXElement') | |
break | |
} | |
case 'JSXFragment': { | |
console.log('unsupported jsx JSXFragment') | |
break | |
} | |
} | |
}) | |
path.replaceWith( | |
t.callExpression( | |
t.arrowFunctionExpression([], t.blockStatement(childs)), | |
[] | |
) | |
) | |
}, | |
JSXElement(path) { | |
isHtmlUsed = true | |
if (!isHImported) { | |
isHImported = true | |
addFactoryImport(path, [ | |
{imported: 'h', local: 'hHtml'}, | |
{imported: 'spec', local: 'specHtml'}, | |
{imported: 'val', local: 'valHtml'}, | |
{imported: 'text', local: 'textHtml'} | |
]) | |
} | |
const tagName = path.node.openingElement.name.name | |
path.node.openingElement.attributes | |
const varMap = {} | |
let nextId = 0 | |
const htmlParts = [`<${tagName}`] | |
const attribs = t.nullLiteral() | |
const args = [t.stringLiteral(tagName), attribs] | |
// order in AST Top to bottom -> (CallExpression => MemberExpression => Identifiers) | |
// below are the steps to create a callExpression | |
const callExpression = t.callExpression( | |
t.memberExpression( | |
t.identifier('React'), | |
t.identifier('createElement') | |
), | |
args | |
) | |
//now add children as a third argument | |
callExpression.arguments = callExpression.arguments.concat( | |
path.node.children | |
) | |
// replace jsxElement node with the call expression node made above | |
path.replaceWith(callExpression, path.node) | |
}, | |
TaggedTemplateExpression(path) { | |
const {node} = path | |
if (node.tag.name !== 'html') return | |
isHtmlUsed = true | |
if (!isHImported) { | |
isHImported = true | |
addFactoryImport(path, [ | |
{imported: 'h', local: 'hHtml'}, | |
{imported: 'spec', local: 'specHtml'}, | |
{imported: 'val', local: 'valHtml'}, | |
{imported: 'text', local: 'textHtml'} | |
]) | |
} | |
const varMap = {} | |
let nextId = 0 | |
const htmlParts = [] | |
if (node.quasi.expressions.length === 0) { | |
htmlParts.push(getQuasiValue(node.quasi.quasis[0])) | |
} else { | |
node.quasi.expressions.forEach((expr, i) => { | |
const id = `FOREST_${++nextId}_QUASI` | |
varMap[id] = {expr, isSpec: t.isObjectExpression(expr)} | |
if (t.isArrowFunctionExpression(expr)) { | |
varMap[id] = { | |
expr: t.isBlockStatement(expr.body) | |
? expr.body | |
: t.blockStatement([t.expressionStatement(expr.body)]), | |
isSpec: true | |
} | |
} | |
if (t.isObjectExpression(expr)) { | |
varMap[id] = { | |
expr: t.callExpression(t.identifier('specHtml'), [expr]), | |
origExpr: expr, | |
isSpec: true | |
} | |
} | |
const quasi = getQuasiValue(node.quasi.quasis[i]) | |
htmlParts.push(`${quasi}${id}`) | |
}) | |
const lastQuasi = node.quasi.quasis[node.quasi.quasis.length - 1] | |
htmlParts.push(getQuasiValue(lastQuasi)) | |
} | |
const htmlAst = parseFragment( | |
parseFragment('<svg></svg>').childNodes[0], | |
unindent(htmlParts.join('')) | |
) | |
const rootCtx = { | |
type: 'seq', | |
items: [] | |
} | |
traverseHtml(htmlAst, rootCtx, varMap) | |
const statements = [] | |
traverseCtx(rootCtx, statements) | |
path.replaceWithMultiple(statements) | |
} | |
}) | |
if (!isHtmlUsed) return null | |
const babelResult = generate( | |
ast, | |
{ | |
sourceMaps: true, | |
sourceFileName: id | |
}, | |
code | |
) | |
return babelResult | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment