acorn-jsxを使えばオリジナルJSXトランスパイラーを作れると思ったのでやってみます。
これはbun向けに作られていますが、bunをnpmに入れ替えたりtsxを使えばnpmなどでもできるはずです。
環境
WSL version: 2.4.13.0
Kernel version: 5.15.167.4-1
WSLg version: 1.0.65
MSRDC version: 1.2.5716
Direct3D version: 1.611.1-81528511
DXCore version: 10.0.26100.1-240331-1435.ge-release
Windows version: 10.0.19045.5796
Bun 1.2.10
準備
$ bun init
✓ Select a project template: Blank
+ .gitignore
+ index.ts
+ tsconfig.json (for editor autocomplete)
+ README.md
To get started, run:
bun run index.ts
bun install v1.2.10 (db2e7d7f)
+ typescript@5.8.3
+ @types/bun@1.2.10
5 packages installed [1144.00ms]
$ bun i acorn acorn-jsx escodegen
bun add v1.2.10 (db2e7d7f)
installed acorn@8.14.1 with binaries:
- acorn
installed acorn-jsx@5.3.2
installed escodegen@2.1.0 with binaries:
- esgenerate
- escodegen
3 packages installed [17.00ms]
$ bun i -D @types/escodegen
bun add v1.2.10 (db2e7d7f)
installed @types/escodegen@0.0.10
1 package installed [259.00ms]
書いてみよう!
1. AST(抽象構文木)の出力
import { Parser } from "acorn";
import jsx from "acorn-jsx";
const jsxParser = Parser.extend(jsx());
const ast = jsxParser.parse(`<Bar/>`, {ecmaVersion: "latest"});
console.log(JSON.stringify(ast))
これの結果をJSON Beautifyしたものはこちら
{
"type": "Program",
"start": 0,
"end": 6,
"body": [
{
"type": "ExpressionStatement",
"start": 0,
"end": 6,
"expression": {
"type": "JSXElement",
"start": 0,
"end": 6,
"openingElement": {
"type": "JSXOpeningElement",
"start": 0,
"end": 6,
"attributes": [],
"name": {
"type": "JSXIdentifier",
"start": 1,
"end": 4,
"name": "Bar"
},
"selfClosing": true
},
"closingElement": null,
"children": []
}
}
],
"sourceType": "script"
}
とこんな感じにJSXElement
という新しいタイプが出来てます。
これをうまく入れ替えればいいのです。
2. とりあえすJSXElement
を通常のトークンに変換
で、ここがJSXトランスパイルをするわけですが、どう変換するかです。
とりあえず自分はサイズを追求する人なので
<AAA name="tanaka">BBB{"C"}</AAA>
はAAA.name("tanaka")("BBB","C")
というようにしたいです。
誰も分からないと思いますがTNTSuperMan/Rjs /src/seg.tsみたいにしようと思います。
トークンをどう書くかわからない人は、型をたよりにしたり実際にacorn
で変換して調べてください。
その型についてなんですが、typescript
にJsxElement
あるやんと思ってたらacorn
と形式が違くて書き直すはめになりました。
import type { Expression, Node } from "acorn";
export interface JSXText extends Node{
type: "JSXText",
value: string,
raw: string
}
export interface JSXExpressionContainer extends Node{
type: "JSXExpressionContainer"
expression: Expression
}
export type JSXChild =
JSXText |
JSXElement |
JSXExpressionContainer |
JSXFragment;
export interface JSXIdentifier extends Node{
type: "JSXIdentifier"
name: string
}
export interface JSXAttribute extends Node{
type: "JSXAttribute"
name: JSXIdentifier
value: Expression
}
export interface JSXOpeningElement extends Node{
type: "JSXOpeningElement"
name: JSXIdentifier
attributes: JSXAttribute[]
selfClosing: boolean
}
export interface JSXClosingElement extends Node{
type: "JSXClosingElement"
name: JSXIdentifier
}
export interface JSXElement extends Node{
type: "JSXElement"
openingElement: JSXOpeningElement
closingElement: JSXClosingElement | null
children: JSXChild[]
}
export interface JSXOpeningFragment extends Node{
type: "JSXOpeningFragment"
attributes: JSXAttribute[]
selfClosing: boolean
}
export interface JSXClosingFragment extends Node{
type: "JSXClosingFragment"
}
export interface JSXFragment extends Node{
type: "JSXFragment"
openingFragment: JSXOpeningFragment
closingFragment: JSXClosingFragment
children: JSXChild[]
}
そしてトランスパイルするコードです。
import type { CallExpression, Expression, Node, SpreadElement } from "acorn";
import type { JSXChild, JSXElement } from "./jsxType";
function JSXChild2Token(token: JSXChild): (Expression|SpreadElement)[]{
switch(token.type){
case "JSXElement": return [JSXEl2Token(token)];
case "JSXExpressionContainer": return [token.expression];
case "JSXFragment": return token.children.map(e=>JSXChild2Token(e)).flat();
case "JSXText": return [{
...token,
type: "Literal"
}]
}
}
export function JSXEl2Token(token: JSXElement): Expression{
console.log(token.children.map(e=>JSXChild2Token(e)))
const jsxT: CallExpression = {
type: "CallExpression",
callee: { ...token.openingElement.name, type: "Identifier" },
arguments: token.children.map(e=>JSXChild2Token(e)).flat(),
optional: false,
start: token.start,
end: token.end
};
token.openingElement.attributes.forEach(e=>{
jsxT.callee = {
type: "CallExpression",
arguments: [e.value] as any as Expression[],
optional: false,
start: e.value.start,
end: e.value.end,
callee: {
type: "MemberExpression",
object: jsxT.callee,
property: { ...e.name, type: "Identifier" },
computed: false,
optional: false,
start: e.name.start,
end: e.name.end
}
}
})
return jsxT;
}
がんばってかきました。つかれますた。(放心状態)
3. 最終形態 トランスパイル後のコードを出力するモジュール
import { Parser } from "acorn";
import jsx from "acorn-jsx";
import { JSXEl2Token } from "./jsx2token";
import { generate } from "escodegen";
const jsxParser = Parser.extend(jsx());
export const parseJSX = (code: string) => {
const ast = jsxParser.parse(code, {ecmaVersion: "latest", sourceType: "module"});
const json = JSON.stringify(ast, (_, v)=>
v && typeof v == "object" && v.type == "JSXElement" ?
JSXEl2Token(v) : v
)
const transpiledAST = JSON.parse(json);
const result = generate(transpiledAST);
return result;
}
JSXのトークン生成を分離したおかげでスッキリです。
構文木をすべて検索するのは大変なので、JSON.stringify
の第二引数でオブジェクト内のすべてを置換できるのですごく便利でした。
感想
JSXの出力形式を構文木の形式として書くのが大変でした
しかし構文木として書くことで様々な形式のコードに柔軟に対応できるでしょう。
まあ自分で0からJSXを認識するコードを書くよりはましですね。
結論
-
acorn
+acorn-jsx
でJSXをAST化できる - ASTを上手く操作して
escodegen
にASTを渡せばオリジナルJSXトランスパイラーを(比較的)簡単に作れる
作ったもの
今回作ったものに色々したものがこちらです。ぜひ参考にしてください。