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 headingrehypeAutolinkHeadings, { type
Options } 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) => 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 headingrehypeAutolinkHeadings, {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
Options, ];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)許可,閣下可自由地共享(複製、發行) 和演繹(修改、轉換或二次創作) 這一作品,唯須遵守許可協議條款。
評論