如何在你的網站中使用 Moonpad
在MoonBit官網和語言導覽中都有一個組件可以在瀏覽器中直接編寫 MoonBit 代碼並實時編譯運行。它就是我們開發的 Moonpad 組件,目前已經發布到 npm 上,這篇博客將介紹如何在你的網站中使用 Moonpad。
這篇博客中出現的所有代碼已都上傳到 github,你可以在 https://github.com/moonbit-community/moonpad-blog-examples 中看到。
什麼是 Moonpad
MoonBit 插件目前已具備多種為開發者帶來極大便利的功能。除了針對 MoonBit 語法的高亮顯示、自動補全,以及錯誤提示等功能的支持外,還實現了開箱即用的調試器、實時值追蹤、測試以及內置的MoonBit AI助手,能夠非常有效地減少除核心開發以外的工作量,提供一個高效流暢的開發環境。
在本篇教程中介紹的 Moonpad 是一個基於 monaco editor 的在線 MoonBit 編輯器,支持 MoonBit 語法高亮、自動補全、錯誤提示等功能。除此之外還支持在瀏覽器中實時編譯 MoonBit 代碼,可以説是一個在瀏覽器上的簡單版 MoonBit 插件。它在 MoonBit 的官網和語言導覽已經有所使用。如果你想體驗完整版的 MoonBit 開發功能可以安裝我們的 VS Code 插件。
如何使用 Moonpad
準備
新建一個 JS 項目:
mkdir moonpad
cd moonpad
npm init -y
安裝依賴:
npm i @moonbit/moonpad-monaco esbuild monaco-editor-core
每個依賴的作用如下:
@moonbit/moonpad-monaco是一個 monaco editor 的插件,提供 MoonBit 語法高亮、自動補全、錯誤提示、編譯源代碼等功能。esbuild是一個快速的 JavaScript 打包工具,用於打包源碼。monaco-editor-core是 monaco editor 的核心庫。選擇這個庫而不是monaco-editor的原因是,這個庫沒有 monaco editor 自帶的 MoonBit 用不上的各種其他語言的語法高亮和 html, css, js 等語言的語義支持,打包出來的體積更小。
編寫代碼
接下來我們需要編寫使用 moomnpad 和 monaco editor 的代碼以及構建腳本。
以下展示的代碼都是在 moonpad 目錄下編寫的。並且帶有大量註釋,以便你更好的理解。
創建 index.js 文件並輸入以下代碼:
import * as moonbitMode from "@moonbit/moonpad-monaco";
import * as monaco from "monaco-editor-core";
// monaco editor 要求的全局變量,具體可以查看其文檔:https://github.com/microsoft/monaco-editor
self.MonacoEnvironment = {
getWorkerUrl: function () {
return "/moonpad/editor.worker.js";
},
};
// moonbitMode 是一個 monaco-editor 的擴展,也就是我們提到的 Moonpad。
// moonbitMode.init 會初始化 MoonBit 的各種功能,並且會返回一個簡單的 MoonBit 構建系統,用於編譯/運行 MoonBit 代碼。
const moon = moonbitMode.init({
// 一個可以請求到 onig.wasm 的 URL 字符串
// onig.wasm 是 oniguruma 的 wasm 版本。oniguruma 是一個正則表達式引擎,在這裏用於支持 MoonBit 的 textmate 語法高亮。
onigWasmUrl: new URL("./onig.wasm", import.meta.url).toString(),
// 一個運行 MoonBit LSP 服務器的 Worker,用於給 MoonBit 語言提供LSP服務。
lspWorker: new Worker("/moonpad/lsp-server.js"),
// 一個工廠函數,返回一個運行 MoonBit 編譯器的 Worker,用於在瀏覽器中直接編譯 MoonBit 代碼。
mooncWorkerFactory: () => new Worker("/moonpad/moonc-worker.js"),
// 一個配置函數,用於配置哪些codeLens需要被顯示。這裏我們出於簡單的考慮,直接返回false,不顯示任何codeLens。
codeLensFilter() {
return false;
},
});
// 值得一提的是,這裏所有的路徑都是硬編碼的,這意味着後面在編寫構建腳本時,我們需要確保這些路徑都是正確的。
// 掛載 Moonpad
// 創建一個 editor model,並且指定其 languageId 為 "moonbit",在這裏我們可以初始化代碼內容,
// moonpad 只對 languageId 為 "moonbit" 的 model 提供 LSP 服務。
const model = monaco.editor.createModel(
`fn main {
println("hello")
}
`,
"moonbit",
);
// 創建一個 monaco editor,展示我們之前創建的 model,並將其掛載到 `app` div 元素上。
monaco.editor.create(document.getElementById("app"), {
model,
// 這個主題是 moonpad 提供的主題,相比於 monaco 自帶的主題,語法高亮效果更好。
// 此外 moonpad 還提供了一個暗色主題 "dark-plus",你可以嘗試將其替換為 "dark-plus"。
theme: "light-plus",
});
創建 esbuild.js 並輸入以下代碼,這是我們的構建腳本,用於打包 index.js :
const esbuild = require("esbuild");
const fs = require("fs");
// dist 是我們的輸出目錄,這裏把它清空,確保 esbuild 總是從頭開始構建
fs.rmSync("./dist", { recursive: true, force: true });
esbuild.buildSync({
entryPoints: [
"./index.js",
// 打包 monaco editor 用於提供編輯服務的 worker,這也是 `index.js` 中 MonacoEnvironment.getWorkerUrl 的返回值。
// 之前提到所有的路徑都是硬編碼的,所以下面會使用 `entryNames` 確保這個 worker 打包之後的名字是 `editor.worker.js`。
"./node_modules/monaco-editor-core/esm/vs/editor/editor.worker.js",
],
bundle: true,
minify: true,
format: "esm",
// 輸出目錄對應我們在 `index.js` 中硬編碼的路徑。
outdir: "./dist/moonpad",
entryNames: "[name]",
loader: {
".ttf": "file",
".woff2": "file",
},
});
fs.copyFileSync("./index.html", "./dist/index.html");
// 複製 `index.js` 中初始化 moonpad 所需要的各種 worker 文件。
// 由於它們已經被打包過了,所以不需要用 esbuild 再次打包。
fs.copyFileSync(
"./node_modules/@moonbit/moonpad-monaco/dist/lsp-server.js",
"./dist/moonpad/lsp-server.js",
);
fs.copyFileSync(
"./node_modules/@moonbit/moonpad-monaco/dist/moonc-worker.js",
"./dist/moonpad/moonc-worker.js",
);
fs.copyFileSync(
"./node_modules/@moonbit/moonpad-monaco/dist/onig.wasm",
"./dist/moonpad/onig.wasm",
);
最後是 index.html 文件,非常簡單,沒什麼值得注意的。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<link rel="stylesheet" href="/moonpad/index.css" />
</head>
<body>
<div id="app" style="height: 500px"></div>
<script type="module" src="/moonpad/index.js"></script>
</body>
</html>
構建並啓動服務器
運行構建腳本
node esbuild.js
此時 dist 文件夾應包含以下文件:
dist
├── index.html
└── moonpad
├── codicon-37A3DWZT.ttf
├── editor.worker.js
├── index.css
├── index.js
├── lsp-server.js
├── moonc-worker.js
└── onig.wasm
2 directories, 8 files
此處展示的 codicon-37A3DWZT.ttf 文件後面的哈希值不一定與你實際操作下來的相同,但是無傷大雅。
在 dist 文件夾下啓動一個 http 服務器:
python3 -m http.server 8081 -d ./dist
打開 locahost:8081,可以看到 moonpad 已經成功渲染出來了。
如何在你的網站中使用 Moonpad
接下來我們以 jekyll 和 marp 為例,展示如何在你的網站中使用 Moonpad。
原理
在上一節中我們最終得到了一個 moonpad 文件夾,其中包含使用 Moonpad 需要的所有文件。任何網頁只要修改一下 index.js 最後硬編碼的掛載邏輯然後導入 moonpad/index.js 和 moonpad/index.css,就可以直接使用 Moonpad。
所以,想要在網頁中使用 Moonpad,我們需要做的是:
- 根據不同的網頁框架修改
moonpad/index.js中的掛載邏輯。 - 在需要使用 Moonpad 的頁面中導入
moonpad/index.js和moonpad/index.css。 - 將
moonpad文件夾放到網站的靜態資源目錄下。確保它的路徑是/moonpad
在 jekyll 中使用 Moonpad
Jekyll 是一個簡單的、博客感知的靜態網站生成器。它使用 Markdown 或 Textile 以及 Liquid 模板引擎來生成靜態網頁。Jekyll 通過將內容與模板結合,生成可以直接部署到任何 Web 服務器上的靜態文件。它特別適合用於 GitHub Pages,允許用户輕鬆地創建和維護博客或網站。
觀察 jekyll 渲染代碼塊的結構
在 jekyll 中對於這樣的 markdown 代碼塊:
```moonbit
fn main {
println("hello, moonbit")
}
```
會生成如下的 html 結構:
<pre><code class="language-moonbit">fn main {
println("hello, moonbit")
}
</code></pre>
如果我們希望 jekyllrb 中所有的 moonbit 代碼塊都在 Moonpad 中渲染,那麼我們需要將生成的 pre 元素替換為 div 並將 Moonpad 掛載在這個 div 上。
以下是這個邏輯的實現:
// 便利所有的 moonbit 代碼塊
for (const pre of document.querySelectorAll("pre:has(code.language-moonbit)")) {
// 獲得代碼塊的內容
const code = pre.textContent;
// 創建一個 div 元素,這將會是 monaco editor 的掛載點
const div = document.createElement("div");
// 根據代碼內容設置 div 的高度
const height = code.split("\n").length * 20;
div.style.height = `${height}px`;
// 將代碼塊替換為 div
pre.replaceWith(div);
// 使用獲得的代碼內容創建一個 monaco editor model
const model = monaco.editor.createModel(code, "moonbit");
// 創建 monaco editor 並掛載到 div 上,展示上一行創建的 model
monaco.editor.create(div, {
model,
theme: "light-plus",
});
}
只需將上面的代碼替換掉 index.js 中的掛載邏輯即可。
導入 Moonpad
jekyll 支持在 markdown 中直接使用 html,所以我們可以在 markdown 中直接導入 Moonpad。在需要使用 Moonpad 的 markdown 文件中的最後加入以下代碼即可。
<link rel="stylesheet" href="/moonpad/index.css" />
<script type="module" src="/moonpad/index.js"></script>
將 moonpad 文件夾放到 jekyll 的靜態資源目錄下
在執行 jekyll build 之後,jekyll 會將所有的靜態資源放到 _site 目錄下。我們只需要將 moonpad 文件夾複製到 _site 目錄下即可。
複製後 _site 文件夾的目錄結構應為
_site/
├── ... # 其他靜態資源
└── moonpad
├── codicon-37A3DWZT.ttf
├── editor.worker.js
├── index.css
├── index.js
├── lsp-server.js
├── moonc-worker.js
└── onig.wasm
結果
完成以上步驟後,即可在 jekyll 中使用 Moonpad。效果如下:
在 marp 中使用 Moonpad
Marp 是一個 Markdown 轉換工具,可以將 Markdown 文件轉換為幻燈片。它基於 Marpit 框架,支持自定義主題,並且可以通過簡單的 Markdown 語法來創建和設計幻燈片,非常適合用於製作技術演示文稿。
觀察 marp 渲染代碼塊的結構
對於和上一小節 jekyll 中相同的代碼塊,marp 渲染出來的 html 結構大致如下:
<marp-pre>
...
<code class="language-moonbit">fn main { println("hello, moonbit") } </code>
</marp-pre>
顯然,如果我們希望所有的 moonbit 代碼塊都使用 moonpad 渲染,我們需要將 marp-pre 替換為 div 並將 Moonpad 掛載在這個 div 上。
以下是這個邏輯的實現,和 jekyll 的例子大同小異:
for (const pre of document.querySelectorAll(
"marp-pre:has(code.language-moonbit)",
)) {
const code = pre.querySelector("code.language-moonbit").textContent;
const div = document.createElement("div");
const height = code.split("\n").length * 20;
div.style.height = `${height}px`;
pre.replaceWith(div);
const model = monaco.editor.createModel(code, "moonbit");
monaco.editor.create(div, {
model,
theme: "light-plus",
});
}
只需將上面的代碼替換掉 index.js 中的掛載邏輯即可。
導入 Moonpad
marp 同樣支持在 markdown 中使用 html,但需要顯示開啓這個選項。在需要使用 Moonpad 的 markdown 文件中的最後面加入以下代碼:
<link rel="stylesheet" href="/moonpad/index.css" />
<script type="module" src="/moonpad/index.js"></script>
並在構建時開啓 html 選項:
marp --html
將 moonpad 文件夾放到 marp 的靜態資源目錄下
在 marp 中預覽幻燈片一般有兩種方式,一種是使用 marp 自帶的 server 功能,另一種是將 markdown 文件導出為 html,自行配置服務器。
對於 marp 自帶的 server 功能,我們需要將 moonpad 文件夾放到 marp --server 命令指定的文件夾內。
對於將 markdown 導出為 html 的情況,我們需要保證 moonpad 文件夾和導出的 html 文件在同一個目錄下。
結果
完成以上步驟後,即可在 marp 中使用 Moonpad。效果如下:
可惜的是,在 marp 中 monaco editor 的 hover 提示位置不對,目前我們還不知道如何解決這一問題。
如何使用 Moonpad 編譯 MoonBit 代碼
上面提到 moonbitMode.init 會返回一個簡單的構建系統,我們可以使用這個構建系統來編譯並運行 MoonBit 代碼。它暴露了兩個方法: compile 和 run:分別用來編譯和運行 MoonBit 代碼。例如:
// compile 還可以通過參數進行更多的配置,例如編譯帶測試的文件等。這裏我們只編譯一個單文件
const result = await moon.compile({ libInputs: [["a.mbt", model.getValue()]] });
switch (result.kind) {
case "success":
// 如果編譯成功,則會返回編譯器 js 後端編譯出來的 js 代碼
const js = result.js;
// 可以使用 run 方法運行編譯出來的 js,得到標準輸出的流。
// 值得注意的是,這裏流的最小單位不是字符,而是標準輸出每一行的字符串。
const stream = moon.run(js);
// 將流中的內容收集到 buffer 中,並在控制枱輸出。
let buffer = "";
await stream.pipeTo(
new WritableStream({
write(chunk) {
buffer += `${chunk}\n`;
},
}),
);
console.log(buffer);
break;
case "error":
break;
}
打開控制枱,可以看到輸出了 hello。
對於compile函數的更多用法以及把代碼輸出展示在頁面上的方法,可以參考語言導覽中的代碼:moonbit-docs/moonbit-tour/src/editor.ts#L28-L61