JavaScriptの中に「俺々DSL」を安全に埋め込むトランスパイラを作った話(N6LTranspiler-WEBLLVM)
こんにちは!今回は、JavaScriptの中に独自のカスタム構文(DSL)を変換範囲を限定して埋め込める、ブラウザ上でリアルタイムにトランスパイル・実行できるオープンソースのツール N6LTranspiler-WEBLLVM を作ったので、その仕組みとこだわりをご紹介します。
JavaScript の中に安全に埋め込める “俺々DSL” を作れるトランスパイラです。
専用ブロック内の記法だけを置換し、リアルタイムに JS に変換して実行できます。
ここでいうトランスパイルは、DSLを機械語などへコンパイルするのではなく、DSLをJavaScriptへ変換してからJavaScriptとして実行するという意味です。
GPL-v3.0で公開しています。
ソースコード・設定ファイル・デモを含めてGitHubで公開しています。
👉 デモページ:N6LTranspiler-WEBLLVM Demo
👉 リポジトリ:GitHub (README Japanese)
🚀 なぜ作ったのか?
「JavaScriptの中に、別の言語や独自の数学的記法を安全に混ぜ込みたい」と思ったことはないでしょうか。
中身だけを見ると「要するに置換テーブルを作っただけ」に見えます。
しかし実際に問題になるのは、何を置換するか以上に、どこをDSLとして解釈するかです。
そこで、JavaScript全体を無差別に置換するのではなく、専用ブロックを設けて変換範囲を限定しています。
このツールの目的は次の通りです:
- JavaScript の中に「別言語」を安全に埋め込む仕組みを作ること
- ユーザーが自由に構文・記法を定義できる柔軟なトランスパイル環境を提供すること
- JavaScript だけでは表現しにくい数学的・構造的な記法を導入すること
- ブロック構造により変換範囲を限定し、誤変換を防ぐこと
🧩 特徴と仕組み
1. ブロック構造による安全なスコープ限定
通常のJSコードとN6LScriptが混ざってカオスにならないよう、専用のブロック構文で囲まれた範囲だけを変換対象にします。
- JS 全体を置換すると 誤爆 が起きる
- DSL の記号が JS と衝突すると 再帰的誤変換 が起きる
- ブロック構造は “変換の境界” を明示するための安全装置
\N---[
// この中だけが N6LScript として扱われる
\N---<
// この中は 数式ブロックとして変換前に空白やタブを削除するブロックとして扱われる
>---\N
]---\N
// 外側は通常の JavaScript としてそのままスルーされる
2. JSONによる柔軟な置換テーブル
設定ファイル(JSON)で、「JS側の記法」と「独自構文側の記法」のペアを定義できます。
{
"langName": "N6LScript",
"langBlockSyntax": {
"start": "\\N---[",
"end": "]---\\N",
"esstart": "\\N---<",
"esend": ">---\\N"
},
"replace": [
[ ".add(", "\\+" ],
[ ".mul(", "\\*" ],
[ "console.log(", "\\cout(" ],
[ "FourArithmeticOperations", "\\Num" ]
]
}
※置換テーブルは [JS側, 独自構文側] の順で記述し、内部で独自構文側をJS側に置換していく仕組みです。
例えば
[ ".add(", "\\\+" ]
は「DSL側では \+ と書くものを、JavaScript側では .add( として扱う」という意味です。
3. おふざけも本気でやると形になる(変換例)
例えば、次のような「猫語風」のめちゃくちゃな置換ルールも設定可能です。
-
new Num(1)$\leftarrow$one -
new Num(2)$\leftarrow$two -
).val+AAA$\leftarrow$cats. -
$\leftarrow$cat -
' cats.'$\leftarrow$AAA -
.add($\leftarrow$add
これによって、以下のコードが:
console.log(one cat add two cats.);
正しく次のように変換されます。
console.log(new Num(1).add(new Num(2)).val+` cats.`);
console.log
3 cats.
(※実用性はともかく、こういう「意味のないことにマジになる」アプローチの実験場としても機能します)
もちろん猫語を書くためにトランスパイラを作ったわけではありません(笑)。
重要なのは、構文そのものに意味があるのではなく、変換規則によって意味を与えられるという点です。
📘 使用例:四則演算クラスのDSL化
実際にトランスパイラを通したコード例です。
入力コンフィグ(N6LScript)
{
"langName": "N6LScript",
"langBlockSyntax": {
"start": "\\N---[",
"end": "]---\\N",
"esstart": "\\N---<",
"esend": ">---\\N"
},
"replace": [
[ "", "\\L(" ],
[ ")", "\\M)" ],
[ ").add(", "\\M+" ],
[ ").sub(", "\\M-" ],
[ ").mul(", "\\M*" ],
[ ").div(", "\\M/" ],
[ ".add(", "\\N+" ],
[ ".sub(", "\\N-" ],
[ ".mul(", "\\N*" ],
[ ".div(", "\\N/" ],
[ "console.log(", "\\Ncout(" ],
[ "FourArithmeticOperations", "\\Num" ]
]
}
入力コード(N6LScript)
//JSBlock1
console.log(`Inside JSBlock1`);
\N---[
//MyLangBlock1
console.log(`Inside MyLangBlock1`);
class \Num{
constructor(num) {
this.val = num;
}
add(rh) { return new \Num(this.val + rh.val); }
sub(rh) { return new \Num(this.val - rh.val); }
mul(rh) { return new \Num(this.val * rh.val); }
div(rh) { return new \Num(this.val / rh.val); }
}
let a = new \Num(5);
let b = new \Num(4);
let c = new \Num(2);
\N---<
\Ncout( a \N+ b \M* c \M/ c \M- b \M) .val );
//If you insist on mathematical rigor, you would write it as follows:
//\Ncout( \L( a \N+ b ) \N* c \M/ c \M- b \M) .val );
>---\N
]---\N
//JSBlock2
console.log(`Inside JSBlock2`);
出力コード(JavaScript)
//JSBlock1
console.log(`Inside JSBlock1`);
//[N6LScript BlockStart transpiled]
//MyLangBlock1
console.log(`Inside MyLangBlock1`);
class FourArithmeticOperations{
constructor(num) {
this.val = num;
}
add(rh) { return new FourArithmeticOperations(this.val + rh.val); }
sub(rh) { return new FourArithmeticOperations(this.val - rh.val); }
mul(rh) { return new FourArithmeticOperations(this.val * rh.val); }
div(rh) { return new FourArithmeticOperations(this.val / rh.val); }
}
let a = new FourArithmeticOperations(5);
let b = new FourArithmeticOperations(4);
let c = new FourArithmeticOperations(2);
console.log(a.add(b).mul(c).div(c).sub(b).val);
//Ifyouinsistonmathematicalrigor,youwouldwriteitasfollows:
//console.log(a.add(b).mul(c).div(c).sub(b).val);
//[N6LScript BlockEnd]
//JSBlock2
console.log(`Inside JSBlock2`);
🛠 内部のシンプルな実装構造
独自構文ソース
│
▼
┌─────────────┐
│ BlockParser │
└──────┬──────┘
│
▼
JSブロック / DSLブロック
│
▼
┌─────────────┐
│ReplaceEngine│ ← JSON設定
└──────┬──────┘
│
▼
JavaScript
│
▼
┌─────────────┐
│ Executor │
└─────────────┘
│
▼
実行
コアとなる仕組みは非常にシンプルで、以下のコンポーネントで構成されています。
- N6LTranspilerBlock: ブロックの種類(JS / N6LScript)と内容を保持するクラス。
- N6LTranspiler: N6LScriptあるいは独自記法からJSへ変換するためのクラス。
- BlockParser: ソースコードをブロック単位に綺麗に分解する。
- ReplaceEngine: 設定ファイルに基づき、独自記法をJS記法へ置き換える。
- Executor: トランスパイル後のJSを安全にブラウザ上で実行する。
GPL-v3.0で公開しています。
ソースコード・設定ファイル・デモを含めてGitHubで公開しています。
👉 DemoPage
結局のところ、やっていることは「置換」です。
ただし、実際に作ってみると「何を置換するか」よりも「どこを置換するか」「どの順番で置換するか」「どの構文をどの構文として解釈するか」のほうが問題になります。
そこを真面目に考えていった結果、BlockParserやReplaceEngineを持つトランスパイラになりました。
……そして、猫語までJavaScriptに変換できるようになりました(笑)。
最後までお読みいただきありがとうございました!感想や「こんな面白い置換ルールが思いついた」などがあれば、ぜひコメントやフィードバックをいただけると嬉しいです。
作者
GitHub: https://github.com/NAS6mixfoolv
X(旧Twitter): https://x.com/NAS6_oxo
作者HP: https://nas6.net
気に入っていただけたら GitHub に ⭐ をいただけると嬉しいです!