Files
Momento/memento-note/lib/editor/markdown-export.ts
Antigravity ee70e74bf5
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Successful in 22s
fix: 5 bugs critiques de l'éditeur (Phase 1 audit)
1. replaceAll (Find & Replace) — une seule transaction ProseMirror
   au lieu d'un forEach cassé. Tous les matchs sont maintenant remplacés.

2. Link Preview unwrap — deleteNode() au lieu de clearer les attrs
   qui laissaient un nœud fantôme invisible dans le document.

3. Conversion Markdown → richtext — breaks: true dans marked.parse()
   Les simple newlines sont maintenant convertis en <br>.
   + préserve les blocs custom (toggle, callout, math, columns,
   outline, link-preview) en commentaires HTML lors de l'export MD.

4. emitNoteChange exercices — shape corrigée (type:'created' attend
   un objet Note, pas noteId/notebookId séparés).

5. Raccourcis clavier sans conflit :
   Cmd+Shift+C → Cmd+Alt+C (callout, avant: copier)
   Cmd+Shift+O → Cmd+Alt+O (outline, avant: historique/signets)
   Cmd+Shift+L → Cmd+Alt+L (colonnes, avant: lock screen macOS)
2026-06-20 15:48:18 +00:00

275 lines
9.8 KiB
TypeScript

/**
* markdown-export.ts
* Utilities for TipTap HTML ↔ Markdown conversion.
*
* Uses:
* - turndown (+ turndown-plugin-gfm) : HTML → Markdown
* - marked : Markdown → HTML
*/
import TurndownService from 'turndown'
import { tables, taskListItems, strikethrough } from 'turndown-plugin-gfm'
import { marked } from 'marked'
// ── Markdown heuristic detection ────────────────────────────────────────────
const MARKDOWN_PATTERNS = [
/^#{1,6}\s/m, // headings
/^\s*[-*+]\s/m, // unordered list
/^\s*\d+\.\s/m, // ordered list
/^\s*>\s/m, // blockquote
/^```/m, // code fence
/`[^`]+`/, // inline code
/\*\*[^*]+\*\*/, // bold
/\*[^*]+\*/, // italic
/^[|].+[|]/m, // table
/\[.+\]\(.+\)/, // link
/!\[.+\]\(.+\)/, // image
/~~[^~]+~~/, // strikethrough
]
/**
* Returns true if the given plain text looks like it contains Markdown syntax.
* Used by the paste handler to decide whether to convert before inserting.
*/
export function looksLikeMarkdown(text: string): boolean {
if (!text || text.trim().length < 3) return false
return MARKDOWN_PATTERNS.some((re) => re.test(text))
}
// ── Turndown service factory ─────────────────────────────────────────────────
function createTurndownService(): TurndownService {
const td = new TurndownService({
headingStyle: 'atx',
hr: '---',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
fence: '```',
emDelimiter: '_',
strongDelimiter: '**',
linkStyle: 'inlined',
linkReferenceStyle: 'full',
})
// GFM plugins: tables + task lists + strikethrough
td.use([tables, taskListItems, strikethrough])
// Custom rule: liveBlock → HTML comment
td.addRule('liveBlock', {
filter(node) {
return (
node.nodeName === 'DIV' &&
(node as HTMLElement).hasAttribute('data-live-block')
)
},
replacement(_content, node) {
const el = node as HTMLElement
const sourceNoteId = el.getAttribute('sourcenoteId') || el.getAttribute('sourcenoteId') || el.getAttribute('sourcenoteid') || ''
const blockId = el.getAttribute('blockId') || el.getAttribute('blockid') || ''
return `\n\n<!-- live-block: ${sourceNoteId}#${blockId} -->\n\n`
},
})
// Custom rule: structuredViewBlock → HTML comment
td.addRule('structuredViewBlock', {
filter(node) {
return (
node.nodeName === 'DIV' &&
(node as HTMLElement).hasAttribute('data-structured-view-block')
)
},
replacement(_content, node) {
const el = node as HTMLElement
const attrs: Record<string, string> = {}
for (const attr of Array.from(el.attributes)) {
if (attr.name !== 'data-structured-view-block') {
attrs[attr.name] = attr.value
}
}
return `\n\n<!-- structured-view: ${JSON.stringify(attrs)} -->\n\n`
},
})
return td
}
// Singleton (lazy-init) — safe for server + client usage
let _tdService: TurndownService | null = null
function getTurndownService(): TurndownService {
if (!_tdService) _tdService = createTurndownService()
return _tdService
}
// ── Custom node pre-processor ─────────────────────────────────────────────
// Sentinel prefix — alphanumeric only to avoid Markdown escaping by turndown
const SENTINEL_PREFIX = 'MOMENTOBLOCKSENTINEL'
interface BlockPlaceholder {
key: string
comment: string
}
/**
* Pre-process HTML before passing to turndown:
* - Replace empty custom node divs (liveBlock, structuredViewBlock) with text
* placeholders so they survive turndown processing (turndown drops blank nodes
* and strips HTML comments).
* - Return the modified HTML and a map of placeholder → HTML comment.
*/
function preprocessCustomNodes(html: string): { html: string; placeholders: BlockPlaceholder[] } {
const placeholders: BlockPlaceholder[] = []
// liveBlock
let result = html.replace(
/<div([^>]*?data-live-block[^>]*?)>\s*<\/div>/gi,
(_match, attrs) => {
const snId = (attrs.match(/sourcenoteid="([^"]*)"/i) || attrs.match(/sourcenoteid='([^']*)'/i) || [])[1] || ''
const bId = (attrs.match(/blockid="([^"]*)"/i) || attrs.match(/blockid='([^']*)'/i) || [])[1] || ''
const key = `${SENTINEL_PREFIX}LIVEBLOCK${placeholders.length}`
placeholders.push({ key, comment: `<!-- live-block: ${snId}#${bId} -->` })
return `<p>${key}</p>`
}
)
// structuredViewBlock
result = result.replace(
/<div([^>]*?data-structured-view-block[^>]*?)>\s*<\/div>/gi,
(_match, attrs) => {
const attrMap: Record<string, string> = {}
const attrRe = /(data-[a-z-]+)="([^"]*)"/gi
let m: RegExpExecArray | null
while ((m = attrRe.exec(attrs)) !== null) {
if (m[1] !== 'data-structured-view-block') attrMap[m[1]] = m[2]
}
const key = `${SENTINEL_PREFIX}SVBLOCK${placeholders.length}`
placeholders.push({ key, comment: `<!-- structured-view: ${JSON.stringify(attrMap)} -->` })
return `<p>${key}</p>`
}
)
// toggleBlock — preserve as HTML comment
result = result.replace(
/<div([^>]*?data-type="toggle-block"[^>]*?)>([\s\S]*?)<\/div>/gi,
(_match, attrs, content) => {
const opened = attrs.match(/data-opened="([^"]*)"/i)?.[1] || 'true'
const key = `${SENTINEL_PREFIX}TOGGLE${placeholders.length}`
placeholders.push({ key, comment: `<!-- toggle: opened=${opened} -->${content}\n<!-- /toggle -->` })
return `<p>${key}</p>`
}
)
// calloutBlock
result = result.replace(
/<div([^>]*?data-type="callout-block"[^>]*?)>([\s\S]*?)<\/div>/gi,
(_match, attrs, content) => {
const type = attrs.match(/data-callout-type="([^"]*)"/i)?.[1] || 'info'
const key = `${SENTINEL_PREFIX}CALLOUT${placeholders.length}`
placeholders.push({ key, comment: `<!-- callout: type=${type} -->${content}\n<!-- /callout -->` })
return `<p>${key}</p>`
}
)
// mathEquationBlock
result = result.replace(
/<div([^>]*?data-type="math-equation"[^>]*?)>([\s\S]*?)<\/div>/gi,
(_match, attrs, content) => {
const latex = attrs.match(/data-latex="([^"]*)"/i)?.[1] || content.trim()
const key = `${SENTINEL_PREFIX}MATH${placeholders.length}`
placeholders.push({ key, comment: `<!-- math: ${latex} -->` })
return `<p>${key}</p>`
}
)
// outlineBlock
result = result.replace(
/<div[^>]*data-type="outline-block"[^>]*><\/div>/gi,
() => {
const key = `${SENTINEL_PREFIX}OUTLINE${placeholders.length}`
placeholders.push({ key, comment: `<!-- outline -->` })
return `<p>${key}</p>`
}
)
// columns
result = result.replace(
/<div([^>]*?data-type="columns"[^>]*?)>([\s\S]*?)<\/div>/gi,
(_match, attrs, content) => {
const cols = attrs.match(/cols="([^"]*)"/i)?.[1] || '2'
const key = `${SENTINEL_PREFIX}COLUMNS${placeholders.length}`
placeholders.push({ key, comment: `<!-- columns: cols=${cols} -->${content}\n<!-- /columns -->` })
return `<p>${key}</p>`
}
)
// linkPreviewBlock
result = result.replace(
/<div([^>]*?data-type="link-preview-block"[^>]*?)>[\s\S]*?<\/div>/gi,
(_match, attrs) => {
const url = attrs.match(/data-url="([^"]*)"/i)?.[1] || ''
const key = `${SENTINEL_PREFIX}LINKPREVIEW${placeholders.length}`
placeholders.push({ key, comment: `<!-- link-preview: ${url} -->` })
return `<p>${key}</p>`
}
)
return { html: result, placeholders }
}
/**
* Post-process the markdown output: replace sentinel placeholders with HTML comments.
*/
function postprocessPlaceholders(md: string, placeholders: BlockPlaceholder[]): string {
let result = md
for (const { key, comment } of placeholders) {
result = result.replace(key, `\n\n${comment}\n\n`)
}
return result
}
// ── HTML → Markdown ──────────────────────────────────────────────────────────
/**
* Convert a TipTap-generated HTML string to GitHub-Flavored Markdown.
* Custom nodes (liveBlock, structuredViewBlock) are serialised as HTML comments.
*/
export function tiptapHTMLToMarkdown(html: string): string {
if (!html || html.trim() === '') return ''
const { html: preprocessed, placeholders } = preprocessCustomNodes(html)
const td = getTurndownService()
const md = td.turndown(preprocessed).trim()
return postprocessPlaceholders(md, placeholders).trim()
}
// ── Markdown → HTML ──────────────────────────────────────────────────────────
/**
* Convert a Markdown string to HTML suitable for injection into TipTap via
* `editor.commands.setContent(html)`.
*
* Uses marked with GFM enabled (tables, task lists, line breaks).
*/
export function markdownToHTML(markdown: string): string {
if (!markdown || markdown.trim() === '') return ''
// breaks: true — single \n becomes <br>, matching WYSIWYG expectations
const html = marked.parse(markdown, {
gfm: true,
breaks: true,
}) as string
return html
}
// ── Title extraction from Markdown ──────────────────────────────────────────
/**
* Extract the first H1 title from a Markdown string.
* Returns null if no H1 is found.
*/
export function extractMarkdownTitle(markdown: string): string | null {
const match = markdown.match(/^#\s+(.+)/m)
return match ? match[1].trim() : null
}