Skip to content

Instantly share code, notes, and snippets.

@zerobias
Last active August 21, 2023 19:26
Show Gist options
  • Save zerobias/70b3469a108aaf8e298795c51645b140 to your computer and use it in GitHub Desktop.
Save zerobias/70b3469a108aaf8e298795c51645b140 to your computer and use it in GitHub Desktop.
HTML to forest rollup plugin
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