Created
September 13, 2024 20:33
-
-
Save bvisness/15dcfb99f7f182063c1569320adbe373 to your computer and use it in GitHub Desktop.
React "detranspiler"
This file contains hidden or 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
#!/usr/bin/env node | |
// This script takes a script called `s01.js` and tries to de-transpile it somewhat, | |
// converting calls to `jsx` and `jsxs` into JSX syntax. | |
const acorn = require("acorn"); | |
const walk = require("acorn-walk"); | |
const { readFileSync, writeFileSync } = require("fs"); | |
const prettier = require("prettier"); | |
const s01 = readFileSync("s01.js", "utf-8"); | |
function s(source, node) { | |
return source.substring(node.start, node.end); | |
} | |
function printNode(source, node) { | |
console.log(); | |
} | |
// s02: Coalesce (0, foo) to (foo) | |
{ | |
const dumbos = []; | |
walk.simple(acorn.parse(s01, { ecmaVersion: 2020 }), { | |
SequenceExpression(node, state) { | |
const first = node.expressions[0]; | |
if (first.type === "Literal" && first.value === 0) { | |
dumbos.push(node); | |
} | |
}, | |
}); | |
function replaceNodes(source, nodes, f) { | |
let result = source; | |
for (let i = nodes.length - 1; i >= 0; i--) { | |
const n = nodes[i]; | |
const before = result.substring(0, n.start); | |
const after = result.substring(n.end); | |
result = before + f(n) + after; | |
} | |
return result; | |
} | |
const s02 = replaceNodes(s01, dumbos, node => s(s01, node.expressions[1])); | |
writeFileSync("s02.js", s02); | |
} | |
// s03: Rewrite `s.jsxs` and `s.jsx` to JSX components | |
{ | |
const s02 = readFileSync("s02.js", "utf-8"); | |
function isJSX(node) { | |
return ( | |
node.type === "CallExpression" | |
&& node.callee.type === "MemberExpression" | |
&& ["jsx", "jsxs"].includes(node.callee.property.name) | |
); | |
} | |
// Build a tree of just JSX nodes that we can recursively walk later to find and replace. | |
const currentJSXes = []; | |
walk.ancestor(acorn.parse(s02, { ecmaVersion: 2020 }), { | |
CallExpression(node, state, ancestors) { | |
if (!isJSX(node)) { | |
return; | |
} | |
const lastDepth = currentJSXes.length - 1; | |
const depth = ancestors.filter(n => n !== node && isJSX(n)).length; | |
if (depth < lastDepth) { | |
// We have jumped up to a parent node. Create that parent node and push all the | |
// current-depth children to it. | |
console.assert(lastDepth - depth === 1, lastDepth, depth); // invariant of what "depth" means + the walk order | |
const newParent = { n: node, children: currentJSXes[currentJSXes.length - 1] }; | |
currentJSXes[depth].push(newParent); | |
currentJSXes.length--; | |
} else if (depth > lastDepth) { | |
// We have gone deeper. Push empty arrays for each new level, and the new node at the end. | |
const numNewLevels = depth - lastDepth; | |
for (let i = 0; i < numNewLevels - 1; i++) { | |
currentJSXes.push([]); | |
} | |
currentJSXes.push([{ n: node, children: [] }]); | |
console.assert(currentJSXes.length === depth + 1, "pushing new levels: expected length %o, got length %o", depth + 1, currentJSXes.length); | |
} else { | |
// Sibling at current depth with no JSX children. Push to current depth list. | |
currentJSXes[currentJSXes.length-1].push({ n: node, children: [] }); | |
} | |
}, | |
}); | |
console.assert(currentJSXes.length === 1); | |
const jsxes = currentJSXes[0]; | |
function replaceJSXes(source, sourceStart, jsxes) { | |
let result = source; | |
for (let i = jsxes.length - 1; i >= 0; i--) { | |
const jsx = jsxes[i]; | |
const before = result.substring(0, jsx.n.start - sourceStart); | |
const after = result.substring(jsx.n.end - sourceStart); | |
const [component, props, key] = jsx.n.arguments; // TODO: Handle key? | |
let componentStr = component.type === "Literal" ? component.value : s(s02, component); | |
if (componentStr.endsWith("()")) { | |
componentStr = componentStr.replace("()", "_Call"); // hack to avoid syntax errors | |
} | |
const propsStrs = []; | |
for (const property of props.properties) { // because ObjectExpression | |
let name, value; | |
if (property.type === "SpreadElement") { | |
// for spreads, use no name...very hacky | |
name = null; | |
value = property; | |
} else { | |
console.assert(property.type === "Property", "expected Property, got %o", property.type); | |
name = property.key.name; | |
value = property.value; | |
} | |
if (name === "children") { | |
continue; | |
} | |
let valueStr = s(s02, value); | |
if (property.method) { | |
valueStr = "function" + valueStr; | |
} | |
let propStr; | |
if (!name) { | |
propStr = `{ ${valueStr} }`; | |
} else { | |
propStr = `${name}={ ${valueStr} }`; | |
} | |
propsStrs.push(propStr); | |
} | |
const propsStr = (propsStrs.length ? " " : "") + propsStrs.join(" "); | |
const childrenProp = props.properties.find(p => p.type === "Property" && p.key.name === "children"); | |
let childNodes = []; | |
if (childrenProp) { | |
const allChildrenNode = childrenProp.value; | |
childNodes = allChildrenNode.type === "ArrayExpression" ? allChildrenNode.elements : [allChildrenNode]; | |
} | |
const childStrs = []; | |
for (const cn of childNodes) { | |
// We have previously tracked all the JSX nodes that occur inside this node, | |
// but when rendering this node's children, we don't know which of those actually | |
// occur inside that particular child node. So, filter based on start and end. | |
const jsxChildrenOfChild = jsx.children.filter(childJSX => | |
cn.start <= childJSX.n.start && childJSX.n.end <= cn.end | |
); | |
const rendered = replaceJSXes(s(s02, cn), cn.start, jsxChildrenOfChild); | |
if (isJSX(cn)) { | |
childStrs.push(rendered); | |
} else { | |
childStrs.push(`{ ${rendered} }`); | |
} | |
} | |
let childrenStr = ("\n" + childStrs.join("\n") + "\n"); | |
if (childrenStr.trim() === "") { | |
childrenStr = ""; | |
} | |
const transpiled = `<${componentStr}${propsStr}>${ childrenStr }</${componentStr}>`; | |
result = before + transpiled + after; | |
} | |
return result; | |
} | |
const s03 = replaceJSXes(s02, 0, jsxes); | |
writeFileSync("s03.js", s03); | |
} | |
// s04: autoformat | |
(async () => { | |
const s03 = readFileSync("s03.js", "utf-8"); | |
const s04 = await prettier.format(s03, { parser: "acorn" }); | |
writeFileSync("s04.js", s04); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment