ブログに戻る

「構造化されたカスタムテンプレート」をどう作ったか:任意の CSS を開放せずにユーザーがスタイルを変えられるようにする

MD-TO チーム

ユーザーから何度も同じ要望をもらいます:「GitHub テンプレートはいいんだけど、見出しの色を会社のブランドカラーに変えられない?行間ももう少し広く」

小さな機能に聞こえますが、実装するとなると避けて通れないトレードオフがあります:任意の CSS を開放するかどうか?

この記事では、私たちが最終的に選んだ方法——既存のシステムテンプレートに構造化オーバーライドを重ねる——と、それが md-to.com のような「1 つのコンテンツを複数フォーマットへエクスポートする」ツールに合っている理由を記録します。


よくある 2 つの極端な手法

世の中の Markdown ツールが「カスタムスタイル」を扱う方法は、基本的に 2 通りです:

第 1 案:固定テンプレートのみ。 シンプルで安定していますが、足りません。ユーザーは最も近いテンプレートを選んだあと、結局「見出しだけ色を変えたい」というところで止まります。

第 2 案:任意の CSS を開放。 自由度は最大、プレビューはいくらでもいじれます。問題は、ブラウザで綺麗でもエクスポート後に綺麗とは限らない、という点です。

私たちは Markdown を PDF / Word / HTML / 画像に変換するツールを作っています。エクスポート経路の中には CSS を消費しないものがいくつもあります:

  • Word(.docx)は OOXML スタイルで動いており、「任意の CSS」という概念がそもそもありません。
  • PDF 印刷には独自のページ分割・余白ルールがあり、ブラウザ CSS だけで作ったレイアウトは印刷時に崩れがちです。
  • 画像エクスポートは canvas スクリーンショット経由で、特定の CSS 機能(一部の filterbackdrop-filter など)はサポートが限定的です。

ユーザーに任意の CSS を書かせると、プレビューは正常でもエクスポート後にスタイルが崩れます——この「見えているものと得られるものが違う」状態は最悪のユーザー体験です。

そこで第 3 の道を取りました。


私たちのアプローチ:ベーステンプレート + 構造化オーバーライド

中心となる考え方は一文で:ユーザーはまずシステムテンプレートを選び、その上に構造化されたフィールドを変更する

完全なテンプレートを保存するのではなく、「ベーステンプレート ID + オーバーライド」を保存します:

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

なぜ完全なテンプレートを保存しないのか。理由は 3 つ:

  1. ベースが改善されたときにユーザーも恩恵を受ける。 例えば後で Classic テンプレートのコードブロック余白を最適化した場合、オーバーライドにそのフィールドがなければカスタムテンプレートも自動的に新しい値を採用します。
  2. payload が小さく、フロント・バック両方の検証が簡単。 バックエンドはホワイトリスト上の少数のフィールドだけを検証すればよく、テンプレートオブジェクト全体を検証する必要がありません。
  3. 「どのテンプレートをベースにした変更か」というセマンティクスが明示的に保存される。 UI で「あなたは Classic に対して 3 つの変更を加えています」と伝えられます。

以下が現在の編集 UI で、色・フォント・フォントサイズ・間隔という 4 つの高頻度グループをカバーしています:

カスタムテンプレートエディタ


実行経路:1 回のクリックから 1 回のエクスポートまで

システム全体は 3 本の経路でつながっています。

経路 1:リアルタイムプレビュー

ユーザーがエディタで色を変えたら、プレビューは即座に更新されなければなりません:

  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 ?? {}),
  },
};

経路 2:プライベートテンプレートとして保存

ユーザーが「テンプレートを保存」を押すと、ツール adapter が現在の状態をパッケージ化します:

{
  version: 1,
  toolSlug,
  documentTemplateId,
  customDocumentTemplate
}

この payload は saved_templates.settings_json に書き込まれます。documentTemplateIdcustomDocumentTemplate を分けて保存している点に注目してください——プライベートテンプレートのセマンティクスは「テンプレート X の上に Y 個の変更」であって、出自を切り離したスタイルスナップショットではありません。

経路 3:プライベートテンプレートを適用

ユーザーが PrivateTemplateSelector から再選択するか、?tpl=<id> パラメータでロードしたとき:

  1. adapter が applySettings(settingsJson) を呼ぶ。
  2. payload に customDocumentTemplate があれば localStorage に書き戻し、なければクリアする。
  3. documentTemplateId を適用する。
  4. template-change を発行し、プレビューとエクスポート経路はマージ後の完全なテンプレートを受け取る。

見落としがちだが重要な点:システムテンプレートを選んだらカスタムオーバーライドをクリアする

setActiveTemplate(templateId) の中に 1 行追加しています:

localStorage.removeItem('customDocumentTemplate');

なぜか?クリアしないと、こんな奇妙な状況が起きます:

  • ユーザーが Classic テンプレートで見出し色を緑に変え、localStorage に保存される。
  • ユーザーが Dracula テンプレートに切り替える。
  • Classic に戻すと、見出しがまた緑——でもユーザーは変更したことを覚えていない。

私たちは「システムテンプレートを選ぶ」ことを明示的なリセット動作として定義しました:きれいなテンプレートを選んだなら、それはきれいです。その上で変えたければ「カスタマイズ」をもう一度押す。これは「ユーザーの最後の編集をできる限り維持する」よりはるかに分かりやすいセマンティクスです。

TemplatePickerModal で「適用」を押したときも同じクリーンアップが走ります。


バックエンド検証:ホワイトリストが安全網

バックエンドの validateTemplateInput()settingsJson.customDocumentTemplate を厳しく検証します:

  • customDocumentTemplate はオプショナル;存在する場合はオブジェクトでなければならない。
  • version1 でなければならない(後の移行余地を残すため)。
  • 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 値です。すべてが同じマージ済みテンプレートから読み出されているからです。


現時点の制約

第 1 版は意図的に範囲を狭くしています:

  • 高頻度フィールドのみ開放。すべての色、フォントサイズ、間隔値を編集できるわけではない。
  • カスタムテンプレートの命名入口はなし;命名は「テンプレートを保存」ダイアログ経由のまま。
  • テンプレートの JSON インポート / エクスポートは未対応。
  • 任意の CSS は未開放。
  • エディタ UI は軽量な初版——グループ化されたパネル、プリセットカラー、モバイルレイアウトはまだ改善余地あり。

今後の方向

この構造から先に進むためのいくつかの目印:

  • 編集可能フィールドの追加。 表の枠線色、交互行の背景、引用の背景、コードの文字色——ホワイトリストには既に枠が用意してあり、足りないのは UI だけ。
  • 統一されたフィールド schema。 フロントのフォームとバックの検証が同じ定義を共有し、二重管理によるズレを防ぐ。
  • workspace のテンプレート一覧に「編集」入口を追加。 今は新しいコピーとしてしか保存できず、既存のプライベートテンプレートを継続編集できない。
  • 「システムテンプレートをプライベートテンプレートとして複製」する明示的なフロー。 「Classic をベースに自分のテンプレートを作りたい」を直接的な操作にする。
  • カスタムテンプレートの JSON インポート / エクスポート。 チーム内でスタイルを共有するときに役立つ。

まとめ

振り返ると、この設計の鍵は「何を作ったか」ではなく「何を作らなかったか」です:

  • 任意の CSS を開放しなかった——フォーマット間の崩壊を回避。
  • 完全なテンプレートスナップショットを保存しなかった——ベーステンプレートのアップグレード恩恵を保持。
  • システムテンプレート切替時にオーバーライドを密かに残さなかった——「変えてないのにスタイルが違う」混乱を回避。
  • バックエンドはホワイトリスト外のフィールドを受け付けなかった——将来の進化に余地を残した。

制約はむしろ予測可能な体験をもたらします。複数のエクスポートフォーマットをまたぐツールでは、その予測可能性は自由度より価値があります。

「1 つのコンテンツ、複数のエクスポート」を作るプロダクトに関わっているなら、md-to.com でこのカスタムテンプレートを試してみてください——あなたが探していた「ちょうどいい」自由度かどうかを確かめてみてください。