Où ça tourne
Le cœur ne connaît ni le DOM, ni React, ni un serveur. Ce n'est pas une élégance d'architecture : c'est la raison pour laquelle le même éditeur est une bibliothèque à embarquer, une application de bureau, un plugin Obsidian, une application iOS et un pair sans écran, sans qu'aucune de ces formes soit un portage d'une autre.
Une bibliothèque à embarquer
@nbe/core est un éditeur de texte sans interface : un document, un schéma, une sélection, sept opérations, une pile d'annulation. Vous l'embarquez comme vous embarqueriez un moteur de rendu ou un parseur — y compris à l'intérieur d'un produit qui a déjà son propre cadre.
// aucune dépendance au DOM dans le cœur : le même objet
// tourne dans un onglet, dans un test Node, dans un plugin, dans Tauri
import { Editor, createDoc, docToJSON } from '@nbe/core';
const editor = new Editor({ doc: createDoc() });
editor.on(() => host.save(docToJSON(editor.doc)));
// une projection dans le navigateur…
import { EditorView } from '@nbe/dom';
const view = new EditorView(el, editor);
// …ou aucune : du HTML sans navigateur, pour un rendu serveur
import { renderToHTML } from '@nbe/static-renderer';
const html = renderToHTML(docToJSON(editor.doc));Les couches sont séparées pour être remplaçables : @nbe/domprojette dans un navigateur, @nbe/static-renderer projette en HTML sans navigateur, @nbe/markdown projette en Markdown. Aucune n'est privilégiée, et le document ne sait pas laquelle existe. Les liaisons React, Vue et Svelte ont exactement deux dépendances — c'est vérifié en intégration continue : une troisième signifierait que la fonctionnalité appartient à la couche du dessous.
Une application de bureau
Carnet est l'application : une fenêtre Tauri autour de l'éditeur, dont le stockage est un dossier que vous pouvez lire. Vous en choisissez un au premier lancement, et il contient exactement deux choses :
mon-carnet/
pages/ ← le vault Markdown : c'est ça que vous lisez
Projets.md
Projets/
Éditeur.md
.nbe/ ← les documents canoniques, en JSON
019fdbe8-….json
collections.json ← schémas et vues des bases de donnéespages/ est un vault Obsidian : ouvrez-le dans Obsidian et tout y est, la hiérarchie étant l'arborescence des dossiers. Il y a deux copies parce que le Markdown est une projection et non un format de stockage — il ne sait pas écrire un bloc vide, et il replie les paragraphes coupés à la main. Le JSON fait donc foi et le Markdown estrégénéré ; en échange, le Markdown est toujours sûr à lire, à modifier et à comparer dans un git diff.
Un plugin Obsidian
Le même éditeur, sur une note à la fois, et le fichier reste du Markdown. Le menu « / », les poignées, la sélection de blocs, l'autoformat, les tableaux, les callouts et les toggles fonctionnent ; Obsidian garde la propriété du chargement, de l'enregistrement, du renommage et des conflits.
// le plugin Obsidian, dans sa forme la plus courte : le fichier reste
// du Markdown, Obsidian garde la propriété du chargement et de la sauvegarde
import { markdownToBlocks, blocksToMarkdown } from '@nbe/markdown';
const editor = new Editor({ doc: docFromJSON({ …page, children: markdownToBlocks(text) }) });
new EditorView(container, editor);
editor.on(() => obsidian.save(blocksToMarkdown(docToJSON(editor.doc).children)));Ce n'est pas un espace de travail : ni commentaires, ni temps réel, ni fichier annexe, ni dossier .nbe/. Cette frontière est le design, pas une limite à lever plus tard. Le plugin ne confisque pas non plus le Markdown : vos notes s'ouvrent toujours dans l'éditeur d'Obsidian, et Carnet s'active note par note depuis la palette de commandes.
Une application iOS, et un format lu par une autre langue
native/swift est une seconde implémentation du format et du CRDT : elle lit et écrit le même document, avec ses propres commandes structurelles — scinder, fusionner, changer de type, indenter, déplacer — reflétées ligne à ligne depuis packages/core. Un test échoue si les deux tables d'autoformat divergent, parce que c'est exactement le bug qu'une deuxième implémentation produit : les deux côtés marchent, et la même frappe fait deux choses différentes.
L'application iOS s'appuie dessus : SwiftUI, un UITextViewpar bloc — parce que les contrôles de texte SwiftUI ne savent ni signaler un Retour arrière en position 0 ni intercepter Entrée, et que c'est l'essentiel de ce qui fait un éditeur de blocs —, le menu « / », les préfixes Markdown, l'indentation, le réordonnancement et une barre de clavier qui est la réponse du téléphone à la touche Tab.
Un intérêt qui n'était pas prévu : une seconde implémentation trouve les angles morts de la première. Protéger Swift contre l'écriture pendant une composition a mené directement au bug miroir côté web, où une édition distante reconstruisait le DOM sous un mot à moitié tapé.
Entre pairs, sans serveur au milieu
La synchronisation passe par un CRDT (Loro), donc l'ordre d'arrivée des modifications n'a pas d'importance et le mode hors ligne n'est pas un cas particulier. Le transport, lui, tient en une phrase : le relais négocie la connexion, puis sort du chemin, et sert aussi de repli.Il n'y a donc pas de serveur TURN à héberger — un port, un service, et une ligne d'état qui dit quel chemin est actif, parce qu'une optimisation que personne ne peut observer est une optimisation que personne ne peut déboguer.
Trois clients ont tenu un même document de bout en bout : une frappe dans Chrome, lue sur un iPhone, écrite sur le disque par nbe peer.
Sans interface du tout
@nbe/cli fait tourner le même modèle en ligne de commande : lire un vault, chercher, régénérer le miroir Markdown, vérifier que tout reste lisible sans l'outil — etservir un relais de synchronisationen headless, avec un port pour toute configuration.
nbe ls # les pages
nbe cat <id> # une page, en Markdown
nbe search <requête> # recherche classée (index SQLite)
nbe sync # régénère le miroir Markdown
nbe check # vérifie que tout est lisible sans cet outil
nbe relay --port 8787 # relais entre pairs
nbe serve --port 8787 # relais + pair permanent (un NAS)
nbe peer <salle> # un pair WebRTC sans écran, qui écrit sur le disque