我們是如何做「結構化自訂範本」的:在不開放任意 CSS 的前提下讓使用者改樣式
很多使用者反映過同一個需求:「GitHub 範本挺好看的,但能不能把標題色換成我們公司的品牌色?行高再寬一點?」
聽起來很小的功能,但真要做,背後有一個繞不開的取捨:要不要開放任意 CSS?
這篇文章記錄我們最終選擇的方案——基於現有系統範本做結構化覆寫——以及為什麼這樣做更適合 md-to.com 這種「一份內容多格式匯出」的場景。
兩種常見做法的兩個極端
市面上 Markdown 工具處理「自訂樣式」基本是兩種思路:
第一種:只給固定範本。 簡單、穩定,但不夠用。使用者挑了一個最接近的範本之後,往往只差「標題想換個顏色」這一步。
第二種:開放任意 CSS。 自由度拉滿,預覽區想怎麼改就怎麼改。問題是:瀏覽器裡好看不代表匯出後好看。
我們做的是 Markdown 轉 PDF / Word / HTML / 圖片的工具,匯出鏈路裡有幾條是不消費 CSS 的:
- Word(
.docx)走的是 OOXML 樣式,根本沒有「任意 CSS」這個概念。 - PDF 列印有自己的分頁、頁邊距規則,純靠瀏覽器 CSS 調出來的版式很容易在列印時翻車。
- 圖片匯出走 canvas 截圖,對某些 CSS 特性(例如部分
filter、backdrop-filter)支援有限。
如果讓使用者寫任意 CSS,預覽區一切正常,匯出之後樣式各種走樣——這種「看到的不是拿到的」是非常糟糕的使用者體驗。
所以我們走第三條路。
我們的方案:基準範本 + 結構化覆寫
核心思路一句話:使用者先選一個系統範本,再在它上面改一組結構化的欄位。
存的不是完整範本,而是「基準範本 ID + 覆寫項」:
{
version: 1,
baseTemplateId: 'classic',
overrides: {
colors: {
heading: '#0F766E'
},
fonts: {
body: 'Noto Sans SC'
},
fontSizes: {
body: 13
},
spacing: {
lineHeight: 1.7
}
}
}
為什麼不直接存完整範本?三個原因:
- 基準升級時使用者能跟著受益。 例如我們後續最佳化了 Classic 範本的程式碼區塊邊距,使用者的自訂範本會自動用上新值,因為 overrides 裡沒覆寫這一項。
- payload 小,前後端校驗都簡單。 後端只需要校驗白名單裡的幾個欄位,而不是一份完整的範本物件。
- 「基於哪個範本做的修改」這個語意被顯式保留下來。 UI 上可以告訴使用者「你正在 Classic 的基礎上做了 3 處修改」。
下面是目前的編輯器自訂樣式介面,覆蓋了顏色、字型、字級、間距四組高頻欄位:

執行鏈路:從一次點擊到一份匯出
整個系統串起來看是三條鏈路。
鏈路一:即時預覽
使用者在編輯器裡改一個顏色,預覽區要立刻變。流程是:
- 編輯器把新的覆寫值寫入
localStorage.customDocumentTemplate。 - 編輯器派發
template-change事件。 MarkdownPreview監聽到事件,呼叫getActiveTemplate()。getActiveTemplate()讀出documentTemplate(目前基準)和customDocumentTemplate(覆寫項),如果兩者匹配就呼叫createCustomTemplate(baseTemplate, overrides)合併出一份完整範本。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。注意 documentTemplateId 和 customDocumentTemplate 是分開存的——一份私有範本的語意是「基於 X 範本的 Y 個修改」,而不是一份脫離來源的樣式快照。
鏈路三:套用一份私有範本
使用者從 PrivateTemplateSelector 點回來一份範本,或是透過 ?tpl=<id> 參數載入:
- adapter 呼叫
applySettings(settingsJson)。 - 如果 payload 裡有
customDocumentTemplate,寫回 localStorage;沒有則清掉。 - 套用
documentTemplateId。 - 派發
template-change,預覽 / 匯出鏈路都拿到合併後的完整範本。
一個容易忽略但很關鍵的細節:選系統範本時清掉自訂覆寫
我們在 setActiveTemplate(templateId) 裡加了一行:
localStorage.removeItem('customDocumentTemplate');
為什麼?因為如果不清,會出現這種詭異情況:
- 使用者在 Classic 範本上改了標題色為綠色,存到了 localStorage。
- 使用者切換到 Dracula 範本。
- 切回 Classic,標題又是綠色——但使用者根本不記得自己改過。
我們把「選擇系統範本」定義成一個明確的重置動作:你選了一個乾淨的範本,那就是乾淨的。要在它上面改,重新點「自訂」。這個語意比「盡可能保留使用者上次的修改」要清楚得多。
TemplatePickerModal 裡點「套用」時也會執行同樣的清理。
後端校驗:白名單是底線
後端 validateTemplateInput() 對 settingsJson.customDocumentTemplate 做了嚴格校驗:
customDocumentTemplate可選;存在時必須是物件。version必須為1(留遷移空間)。baseTemplateId必須是非空字串。overrides必須是物件。- 未列入白名單的欄位會被拒絕。
白名單覆蓋了現在 UI 裡能改的所有欄位,比 UI 略大一點,給以後補欄位留餘地:
| 類別 | 允許欄位 |
|---|---|
colors | heading, text, link, codeBackground, codeBorder, codeText, quoteText, quoteBorder, quoteBackground, tableHeaderBg, tableHeaderText, tableBorder, tableRowOdd, tableRowEven |
fonts | heading, body, code |
fontSizes | h1, h2, h3, h4, h5, h6, body, code |
spacing | headingBefore, 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 試試這套自訂範本,看看它是不是你想要的那種「剛剛好」的自由度。
相關工具
免費 Markdown 轉 Word 轉換器 - 線上將 MD 轉換為 DOCX
免費線上 Markdown 轉 Word 轉換器,即時預覽,一鍵下載 DOCX 文件。完美保留格式,無需註冊,本機處理保護隱私。立即體驗!
免費 Word 轉 Markdown 轉換器 - 線上將 DOCX 轉換為 MD
免費線上將 Word 文件轉換為 Markdown。DOCX 轉 MD 轉換器完美保留標題、表格、清單和格式。無需註冊,立即體驗!
免費 Markdown 轉 PDF 轉換器 - 線上將 MD 轉換為 PDF
線上免費將 Markdown 轉換為 PDF。20 + 種專業範本,支援數學公式、程式碼醒目提示和表格。無需註冊,本機處理保護隱私。立即體驗!
免費 Markdown 轉 LaTeX 工具 - 線上將 MD 轉換為 TeX
線上免費將 Markdown 轉換為 LaTeX。將 MD 文件轉換為 TeX 格式,適合學術論文和技術文件。無需註冊!