Retour au blog

Comment nous avons construit des modèles personnalisables structurés : laisser les utilisateurs changer le style sans ouvrir un CSS arbitraire

Équipe MD-TO

Les utilisateurs nous remontent toujours la même demande : « Le modèle GitHub est sympa, mais est-ce que je peux changer la couleur des titres pour celle de notre marque ? Et augmenter un peu l’interligne ? »

Cela ressemble à une petite fonctionnalité, mais il y a un arbitrage incontournable derrière : faut-il ouvrir un CSS arbitraire ?

Cet article retrace le chemin que nous avons finalement choisi — des surcharges structurées au-dessus des modèles système existants — et pourquoi cela convient mieux à un outil comme md-to.com, qui repose sur le principe « un seul contenu, plusieurs formats d’export ».


Deux extrêmes courants qui ne fonctionnent pas vraiment

La plupart des outils Markdown gèrent la « personnalisation des styles » de l’une de ces deux manières :

Option 1 : uniquement des modèles fixes. Simple et stable, mais insuffisant. L’utilisateur choisit le modèle le plus proche, puis bute sur la dernière étape : « j’aimerais juste changer la couleur du titre ».

Option 2 : CSS arbitraire. Liberté maximale, on peut tout modifier dans la zone d’aperçu. Le problème : ce qui est joli dans le navigateur ne l’est pas forcément après l’export.

Nous construisons un outil qui convertit du Markdown en PDF / Word / HTML / image, et plusieurs de ces chaînes d’export ne consomment pas de CSS du tout :

  • Word (.docx) utilise des styles OOXML. Il n’y a pas de notion de « CSS arbitraire » là-dedans.
  • L’impression PDF a ses propres règles de pagination et de marges. Une mise en page calibrée uniquement avec du CSS navigateur s’effondre souvent à l’impression.
  • L’export image passe par une capture canvas, et certaines fonctionnalités CSS (parties de filter, backdrop-filter) ont un support limité.

Si on laisse les utilisateurs écrire du CSS arbitraire, l’aperçu sera bon mais les exports casseront silencieusement — ce décalage « ce que vous voyez n’est pas ce que vous obtenez » est une très mauvaise expérience utilisateur.

Nous avons donc pris une troisième voie.


Notre approche : modèle de base + surcharges structurées

L’idée centrale en une phrase : l’utilisateur choisit d’abord un modèle système, puis modifie un petit ensemble de champs structurés par-dessus.

On ne stocke pas un modèle complet, mais « identifiant du modèle de base + surcharges » :

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

Pourquoi ne pas stocker un modèle complet ? Trois raisons :

  1. Les utilisateurs profitent des améliorations de la base. Si nous resserrons plus tard les marges des blocs de code dans Classic, les modèles personnalisés adoptent automatiquement la nouvelle valeur — parce que la surcharge ne touche pas ce champ.
  2. Petit payload, validation simple côté front et back. Le backend valide une poignée de champs sur liste blanche, pas un objet modèle entier.
  3. La sémantique « basé sur quel modèle » est explicitement préservée. L’UI peut indiquer à l’utilisateur « vous avez fait 3 modifications par-dessus Classic ».

Voici l’interface actuelle de l’éditeur, couvrant quatre groupes de champs à forte fréquence : couleurs, polices, tailles et espacements :

Éditeur de modèle personnalisé


Le runtime : d’un clic à un export terminé

L’ensemble du système se résume à trois pipelines.

Pipeline 1 : aperçu en direct

L’utilisateur change une couleur dans l’éditeur, l’aperçu doit se mettre à jour immédiatement :

  1. L’éditeur écrit les nouvelles valeurs de surcharge dans localStorage.customDocumentTemplate.
  2. L’éditeur émet un événement template-change.
  3. MarkdownPreview écoute l’événement et appelle getActiveTemplate().
  4. getActiveTemplate() lit documentTemplate (la base actuelle) et customDocumentTemplate (les surcharges). S’ils correspondent, il appelle createCustomTemplate(baseTemplate, overrides) pour les fusionner en un modèle complet.
  5. generatePreviewCSS(template) régénère les styles d’aperçu.

La logique de fusion est directe — un merge superficiel par groupe de champs :

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 : enregistrer comme modèle privé

Quand l’utilisateur clique sur « Enregistrer le modèle », l’adapter de l’outil emballe l’état courant :

{
  version: 1,
  toolSlug,
  documentTemplateId,
  customDocumentTemplate
}

Ce payload est écrit dans saved_templates.settings_json. Notez que documentTemplateId et customDocumentTemplate sont stockés séparément — un modèle privé signifie « Y modifications sur le modèle X », pas une capture de style détachée de son origine.

Pipeline 3 : appliquer un modèle privé

Quand l’utilisateur en sélectionne un depuis PrivateTemplateSelector, ou le charge via le paramètre ?tpl=<id> :

  1. L’adapter appelle applySettings(settingsJson).
  2. Si le payload contient un customDocumentTemplate, il est réécrit dans le localStorage ; sinon il est effacé.
  3. documentTemplateId est appliqué.
  4. template-change est émis, et les chaînes d’aperçu / d’export voient toutes le modèle complet fusionné.

Un détail facile à manquer mais crucial : effacer les surcharges quand on choisit un modèle système

Nous avons ajouté une seule ligne dans setActiveTemplate(templateId) :

localStorage.removeItem('customDocumentTemplate');

Pourquoi ? Sans cela, on obtient un scénario déroutant :

  • L’utilisateur personnalise Classic avec un titre vert, qui est stocké dans le localStorage.
  • L’utilisateur passe à Dracula.
  • L’utilisateur revient à Classic — et le titre est de nouveau vert, mais il ne se souvient pas de l’avoir modifié.

Nous définissons « choisir un modèle système » comme une réinitialisation explicite : vous choisissez un modèle propre, vous obtenez un modèle propre. Pour le personnaliser à nouveau, cliquez à nouveau sur « Personnaliser ». Cette sémantique est beaucoup plus claire que « préserver autant que possible les dernières modifications de l’utilisateur ».

TemplatePickerModal exécute le même nettoyage quand on clique sur « Appliquer ».


Validation backend : la liste blanche est le filet de sécurité

Le validateTemplateInput() côté backend valide strictement settingsJson.customDocumentTemplate :

  • customDocumentTemplate est optionnel ; s’il est présent, il doit être un objet.
  • version doit valoir 1 (place pour de futures migrations).
  • baseTemplateId doit être une chaîne non vide.
  • overrides doit être un objet.
  • Les champs hors liste blanche sont rejetés.

La liste blanche couvre tout ce que l’UI expose actuellement, avec un peu plus de marge pour de futurs champs :

GroupeChamps autorisés
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

La liste blanche est le filet de sécurité de toute cette conception : parce que nous n’acceptons pas de clés arbitraires, même si nous ajoutons plus tard une logique de rendu à un champ ou que nous le renommons, les anciennes clés sauvegardées par les utilisateurs ne se glisseront pas dans le modèle d’exécution pour polluer la sortie.


Le bénéfice : les chaînes d’export n’ont pas besoin de connaître les « modèles personnalisés »

Après fusion, on a un DocumentTemplate complet ; aucune des fonctions d’export en aval n’a besoin de changer :

  • Aperçu : generatePreviewCSS(template)
  • PDF / impression : generatePdfStyles() / generatePrintStyles()
  • HTML : generateExportHTML()
  • Copie en texte enrichi : generateRichTextHTML()
  • Word : templateToDocxStyles(template)

C’est exactement le bénéfice que nous avons obtenu en refusant le CSS arbitraire dès le départ : tant que toutes les sorties continuent de consommer DocumentTemplate comme représentation intermédiaire unifiée, la cohérence inter-formats est essentiellement gratuite. La couleur de titre choisie par l’utilisateur apparaît à l’identique dans l’aperçu navigateur, le PDF, le Word et les exports image — pas « à peu près la même », mais littéralement la même valeur hex, parce que tout est lu depuis le même modèle fusionné.


Limites actuelles

La première version est volontairement étroite :

  • Seuls les champs à forte fréquence sont exposés ; toutes les couleurs, tailles de police et espacements ne sont pas modifiables.
  • Pas d’entrée dédiée pour nommer un modèle personnalisé — le nommage passe toujours par la boîte de dialogue « Enregistrer le modèle ».
  • Pas d’import / export JSON pour les modèles.
  • Pas de CSS arbitraire.
  • L’UI de l’éditeur est une première passe légère — panneaux groupés, palettes prédéfinies et mise en page mobile ont encore de la marge de progression.

Vers où on va

Quelques jalons clairs pour la suite de cette structure :

  • Plus de champs modifiables. Couleur des bordures de tableau, fonds de lignes alternées, fond des citations, couleur du texte de code — la liste blanche a déjà des emplacements pour ça ; il manque juste l’UI.
  • Un schéma de champs unifié. Que le formulaire frontend et la validation backend partagent une seule définition pour ne pas dériver l’un de l’autre.
  • Une entrée « Modifier » dans la liste des modèles du workspace. Aujourd’hui on ne peut qu’enregistrer une nouvelle copie, pas continuer à éditer un modèle privé existant.
  • Un flux explicite « dupliquer un modèle système en modèle privé ». Faire de « je veux baser un modèle privé sur Classic » une action plus directe.
  • Import / export JSON des modèles personnalisés. Utile pour partager des styles au sein d’une équipe.

En résumé

Avec le recul, la clé de cette conception n’est pas ce que nous avons construit — c’est ce que nous n’avons pas construit :

  • Nous n’avons pas autorisé le CSS arbitraire — ce qui a évité des cassures inter-formats.
  • Nous n’avons pas stocké de captures complètes de modèles — ce qui a préservé le bénéfice des mises à niveau du modèle de base.
  • Nous n’avons pas conservé silencieusement les surcharges au changement de modèle système — ce qui a évité la confusion « je n’ai rien changé mais le style est différent ».
  • Le backend n’accepte pas de champs hors liste blanche — ce qui laisse de la place à l’évolution de la conception.

Les contraintes amènent en réalité une expérience plus prévisible. Dans un outil qui couvre plusieurs formats d’export, cette prévisibilité vaut plus que la liberté.

Si vous construisez aussi un produit « un seul contenu, plusieurs exports », allez sur md-to.com essayer ce système de modèles personnalisés — voyez si c’est ce niveau de liberté « juste comme il faut » que vous cherchiez.