import { parseFragment as parse, serialize } from "parse5" type TagDefinitions = Record type Node = { nodeName: string tagName: string parentNode: Node | null childNodes: Node[] value: string attrs: { name: string, value: string }[] } // search for node function findTagsNode(node: Node): Node | null { if (node.nodeName === "tags" && node.childNodes) { return node } if (node.childNodes) { for (const child of node.childNodes) { const result = findTagsNode(child) if (result) return result } } return null } // pull out 's inner text function tagsNodeText(node: Node): string | null { if (node && node.childNodes) { const textNode = node.childNodes.find((n: Node) => n.nodeName === "#text") if (textNode) return textNode.value } return null } // turn what's inside into a map of tag definitions // ignores //comment lines // format is `tag: parent class1 class2` function parseTagDefinitions(text: string): TagDefinitions { const tags: TagDefinitions = {} text.split("\n") .forEach(line => { line = line.trim() if (line === "") return if (line.startsWith("//")) return const [name, definition] = line.split(/:(.+)/) if (!name || !definition) { console.error(`Invalid tag definition: ${line}`) return } const [parent, ...classes] = definition.trim().split(" ") if (!parent) { console.error(`Invalid tag definition: ${line}`) return } tags[name.trim()] = [parent.trim(), classes] }) return tags } // using our tag definitions, replace the tags in the AST with the real tags w/ classes function replaceTags(ast: Node, tagDefinitions: TagDefinitions): string { const tags = Object.keys(tagDefinitions) for (const tag of tags) { const [parent, classes] = tagDefinitions[tag] as [string, string[]] const parentNodes = findTagsByName(ast, tag) for (const parentNode of parentNodes) { if (parentNode) { parentNode.tagName = parent parentNode.nodeName = parent if (parentNode.attrs.length === 0) { parentNode.attrs = [{ name: "class", value: classes.join(" ") }] } else { const classAttr = parentNode.attrs.find((a: any) => a.name === "class") if (classAttr) { classAttr.value += " " + classes.join(" ") } else { parentNode.attrs.push({ name: "class", value: classes.join(" ") }) } } } } } // delete node const tagsIndex = ast.childNodes.findIndex((n: Node) => n.nodeName === "tags") if (tagsIndex !== -1) { ast.childNodes.splice(tagsIndex, 1) } return serialize(ast as any) } // find tag by name in the AST function findTagByName(ast: Node, tag: string): Node | null { if (ast.nodeName === tag) { return ast } if (ast.childNodes) { for (const child of ast.childNodes) { const result = findTagByName(child, tag) if (result) return result } } return null } // find tags by name in the AST function findTagsByName(ast: Node, tag: string): Node[] { const tags: Node[] = [] if (ast.nodeName === tag) { tags.push(ast) } if (ast.childNodes) { for (const child of ast.childNodes) { tags.push(...findTagsByName(child, tag)) } } return tags } // transform string HTML w/ into new HTML w/ + friends replaced by new HTML function transform(content: string): string { const ast = parse(content) as Node const tagsNode = findTagsNode(ast) if (!tagsNode) { console.error("No element found") return content } const tagsText = tagsNodeText(tagsNode) if (!tagsText) { console.error("No text found") return content } const tagDefinitions = parseTagDefinitions(tagsText) return replaceTags(ast, tagDefinitions) } async function main() { const content = await Bun.file("example.tsx").text() const transformed = transform(content) console.log(transformed.trim()) } if (import.meta.main) { main() }