ADR 0011 – `#tags` du corps : grammaire commune avec les liens wiki, `node_tag.origin`, le corps fait foi
Accepté (2026-10-15, mode autonome)
ADR 0011 – #tags du corps : grammaire commune avec les liens wiki, node_tag.origin, le corps fait foi
Statut : Accepté (2026-10-15, mode autonome)
Décisions prises en mode autonome (Aurélien, 2026-10-15 : options les plus simples, sûres et réversibles) : un # ouvre un tag s’il n’est pas collé à un caractère de mot ; corps du tag = forme canonique de l’ADR 0006 (tout-chiffres et plus de 100 caractères refusés, donc laissés en texte) ; zones exclues = celles de WikiLinks, plus les liens et images Markdown et les occurrences [[…]] / ![[…]] ; table de cas JSON dédiée, lue par Java et Vitest ; colonne node_tag.origin (manual | body) dans la clé primaire, deux ensembles indépendants ; PUT /tags ne touche que manual ; tags.updated avec origin ; NodeView.tags garde son sens (tags saisis), bodyTags s’ajoute ; filtre et /api/tags comptent les deux origines ; rendu en lien vers /nodes?tag=… ; décoration (pas de nœud) dans l’éditeur visuel ; \# préservé ; plafond de 200 tags distincts par corps ; pas de réindexation des corps existants.
Contexte
Le cahier (jalon 1, « Tags ») cite #perso/maison comme syntaxe. L’ADR 0006 a fixé la forme canonique des tags saisis en chips et renvoyé les #tags du corps à plus tard, avec une contrainte : un remplacement des tags (PUT /api/nodes/{id}/tags) ne doit pas les effacer. L’ADR 0008 conserve le corps tel que saisi (Tiptap ne réécrit qu’après une édition réelle). L’ADR 0009 a posé le principe « le corps fait foi, le serveur indexe dans la transaction d’écriture » pour [[…]], avec WikiLinks (commonmark-java) qui calcule les zones exclues, et annoncé pour les tags du corps une colonne node_tag.origin.
Choix difficiles à défaire une fois des notes écrites : la grammaire (ce qui est ou non un tag dans un texte existant, export et import Obsidian en V2), la représentation en base des deux origines, la forme des événements lus par les règles du jalon 3, et le sens de NodeView.tags dans le contrat du client généré.
Ce qu’on a vérifié (2026-10-15) :
@tiptap/markdown3.32.0 :escapeMarkdownSyntaxn’échappe que\ ` * _ [ ] ~;#n’est jamais échappé, en début de ligne non plus. Un jetonescapede marked devient du texte sans antislash :\#projetsaisi en source devient#projetaprès une édition visuelle. Le garde-fou d’aller-retour (markdown-roundtrip.ts, instancemarkedsans extension) ne le voit pas, car les deux rendent<p>#projet</p>.marked17.0.6 : une extension en ligne est appelée avant les tokenizers natifs avec(src, tokens)(tokens= jetons déjà produits dans le contexte en ligne courant) etthis.lexer.state.inLinkvaut vrai pendant l’analyse du texte d’un lien (Markdown, mais aussi après une balise HTML brute<a …>). Le texte d’une emphase, d’un barré ou d’une cellule de tableau est analysé dans un nouveau contexte (tokensvide).- Les délimiteurs qui ouvrent un tel contexte (
*,_,~,|,>, marqueurs de liste et de titre suivis d’une espace) ne sont jamais un caractère de mot : la règle « précédé d’un caractère qui n’est pas un caractère de mot » donne donc le même résultat sur le texte brut (serveur) et au début d’un contexte en ligne (marked). La règle « précédé d’une espace » divergerait (**#a**:*côté serveur, début de contexte côté marked).
Décision
Grammaire (identique côté Java et TypeScript)
- Une occurrence est
#suivi du plus long corps de la formeS(/S)*, oùS=([\p{L}\p{M}\p{N}-]|\\?_)+.\_compte pour un soulignement :@tiptap/markdownéchappe toujours_, donc#snake_casesaisi en visuel est enregistré#snake\_case; l’antislash reste dans le texte (raw) mais est retiré de la valeur (snake_case) et du texte affiché au rendu. Un/final, ou un//, arrête le corps :#a/b/donnea/b,#a//bdonnea. - Frontière avant : le
#est en début de corps, ou le caractère qui le précède (point de code) n’est ni une lettre, ni une marque, ni un chiffre (\p{L}\p{M}\p{N}), ni#,/,\ou&. Conséquences :- pas de tag dans
C#,foo#bar,https://ex.com/page#ancre,https://ex.com/#/route,#; \#projetn’est pas un tag (c’est l’échappement),\\#projetnon plus (même règle, sans compter les antislashs) ;##projetn’est pas un tag ;(#a),"#a",l'#a,**#a**,_#a_,~~#a~~,|#a|,>#asont des tags.
- pas de tag dans
#suivi d’une espace ou d’un caractère hors deSn’est pas une occurrence :# Titrereste un titre Markdown, la séquence de fermeture## Titre ##n’est rien.#Titreen début de ligne (sans espace) n’est pas un titre CommonMark : c’est un tag, comme dans Obsidian.- Valeur : le corps passe par la forme canonique de l’ADR 0006 (NFC, minuscules
Locale.ROOT). Une occurrence dont la forme canonique serait refusée (#2024,#123: uniquement des chiffres ; plus de 100 caractères) n’est pas un tag et reste du texte, sans erreur. Le texte affiché garde la casse saisie (#Projet), la valeur indexée estprojet.#fffest un tag (comme dans Obsidian). - Zones exclues : celles de
WikiLinks(spans et blocs de code, blocs et balises HTML, liens automatiques<…>, définitions de liens par référence), plus :- les liens et images Markdown en entier (texte, destination, titre) :
[voir #a](url),[x](https://ex.com/#a),. Un tag dans le texte d’un lien produirait un lien imbriqué au rendu ; - les occurrences de liens wiki et de transclusions au sens de l’ADR 0009 :
[[Note #a]],[[Note#a]],![[f #a]]. Ce qui n’est pas une occurrence reste analysé :\[[x #a]]et[[ #a]](titre vide) contiennent le taga.
- les liens et images Markdown en entier (texte, destination, titre) :
- Les tags sont reconnus partout ailleurs : titres, listes, tâches, citations, titre et corps des callouts, cellules de tableau, texte entre balises HTML en ligne (
<span>#a</span>). - Une table de cas commune,
frontend/src/app/core/markdown/body-tag-cases.json(sectionsoccurrences:source→ liste ordonnée de{raw, tag}, etnonGuaranteed), lue par Vitest (import JSON) et parBodyTagsTest(Path.of("..", "frontend", …), commeWikiLinksTest). Non ASCII écrit en\uXXXX. - Non garanti (listé dans la table, jamais produit par l’application) : texte entre
<a …>et</a>HTML bruts (indexé par le serveur, non rendu comme tag car marked est alorsinLink) ; URL nue GFM où#suit une ponctuation (https://ex.com/a(#b): marked en fait un lien, le serveur sans extension autolink y voit un tag) ;|dans un span de code d’un tableau ; écarts de version Unicode entre Java 25 et V8 sur\p{L}; emphase par soulignements (_#a_,__#a__: le serveur lita_/a__, marked renda) ; URL nue GFM où#suit un point (https://ex.com/a.#b).
Analyse côté serveur
- La méthode de masquage de
WikiLinksest extraite dans une classe package-privateMarkdownZones(comportement inchangé pourWikiLinks, garanti parWikiLinksTest). Elle prend en option le masquage des nœudsLinketImagede commonmark-java. WikiLinksexpose en package-private les plages de ses occurrences et transclusions, calculées sur le texte déjà masqué, avec ses règles d’échappement et de titre vide. Pas de seconde implémentation de la grammaire wiki.- Classe pure
node/BodyTags: masque (zones communes + liens/images + occurrences wiki), balaie avec l’expression régulière, applique la frontière (un caractère masqué compte comme frontière, comme le jeton précédent côté marked), puis la forme canonique sans exception (Tags.canonical).
Stockage : node_tag.origin
- Migration
V5: colonneorigin text not null('manual'pour les lignes existantes, puis sans valeur par défaut, pour qu’une insertion qui oublie l’origine échoue), contraintecheck (origin in ('manual', 'body')), clé primaire(node_id, origin, tag)à la place de(node_id, tag). Indextext_pattern_opset contrainte de forme de l’ADR 0006 inchangés. - Un même tag peut avoir deux lignes (
manualetbody). Les deux ensembles sont indépendants :PUT /tagsne lit et n’écrit queorigin = 'manual', la réindexation du corps queorigin = 'body'. Aucune règle de priorité, aucun recalcul croisé. - Plafonds : 50 tags saisis (ADR 0006, inchangé) ; 200 tags distincts du corps (après forme canonique). Au-delà, 400 avec la clé
body(« Au plus 200 #tags distincts par contenu »), contrôlé avant les verrous comme le plafond des liens wiki.
Indexation et événements
- Le corps fait foi. Les lignes
bodysont recalculées dans la transaction de toute écriture du corps, par différence avec l’existant :NodeService.create,NodeService.update(après la réécriture éventuelle du renommage) etWikiLinkService.propagateRenamepour chaque source réécrite (le renommage ne touche que l’intérieur de[[…]], zone exclue : en pratique aucun changement, mais la règle « toute écriture du corps réindexe » ne souffre pas d’exception). - Création : pas d’événement dédié,
node.createdportebodyTagsà côté detags(même choix que l’ADR 0006 pour les tags saisis). - Modification : si la liste des tags du corps change, un
tags.updatedavec{"origin": "body", "before": […], "after": […]}(listes triées des tags d’originebody), après lenode.updatedqui porte le corps. Pas detouchsupplémentaire (l’écriture du corps l’a fait). Cet événement ne porte que l’ensemble d’originebody: il ne reflète pas l’ensemble effectif des tags du nœud (un tag à la fois saisi et écrit reste porté quand il quitte le corps). Une règle du jalon 3 qui veut « le tag X est apparu / disparu » doit combiner les deux origines. PUT /tags: letags.updatedporte désormais"origin": "manual". Un événement antérieur sansoriginse lit commemanual.- Verrous : la réindexation s’exécute sous le verrou de la ligne du nœud déjà pris par
update(et parpropagateRenamepour les sources) ;replaceTagsprend le même verrou. Les deux origines n’écrivent de toute façon jamais les mêmes lignes.
Lecture et API
NodeView.tagsetNodeSummary.tagsgardent leur sens : les tags saisis, ceux quePUT /tagsremplace. Le chemin d’enregistrement du front reste inchangé : impossible de « figer » par erreur un tag du corps en tag saisi.- Nouveau
bodyTags(liste triée, jamais nulle) dansNodeViewetNodeSummary, et dans la réponse dePUT /tags. - Filtre
?tag=: un nœud correspond s’il porte le tag ou un descendant, quelle que soit l’origine (conditionEXISTSde l’ADR 0006 inchangée). GET /api/tags: les deux origines ;count= nombre de nœuds distincts hors corbeille (count(distinct node_id)).
Front
- Ctrl/Cmd+clic dans l’éditeur : ouvre
/nodes?tag=…dans un nouvel onglet (window.open(…, '_blank', 'noopener')), comme les liens ; l’éditeur et ses modifications en cours ne sont pas quittés. - Coût : l’extension
markedne coupe le texte qu’aux#qui peuvent ouvrir un tag (frontière remplie), pour rester linéaire sur un texte plein dex#a. - Rendu : extension
markeden lignebodyTagdans l’instance de rendu (même mécanisme quewikiLink). Frontière lue sur le dernier caractère durawdu dernier jeton du contexte (début de contexte = frontière) ; refus sithis.lexer.state.inLink. Sortie :<a href="/nodes?tag=<valeur encodée>" data-body-tag="<valeur>">#Texte saisi</a>, texte échappé, style par[data-body-tag], navigation par le routage existant deMarkdownView. - Garde-fou d’aller-retour : l’instance de
markdown-roundtrip.tsreçoit l’extensionbodyTag. Toute réécriture de Tiptap qui ferait apparaître ou disparaître un tag change le rendu comparé et ouvre la note en mode source. \#préservé :PreservedInlinereconnaît\#(motif ajouté àPRESERVED_PATTERNSet àstart). Un tag échappé reste échappé après une édition visuelle, et\# textene devient plus un titre.- Éditeur visuel : les tags restent du texte (aucun nœud Tiptap, aucun effet sur la sérialisation). Un plugin ProseMirror pose une décoration sur les occurrences de chaque bloc de texte, hors bloc de code, marques
codeetlink, et nœuds atomiques (wikiLink,preservedInline, image), avec la même fonction de balayage que le rendu. Ctrl/Cmd+clic ouvre/nodes?tag=…. La décoration est un affichage : de légers écarts avec le serveur sont possibles et sans conséquence. - Chips :
app-node-tags-fieldgarde l’édition des tags saisis. Sous le champ, unmat-chip-seten lecture seule « Dans le contenu » affichebodyTagsmoins les tags saisis, chaque puce menant à/nodes?tag=…, avec l’info-bulle « Tag écrit dans le contenu : retirez #… du texte pour l’enlever ». La liste reflète le corps enregistré et se met à jour à l’enregistrement. - Liste des nœuds : la colonne « Tags » affiche l’union triée de
tagsetbodyTags.
Alternatives écartées
- Frontière « précédé d’une espace ou en début de ligne » (lecture la plus stricte d’Obsidian) : marked ne connaît pas le caractère brut qui précède un contexte d’emphase ou de cellule.
**#a**ou|#a|seraient des tags au rendu et pas au serveur. - Tags dans le texte des liens Markdown : lien imbriqué au rendu, et cas rare.
- Colonnes booléennes
manual/bodysur une ligne unique : clé primaire inchangée et comptage sansdistinct, mais chaque écriture devient un upsert avec suppression quand les deux drapeaux tombent, et les deux origines partagent la même ligne (verrous, journal). Deux ensembles indépendants sont plus simples à raisonner. - Origine unique,
manualprioritaire : retirer un tag saisi obligeraitreplaceTagsà réanalyser le corps pour savoir s’il faut convertir la ligne enbody. État dérivé, source de bogues. - Table séparée
node_body_tag: filtre, autocomplétion et comptage en union de deux tables, pour rien. NodeView.tags= union,manualTagsà part : plus « naturel », mais tout endroit du front qui continuerait à envoyertagsdansPUT /tagstransformerait en silence les tags du corps en tags saisis.- Événement dédié
bodyTags.updated: une règle « tag X ajouté » du jalon 3 devrait écouter deux types.origindans le payload suffit à les distinguer. - Pas d’événement pour les tags du corps (le
node.updatedcontient déjà le corps) : contraire à « chaque modification de données produit unnode_event», et une règle sur un tag devrait réanalyser des corps. - Un
link.added-like par tag : même raison que l’ADR 0006, une ligne d’historique par tag. - Nœud Tiptap atomique
bodyTag: l’édition d’un tag (retour arrière dans le mot) devient pénible, et un tag tapé sans règle d’entrée resterait du texte. La décoration ne change ni le schéma ni la sérialisation. - Échapper
#dans le texte sérialisé par Tiptap : le sérialiseur n’a pas de point d’extension (ADR 0008), et un#projettapé dans l’éditeur visuel doit rester un tag. - Compter les antislashs comme pour
[[: la frontière exclut déjà\, une seule règle suffit. - 400 sur un
#taginvalide du corps (#2024, trop long) : le corps est du texte libre ; ce n’est simplement pas un tag. - Extension autolink de commonmark-java pour aligner les URL nues : une dépendance de plus pour un cas marginal, listé comme non garanti.
Conséquences
- Migration
V5(clé primaire denode_tagmodifiée). Les corps déjà enregistrés ne sont indexés qu’à leur prochain enregistrement (données de développement, comme l’ADR 0009) ; l’outil de réindexation complète reste reporté. Soldé par l’ADR 0020 (2026-10-15). - Le contrat change de façon additive (
bodyTags,origindanstags.updated). Client à régénérer. - Retirer un tag du corps se fait dans le texte, pas par la puce. Un tag à la fois saisi et écrit reste porté tant que l’une des deux origines l’a.
GET /api/tagspropose aussi les tags du corps à l’autocomplétion des chips : ajouter à la main un tag du corps le « fige » (il survit à sa suppression du texte). C’est voulu.- Le corps est analysé deux fois par écriture (liens wiki, tags), ce qui est acceptable avec la borne de 200 000 caractères. Une seule analyse partagée reste possible sans changer de modèle.
- Le jalon 2 (langage de requête des vues) reprend la même condition :
tags: [perso]couvre les deux origines. - Renommer ou fusionner un tag (V2) réécrira aussi les corps (
#ancienvers#nouveau), avecnode.updatedetreason, comme le renommage des liens wiki.
Reporté
- Autocomplétion après
#dans l’éditeur visuel (@tiptap/suggestionest déjà là) et dans le mode source. - Mise à jour en direct des puces « Dans le contenu » pendant la frappe.
- Réindexation des corps existants (outil commun avec les liens wiki). Soldé par l’ADR 0020 (2026-10-15).
- Renommage et fusion de tags (V2).
- Tags dans un futur frontmatter YAML (export / import Obsidian, V2).
Questions ouvertes tranchées (mode autonome)
#fff,#2024b: acceptés comme tags (choix Obsidian ; seuls les tout-chiffres sont refusés).- Plafond de 200 tags distincts du corps : 400 avec la clé
body(pas d’ignorance silencieuse). - Autocomplétion après
#: reportée hors jalon 1. - Réindexation des corps existants (liens wiki et tags) : ligne ajoutée au backlog, à faire avant le déploiement. Soldé par l’ADR 0020 (2026-10-15).