返回博客

我们是如何做"结构化自定义模板"的:在不开放任意 CSS 的前提下让用户改样式

MD-TO Team

很多用户反馈过同一个需求:“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 试试这套自定义模板,看看它是不是你想要的那种”刚刚好”的自由度。