Typst

如何為 typst 加入 JSX

……或者任何你喜歡的標記語言。

Typst + react-confetti-explosion

本文最初以英文釋出於 我新開的英文部落格。

Astro 是一個 Web 元框架,它非常擅長構建以內容為中心的快速靜態網站,比如本部落格以及我的英文部落格。其最強大的功能之一是 Integrations,藉助它我們可以在同一個專案中使用各種前端框架,如 React、Vue 和 Svelte。雖然 Typst 也能匯出 HTML,但手寫 HTML 從來都不是一件愉快的事。 所以,最好還是把前端的事情交給前端工具來處理——這也是我建立 astro-typst 這個 Astro 外掛的原因之一。

更棒的是,作為一個內容優先的框架,Astro 支援 MDX,這意味著你可以直接在 Markdown 檔案中匯入和嵌入動態元件。那麼,我們如何將這種能力帶到像 Typst 這樣的其他標記語言中呢?

在 Typst 中嵌入元件

先來展示一下最終的 API。欲在 Typst 文件中嵌入一個元件,需要新增一個簡單的輔助函式:

#let jsx = s => html.elem("script", attrs: ("data-jsx": s))

你可以隨心所欲地定製語法,只要確保它返回下面這種格式就行:

// 比如,用程式碼塊的格式
#let jsx2 = cb => html.elem("script", attrs: ("data-jsx": cb.text))

然後,要向頁面中新增一個互動式的計數器元件,你只需這樣寫:

#jsx("import Counter from '../components/Counter.astro'")
#jsx("<Counter client:load />")
 
// 或者:
 
#jsx2[```jsx
<Counter initialCount={10} message='typst' />
```]

這就是你需要知道的全部 API 了。接下來發生的事情才是真正神奇的地方。

渲染管線

我們先來看看 Astro 的 MD(X) 渲染流程 —— 它使用了 Unified 生態系統的處理管線,通過 remark、rehype 和 recma 三個階段將內容轉換為 HTML。

MDX
├ remark-parse
├ remark-mdx
├ remark-mark-and-unravel
├ ...settings.remarkPlugins
├ remark-rehype
├ ...settings.rehypePlugins
├ rehype-remove-raw
├ rehype-recma
├ recma-document
├ recma-jsx-rewrite
├ recma-build-jsx
├ recma-build-jsx-transform
├ recma-jsx
├ recma-stringify
├ ...settings.recmaPlugins
JS

多虧了紙夜 的工作,她為自己的 typst.ts 專案新增了 hᴀsᴛ(rehype 使用的抽象語法樹)輸出。這使得為 Typst 實現同樣的功能變得水到渠成。 我們只需要確保 JSX 的部分存在於 hᴀsᴛ 中。 然後我們可以新增一個 rehype 外掛,將這個 hᴀsᴛ 轉換成和 MDX 產物完全相同的結構。再將這個結果送入 Astro 管線的其餘部分,我們就能有效地替換掉標記語言,同時達到相同的效果。

script 標籤

現在,回到我們之前的 html.elem。當 Typst 編譯器處理你的文件時,#jsx 函式會在中間產物 hᴀsᴛ 中生成一個 <script> 標籤:

<script data-jsx="<Counter client:load />"></script>

這個特殊的標籤起到了一個標記的作用。

但是,這個標籤要如何被處理呢?首先想到的是是偽造一個與 MDX 解析器相似的結構。 於是,我用 Proxy 實現了一個簡單的函式來劫持對一個物件的屬性訪問,看看哪些屬性被實際訪問了,並新增了一個外掛來列印出 AST:

function rehypeStealMdxhast() {
  return function (tree: any, file: any) {
    // 將語法樹儲存在 file.data 中以便後續檢索
    file.data.mdxhast = JSON.parse(JSON.stringify(tree));
  };
}
 
...
 
await compile(mdxContent, {
    outputFormat: 'function-body',
    development: false,
    rehypePlugins: [rehypeStealMdxhast],
});

通過調整外掛的位置,我們可以在恰當的時機獲取到 AST。

下面是一個 AST 的例子:

然後我發現,estree 資料早在 remark 階段就已經被填充了。此路不通,還是得手動解析 JSX 字串。

逐個解析

從這裡開始,我們的自定義處理器 typstx(隨便起的名字,它是我 fork 的 MDX 外掛)接管了工作。它會遍歷 hᴀsᴛ 查詢這些特定的 <script data-jsx="..."> 標籤。每找到一個標籤,typstx 就會初始化一個新的、獨立的 MDX 解析器來處理 data-jsx 屬性內的 JSX 字串。

const createJsxProcessor = () => {
  const pipelineJsx = unified()
    .use(remarkParse)
    .use(remarkMdx)
    .use(remarkMarkAndUnravel)
    .use(remarkRehype, {
      allowDangerousHtml: true,
      passThrough: [...nodeTypes]
    })
    .use(hastHastify)
 
  return pipelineJsx
}

這種方法簡單直接,儘管這意味著頁面上的每個動態元件都會觸發一次獨立的解析過程。該解析器將 JSX 字串轉換為一個代表該元件的 hᴀsᴛ 片段。

上面的管線被包裹在另一個轉換器 rehypeTransformJsxInTypst 中:

export const rehypeTransformJsxInTypst = () => {
  // 找到所有 html.elem("script", attrs: ("data-jsx": "import Button from 'Button.jsx;'"))
  // 並將它們轉換為 html.elem("script", attrs: ("data-jsx": "import Button from 'Button.jsx;'"))
  function compileJsx(node) {
    if (node.type === 'element' && node.tagName === 'script') {
      let hast = jsx2hast(node.properties['data-jsx'])
      hast = hast.children[0]
      ...
      return hast
    }
    if (node.children) {
      node.children = node.children.map(compileJsx)
    }
    return node
  }
 
  return function (tree, file) {
    return compileJsx(tree)
  }
}

通過遞迴呼叫 compileJsx,我們可以確保所有的 <script> 標籤都被處理。這個新的元件片段隨後會替換掉主 hᴀsᴛ 樹中原來的 <script> 標籤。

沒有魔法,只有字串

在所有標籤被替換後,typstx 會將 AST 轉換成一個可供執行的 JavaScript 模組字串。 Astro 並不讀取依賴列表,而是直接接收這個生成的完整指令碼,並用其伺服器端 JavaScript 執行時來執行。 這個執行時自帶的模組載入器會解析這些 import 語句,就像處理任何標準的 .js 或 .ts 檔案一樣。 你的元件就是這樣被定位和打包的。

此外,為了確保元件能在 Astro 生態系統中正確渲染,typstx 接受一個 jsxImportSource 選項。 我們將其設定為 'astro',這會告訴編譯器生成呼叫 Astro 特定的渲染函式(例如,來自 'astro/jsx-runtime')的程式碼,而不是其他框架的。

為了進一步提升效能,這種重複的解析邏輯應該被消除,比如可以使用 rehype 外掛將不同的 import 語句直接轉換為一個已經包含 estree 資料的 AST 物件。但我認為目前的實現更加靈活,也已經足夠好用了。

if (node.type === 'mdxjsEsm' ||
    node.type === 'mdxTextExpression' ||
    node.type === 'mdxFlowExpression' ||
    node.type === 'mdxJsxAttribute' ||
    node.type === 'mdxJsxAttributeValueExpression' ||
    node.type === 'mdxJsxExpressionAttribute') { ... }

unist 的錯誤假設

另一個實現上的細節是,複用 unist 的 pipeline 並不是那麼的容易。一個常規的 MDX 流程是從 Markdown 檔案到 HTML / JSX,輸入輸出都是檔案或字串(即 VFile)。但在我們的例子中,輸入是由 typst.ts 在 Rust 中通過 N-API 建立的 JavaScript 物件 hᴀsᴛ 樹。此外,在建立 MDX parser 時,我們需要的輸出是一個 estree 物件,它不是字串也不是 VFile。

從 VFile 到 AST,或者從 AST 到字串,這兩個過程分別是 Parser 和 Compiler 的工作。 你可能以前用過 remark-parse 和 rehype-stringify 這兩個。我之前也在週報裡放過這張圖:

| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|

          +--------+                     +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
          +--------+          |          +----------+
                            X
                            |
                     +--------------+
                     | Transformers |
                     +--------------+

所以手動偽造了一個 Parser 和一個 Compiler 來處理 hᴀsᴛ 樹:

let __hast = null
function tryParse() {
  // @ts-ignore
  this.parser = parser
  function parser(_doc, file) {
    if (body) {
      __hast = __hast.children.filter((x) => x.tagName === 'body')
      __hast = __hast.at(0)
    }
    return __hast
  }
}
沒錯,因為程式碼不是非同步的,我們每次呼叫 tryParse 時都會修改 __hast。

以及一個假的 Compiler 來讓 unified 以為它已經把 estree 物件轉換成了字串:

export default function hastHastify() {
  /** @type {Processor<undefined, undefined, undefined, Root, string>} */
  const self = this
 
  self.compiler = compiler
 
  function compiler(tree) {
    return tree
  }
}

前端生態

通過這樣做,我們就能享受到前端生態系統帶來的強大能力:

  • 雖然有些前端框架本身不使用 JSX 語法,但 Astro 能很方便地把它們的輸出轉換為 JSX。

  • 我們現在可以(重新)使用 rehype 外掛而不是到處寫 html.elem。這是一種非侵入式地增強 HTML 輸出的方式。例如,為標題新增類似 GitHub 的錨點連結是這樣做的:

    import function rehypeAutolinkHeadings(options?: Readonly<Options> | null | undefined): (tree: Root) => undefined
    Add links from headings back to themselves. ###### Notes This plugin only applies to headings with `id`s. Use `rehype-slug` to generate `id`s for headings that don’t have them. Several behaviors are supported: * `'prepend'` (default) — inject link before the heading text * `'append'` — inject link after the heading text * `'wrap'` — wrap the whole heading text with the link * `'before'` — insert link before the heading * `'after'` — insert link after the heading
    @paramoptions Configuration (optional).@returns Transform.
    rehypeAutolinkHeadings
    , { type
    type Options = {
        behavior?: Behavior | null | undefined;
        content?: Readonly<ElementContent> | ReadonlyArray<ElementContent> | Build | null | undefined;
        group?: Readonly<ElementContent> | ReadonlyArray<ElementContent> | Build | null | undefined;
        headingProperties?: Readonly<Properties> | BuildProperties | null | undefined;
        properties?: Readonly<Properties> | BuildProperties | null | undefined;
        test?: Test | null | undefined;
    }
    Options
    } from "rehype-autolink-headings";
    export default [ function rehypeAutolinkHeadings(options?: Readonly<Options> | null | undefined): (tree: Root) => undefined
    Add links from headings back to themselves. ###### Notes This plugin only applies to headings with `id`s. Use `rehype-slug` to generate `id`s for headings that don’t have them. Several behaviors are supported: * `'prepend'` (default) — inject link before the heading text * `'append'` — inject link after the heading text * `'wrap'` — wrap the whole heading text with the link * `'before'` — insert link before the heading * `'after'` — insert link after the heading
    @paramoptions Configuration (optional).@returns Transform.
    rehypeAutolinkHeadings
    ,
    { content?: Readonly<ElementContent> | readonly ElementContent[] | Build | null | undefined
    Content to insert in the link (default: if `'wrap'` then `undefined`, otherwise `<span class="icon icon-link"></span>`); if `behavior` is `'wrap'` and `Build` is passed, its result replaces the existing content, otherwise the content is added after existing content.
    content
    : [
    { Element.type: "element"
    Node type of elements.
    type
    : "element",
    Element.tagName: string
    Tag name (such as `'body'`) of the element.
    tagName
    : "span",
    Element.properties: Properties
    Info associated with the element.
    properties
    : { className: string[]className: ["anchor"] },
    Element.children: ElementContent[]
    Children of element.
    children
    : [{ Text.type: "text"
    Node type of HTML character data (plain text) in hast.
    type
    : "text", Literal.value: string
    Plain-text value.
    value
    : "#" }],
    }, ], behavior?: Behavior | null | undefined
    How to create links (default: `'prepend'`).
    behavior
    : "append",
    } satisfies
    type Options = {
        behavior?: Behavior | null | undefined;
        content?: Readonly<ElementContent> | ReadonlyArray<ElementContent> | Build | null | undefined;
        group?: Readonly<ElementContent> | ReadonlyArray<ElementContent> | Build | null | undefined;
        headingProperties?: Readonly<Properties> | BuildProperties | null | undefined;
        properties?: Readonly<Properties> | BuildProperties | null | undefined;
        test?: Test | null | undefined;
    }
    Options
    ,
    ];

更妙的是,你可以直接匯入帶有動畫和互動的 ECharts 元件,而無需通過 WASM 渲染,這樣效能也更好。如果你願意,你甚至可以檢查輸出的 target,並使用 json() 在 echarm(用於 PDF 輸出)和你的前端元件(用於 HTML 輸出)之間共享程式碼。有無限可能!

立即嘗試

你可以在這裡看到演示。雖然這個功能已經被合併到了 master 分支,但一個完全基於 hᴀsᴛ 的方案可能會有效能上的影響,而且還沒有經過充分的測試。

理論上,同樣的方法也可以應用於其他標記語言,只要它們有相應的 hᴀsᴛ 生成器。此外,支援其他 JSX 執行時也是可能的,但我還沒有測試過。

如果你想嚐嚐鮮,可以安裝 npm 上的 beta 版本。歡迎提供反饋!

更新:可安裝 0.12.0 及更高版本以使用此功能。

版權許可

  1. 本作品採用知識共享 署名—相同方式共享 4.0 國際許可協議(CC BY-SA 4.0 International)許可,閣下可自由地共享(複製、發行)和演繹(修改、轉換或二次創作)這一作品,唯須遵守許可協議條款。

評論

評論將在稽核後顯示,閣下可以在本部落格的 GitHub 倉庫的 拉取請求列表 中檢視。提交成功後會自動跳轉。

本站不支持 Dark Reader 的暗色模式,请对本站关闭后再访问,亮色模式的对比度、亮度等选项不受影响。部分页面右上角提供暗色模式切换按钮,如果你没看到,说明你的浏览器尚不支持此特性。本提示不依赖于 JavaScript,你可自行查找其他用户在本站发表的关于如何关闭此提示的评论。