Second Brain documentation

ADR 0008 – Éditeur Markdown : Tiptap, source préservée hors édition réelle, rendu marked + DOMPurify

Accepté (2026-10-15)

← Toutes les décisions

ADR 0008 – Éditeur Markdown : Tiptap, source préservée hors édition réelle, rendu marked + DOMPurify

Statut : Accepté (2026-10-15)

Contexte

Le cahier (jalon 1, « Contenu riche ») demande d’éditer le corps d’un nœud en WYSIWYG avec accès à la source Markdown (titres, listes, tâches, tableaux, code), puis, dans les tranches suivantes, les liens wiki [[Titre]], les #tags du corps (ADR 0006), l’export Markdown + frontmatter, et en V2 Mermaid, la transclusion ![[…]] et l’historique du corps. Aujourd’hui node.body est un text libre saisi dans un textarea, indexé tel quel par node.search (to_tsvector('french', body)), et PATCH /api/nodes/{id} le réécrit à chaque enregistrement en produisant un node.updated avec le corps avant/après dès qu’il diffère d’un octet.

Plusieurs choix sont difficiles à défaire une fois des notes écrites :

  • Le format stocké : un éditeur WYSIWYG passe par un modèle de document (ProseMirror) et réécrit le Markdown à sa façon. Si cette réécriture est stockée à chaque ouverture, chaque note ancienne ou importée change sans que l’utilisateur l’ait touchée, l’historique se remplit de faux node.updated, et les syntaxes que l’éditeur ne connaît pas (HTML, [[…]] qu’il échappe) sont détruites.
  • La bibliothèque : elle fixe la manière d’écrire les extensions de syntaxe ([[…]], #tag, callouts) et la forme de sérialisation, donc le texte qu’on retrouvera dans l’export et que le backend analysera pour indexer les liens wiki.
  • La politique d’assainissement : le corps est du texte libre, HTML brut compris, qui sera rendu dans l’éditeur, l’aperçu et plus tard les dashboards. C’est la principale surface XSS de l’application.

Ce qu’on a vérifié sur le registre npm et dans les sources publiées (2026-10-15) :

  • @tiptap/* 3.32.0 (MIT, publié le 2026-10-13, environ 22 M de téléchargements par semaine pour @tiptap/core). Les paquets se déclarent mutuellement en pair à la version exacte (@tiptap/core: 3.32.0). @tiptap/markdown (officiel depuis 3.7, octobre 2025, présenté comme « early release ») s’appuie sur marked ^17 ; ses extensions déclarent parseMarkdown, renderMarkdown et markdownTokenizer. MarkdownManager est exporté et utilisable sans vue. Les extensions table, liste (tâches), image, lien et bloc de code fournissent leurs gestionnaires Markdown ; l’alignement des colonnes de tableau est géré.
  • Le sérialiseur de texte de @tiptap/markdown est codé en dur, sans point d’extension : hors code, il échappe \ ` * _ [ ] ~ et encode &, <, > en entités HTML. Après une édition, [[Titre]] devient \[\[Titre\]\], R&D devient R&amp;D, snake_case devient snake\_case.
  • @milkdown/kit 7.22.3 (MIT, environ 0,6 M par semaine) : sérialisation par remark-stringify, qui échappe aussi [ et _ en texte courant et encode & devant une lettre (&#x26;). @milkdown/crepe embarque Vue, KaTeX et CodeMirror.
  • @toast-ui/editor 3.2.2 : dernière version en février 2023, dépend de DOMPurify 2.
  • marked 18.1.1 est la dernière version, mais @tiptap/markdown dépend de ^17 (dernière : 17.0.6).
  • dompurify 3.4.17 (MPL-2.0 ou Apache-2.0, au choix), types inclus (@types/dompurify est un paquet vide déprécié).

Décision

Bibliothèques

  • Éditeur visuel : Tiptap 3 (cœur sans framework, instancié directement dans un composant Angular avec new Editor({ element }), sans wrapper tiers). Extensions : StarterKit (sans underline, qui n’a pas d’équivalent Markdown), TaskList/TaskItem (@tiptap/extension-list), TableKit (@tiptap/extension-table), Image (@tiptap/extension-image, inline: true, allowBase64: false) et Markdown (@tiptap/markdown). La liste des extensions est définie à un seul endroit et sert à l’éditeur comme aux tests.
  • Mode source : un textarea (police à chasse fixe, cdkTextareaAutosize) accompagné d’un aperçu rendu. Pas de CodeMirror au jalon 1.
  • Rendu en lecture : marked 17, la version déjà tirée par @tiptap/markdown (une seule copie dans le bundle, et le même lexer que l’éditeur), dans une instance dédiée new Marked({ gfm: true, breaks: false }) pour que les tokenizers enregistrés par Tiptap ne modifient pas le rendu. Assainissement : DOMPurify 3, instance dédiée, appliqué au HTML produit juste avant insertion dans le DOM.
  • Chargement paresseux : rien de tout cela n’est importé depuis main.ts, app.config.ts ni la coquille. Le composant éditeur est placé dans un bloc @defer de la page d’édition : Tiptap, marked et DOMPurify forment un chunk à part, et le budget initial ne bouge pas.
  • Versions épinglées : tous les paquets @tiptap/* à la même version exacte (3.32.0, sans ^), montés ensemble. marked en ~17.0.x, aligné sur la plage de @tiptap/markdown. Passer à marked 18 attendra que Tiptap le fasse.

Format stocké : la source telle que saisie

  • node.body reste la source de vérité, en Markdown CommonMark + GFM (tableaux, tâches, barré, liens automatiques) tel que le lit marked. Les syntaxes maison ([[…]], ![[…]], #tag, callouts > [!type], notes [^n]) sont du texte Markdown valide, interprété par les tranches qui les introduisent. Pas de HTML, pas de JSON ProseMirror en base.
  • Aucune réécriture sans édition réelle. À chaque chargement du texte dans l’éditeur visuel (ouverture d’un nœud, retour du mode source), le composant mémorise une référence { source, serialized } : source est le texte reçu, serialized la sérialisation par Tiptap du document obtenu. À chaque mise à jour du document, le composant émet source si la nouvelle sérialisation est égale à serialized, et la nouvelle sérialisation sinon. Ainsi :
    • ouvrir, parcourir, basculer en source puis revenir, ou faire « annuler » jusqu’à l’état initial laisse le texte identique à l’octet près. Le PATCH envoie alors le corps inchangé, et NodeService.update n’écrit ni événement ni mise à jour ;
    • une édition réelle stocke la forme normalisée de Tiptap pour tout le corps (puces -, titres ATX, échappements). Le node.updated de cette première édition montre donc aussi ces normalisations : c’est le prix accepté.
  • Garde-fou d’aller-retour. Au chargement dans l’éditeur visuel, source et serialized sont rendus par marked, puis comparés après normalisation du HTML : espaces entre balises, <p> seul enfant d’un <li>. S’ils diffèrent (HTML brut, construction non gérée par le schéma), l’éditeur s’ouvre en mode source avec un bandeau « L’éditeur visuel modifierait ce contenu ». L’utilisateur peut forcer le mode visuel en connaissance de cause.
  • Syntaxes préservées. Pour que l’échappement de Tiptap ne détruise pas les syntaxes des tranches suivantes, une extension PreservedInline (nœud inline atomique, attribut raw) reconnaît [[…]], ![[…]], [!type] et [^n] à la lecture (markdownTokenizer), les restitue verbatim à l’écriture (renderMarkdown), et les crée à la frappe ou au collage (règles d’entrée). Elle les affiche comme du texte stylé, sans navigation ni indexation. La tranche « liens wiki » remplacera la partie [[…]] par un vrai nœud wikiLink.
  • Enregistrement inchangé : le corps part dans PATCH /api/nodes/{id} (ou dans la création), sans nouvel endpoint ni nouvel événement. Le backend ne normalise pas le corps. Seule nouveauté, une borne de taille (@Size(max = 200 000), voir Conséquences).

Assainissement et rendu

  • Éditeur visuel : le DOM est produit par le schéma ProseMirror (attributs posés un par un, texte en nœuds texte). Le HTML brut présent dans le Markdown est soit converti par les règles parseHTML du schéma (les attributs inconnus comme onerror disparaissent), soit perdu, et le garde-fou l’a alors signalé. Liens : Link configuré avec un isAllowedUri qui n’accepte que http, https, mailto, tel et les URL relatives (l’option protocols est additive : elle ajoute des schémas sans en retirer, d’où ce prédicat), HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' }, openOnClick: false, ouverture par Ctrl/Cmd+clic. Image est étendue comme le rendu : src conservé seulement en https: ou relatif, referrerpolicy="no-referrer" et loading="lazy".
  • Rendu en lecture (fonction pure renderMarkdown(source): string, composant app-markdown-view) :
    1. marked produit le HTML. Le HTML brut du Markdown est conservé puis assaini, pas échappé ;
    2. DOMPurify avec le profil html (pas de SVG ni de MathML). Sont interdits style, form, input (sauf la case à cocher des tâches, forcée en disabled), iframe, object, embed, base, meta et link, ainsi que l’attribut style. Schémas d’URL autorisés : http, https, mailto, tel et les URL relatives ;
    3. un hook afterSanitizeAttributes ajoute target="_blank" rel="noopener noreferrer nofollow" aux liens d’une autre origine. Pour les images, il n’accepte que https: et les URL relatives (pas de http: ni de data:) et ajoute referrerpolicy="no-referrer" et loading="lazy" ;
    4. le résultat est passé à [innerHTML] via DomSanitizer.bypassSecurityTrustHtml. C’est le seul appel à bypassSecurityTrust* autorisé dans le front (sauf l’exception de l’amendement ci-dessous), et il suit toujours DOMPurify : le sanitizer d’Angular supprimerait sinon les cases à cocher et les attributs loading et referrerpolicy ;
    5. un clic sur un lien de même origine est intercepté et routé par le Router (pas de rechargement complet).
  • Le stockage n’est jamais assaini : on assainit à l’affichage, là où le contexte est connu. L’export restitue le texte saisi.

Amendement (ADR 0012, fichiers)

Un second contournement est autorisé : DomSanitizer.bypassSecurityTrustResourceUrl, limité au src de l’<iframe> d’aperçu PDF du panneau « Fichier » (app-node-file-panel). Il ne s’applique qu’à la valeur renvoyée par nodeFileUrl(id), construite à partir d’un UUID validé, jamais à une chaîne libre. Le fichier servi porte sa propre Content-Security-Policy (ADR 0012).

Alternatives écartées

  • Milkdown (@milkdown/kit) : pensé « Markdown d’abord », avec la chaîne de référence remark/micromark, une sérialisation configurable et le GFM complet (notes de bas de page comprises). Mais il échappe lui aussi [ et _, et encode &, si bien que le gain en fidélité est faible. Il faudrait écrire une extension micromark pour chaque syntaxe maison. L’autocomplétion par déclencheur ([[) est moins aboutie que @tiptap/suggestion. Son écosystème est environ trente fois plus petit et repose sur un mainteneur principal. Crepe, sa version prête à l’emploi, embarque Vue, KaTeX et CodeMirror. Repli si la table d’aller-retour de la tranche 1 échoue sur Tiptap.
  • Toast UI Editor : plus de version depuis février 2023, DOMPurify 2, éditeur visuel et Markdown en deux modèles séparés.
  • ProseMirror direct + prosemirror-markdown : c’est ce que fait Tiptap, sans les extensions prêtes (tableaux GFM, tâches, règles d’entrée, suggestion) ; tout serait à écrire et à maintenir ici.
  • CodeMirror 6 seul (source enrichie façon Obsidian, avec un rendu séparé) : fidélité parfaite puisque la source n’est jamais réécrite, et autocomplétion de très bonne qualité. Mais ce n’est pas du WYSIWYG : un « live preview » avec tableaux éditables est un chantier en soi. On garde l’option pour le mode source plus tard, si le textarea ne suffit plus.
  • Simple bascule source / aperçu (sans WYSIWYG) : ne répond pas au cahier (« Édition WYSIWYG »).
  • Stocker la sérialisation de l’éditeur à chaque ouverture : faux node.updated, perte silencieuse du HTML et des syntaxes inconnues, historique illisible.
  • Stocker le JSON ProseMirror (en plus ou à la place du Markdown) : deux sources de vérité, export et recherche plein texte à refaire, fichiers illisibles hors de l’outil.
  • markdown-it ou unified/remark + rehype-sanitize pour le rendu : un second parseur Markdown, qui divergerait de celui de l’éditeur sur les cas limites et ajouterait du poids, alors que marked est déjà dans le chunk.
  • Échapper tout HTML brut au rendu : plus simple, mais le contenu importé (Obsidian, HTML <details>, <kbd>) s’afficherait balises comprises.
  • Sanitizer d’Angular seul : il supprime les cases à cocher des tâches et ne permet pas de poser rel et target de façon centralisée.
  • Assainir à l’écriture côté serveur (OWASP Java HTML Sanitizer) : modifie le texte de l’utilisateur, ne protège pas les données déjà stockées et ne dispense pas d’assainir au rendu.
  • Rendu côté serveur (commonmark-java) : un aller-retour réseau par aperçu et deux parseurs à garder alignés.

Conséquences

  • Le poids du chunk de l’éditeur est estimé à environ 450 à 550 Ko minifiés (environ 150 Ko gzip). Repères bundlephobia : @tiptap/starter-kit 340 Ko / 106 Ko gzip, ProseMirror compris ; DOMPurify 29 Ko / 11 Ko gzip. Le chiffre est à mesurer dans la tranche 1, et le budget initial ne doit pas augmenter.
  • Toute note éditée dans l’éditeur visuel prend la forme de Tiptap : \_, \*, \[, \~, &amp;, &lt;, &gt; dans le texte courant. Le rendu est identique, mais la source et l’export sont moins lisibles. Cela reste limité aux notes réellement éditées. Si cela gêne, on corrigera en amont (sérialiseur de texte surchargeable) plutôt qu’en patchant une méthode privée.
  • La table d’aller-retour (Vitest) documente le comportement réel de Tiptap : cas identiques, équivalents au rendu, signalés par le garde-fou. Elle sert de test de non-régression à chaque montée de version de @tiptap/*.
  • Le dialecte stocké (CommonMark + GFM + syntaxes maison en texte) s’impose au backend : l’indexation des liens wiki (tranche suivante) analysera ce texte en Java, en ignorant le code. Il faudra un parseur côté serveur dont le comportement sur [[…]] est aligné sur le tokenizer front, testé par une même table de cas.
  • Les #tags du corps ne sont pas indexés (ADR 0006). Tiptap ne les échappe pas. La décision d’indexation (origine du tag en base, pour que PUT /tags ne les efface pas) se prendra avec la tranche liens wiki.
  • node.body est borné à 200 000 unités UTF-16 (@Size sur CreateNodeRequest.body et UpdateNodeRequest.body, 400 avec la clé body) : un corps démesuré ferait échouer la colonne générée search (tsvector limité à 1 Mo), soit une 500. La borne apparaît en maxLength dans le contrat OpenAPI. Chaque modification du corps reste journalisée en entier dans node_event (avant/après) ; un historique par diff relève de « Historique du contenu » (V2).
  • PATCH /api/nodes/{id} continue d’écraser le corps (ADR 0006) : deux onglets ouverts sur la même note, c’est le dernier qui gagne. Un contrôle de concurrence optimiste (If-Match sur updatedAt) reste possible plus tard sans changer ce modèle.
  • La coloration syntaxique des blocs de code (lowlight), le rendu des callouts, Mermaid (V2) et une Content-Security-Policy (tranche déploiement) s’ajoutent sans changer le format stocké : la langue des blocs ```ts et le marqueur [!type] sont déjà conservés.