跳到主要內容

Markdown 解析與淨化

可信與不可信兩條路徑、為什麼在 Workers 上不能用 DOMPurify、標題錨點與目錄,以及讓 AI 輸出安全落地的最小策略。

約 7 分鐘 · markdown-parser.md

1. 先分清楚兩種 markdown

來源 原始 HTML 用什麼
可信 你自己寫的文件、CMS 裡自己人寫的內容 允許 renderTrusted()
不可信 使用者留言、模型輸出、外部 API 禁止 renderUntrusted()

模型輸出算不可信。它可能被使用者的輸入誘導,產生 <img src=x onerror=...>。「AI 不會做壞事」不是安全模型。

2. 不可信路徑:先逃脫,再解析

export function renderUntrusted(markdown: string): string {
  const escaped = markdown
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');

  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; }

pretable 的橫向捲動是必要的:一段長程式碼或一張寬表格,會把整個頁面撐寬。

7. 檢查清單

  • 使用者與模型的內容走 renderUntrusted()
  • URL 有檢查 scheme
  • slug 支援 Unicode
  • 標題有 scroll-margin-top
  • 靜態文件在伺服器解析
  • 注入的 HTML 有對應的 :global 樣式
  • pretable 會在自己的容器裡橫捲
  • 串流中的 markdown 有先補完未閉合語法(見 llm-streaming

顯示設定

這裡改的每一項,會即時套用到站上所有預覽。

風格

密度

圓角

動態

系統層級的「減少動態效果」永遠優先於這裡的設定。

語言