Markdown 解析與淨化
可信與不可信兩條路徑、為什麼在 Workers 上不能用 DOMPurify、標題錨點與目錄,以及讓 AI 輸出安全落地的最小策略。
1. 先分清楚兩種 markdown
| 來源 | 原始 HTML | 用什麼 | |
|---|---|---|---|
| 可信 | 你自己寫的文件、CMS 裡自己人寫的內容 | 允許 | renderTrusted() |
| 不可信 | 使用者留言、模型輸出、外部 API | 禁止 | renderUntrusted() |
模型輸出算不可信。它可能被使用者的輸入誘導,產生 <img src=x onerror=...>。「AI 不會做壞事」不是安全模型。
2. 不可信路徑:先逃脫,再解析
export function renderUntrusted(markdown: string): string {
const escaped = markdown
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
const html = marked.parse(escaped, { async: false }) as string;
return stripDangerousUrls(html);
}
原理:先把可以開啟標籤的字元換掉,再交給 parser。 於是輸出的 HTML 裡只可能有 parser 自己產生的標籤(<p> <ul> <code> …),不可能有輸入帶進來的標籤。
剩下唯一的破口是 URL 屬性,所以再過一次:
const DANGEROUS_SCHEME = /^\s*(javascript|data|vbscript|file):/i;
function stripDangerousUrls(html: string): string {
return html.replace(/\s(href|src)="([^"]*)"/gi, (match, attr, url) =>
DANGEROUS_SCHEME.test(url) ? ` ${attr}="#blocked"` : match
);
}
這個正則之所以可靠,是因為它處理的是已經產生的 HTML,而 marked 產出的屬性一律用雙引號 —— 不是在猜使用者寫了什麼。
為什麼不用 DOMPurify
DOMPurify 需要 DOM。Cloudflare Workers 沒有,為了淨化一個段落而拉進 jsdom 是不成比例的。
上面的策略範圍更窄但完整:一份文件,如果它的標籤全部由 parser 產生,而且 URL 都檢查過 scheme,就沒有辦法執行 script。
如果你的環境有 DOM(純瀏覽器渲染),DOMPurify 仍然是好選擇,尤其當你需要允許部分 HTML(例如允許 <img> 但不允許 <script>)。上面的做法是「完全不允許 HTML」,這對聊天與留言來說剛好夠用。
3. 可信路徑:加上錨點
自家文件需要目錄,所以標題要有 id:
function headingRenderer(headings: Heading[]) {
return {
heading(token: Tokens.Heading) {
const text = token.text;
const id = slugify(text);
headings.push({ depth: token.depth, text, id });
return `<h${token.depth} id="${id}">${marked.parseInline(text)}</h${token.depth}>\n`;
}
};
}
slug 必須支援 Unicode,否則中文標題會全部變成空字串:
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[\s ]+/g, '-') // 半形與全形空白
.replace(/[^\p{Letter}\p{Number}-]/gu, '') // 保留所有語言的字母數字
.replace(/-{2,}/g, '-');
}
\p{Letter} 需要 u 旗標。用 [^a-z0-9] 的話,「怎麼下 Prompt」會變成 prompt,而「怎麼寫 Prompt」也會變成 prompt —— 兩個標題撞 id。
錨點要記得留出 sticky header 的高度:
h2 { scroll-margin-top: calc(var(--nav-height) + 24px); }
4. 在哪裡解析
在伺服器(load 函式),不是在元件裡。
export const load: PageLoad = ({ params }) => {
const doc = getDoc(params.slug);
const { html, headings } = renderTrusted(doc.body);
return { html, headings };
};
好處:首次瀏覽時瀏覽器直接收到完成的 HTML,parser 不需要進客戶端 bundle,也不會有「先看到原始 markdown 再變成排版」的閃爍。
例外是串流:那必須在客戶端逐字解析。
5. 文件從哪裡來
const raw = import.meta.glob('/src/content/docs/*.md', {
query: '?raw',
import: 'default',
eager: true
});
build 時就把檔案內容內嵌進去。三個好處:Workers 上沒有檔案系統也能用、少一個檔案就是 build 失敗而不是 production 500、同一份字串同時餵給網頁和下載包。
極簡 front matter
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
只解析 key: value。為了讀六個我們自己控制的欄位而引入完整 YAML parser 並不划算。前提是欄位由你自己寫;如果 front matter 來自使用者,就該用真正的 parser。
中文的閱讀時間
「每分鐘 200 字」對中文沒有意義,因為中文沒有空白分詞:
function readingMinutes(body: string): number {
const chars = body.replace(/\s/g, '').length;
return Math.max(1, Math.round(chars / 480));
}
6. 樣式
注入的 HTML 是編譯器沒看過的,所以 scoped CSS 對它無效。Svelte 要用 :global(),Vue 要用 :deep(),React 的 CSS Modules 要用 :global:
.prose :global(h2) { … }
.prose :global(pre) { overflow-x: auto; }
.prose :global(table) { display: block; overflow-x: auto; }
pre 和 table 的橫向捲動是必要的:一段長程式碼或一張寬表格,會把整個頁面撐寬。
7. 檢查清單
- 使用者與模型的內容走
renderUntrusted() - URL 有檢查 scheme
- slug 支援 Unicode
- 標題有
scroll-margin-top - 靜態文件在伺服器解析
- 注入的 HTML 有對應的
:global樣式 -
pre與table會在自己的容器裡橫捲 - 串流中的 markdown 有先補完未閉合語法(見 llm-streaming)