Markdown : mieux édité, jamais réécrit

Le Markdown a gagné parce qu'il est du texte : lisible sans logiciel, comparable dans un git diff, durable au-delà de l'outil qui l'a produit. Son défaut n'a jamais été le format — c'est l'édition. On tape des astérisques, on compte des espaces d'indentation, on déplace un paragraphe en coupant-collant des lignes, et on prévisualise dans un second volet pour vérifier ce qu'on a écrit.

L'ambition tient en une phrase : apporter une meilleure édition et une meilleure vision du Markdown, sans toucher au format ni à son stockage.

Ce qu'on ne change pas

  • Le format. Pas de dialecte propriétaire, pas de métadonnées injectées dans vos titres, pas d'attributs cachés au bout des lignes. Ce qui sort est du Markdown ordinaire.
  • Le stockage. Vos fichiers restent des fichiers, à l'endroit où vous les avez mis. Aucun verrou de base de données, aucun format binaire, aucune migration à subir pour rouvrir une note de l'an dernier.
  • La propriété. Dans le plugin Obsidian, c'est Obsidian qui charge, enregistre, renomme et gère les conflits. L'éditeur fournit une surface d'édition et se tient à l'écart du reste.

Ce qu'on change

L'édition devient par blocs : un titre, un paragraphe, une liste, un tableau sont des objets qu'on attrape, qu'on déplace, qu'on duplique et qu'on commente. Le menu « / » remplace la syntaxe qu'il fallait connaître, l'autoformat garde la syntaxe pour ceux qui la préfèrent, et il n'y a plus de volet d'aperçu parce qu'il n'y a plus rien à prévisualiser.

Le fichier, lui, ne bouge pas de forme. Vous éditez comme dans Notion et vous versionnez comme du texte.

import { markdownToBlocks, blocksToMarkdown } from '@nbe/markdown';

// entrer : votre fichier, tel qu'il est sur le disque
const blocks = markdownToBlocks(await readFile('note.md', 'utf8'));

// sortir : du Markdown que n'importe quel autre outil relit
await writeFile('note.md', blocksToMarkdown(blocks));

Pourquoi le Markdown n'est pas le stockage

Le Markdown est une projection, pas une base de données — et c'est une distinction honnête, pas une échappatoire. Il a des pertes documentées : il ne sait pas écrire un bloc vide, et il replie volontairement les paragraphes coupés à la main. Si le Markdown était l'unique copie, ces pertes s'accumuleraient à chaque enregistrement.

D'où les deux régimes, choisis selon ce que l'hôte peut garantir :

  • Une note à la fois (le plugin Obsidian) : le Markdown est la seule copie, et la perte est acceptée et documentée. Les mises en colonnes s'aplatissent ; ce qui n'a pas d'équivalent Markdown est écrit en commentaire HTML plutôt que perdu.
  • Un espace de travail (l'application de bureau) : le JSON fait foi dans .nbe/, et le Markdown de pages/est régénéré. Vous gardez un vault lisible et un document sans perte.

Le stockage : ce qu'il y a sur le disque

La disposition est la convention d'Obsidian, parce qu'un espace de travail exporté doit s'ouvrir comme un vault plutôt que lui ressembler : une page est <Titre>.md, et une page qui a des enfants possède en plus un dossier <Titre>/ qui les contient. La hiérarchie est donc l'arborescence des dossiers — lisible avant que quoi que ce soit ne l'analyse.

mon-carnet/
  pages/                     ← le vault : c'est ça qu'on lit sans l'outil
    Projets.md
    Projets/                 ← une page qui a des enfants possède un dossier
      Éditeur.md
      Éditeur/
        Notes de version.md
    assets/                  ← les binaires, à côté de la prose
      3f9a1c…png
  .nbe/                      ← les documents canoniques, en JSON
    019fdbe8-….json
    collections.json

Les binaires vivent dans assets/, à côté de la prose. Une référence interne asset:<empreinte> ne veut rien dire hors de l'application : à l'export elle est réécrite en cheminrelatif à la page qui la porte, pour que le vault reste autonome où qu'on le déplace. Un vault avec des images mortes serait la première chose que « lisible sans l'outil » ne peut pas se permettre.

Les métadonnées : trois endroits, jamais un de plus

Une métadonnée doit aller au seul endroit où elle reste vraie, et il n'y en a que trois.

  1. Le frontmatter YAML, pour ce qui appartient à lapage plutôt qu'à une ligne : son identifiant, son titre, sa discussion. L'identifiant est ce qui fait qu'un aller-retour par un éditeur de texte garde intacts les ancres, les rétroliens et les liens profonds. Une valeur n'est mise entre guillemets que si elle se relirait autrement — un titre avec un deux-points, un titre qui s'écrit « 42 ».
  2. La ligne elle-même, pour ce que le Markdown sait dire : le niveau d'un titre, l'état d'une case à cocher, la langue d'un bloc de code. Un bloc déclare ces propriétés dans son schéma (spelledProps) et elles ne sont jamais écrites deux fois.
  3. Un marqueur en fin de ligne,<!-- nbe:type {…} -->, pour le reste. C'est un commentaire HTML : invisible au rendu de tous les autres outils, inoffensif pour eux, et suffisant pour nous.
---
id: 019fdbe8-7c31-7a4e-9f0b-2c6d1e4a5b70
title: "Éditeur : notes"
---

## Ce qui reste à faire

- [ ] relire la projection
- [x] mesurer la frappe à 500 blocs

> [!warning] ⚠️ La perte est documentée, pas silencieuse

- Le détail <!-- nbe:toggle -->
    - Une ligne qui n'apparaît qu'une fois ouvert

```ts
const html = renderToHTML(doc);
```

Un [rapport](assets/3f9a1c…pdf) <!-- nbe:file {"props":{"mime":"application/pdf","size":184320}} -->

Le marqueur sert deux cas, et le second est celui qu'on oublie. D'abord porter une propriété qu'aucune syntaxe ne dit — le type MIME et la taille d'un fichier joint, que le bloc affiche. Ensuite dire ce que le bloc est : un toggle s'écrit comme une puce, un fichier comme un lien, une sous-page comme un wikilink. Sans marqueur, chacun revient en l'autre bloc — un toggle qui rentre en élément de liste. Un type déclarémarkdownAmbiguous écrit donc son marqueur même quand il n'a rien à transporter : le marqueur est le type.

Et pour un type dont le plugin n'est pas chargé — un sommaire dans un hôte qui n'a que les blocs de base — le marqueur emporte les propriétés et le texte. Markdown étant ici le format de stockage, un marqueur nu ferait de chaque enregistrement une perte de données, pas un détail de rendu.

// vers un fichier que cet éditeur relira : les marqueurs, par défaut
blocksToMarkdown(blocks, { plugins });

// vers l'extérieur (le presse-papiers en texte brut, un aperçu) : sans eux,
// parce qu'un commentaire invisible au rendu reste visible dans un collage
blocksToMarkdown(blocks, { plugins, markers: false });

Le frontmatter : l'entête partagé

Trois tirets, une map YAML, trois tirets, tout en haut du fichier : c'est la seule convention sur laquelle tous les outils Markdown se sont déjà mis d'accord. Obsidian l'affiche en propriétés, Jekyll, Hugo, Astro et Zola le lisent comme des métadonnées, un éditeur de texte le montre comme cinq lignes lisibles. C'est donc là que va tout ce qui parle du document — et nulle part dans la prose.

---
title: "Réunion : 2026/07"     ← le titre que le nom de fichier ne peut pas porter
tags:
  - projet
  - 2026
# une note laissée par quelqu'un : elle ressort telle quelle
aliases: ["Réu"]
nbe: {"comments":[{"id":"t1","blockId":"019f…","messages":[]}]}
---

# Ce qu'on s'est dit

L'entête n'est pas le nôtre : c'est celui du fichier, et quelqu'un d'autre y écrit aussi. Deux règles rendent ça vivable.

  1. Une clé qu'on n'a pas touchée ressort telle quelle — recopiée, pas ré-écrite : la liste tags tapée à la main, ses commentaires et son alignement traversent une sauvegarde intacts. Autrement, ouvrir une note et la refermer laisserait un diff sur les propriétés de quelqu'un, ce que ce projet promet précisément de ne pas faire.
  2. Tout ce qui nous appartient tient sous une seule clé,nbe. Le title, les tags ou même un comments de la note ne peuvent pas entrer en collision avec les nôtres. Les valeurs structurées sont écrites en JSON, qui est le style flow de YAML : valide pour tous les analyseurs, et relu sans embarquer une implémentation YAML.

C'est aussi le point d'extension. Un greffon qui veut retenir quelque chose sur la note appelle setSection avec son propre nom : la section est fusionnée, jamais substituée, donc deux greffons ne s'écrasent pas — et la dernière section retirée emporte la clé nbe avec elle, pour qu'une note sans rien à retenir ne garde aucune trace de cet éditeur.

import { markdownToDocument, documentToMarkdown } from '@nbe/markdown';

const doc = markdownToDocument(await readFile('note.md', 'utf8'));
doc.blocks;                      // la prose
doc.frontmatter.get('tags');     // ['projet', 2026]

// ce qui vous appartient vit dans votre section, jamais à la racine
doc.frontmatter.setSection('révision', { relu: '2026-08-10' });

// tout ce qu'on n'a pas touché ressort octet pour octet
await writeFile('note.md', documentToMarkdown(doc));

Deux choses y ont déménagé. Les fils de commentaires, qui étaient un bloc en fin de note : invisible au rendu, mais du texte quand même — il bougeait dès qu'on ajoutait à la note, le compteur de mots le comptait, la recherche trouvait dedans. Et le titre qu'un nom de fichier ne peut pas porter : « Réunion : 2026/07 » est une note appelée ainsi dans un fichier Réunion 2026 07.md, parce qu'un vault nomme une note <Titre>.md et résout les[[wikilinks]] par ce nom. La propriété n'est écrite que si les deux diffèrent : une note ordinaire reste ordinaire.

Le parsing

markdownToBlocks lit ligne à ligne. Pour chaque bloc candidat, les règles fromMarkdown des plugins sont consultéesavant la table intégrée, dans l'ordre de précédence, et la première correspondance gagne : un plugin peut donc revendiquer une syntaxe. Chaque règle rend le bloc et le nombre de lignes qu'elle a consommées, ce qui permet aux constructions de plusieurs lignes de coexister sans que l'analyseur les devine.

import { markdownToBlocks } from '@nbe/markdown';
import { tableBlocks } from '@nbe/blocks-table';

// Les règles des plugins passent avant la table intégrée, dans l'ordre de
// précédence : un plugin peut donc revendiquer une syntaxe.
const blocks = markdownToBlocks(text, { plugins: editor.plugins });

// Sans le registre, un tableau revient en commentaire-marqueur : visible,
// mais plus un tableau. L'export prend la même option — c'est la paire qui
// rend l'aller-retour stable.

Puis vient la passe en ligne, qui reconnaît ce qu'elle écrit :**gras**, *italique*, ~~barré~~,`code`, [lien](url), ==surlignage==, et les trois balises que Markdown n'a pas —<u>, <sup>, <sub>, plus <mark> parce que c'est ce qu'écrivent les fichiers venus d'ailleurs. La symétrie n'est pas cosmétique : le souligné a été écrit pendant des mois sans jamais être relu, et revenait donc en<u> littéral dans le texte.

Enfin l'import d'un vault entier tranche ce qu'une ligne seule ne peut pas : un wikilink dont la cible est un fichier du dossier de cette page est une sous-page ; tout autre wikilink est un lien.C'est l'inférence qu'une personne ferait en lisant le vault, elle ne demande aucun marqueur à comprendre, et elle a une conséquence qu'on assume : déplacer un fichier à la main dans Obsidian re-parente la page. C'est exactement le sens de « file over app ».

L'affichage

Il n'y a pas de volet d'aperçu, parce qu'il n'y a rien à prévisualiser : le Markdown n'est jamais l'état affiché. Il est analysé une fois en blocs, et ce sont les blocs qui se rendent — dans l'éditeur, ou en HTML par@nbe/static-renderer, sans navigateur ni instance d'éditeur. Le même arbre alimente les deux, donc une page rendue sur un serveur et la même page à l'écran ne peuvent pas diverger.

import { markdownToBlocks, blocksToMarkdown } from '@nbe/markdown';

// entrer : votre fichier, tel qu'il est sur le disque
const blocks = markdownToBlocks(await readFile('note.md', 'utf8'));

// sortir : du Markdown que n'importe quel autre outil relit
await writeFile('note.md', blocksToMarkdown(blocks));

1:1 : ce qui revient à l'identique, et ce qui ne peut pas

Le tableau est dérivé du code, pas d'une intention. « Perdu » n'y veut jamais dire « silencieusement perdu » : une projection qui ne peut pas représenter un bloc émet quand même quelque chose et déclare ce qu'elle a laissé.

ÉlémentAller-retourMécanisme, ou raison
Titres, listes, cases à cocher, citations, code, séparateurs1:1La syntaxe ordinaire suffit à les décrire.
Gras, italique, barré, code, liens1:1Syntaxe Markdown standard.
Souligné, exposant, indice1:1Écrits en <u>, <sup>, <sub> — le HTML qu'Obsidian et tout renderer comprennent déjà, et relus par la même table.
Surlignagepartiel== est le surlignage d'Obsidian, et Markdown n'en a qu'un : la couleur de la palette est perdue, le surlignage non.
Tableaux, callouts, blocs de code, sommaire1:1 avec le pluginSans le registre passé aux deux appels, le bloc tombe sur le marqueur : rien n'est perdu, mais ce n'est plus un tableau.
Toggle, fichier1:1 par marqueurLeur ligne ne peut pas dire ce qu'ils sont — un toggle s'écrit comme une puce, un fichier comme un lien. Le marqueur est alors la seule chose qui le dit.
Images et fichiers joints1:1La référence asset: est réécrite en chemin relatif vers assets/, donc le vault reste autonome où qu'on le déplace.
Sous-page contre lien vers une page1:1 dans un vaultLes deux s'écrivent [[wikilink]]. L'import tranche par le dossier : une cible qui est un fichier du dossier de cette page est un enfant. Hors vault, la distinction est perdue.
Colonnesperdu, documentéLes contenus sont aplatis à la suite. Markdown n'a pas de mise en colonnes, et en inventer une casserait la lisibilité par un autre outil.
Bloc videperduMarkdown ne sait pas écrire « ici il y a un paragraphe, et il est vide ».
Paragraphe coupé à la mainrepliéLes retours durs d'un même paragraphe sont réunis : le texte est identique, sa mise en page dans le fichier non.
Type de bloc inconnupréservé<!-- nbe:type {"props":…,"text":…} --> : props et texte voyagent dans le marqueur, donc un aller-retour dans un hôte qui ne charge pas ce plugin ne le vide pas.

« Supprimez l'application, lisez les fichiers » est une propriété qu'on vérifie, pas une promesse. nbe check est ce test d'acceptation, écrit pour une machine : chaque page doit exister dans le vault, son fichier doit porter son identifiant en frontmatter, il ne doit pas ramener le modèle en fraude sous forme de JSON ou de balises, et chaque asset référencé doit être présent. Il rend ce qui a échoué, pas un booléen — parce qu'un échec ici est la promesse centrale du projet qui casse.

$ nbe check
page "Éditeur : notes" (019fdbe8-…) has no file in the vault
pages/Projets.md: no frontmatter, so its id is lost
asset 3f9a1c… is referenced but not in the vault

Voir aussi Pourquoi cet éditeur etOù ça tourne.