ADR 0008 – Éditeur Markdown : Tiptap, source préservée hors édition réelle, rendu marked + DOMPurify
Accepté (2026-10-15)
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 surmarked ^17; ses extensions déclarentparseMarkdown,renderMarkdownetmarkdownTokenizer.MarkdownManagerest 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/markdownest 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&DdevientR&D,snake_casedevientsnake\_case. @milkdown/kit7.22.3 (MIT, environ 0,6 M par semaine) : sérialisation parremark-stringify, qui échappe aussi[et_en texte courant et encode&devant une lettre (&).@milkdown/crepeembarque Vue, KaTeX et CodeMirror.@toast-ui/editor3.2.2 : dernière version en février 2023, dépend de DOMPurify 2.marked18.1.1 est la dernière version, mais@tiptap/markdowndépend de^17(dernière : 17.0.6).dompurify3.4.17 (MPL-2.0 ou Apache-2.0, au choix), types inclus (@types/dompurifyest 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(sansunderline, qui n’a pas d’équivalent Markdown),TaskList/TaskItem(@tiptap/extension-list),TableKit(@tiptap/extension-table),Image(@tiptap/extension-image,inline: true,allowBase64: false) etMarkdown(@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 :
marked17, 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éenew 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.tsni la coquille. Le composant éditeur est placé dans un bloc@deferde la page d’édition : Tiptap, marked et DOMPurify forment un chunk à part, et le budgetinitialne bouge pas. - Versions épinglées : tous les paquets
@tiptap/*à la même version exacte (3.32.0, sans^), montés ensemble.markeden~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.bodyreste la source de vérité, en Markdown CommonMark + GFM (tableaux, tâches, barré, liens automatiques) tel que le litmarked. 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 }:sourceest le texte reçu,serializedla sérialisation par Tiptap du document obtenu. À chaque mise à jour du document, le composant émetsourcesi 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
PATCHenvoie alors le corps inchangé, etNodeService.updaten’é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). Lenode.updatedde cette première édition montre donc aussi ces normalisations : c’est le prix accepté.
- ouvrir, parcourir, basculer en source puis revenir, ou faire « annuler » jusqu’à l’état initial laisse le texte identique à l’octet près. Le
- Garde-fou d’aller-retour. Au chargement dans l’éditeur visuel,
sourceetserializedsont rendus parmarked, 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, attributraw) 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œudwikiLink. - 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
parseHTMLdu schéma (les attributs inconnus commeonerrordisparaissent), soit perdu, et le garde-fou l’a alors signalé. Liens :Linkconfiguré avec unisAllowedUriqui n’accepte quehttp,https,mailto,telet les URL relatives (l’optionprotocolsest 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.Imageest étendue comme le rendu :srcconservé seulement enhttps:ou relatif,referrerpolicy="no-referrer"etloading="lazy". - Rendu en lecture (fonction pure
renderMarkdown(source): string, composantapp-markdown-view) :markedproduit le HTML. Le HTML brut du Markdown est conservé puis assaini, pas échappé ;- DOMPurify avec le profil
html(pas de SVG ni de MathML). Sont interditsstyle,form,input(sauf la case à cocher des tâches, forcée endisabled),iframe,object,embed,base,metaetlink, ainsi que l’attributstyle. Schémas d’URL autorisés :http,https,mailto,telet les URL relatives ; - un hook
afterSanitizeAttributesajoutetarget="_blank" rel="noopener noreferrer nofollow"aux liens d’une autre origine. Pour les images, il n’accepte quehttps:et les URL relatives (pas dehttp:ni dedata:) et ajoutereferrerpolicy="no-referrer"etloading="lazy"; - le résultat est passé à
[innerHTML]viaDomSanitizer.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 attributsloadingetreferrerpolicy; - 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
textareane 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-itou unified/remark +rehype-sanitizepour le rendu : un second parseur Markdown, qui divergerait de celui de l’éditeur sur les cas limites et ajouterait du poids, alors quemarkedest 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
relettargetde 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-kit340 Ko / 106 Ko gzip, ProseMirror compris ; DOMPurify 29 Ko / 11 Ko gzip. Le chiffre est à mesurer dans la tranche 1, et le budgetinitialne doit pas augmenter. - Toute note éditée dans l’éditeur visuel prend la forme de Tiptap :
\_,\*,\[,\~,&,<,>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
#tagsdu corps ne sont pas indexés (ADR 0006). Tiptap ne les échappe pas. La décision d’indexation (origine du tag en base, pour quePUT /tagsne les efface pas) se prendra avec la tranche liens wiki. node.bodyest borné à 200 000 unités UTF-16 (@SizesurCreateNodeRequest.bodyetUpdateNodeRequest.body, 400 avec la clébody) : un corps démesuré ferait échouer la colonne généréesearch(tsvectorlimité à 1 Mo), soit une 500. La borne apparaît enmaxLengthdans le contrat OpenAPI. Chaque modification du corps reste journalisée en entier dansnode_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-MatchsurupdatedAt) 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```tset le marqueur[!type]sont déjà conservés.