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 的錨點連結是這樣做的:importfunction rehypeAutolinkHeadings(options?: Readonly<Options> | null | undefined): (tree: Root) => undefinedAdd 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 headingrehypeAutolinkHeadings, { typeOptions } from "rehype-autolink-headings"; export default [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; }function rehypeAutolinkHeadings(options?: Readonly<Options> | null | undefined): (tree: Root) => undefinedAdd 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 headingrehypeAutolinkHeadings, {content?: Readonly<ElementContent> | readonly ElementContent[] | Build | null | undefinedContent 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: stringTag name (such as `'body'`) of the element.tagName: "span",Element.properties: PropertiesInfo 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: stringPlain-text value.value: "#" }], }, ],behavior?: Behavior | null | undefinedHow to create links (default: `'prepend'`).behavior: "append", } satisfiesOptions, ];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; }
更妙的是,你可以直接匯入帶有動畫和互動的 ECharts 元件,而無需透過 WASM 渲染,這樣效能也更好。如果你願意,你甚至可以檢查輸出的 target,並使用 json() 在 echarm(用於 PDF 輸出)和你的前端元件(用於 HTML 輸出)之間共享程式碼。有無限可能!
立即嘗試
你可以在這裡看到一個實際的演示。雖然這個功能已經被合併到了 master 分支,但一個完全基於 hᴀsᴛ 的方案可能會有效能上的影響,而且還沒有經過充分的測試。
理論上,同樣的方法也可以應用於其他標記語言,只要它們有相應的 hᴀsᴛ 生成器。此外,支援其他 JSX 執行時也是可能的,但我還沒有測試過。
如果你想嚐嚐鮮,可以安裝 npm 上的 beta 版本。歡迎提供反饋!
版權許可
- 本作品 採用 知識共享 署名—相同方式共享 4.0 國際許可協議(CC BY-SA 4.0 International)許可,閣下可自由地共享(複製、發行) 和演繹(修改、轉換或二次創作) 這一作品,唯須遵守許可協議條款。
評論