Stories

Detail Return Return

Moonpad:打造你的專屬 MoonBit 網頁代碼編輯器! - Stories Detail

如何在你的網站中使用 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.jsmoonpad/index.css,就可以直接使用 Moonpad。

所以,想要在網頁中使用 Moonpad,我們需要做的是:

  1. 根據不同的網頁框架修改 moonpad/index.js 中的掛載邏輯。
  2. 在需要使用 Moonpad 的頁面中導入 moonpad/index.jsmoonpad/index.css
  3. 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 代碼。它暴露了兩個方法: compilerun:分別用來編譯和運行 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

user avatar openbayescom Avatar axiaoxin_blog Avatar dalidezhuantou_bpc01t Avatar changhao_flag Avatar
Favorites 4 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.