返回部落格

我們是如何做「結構化自訂範本」的:在不開放任意 CSS 的前提下讓使用者改樣式

MD-TO 團隊

很多使用者反映過同一個需求:「GitHub 範本挺好看的,但能不能把標題色換成我們公司的品牌色?行高再寬一點?」

聽起來很小的功能,但真要做,背後有一個繞不開的取捨:要不要開放任意 CSS?

這篇文章記錄我們最終選擇的方案——基於現有系統範本做結構化覆寫——以及為什麼這樣做更適合 md-to.com 這種「一份內容多格式匯出」的場景。


兩種常見做法的兩個極端

市面上 Markdown 工具處理「自訂樣式」基本是兩種思路:

第一種:只給固定範本。 簡單、穩定,但不夠用。使用者挑了一個最接近的範本之後,往往只差「標題想換個顏色」這一步。

第二種:開放任意 CSS。 自由度拉滿,預覽區想怎麼改就怎麼改。問題是:瀏覽器裡好看不代表匯出後好看。

我們做的是 Markdown 轉 PDF / Word / HTML / 圖片的工具,匯出鏈路裡有幾條是不消費 CSS 的:

  • Word(.docx)走的是 OOXML 樣式,根本沒有「任意 CSS」這個概念。
  • PDF 列印有自己的分頁、頁邊距規則,純靠瀏覽器 CSS 調出來的版式很容易在列印時翻車。
  • 圖片匯出走 canvas 截圖,對某些 CSS 特性(例如部分 filterbackdrop-filter)支援有限。

如果讓使用者寫任意 CSS,預覽區一切正常,匯出之後樣式各種走樣——這種「看到的不是拿到的」是非常糟糕的使用者體驗。

所以我們走第三條路。


我們的方案:基準範本 + 結構化覆寫

核心思路一句話:使用者先選一個系統範本,再在它上面改一組結構化的欄位

存的不是完整範本,而是「基準範本 ID + 覆寫項」:

{
  version: 1,
  baseTemplateId: 'classic',
  overrides: {
    colors: {
      heading: '#0F766E'
    },
    fonts: {
      body: 'Noto Sans SC'
    },
    fontSizes: {
      body: 13
    },
    spacing: {
      lineHeight: 1.7
    }
  }
}

為什麼不直接存完整範本?三個原因:

  1. 基準升級時使用者能跟著受益。 例如我們後續最佳化了 Classic 範本的程式碼區塊邊距,使用者的自訂範本會自動用上新值,因為 overrides 裡沒覆寫這一項。
  2. payload 小,前後端校驗都簡單。 後端只需要校驗白名單裡的幾個欄位,而不是一份完整的範本物件。
  3. 「基於哪個範本做的修改」這個語意被顯式保留下來。 UI 上可以告訴使用者「你正在 Classic 的基礎上做了 3 處修改」。

下面是目前的編輯器自訂樣式介面,覆蓋了顏色、字型、字級、間距四組高頻欄位:

編輯器自訂樣式


執行鏈路:從一次點擊到一份匯出

整個系統串起來看是三條鏈路。

鏈路一:即時預覽

使用者在編輯器裡改一個顏色,預覽區要立刻變。流程是:

  1. 編輯器把新的覆寫值寫入 localStorage.customDocumentTemplate
  2. 編輯器派發 template-change 事件。
  3. MarkdownPreview 監聽到事件,呼叫 getActiveTemplate()
  4. getActiveTemplate() 讀出 documentTemplate(目前基準)和 customDocumentTemplate(覆寫項),如果兩者匹配就呼叫 createCustomTemplate(baseTemplate, overrides) 合併出一份完整範本。
  5. generatePreviewCSS(template) 重新產生預覽樣式。

合併的邏輯很直接,就是欄位層面的淺合併:

return {
  ...baseTemplate,
  id: `custom:${baseTemplate.id}`,
  colors: {
    ...baseTemplate.colors,
    ...(overrides.colors ?? {}),
  },
  fonts: {
    ...baseTemplate.fonts,
    ...(overrides.fonts ?? {}),
  },
  fontSizes: {
    ...baseTemplate.fontSizes,
    ...(overrides.fontSizes ?? {}),
  },
  spacing: {
    ...baseTemplate.spacing,
    ...(overrides.spacing ?? {}),
  },
};

鏈路二:儲存為私有範本

使用者點「儲存範本」時,工具 adapter 會把目前狀態打包:

{
  version: 1,
  toolSlug,
  documentTemplateId,
  customDocumentTemplate
}

這份 payload 寫到 saved_templates.settings_json。注意 documentTemplateIdcustomDocumentTemplate 是分開存的——一份私有範本的語意是「基於 X 範本的 Y 個修改」,而不是一份脫離來源的樣式快照。

鏈路三:套用一份私有範本

使用者從 PrivateTemplateSelector 點回來一份範本,或是透過 ?tpl=<id> 參數載入:

  1. adapter 呼叫 applySettings(settingsJson)
  2. 如果 payload 裡有 customDocumentTemplate,寫回 localStorage;沒有則清掉。
  3. 套用 documentTemplateId
  4. 派發 template-change,預覽 / 匯出鏈路都拿到合併後的完整範本。

一個容易忽略但很關鍵的細節:選系統範本時清掉自訂覆寫

我們在 setActiveTemplate(templateId) 裡加了一行:

localStorage.removeItem('customDocumentTemplate');

為什麼?因為如果不清,會出現這種詭異情況:

  • 使用者在 Classic 範本上改了標題色為綠色,存到了 localStorage。
  • 使用者切換到 Dracula 範本。
  • 切回 Classic,標題又是綠色——但使用者根本不記得自己改過。

我們把「選擇系統範本」定義成一個明確的重置動作:你選了一個乾淨的範本,那就是乾淨的。要在它上面改,重新點「自訂」。這個語意比「盡可能保留使用者上次的修改」要清楚得多。

TemplatePickerModal 裡點「套用」時也會執行同樣的清理。


後端校驗:白名單是底線

後端 validateTemplateInput()settingsJson.customDocumentTemplate 做了嚴格校驗:

  • customDocumentTemplate 可選;存在時必須是物件。
  • version 必須為 1(留遷移空間)。
  • baseTemplateId 必須是非空字串。
  • overrides 必須是物件。
  • 未列入白名單的欄位會被拒絕。

白名單覆蓋了現在 UI 裡能改的所有欄位,比 UI 略大一點,給以後補欄位留餘地:

類別允許欄位
colorsheading, text, link, codeBackground, codeBorder, codeText, quoteText, quoteBorder, quoteBackground, tableHeaderBg, tableHeaderText, tableBorder, tableRowOdd, tableRowEven
fontsheading, body, code
fontSizesh1, h2, h3, h4, h5, h6, body, code
spacingheadingBefore, headingAfter, paragraphBefore, paragraphAfter, lineHeight

白名單是這套設計的安全底線:因為我們不接受任意鍵,所以以後給某個欄位加渲染邏輯或者改名字,就算線上有使用者存了舊欄位,它也不會混進執行時範本裡污染輸出。


收益:匯出鏈路完全不用關心「自訂範本」

合併之後是一份完整的 DocumentTemplate,所以下游所有匯出函式都不需要改:

  • 預覽:generatePreviewCSS(template)
  • PDF / 列印:generatePdfStyles() / generatePrintStyles()
  • HTML:generateExportHTML()
  • 富文字複製:generateRichTextHTML()
  • Word:templateToDocxStyles(template)

這恰好就是我們一開始拒絕「任意 CSS」的最大回報:只要所有輸出繼續消費 DocumentTemplate 這個統一中間表示,跨格式一致性幾乎是免費的。使用者改的標題顏色,在瀏覽器預覽、PDF、Word、長圖裡看到的都是同一個顏色——不是「看起來差不多」,是字面意義上的同一個 hex 值,因為最終都是從同一份合併後的範本裡讀出來的。


目前的限制

第一版我們是有意做窄的:

  • 只開放高頻欄位,沒有把所有顏色、字級、間距都暴露出來。
  • 沒有自訂範本的命名入口;命名仍然走「儲存範本」彈窗。
  • 不支援範本 JSON 匯入 / 匯出。
  • 不開放任意 CSS。
  • 編輯器 UI 是輕量實作,分組面板、預設色盤、行動端版面都還有最佳化空間。

後續方向

這套結構往前走有幾個明顯的路標:

  • 更多可編輯欄位。 表格邊框色、奇偶行背景、引用背景、程式碼文字色——白名單裡其實已經留好了位置,缺的是 UI。
  • 統一的欄位 schema。 讓前端表單和後端校驗共用同一份定義,避免兩邊漂移。
  • workspace 範本列表加「編輯」入口。 現在改完只能新存一份,不能在原範本上繼續改。
  • 「複製系統範本為私有範本」的顯式流程。 讓「我想基於 Classic 改一份自己的」變成一個更直接的動作。
  • 匯入 / 匯出自訂範本 JSON。 團隊內共享樣式時會很有用。

總結

回頭看,這套方案的關鍵不是「我們做了什麼功能」,而是「我們沒做什麼」:

  • 沒做任意 CSS——避免了跨格式翻車。
  • 沒存完整範本快照——保留了基準範本升級的紅利。
  • 沒在切換系統範本時偷偷保留覆寫——避免了「我沒改但樣式變了」的迷惑。
  • 後端沒接受白名單外的欄位——給將來留了演進空間。

約束反而帶來了更可預測的體驗。在一個跨多種匯出格式的工具裡,這種可預測性比自由度更值錢。

如果你也在做一份內容、多種匯出的產品,可以來 md-to.com 試試這套自訂範本,看看它是不是你想要的那種「剛剛好」的自由度。