Zurück zum Blog

Wie wir strukturierte benutzerdefinierte Templates gebaut haben: Nutzer den Stil ändern lassen, ohne beliebiges CSS zu öffnen

MD-TO Team

Wir hören immer wieder denselben Wunsch von Nutzern: „Das GitHub-Template sieht gut aus, aber kann ich die Überschriftenfarbe auf unsere Markenfarbe ändern? Und den Zeilenabstand etwas vergrößern?”

Klingt nach einem kleinen Feature, aber dahinter steht eine unausweichliche Abwägung: beliebiges CSS öffnen oder nicht?

Dieser Beitrag zeichnet den Weg nach, den wir letztlich eingeschlagen haben — strukturierte Overrides auf bestehenden System-Templates — und warum das besser zu einem Tool wie md-to.com passt, das nach dem Prinzip „ein Inhalt, viele Exportformate” arbeitet.


Zwei verbreitete Extreme, die nicht so recht funktionieren

Die meisten Markdown-Tools handhaben „Custom Styling” auf eine von zwei Arten:

Variante 1: nur feste Templates. Einfach und stabil, aber zu wenig. Nutzer wählen das nächstbeste Template und scheitern dann am letzten Schritt — „nur die Überschriftenfarbe will ich ändern”.

Variante 2: beliebiges CSS. Maximale Freiheit, der Vorschaubereich lässt sich endlos anpassen. Der Haken: gut im Browser heißt nicht gut nach dem Export.

Wir bauen ein Tool, das Markdown nach PDF / Word / HTML / Bild konvertiert, und mehrere dieser Exportpfade verarbeiten gar kein CSS:

  • Word (.docx) verwendet OOXML-Stile. Da gibt es keinen Begriff von „beliebigem CSS”.
  • PDF-Druck hat eigene Regeln für Pagination und Ränder. Layouts, die rein über Browser-CSS abgestimmt sind, fallen beim Druck oft auseinander.
  • Bildexport läuft über Canvas-Screenshots, und einige CSS-Features (Teile von filter, backdrop-filter) werden nur eingeschränkt unterstützt.

Wenn wir Nutzer beliebiges CSS schreiben lassen, sieht die Vorschau gut aus, aber die Exporte brechen still — diese „was du siehst, ist nicht was du bekommst”-Lücke ist eine sehr schlechte User Experience.

Also haben wir einen dritten Weg gewählt.


Unser Ansatz: Basis-Template + strukturierte Overrides

Die Kernidee in einem Satz: Nutzer wählen zuerst ein System-Template und ändern dann eine kleine Menge strukturierter Felder darüber.

Wir speichern nicht das vollständige Template, sondern „Basis-Template-ID + Overrides”:

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

Warum nicht ein vollständiges Template-Snapshot speichern? Drei Gründe:

  1. Nutzer profitieren, wenn die Basis sich verbessert. Wenn wir später z. B. das Padding der Code-Blöcke in Classic optimieren, übernehmen die Custom-Templates den neuen Wert automatisch — weil das Override dieses Feld nicht berührt.
  2. Kleines Payload, einfache Validierung auf beiden Seiten. Das Backend muss nur eine Handvoll Whitelist-Felder validieren, kein komplettes Template-Objekt.
  3. Die Semantik „basiert auf welchem Template” bleibt explizit erhalten. Die UI kann dem Nutzer sagen: „Du hast 3 Änderungen über Classic vorgenommen.”

Hier die aktuelle Editor-UI, die vier hochfrequente Feldgruppen abdeckt: Farben, Schriften, Schriftgrößen und Abstände:

Editor für benutzerdefinierte Templates


Der Runtime: vom einzelnen Klick bis zum fertigen Export

Das gesamte System besteht aus drei Pipelines.

Pipeline 1: Live-Vorschau

Der Nutzer ändert eine Farbe im Editor und die Vorschau muss sofort aktualisieren:

  1. Der Editor schreibt die neuen Override-Werte in localStorage.customDocumentTemplate.
  2. Der Editor löst ein template-change-Event aus.
  3. MarkdownPreview hört auf das Event und ruft getActiveTemplate() auf.
  4. getActiveTemplate() liest documentTemplate (die aktuelle Basis) und customDocumentTemplate (die Overrides). Stimmen beide überein, ruft es createCustomTemplate(baseTemplate, overrides) auf, um sie zu einem vollständigen Template zu mergen.
  5. generatePreviewCSS(template) erzeugt die Vorschau-Styles neu.

Die Merge-Logik ist geradlinig — ein flacher Merge pro Feldgruppe:

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

Pipeline 2: Als privates Template speichern

Wenn der Nutzer „Template speichern” klickt, packt der Tool-Adapter den aktuellen Zustand:

{
  version: 1,
  toolSlug,
  documentTemplateId,
  customDocumentTemplate
}

Dieses Payload landet in saved_templates.settings_json. Beachte, dass documentTemplateId und customDocumentTemplate getrennt gespeichert werden — ein privates Template bedeutet „Y Modifikationen über Template X”, nicht ein von der Herkunft losgelöster Stil-Snapshot.

Pipeline 3: Privates Template anwenden

Wenn der Nutzer eines aus PrivateTemplateSelector auswählt oder über den ?tpl=<id>-Parameter lädt:

  1. Der Adapter ruft applySettings(settingsJson) auf.
  2. Hat das Payload ein customDocumentTemplate, wird es zurück in den localStorage geschrieben; sonst wird es gelöscht.
  3. documentTemplateId wird angewendet.
  4. template-change wird ausgelöst, und Vorschau- / Exportpfade sehen alle das gemergte vollständige Template.

Ein leicht zu übersehendes, aber kritisches Detail: Overrides löschen, wenn ein System-Template gewählt wird

Wir haben innerhalb von setActiveTemplate(templateId) eine einzige Zeile hinzugefügt:

localStorage.removeItem('customDocumentTemplate');

Warum? Ohne dies entsteht ein verwirrendes Szenario:

  • Nutzer passt Classic mit grüner Überschrift an, das wird im localStorage gespeichert.
  • Nutzer wechselt zu Dracula.
  • Nutzer wechselt zurück zu Classic — und die Überschrift ist wieder grün, aber er erinnert sich nicht, das geändert zu haben.

Wir definieren „ein System-Template wählen” als expliziten Reset: du wählst ein sauberes Template und bekommst das saubere Template. Um es wieder anzupassen, klicke erneut auf „Anpassen”. Diese Semantik ist viel klarer als „die letzten Änderungen des Nutzers möglichst beibehalten”.

TemplatePickerModal führt dieselbe Aufräumaktion aus, wenn auf „Anwenden” geklickt wird.


Backend-Validierung: die Whitelist ist das Sicherheitsnetz

Das Backend validateTemplateInput() validiert settingsJson.customDocumentTemplate strikt:

  • customDocumentTemplate ist optional; wenn vorhanden, muss es ein Objekt sein.
  • version muss 1 sein (Spielraum für spätere Migrationen).
  • baseTemplateId muss ein nicht leerer String sein.
  • overrides muss ein Objekt sein.
  • Felder außerhalb der Whitelist werden abgelehnt.

Die Whitelist deckt alles ab, was die UI heute exponiert, mit etwas mehr Spielraum für künftige Felder:

GruppeErlaubte Felder
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

Die Whitelist ist das Sicherheitsnetz dieses ganzen Designs: weil wir keine beliebigen Schlüssel akzeptieren, gelangen alte Schlüssel, die Nutzer einmal gespeichert haben, später nicht ins Runtime-Template und verschmutzen die Ausgabe nicht — selbst wenn wir später Renderlogik zu einem Feld hinzufügen oder es umbenennen.


Der Gewinn: Exportpfade müssen „Custom Templates” gar nicht kennen

Nach dem Merge liegt ein vollständiges DocumentTemplate vor, daher muss keine der Downstream-Exportfunktionen geändert werden:

  • Vorschau: generatePreviewCSS(template)
  • PDF / Druck: generatePdfStyles() / generatePrintStyles()
  • HTML: generateExportHTML()
  • Rich-Text-Kopie: generateRichTextHTML()
  • Word: templateToDocxStyles(template)

Genau das ist die Belohnung dafür, dass wir beliebiges CSS von Anfang an abgelehnt haben: solange jeder Output weiterhin DocumentTemplate als einheitliche Zwischendarstellung konsumiert, ist format­übergreifende Konsistenz im Wesentlichen kostenlos. Die vom Nutzer gewählte Überschriftenfarbe erscheint identisch in der Browser-Vorschau, im PDF, in Word und in Bildexporten — nicht „ungefähr gleich”, sondern wortwörtlich derselbe Hex-Wert, weil alles aus demselben gemergten Template gelesen wird.


Aktuelle Einschränkungen

Die erste Version ist bewusst eng gehalten:

  • Nur hochfrequente Felder sind exponiert; nicht jede Farbe, Schriftgröße oder jeder Abstand ist editierbar.
  • Es gibt keinen dedizierten Einstiegspunkt zum „Benennen deines Custom-Templates” — die Benennung läuft weiterhin über den „Template speichern”-Dialog.
  • Kein JSON-Import / -Export für Templates.
  • Kein beliebiges CSS.
  • Die Editor-UI ist ein leichtgewichtiger erster Wurf — gruppierte Panels, voreingestellte Farbpaletten und mobiles Layout haben noch Luft nach oben.

Wohin das geht

Ein paar klare nächste Schritte für diese Struktur:

  • Mehr editierbare Felder. Tabellenrahmenfarbe, abwechselnde Zeilenhintergründe, Zitat-Hintergrund, Code-Textfarbe — die Whitelist hat bereits Plätze dafür; es fehlt nur die UI.
  • Ein einheitliches Feld-Schema. Frontend-Formular und Backend-Validierung sollen sich eine Definition teilen, damit sie nicht auseinanderdriften.
  • Ein „Bearbeiten”-Eintrag in der Workspace-Template-Liste. Aktuell kann man nur eine neue Kopie speichern; ein bestehendes privates Template lässt sich nicht weiterbearbeiten.
  • Ein expliziter Flow „System-Template als privates Template duplizieren”. Damit „ich will eines basierend auf Classic privat haben” eine direktere Aktion wird.
  • JSON-Import / -Export für Custom-Templates. Nützlich, um Stile im Team zu teilen.

Fazit

Im Rückblick liegt der Schlüssel dieses Designs nicht in dem, was wir gebaut haben — sondern in dem, was wir nicht gebaut haben:

  • Wir haben kein beliebiges CSS erlaubt — das hat format­übergreifende Brüche verhindert.
  • Wir haben keine vollständigen Template-Snapshots gespeichert — das hat den Vorteil von Basis-Template-Upgrades erhalten.
  • Wir haben Overrides beim Wechsel des System-Templates nicht still beibehalten — das hat die Verwirrung „ich habe nichts geändert, aber der Stil ist anders” vermieden.
  • Das Backend akzeptiert keine Felder außerhalb der Whitelist — das lässt Raum für die Weiterentwicklung des Designs.

Beschränkungen liefern tatsächlich eine vorhersagbarere Erfahrung. In einem Tool, das mehrere Exportformate abdeckt, ist diese Vorhersagbarkeit mehr wert als Freiheit.

Wenn du auch ein „ein Inhalt, mehrere Exporte”-Produkt baust, schau auf md-to.com vorbei und probier dieses Custom-Template-System aus — sieh, ob es das „genau richtige” Maß an Freiheit ist, nach dem du gesucht hast.