Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
7.9 KiB
Brief Technique : US-EDITOR-MARKDOWN — Round-Trip TipTap ↔ Markdown
ID: US-EDITOR-MARKDOWN Priorité: Beta blocker (confirmé) Dépendances: Rich-text editor TipTap existant (
rich-text-editor.tsx) Complexité estimée: Moyenne (2–5 jours selon approche choisie)
Contexte
TipTap/ProseMirror stocke le contenu en JSON natif (format ProseMirror Doc). Actuellement :
- L'éditeur lit et écrit du JSON en base de données
marked,react-markdown,remark-gfmsont installés mais utilisés uniquement pour l'affichage de contenu Markdown externe (Web Clipper, import)- Il n'existe aucune sérialisation TipTap → Markdown ni Markdown → TipTap dans le code actuel
L'objectif est un round-trip fidèle :
Note TipTap (JSON) → Export Markdown → Re-import → JSON identique (byte-for-byte sur les éléments supportés)
Analyse des Options
Option A — @tiptap/extension-markdown (recommandé ✅)
Package : @tiptap/extension-markdown (officiel TipTap, payant pour certaines extensions avancées — vérifier licence)
Principe :
- Extension TipTap officielle qui ajoute une méthode
.storage.markdown.getMarkdown()sur l'éditeur - Import Markdown via
editor.commands.setContent(markdownString, { parseOptions: { markdown: true } }) - Supporte GFM (GitHub Flavored Markdown) : tableaux, listes de tâches, code fences
Avantages :
- Intégration native TipTap — zéro friction
- Maintenu par l'équipe TipTap
- Support des nœuds custom via
markdownSerializersur chaque extension
Inconvénients :
- Certaines extensions sont sous licence TipTap Pro ($149/mois)
- Les nœuds custom de Memento (
liveBlock,structuredViewBlock) nécessitent des serializers manuels - Round-trip parfait impossible pour ces nœuds (dégradation gracieuse : placeholder HTML comment)
Implémentation :
// Installation
npm install @tiptap/extension-markdown
// Dans rich-text-editor.tsx
import { Markdown } from '@tiptap/extension-markdown'
const editor = useEditor({
extensions: [
// ... extensions existantes ...
Markdown.configure({
html: false, // Désactiver HTML brut dans le MD
tightLists: true, // Listes compactes
tightListClass: 'tight',
bulletListMarker: '-',
linkify: false,
breaks: false,
transformPastedText: true, // Coller du Markdown → conversion auto
transformCopiedText: false,
}),
],
})
// Export
const markdown = editor.storage.markdown.getMarkdown()
// Import
editor.commands.setContent(markdownString)
Option B — prosemirror-markdown (alternative robuste ✅)
Package : prosemirror-markdown (officiel ProseMirror)
Principe :
- Bibliothèque bas niveau qui fournit un
MarkdownSerializeret unMarkdownParser - S'intègre à TipTap via le schéma ProseMirror sous-jacent
- Utilise
remark(déjà installé) pour le parsing
Avantages :
- Open source, pas de licence Pro
- Contrôle total du serializer (chaque nœud est défini explicitement)
- Utilisé en production par de nombreux éditeurs (GitLab, Linear)
Inconvénients :
- Plus verbeux — chaque extension TipTap nécessite son
toMarkdownetfromMarkdown - Effort initial plus élevé (~2 jours de mapping)
- À maintenir à chaque nouvelle extension ajoutée
Implémentation :
import { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown'
import { MarkdownParser } from 'prosemirror-markdown'
import markdownit from 'markdown-it'
// Serializer — mapper chaque nœud TipTap
const momentoSerializer = new MarkdownSerializer(
{
...defaultMarkdownSerializer.nodes,
// Nœuds Memento custom
liveBlock: (state, node) => {
state.write(`<!-- live-block: ${node.attrs.sourceNoteId}#${node.attrs.blockId} -->`)
state.closeBlock(node)
},
structuredViewBlock: (state, node) => {
state.write(`<!-- structured-view: ${JSON.stringify(node.attrs)} -->`)
state.closeBlock(node)
},
},
defaultMarkdownSerializer.marks
)
// Export
const markdown = momentoSerializer.serialize(editor.state.doc)
// Parser
const md = markdownit('commonmark', { html: true })
const parser = new MarkdownParser(editor.schema, md, { /* token map */ })
const doc = parser.parse(markdownString)
editor.commands.setContent(doc.toJSON())
Option C — Milkdown (remplacement complet ❌ déconseillé)
Milkdown est un éditeur Markdown-first qui remplace TipTap/ProseMirror. Le migrer vers Milkdown signifie :
- Réécriture complète de
rich-text-editor.tsx(~800 lignes) - Perte de toutes les extensions custom (Living Blocks, Structured Views, Smart Paste, etc.)
- Délai estimé : 2-4 semaines
Verdict : ❌ Trop risqué, trop long pour la beta.
Recommandation
Court terme (beta) : Option A (@tiptap/extension-markdown)
Raisons :
- Intégration en 1 journée dans l'éditeur existant
- Couvre 95% des cas d'usage (texte, listes, headings, code, tables, tâches)
- Le
transformPastedText: truerésout aussi un bug UX courant (coller du Markdown brut) - Les nœuds Memento non-supportés sont préservés via commentaires HTML (dégradation gracieuse)
Long terme : Option B en complément
Pour les cas avancés (export propre des nœuds custom, CI de round-trip byte-for-byte), implémenter Option B en remplacement d'Option A une fois la beta stabilisée.
Scope de l'US-EDITOR-MARKDOWN (Beta)
Ce qui est inclus
| Feature | Priorité |
|---|---|
Export note → fichier .md téléchargeable |
🔴 P0 |
| Coller du Markdown → conversion auto en blocs TipTap | 🔴 P0 |
Import note depuis fichier .md |
🟡 P1 |
| Copy note as Markdown (dans le presse-papier) | 🟡 P1 |
| Round-trip fidèle pour : headings, bold, italic, lists, tasks, code, blockquote, links, tables | 🔴 P0 |
Dégradation gracieuse pour liveBlock et structuredViewBlock (commentaire HTML) |
🟡 P1 |
Ce qui est exclu (post-beta)
- Round-trip byte-for-byte des nœuds Memento custom
- Édition native en mode source Markdown (raw text editor)
- Sync bidirectionnelle temps réel Markdown ↔ TipTap
Fichiers à créer / modifier
| Fichier | Action |
|---|---|
memento-note/package.json |
Ajouter @tiptap/extension-markdown |
components/rich-text-editor.tsx |
Ajouter extension Markdown à la config TipTap |
lib/editor/markdown-export.ts |
Créer — helper tiptapDocToMarkdown(doc) et markdownToTiptapDoc(md) |
components/note-actions.tsx |
Ajouter action "Exporter en Markdown" |
app/api/notes/[id]/export/route.ts |
Créer — GET /api/notes/:id/export?format=markdown |
locales/en.json + fr.json |
Ajouter clés editor.exportMarkdown, editor.importMarkdown, editor.pasteMarkdown |
Critères d'acceptation
Given j'ai une note avec du contenu riche (headings, listes, code, tasks)
When je clique "Exporter en Markdown"
Then je télécharge un fichier .md avec le contenu fidèlement sérialisé
Given je copie du Markdown depuis un éditeur externe When je colle dans l'éditeur Memento Then le Markdown est automatiquement converti en blocs TipTap correspondants (pas du texte brut)
Given j'importe un fichier .md
When le parsing est terminé
Then les headings/listes/code/tables sont correctement représentés dans l'éditeur
Given ma note contient un liveBlock (bloc vivant)
When j'exporte en Markdown
Then le bloc vivant est exporté en commentaire HTML <!-- live-block: ... --> sans erreur
Estimation
| Tâche | Durée estimée |
|---|---|
Installation + config @tiptap/extension-markdown |
2h |
Helper lib/editor/markdown-export.ts |
2h |
| Route API export + action UI | 2h |
Import fichier .md via modal |
3h |
| Tests manuels round-trip + fix edge cases | 4h |
| i18n (EN + FR) | 1h |
| Total | ~2 jours |