UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

1,014 lines (1,013 loc) 70.7 kB
import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Image from '@tiptap/extension-image'; import { Table } from '@tiptap/extension-table'; import { TableRow } from '@tiptap/extension-table-row'; import { TableHeader } from '@tiptap/extension-table-header'; import { TableCell } from '@tiptap/extension-table-cell'; import { Markdown } from 'tiptap-markdown'; import { restoreWikiLinks, rewriteWikiLinks } from './linking.js'; import { createSlugTracker } from './slugify.js'; const FRONTMATTER_RE = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/; // Match any markdown inline link: `[text](url)`. We don't restrict the // text or URL further at the regex level — instead, isFragileLink() // inspects each match to decide whether Tiptap would mangle it. const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; // Match a `**…**` bold span whose contents contain at least one inline // code segment. ProseMirror's flat-mark schema can't cleanly represent a // bold wrapping inline code, so Tiptap shifts the bold delimiters around // the code in non-obvious ways on serialize. We placeholder these spans // during preprocess and restore them after. // // Pattern detail: // \*\* opening ** // ([^*\n]*? any non-`*`, non-newline chars, lazy // `[^`\n]+` at least one `` `inline code` `` segment // [^*\n]*?) then more non-`*` chars (lazy) // \*\* closing ** // // The lazy quantifiers keep us from spanning multiple bold groups. const BOLD_AROUND_CODE_RE = /\*\*([^*\n]*?`[^`\n]+`[^*\n]*?)\*\*/g; // Token used to placeholder `\|` escapes. Chosen so it's: // - ASCII letters/digits only (survives Tiptap's parse/serialize round trip) // - distinctive enough to never collide with real document content const PIPE_ESCAPE_TOKEN = 'TIPTAPPIPEESCX'; /** * Decide whether a markdown inline link will be mangled by Tiptap, in * which case we should placeholder it during preprocess. * * Two failure modes are known: * * 1. Link text is purely inline code (`[\`x\`](url)`). tiptap-markdown * drops the surrounding `[...](url)` and leaves just `\`x\``. * * 2. URL is a relative path with subdirectory but no leading prefix * (`scripts/foo.mjs`, `references/output.md`). The Link extension's * URL validator rejects these as non-URLs; the link is silently * dropped on parse and the text alone survives. * * URLs Tiptap accepts and we leave alone: * - Absolute URLs (`https://`, `http://`, `mailto:`, `tel:`, `ftp:`) * - Anchors (`#section`) * - Single-segment relative paths (`file.md`, `file.md#section`) * - Explicitly-relative paths (`./foo`, `../foo`, `/foo`) */ function isFragileLink(text, url) { // Code-text link: text is exactly `` `...` `` with nothing else. if (/^`[^`]+`$/.test(text)) return true; // URL has no scheme prefix and no leading-slash / relative-prefix // and contains at least one path separator → Tiptap rejects it. const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(url); const hasRelativePrefix = url.startsWith('./') || url.startsWith('../') || url.startsWith('/') || url.startsWith('#'); if (!hasScheme && !hasRelativePrefix && url.includes('/')) return true; return false; } /** * Pre-process a document before handing it to Tiptap. Returns a context * object that `applyPostProcess` uses to restore stripped portions. */ export function preprocessForEditor(input) { const eol = input.includes('\r\n') ? '\r\n' : '\n'; // Normalise to LF for the editor — Tiptap's parser doesn't reliably // preserve CRLF, and we'll re-introduce it on output. const lf = eol === '\r\n' ? input.replace(/\r\n/g, '\n') : input; const frontMatch = lf.match(FRONTMATTER_RE); const frontmatter = frontMatch ? frontMatch[0] : ''; let afterFront = frontmatter ? lf.slice(frontmatter.length) : lf; // Capture leading blank lines that appeared AFTER the frontmatter so // we can put them back. Tiptap's parser strips them. const gap = afterFront.match(/^\n*/)?.[0] ?? ''; afterFront = afterFront.slice(gap.length); const trailingNewline = afterFront.endsWith('\n') ? '\n' : ''; // tiptap-markdown drops the URL on certain link shapes (see // isFragileLink — currently code-text links and bare-relative-subpath // links). Replace those with ASCII placeholders that survive the // parse-and-serialize round-trip unchanged; we restore them in // applyPostProcess. const codeLinks = []; let withPlaceholders = afterFront; let codeLinkIndex = 0; withPlaceholders = withPlaceholders.replace(INLINE_LINK_RE, (match, text, url) => { if (!isFragileLink(text, url)) return match; const placeholder = `TIPTAPCODELINK${String(codeLinkIndex).padStart(4, '0')}`; codeLinks.push({ placeholder, original: match }); codeLinkIndex += 1; return placeholder; }); // Bold spans containing inline code are restructured by Tiptap on // round-trip (the bold mark gets shifted around the code in ways // ProseMirror's flat-mark schema can express). Placeholder them // alongside fragile links — same trick, same restore pass. const boldCodeRuns = []; let boldCodeIndex = 0; withPlaceholders = withPlaceholders.replace(BOLD_AROUND_CODE_RE, (match) => { const placeholder = `TIPTAPBOLDCODE${String(boldCodeIndex).padStart(4, '0')}`; boldCodeRuns.push({ placeholder, original: match }); boldCodeIndex += 1; return placeholder; }); // Authors escape `|` as `\|` inside table cells when the cell // contains literal pipes (Mermaid edge labels in code, shell // pipelines, etc.) — bare `|` would split the cell. Tiptap's // serializer drops the backslash and the table re-parses with a // different shape next time. Replace with an ASCII token; restore // after serialize. let pipeEscapeCount = 0; withPlaceholders = withPlaceholders.replace(/\\\|/g, () => { pipeEscapeCount += 1; return PIPE_ESCAPE_TOKEN; }); // Tiptap mutates trailing newlines — we trim and put it back. Wikilinks // are rewritten to a placeholder shape that survives Tiptap. const editorInput = rewriteWikiLinks(withPlaceholders); return { editorInput, context: { originalInput: input, frontmatter, frontmatterGap: gap, trailingNewline, eol, codeLinks, boldCodeRuns, pipeEscapeCount, }, }; } /** * Post-process the markdown Tiptap emits back into the user's expected * form: re-attach frontmatter, restore wikilink syntax, restore trailing * newline, undo unnecessary character escapes, and re-apply the original * EOL convention. */ export function applyPostProcess(serialized, context) { let out = restoreWikiLinks(serialized); // Restore code-text links replaced with placeholders during preprocess. // Done before any other repair so subsequent text-shape fixups operate // on the original markdown form. for (const { placeholder, original } of context.codeLinks) { out = out.split(placeholder).join(original); } // Restore `**…\`code\`…**` placeholder runs alongside the link // restore — same shape, different schema-level reason for needing it. for (const { placeholder, original } of context.boldCodeRuns) { out = out.split(placeholder).join(original); } // Restore escaped pipe placeholders. Each token unconditionally maps // back to `\|` regardless of position — the user's escape is // syntactically required wherever it appears. if (context.pipeEscapeCount > 0) { out = out.split(PIPE_ESCAPE_TOKEN).join('\\|'); } // Tiptap's serializer over-escapes characters that have no syntactic // meaning in the position they appear. We selectively unescape: // - `\[` and `\]` outside link constructs (so `- [x] task` stays `- [x] task`) // - `\~` (we already disabled strike, but tiptap-markdown's // escape pass can still emit `\~` for any `~` it wasn't sure // about — reverse it). // We do this with conservative regexes that don't touch valid escapes // inside fenced code blocks or inline code. out = unescapeSafeChars(out, context.originalInput); // Tiptap's HTML output path HTML-escapes bare `<` characters in // prose because they could in theory open a tag. tiptap-markdown // then serialises the entity as a literal `&lt;`. Reverse the // entity in positions where CommonMark says `<` could not have been // a tag opener (followed by space, digit, `$`, etc.) — preserves // the source bytes without changing parser interpretation. out = unescapeHtmlEntitiesInProse(out, context.originalInput); // Tiptap serialises CommonMark hard breaks (two trailing spaces in // the source) either as a `\` line-continuation or by dropping them // entirely (inside list items). Restore the original two-space form // wherever the source used it. out = restoreTrailingHardBreaks(out, context.originalInput); // Tiptap normalises GFM table separator rows to a spaced form // (`| --- | --- |`) regardless of input shape. If the original used // a more compact form (`|---|---|`), restore it line-by-line. out = restoreTableSeparatorStyle(out, context.originalInput); // tiptap-markdown is configured with `bulletListMarker: '-'` so every // bullet is emitted as `- `. If the source used `*` (or a mix), we'd // overwrite the user's preference on every save. Restore the original // marker by mapping output bullet lines onto their corresponding // source bullet lines positionally. out = restoreBulletMarkers(out, context.originalInput); // Tiptap inserts a leading blank line when the document starts with // a block element. Strip it so we can re-attach the original // post-frontmatter spacing exactly. out = out.replace(/^\n+/, ''); // Tiptap (with `breaks: false`) joins consecutive non-blank lines // inside a paragraph with a space — that's CommonMark's soft-break // semantics. The user's source had them as separate lines, so the // file has been "modified" even though the visible content is the // same. Restore the original line breaks where Tiptap collapsed them. // This MUST run before collapseBlockSeparators because the latter // matches the surrounding lines against pairs from the original — and // those pairs are line-wise, not paragraph-wise. out = restoreSoftBreaks(out, context.originalInput); // Tiptap normalises block separators to a blank line. If the user // authored adjacent blocks with single-line separators, restore the // original single-line spacing. out = collapseBlockSeparators(out, context.originalInput); // Tiptap's serializer can leave its own trailing newline; normalise to // exactly the trailing-newline state the original had. out = out.replace(/\n+$/, '') + context.trailingNewline; // Re-attach frontmatter at the very top, with the original gap. if (context.frontmatter) { out = context.frontmatter + context.frontmatterGap + out; } // Apply original EOL convention. if (context.eol === '\r\n') { out = out.replace(/\n/g, '\r\n'); } return out; } /** * Tiptap's table serializer always outputs separator rows in the spaced * form `| --- | --- |`. If the source document used a more compact form * (`|---|---|`), or any other consistent form, restore that style by * collecting the separator rows from the original and matching them * positionally to the separators in the output. Both forms are valid GFM * and parse identically — this is purely cosmetic and keeps autosave from * emitting one-line edit_block calls just because of whitespace. */ function restoreTableSeparatorStyle(serialized, originalInput) { // Identify separator rows. A separator row matches /^\|([:\-\s|]+)\|$/ // — only `:`, `-`, `|`, and whitespace. const SEP_RE = /^\|[\s:\-|]+\|$/; const origSeparators = originalInput .replace(/\r\n/g, '\n') .split('\n') .filter((line) => SEP_RE.test(line)); if (origSeparators.length === 0) return serialized; const outLines = serialized.split('\n'); let sepIndex = 0; for (let i = 0; i < outLines.length; i += 1) { if (SEP_RE.test(outLines[i]) && sepIndex < origSeparators.length) { // Confirm the column count matches before substituting; if it // doesn't, the table has been edited and we leave the new // form alone (otherwise we'd corrupt the user's structural // changes). const origCols = origSeparators[sepIndex].split('|').length; const outCols = outLines[i].split('|').length; if (origCols === outCols) { outLines[i] = origSeparators[sepIndex]; } sepIndex += 1; } } return outLines.join('\n'); } /** * Restore the user's original bullet-list marker style. * * tiptap-markdown's serializer has a single `bulletListMarker` config * (we set it to `-`). That means a source file written with `*` bullets * comes back with `-` bullets — no data loss, but the file diff is full * of one-character changes the user didn't make. * * Strategy: collect every "bullet line" from the original (lines starting * with optional indent + `*`/`-`/`+` + space), in order. Walk the output; * for each bullet line, restore the marker style at the same ordinal * position. If the structure shifted (the user added a bullet that wasn't * in the source), trailing extra bullets keep the editor's `-` style — * that's correct for new content. */ function restoreBulletMarkers(serialized, originalInput) { const BULLET_RE = /^(\s*)([*\-+])(\s)/; const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); // Collect markers in source order. We index purely by position in // the bullet sequence — no attempt to match by content, so re-ordered // bullets still get sensible markers. const origMarkers = []; for (const line of origLines) { const m = line.match(BULLET_RE); if (m) origMarkers.push(m[2]); } if (origMarkers.length === 0) return serialized; const outLines = serialized.split('\n'); let bulletIdx = 0; for (let i = 0; i < outLines.length; i += 1) { const m = outLines[i].match(BULLET_RE); if (!m) continue; const wanted = origMarkers[bulletIdx]; if (wanted && wanted !== m[2]) { outLines[i] = m[1] + wanted + m[3] + outLines[i].slice(m[0].length); } bulletIdx += 1; } return outLines.join('\n'); } /** * Restore soft line-breaks Tiptap collapsed. * * tiptap-markdown is configured with `breaks: false`, which matches * CommonMark's default: a single newline inside a paragraph is treated as * a soft break and rendered/serialised as a single space. So an input of * * First line. * Second line. * * comes back as `First line. Second line.` — same visible content, but * the file on disk now differs from what the user authored. This function * walks pairs of adjacent non-blank lines from the original and, where * Tiptap joined them with a space, restores the original line break. * * Limitations: if the user actually had `First line. Second line.` on a * single line in the source, we won't break it (we only re-introduce * breaks that existed in the source). If the same `A` line appears * multiple times in the source followed by different `B` lines, we * conservatively only repair the FIRST match — the rest are left as * Tiptap emitted them (rare in practice). */ function restoreSoftBreaks(serialized, originalInput) { const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); let out = serialized; for (let i = 0; i < origLines.length - 1; i += 1) { const a = origLines[i]; const b = origLines[i + 1]; // Only consider pairs where BOTH lines are non-blank prose. A // blank line means the pair was paragraph-separated, which Tiptap // already serialises as `\n\n` — handled elsewhere. if (!a || !b) continue; // Skip lines that look like markdown structure: list markers, // headings, fences, table rows, blockquotes. Tiptap handles those // as their own block kinds; we don't want to break list items in // half — EXCEPT for the specific case of a list item followed by // its 2-space-indented lazy continuation. CommonMark joins those // into one paragraph too, and Tiptap collapses them into a single // line. The source authored them as separate lines so we must // restore the break. const aIsListHeader = /^\s*([-*+]|\d+\.)\s/.test(a); const bIsIndentedCont = /^ +\S/.test(b) && !/^\s*([-*+]|\d+\.)\s/.test(b); const isListContinuation = aIsListHeader && bIsIndentedCont; if (!isListContinuation) { if (looksStructural(a) || looksStructural(b)) continue; } const broken = `${a}\n${b}`; if (out.indexOf(broken) !== -1) continue; // Tiptap joins paragraph-internal lines with EITHER a space (the // common case for prose) OR no separator at all (when the // boundary is between punctuation like `)` and a non-letter // character like an emoji). For list-item lazy continuations, // Tiptap STRIPS the leading whitespace from the second line and // then joins with a single space, so we have to compare against // the de-indented form of `b`. const candidates = [ { joiner: ' ', b }, { joiner: '', b }, ]; if (isListContinuation) { const deindented = b.replace(/^\s+/, ''); candidates.push({ joiner: ' ', b: deindented }); } for (const { joiner, b: bForm } of candidates) { const joined = `${a}${joiner}${bForm}`; const idx = out.indexOf(joined); if (idx === -1) continue; out = out.slice(0, idx) + broken + out.slice(idx + joined.length); break; } } return out; } /** * Heuristic: does this line look like markdown structure (heading, list, * fence, table, blockquote) rather than plain prose? Used by * restoreSoftBreaks to avoid mangling structural content. */ function looksStructural(line) { return /^\s*(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```|\|.*\|\s*$|---|\s*$)/.test(line); } /** * Unescape characters that tiptap-markdown's serializer over-escapes. * We only undo escapes for characters that are NEVER syntactically active * in plain prose: brackets in body text, tildes outside strikethrough, * etc. * * Round-trip safety: only undo an escape if the SAME escape was not * already present in the original source. If the user's file had `\~190M` * literally (e.g. left over from a previous Tiptap save before we * disabled strike), we leave it alone. If the editor introduced a NEW * escape that wasn't in the source, we remove it. This preserves the * file-on-disk vs. cleaning-up tension on the safe side. * * Code fences are skipped so language-internal escapes survive. */ function unescapeSafeChars(md, originalInput) { // The fix is per-line, not per-document. For each output line, find a // matching source line by stripping all `\X` escapes from candidates; // if a stripped source line equals the output line (after also // stripping the same escapes), the user did NOT author those escapes // in this region and we may safely remove them. If no source line // matches even after stripping, we err on the safe side and keep the // escapes (they may be intentional). const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); let insideFence = false; const lines = md.split('\n'); for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; if (/^\s*```/.test(line)) { insideFence = !insideFence; continue; } if (insideFence) continue; // Quick check: if no candidate escapes are even present in this // output line, nothing to do. if (!/\\[\[\]~]/.test(line)) continue; const stripped = stripSafeEscapes(line); // Does ANY source line match this output line, with both sides // stripped of safe escapes? If yes, the source had this content // without those escapes, so Tiptap added them — strip them. const sourceHasEquivalent = origLines.some((origLine) => stripSafeEscapes(origLine) === stripped); if (sourceHasEquivalent) { // Look for an exact source line match (escapes intact). If // there's an exact match, use it to know which escapes were // authored vs added. const exact = origLines.find((origLine) => origLine === line); if (exact !== undefined) { // Source had this exact line including escapes — preserve. continue; } // Source had the equivalent without authoring these escapes — // strip them. lines[i] = stripped; } // Otherwise: source line is genuinely different from output. Could // be an edit, could be a region we don't have a per-line match // for. Leave the escapes alone — round-trip safety wins over // cleanup. } return lines.join('\n'); } /** * Remove the safe-escape prefixes (`\[`, `\]`, `\~`) from a line. Used to * compare an output line against source lines after both have been * normalised — if they then match, neither side had user-authored escapes * for these specific characters. */ function stripSafeEscapes(line) { return line.replace(/\\([\[\]~])/g, '$1'); } /** * Replace `&lt;` / `&gt;` / `&amp;` HTML entities with their literal * characters in positions where they cannot be HTML or markdown syntax. * * Tiptap's HTML output path escapes bare `<` and `&` in prose because * the characters could in theory open a tag or entity. tiptap-markdown * then serialises those entities verbatim, so a source like `< $0.01` * round-trips as `&lt; $0.01`. We undo the escape only when the * surrounding context proves it can't be markup: * * - `&lt;` followed by space, digit, `$`, end-of-line, or a punctuation * character that can't begin an HTML tag name. * - `&gt;` likewise; in CommonMark `>` only has block-level meaning at * the start of a line (blockquote), and we never produce that here. * - `&amp;` always — `&` followed by anything that isn't a known entity * prefix wouldn't survive parsing as a real entity anyway. * * Code fences and inline code are skipped so that intentionally-escaped * entities inside code samples are left intact. * * Round-trip safety: if the same entity appears in the source on a * matching line, we leave it alone (the user authored the entity and we * mustn't strip it). This mirrors the line-aligned rule in * unescapeSafeChars. */ function unescapeHtmlEntitiesInProse(md, originalInput) { const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); let insideFence = false; const lines = md.split('\n'); for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; if (/^\s*```/.test(line)) { insideFence = !insideFence; continue; } if (insideFence) continue; if (!/&(?:lt|gt|amp);/.test(line)) continue; // Only act if there's a source line that, when both are stripped // of these specific entities, matches this output line. Otherwise // we don't have enough confidence the entity was Tiptap's doing. const stripped = stripHtmlEntities(line); const sourceMatches = origLines.some((src) => stripHtmlEntities(src) === stripped); if (!sourceMatches) continue; const exact = origLines.find((src) => src === line); if (exact !== undefined) { // Source had this exact line including entities — preserve. continue; } // Otherwise the source had the equivalent without entities; // Tiptap added them — strip. lines[i] = stripped; } return lines.join('\n'); } function stripHtmlEntities(line) { // Conservative replacements — only the three Tiptap actually emits. return line .replace(/&lt;/g, '<') .replace(/&gt;/g, '>') .replace(/&amp;/g, '&'); } /** * Restore CommonMark hard-break syntax (two trailing spaces at end of * line) where Tiptap stripped or rewrote it. * * Tiptap's serializer represents a hard break either as `\` followed by * a newline (paragraphs) or by silently dropping it (list items). The * source convention is two trailing spaces; we honour the source. * * Strategy: collect every source line that ends in ` ` (exactly two * spaces). For each, find a matching output line — either: * - same content with no trailing whitespace (the dropped case), or * - same content followed by `\\` line continuation (the rewritten * case — `expand left\\\nleft`). * Replace with the source's two-space form. */ function restoreTrailingHardBreaks(serialized, originalInput) { const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); // Lines that ended in exactly two trailing spaces — paired with // their content sans the trailing spaces, for cheap matching. const hardBreakSources = []; for (const line of origLines) { if (/[^ ] $/.test(line)) { hardBreakSources.push(line.slice(0, -2)); } } if (hardBreakSources.length === 0) return serialized; let out = serialized; for (const stem of hardBreakSources) { // Case 1: paragraph hard break — `stem\\\nNEXT` → `stem \nNEXT`. const backslashForm = `${stem}\\\n`; if (out.includes(backslashForm)) { out = out.replace(backslashForm, `${stem} \n`); continue; } // Case 2: silently dropped (list-item case). Look for the bare // `stem\n` and re-introduce the two trailing spaces. We only // repair the FIRST match — adding a hard break to the wrong // duplicate is worse than missing one. const bareForm = `${stem}\n`; const idx = out.indexOf(bareForm); if (idx !== -1) { out = out.slice(0, idx) + `${stem} \n` + out.slice(idx + bareForm.length); } } return out; } /** * If the user's original document used single-line separators between * adjacent block elements (e.g. `### A\nBody.\n### B\n`), Tiptap will * normalise those to blank-line separators (`\n\n`). Compare structure * pairwise and put back the original spacing wherever Tiptap diverged. * * This is a "best effort" fixup: it doesn't try to rewrite content, only * to remove spurious blank lines that Tiptap injected between block * elements that were adjacent in the source. */ function collapseBlockSeparators(serialized, originalInput) { // Tokenise both into "block" units separated by blank-line vs single- // newline boundaries. If the original had no blank line between two // adjacent block lines that match (heading -> body, body -> heading, // etc.), strip the blank line Tiptap inserted between the same pair. const origLines = originalInput.replace(/\r\n/g, '\n').split('\n'); const adjacentPairs = new Set(); for (let i = 0; i < origLines.length - 1; i += 1) { const a = origLines[i]; const b = origLines[i + 1]; if (a && b) { // Both non-empty consecutive lines — adjacent in the original. adjacentPairs.add(`${a}\u0001${b}`); } } const outLines = serialized.split('\n'); const result = []; for (let i = 0; i < outLines.length; i += 1) { const cur = outLines[i]; // If this is a blank line and the lines around it were adjacent // in the original, drop the blank. if (cur === '' && i > 0 && i < outLines.length - 1) { const prev = outLines[i - 1]; const next = outLines[i + 1]; if (prev && next && adjacentPairs.has(`${prev}\u0001${next}`)) { continue; } } result.push(cur); } return result.join('\n'); } /** * Build the Tiptap extension array used by both production and the test * suite. Centralising this means the regression tests exercise the exact * configuration that ships, so any fix here flows through to autosave too. * * Notable choices: * - StarterKit's strike extension is DISABLED. The default behaviour * escapes literal `~` to `\~` (and breaks `~/path`) on serialize, * because tiptap-markdown configures markdown-it with the strike * plugin enabled, which in turn enables `~` as an escape target. * Disabling strike costs us nothing visible (the editor never offered * a strike button) and unblocks two #440 corruption modes. */ export function buildTiptapExtensions() { return [ StarterKit.configure({ heading: { levels: [1, 2, 3, 4, 5, 6] }, codeBlock: { HTMLAttributes: { class: 'code-viewer' } }, link: { openOnClick: false, autolink: true, HTMLAttributes: { 'data-markdown-link': 'true' }, }, // Disable strikethrough — see comment above. The serializer // would otherwise treat `~` as a strike delimiter character // and emit `\~` to escape it. strike: false, }), Image.configure({ allowBase64: true, inline: true }), // GFM pipe table support. Without these four extensions Tiptap's // parser sees `| A | B |` rows as plain paragraphs and concatenates // the cell text — the canonical #437 corruption pattern. With them, // tiptap-markdown round-trips tables correctly. Table.configure({ resizable: false, HTMLAttributes: { class: 'markdown-table' } }), TableRow, TableHeader, TableCell, Markdown.configure({ html: true, tightLists: true, bulletListMarker: '-', // `linkify: true` made tiptap-markdown auto-wrap bare URLs in // <…> autolink brackets on serialize, even when the source had // them as bare URLs. The editor still recognises pasted URLs // as clickable via Tiptap's link extension; this only affects // the parser's "treat any URL-shaped string as a Link node" // behaviour, which is what was rewriting `https://...` to // `<https://...>` on round-trip. linkify: false, breaks: false, transformPastedText: true, transformCopiedText: false, }), ]; } /** * Convenience wrapper for tests and tools that want to mount the editor, * call getMarkdown(), tear down, all in one shot. Production uses the * pieces individually (preprocessForEditor at mount time, getMarkdown * during autosave, applyPostProcess before writing to disk). */ export function roundTripMarkdown(input) { const { editorInput, context } = preprocessForEditor(input); const target = document.createElement('div'); const editor = new Editor({ element: target, extensions: buildTiptapExtensions(), content: editorInput, }); const storage = editor.storage; const serialized = storage.markdown?.getMarkdown() ?? ''; editor.destroy(); return applyPostProcess(serialized, context); } function computeSerializedEditRanges(before, after) { if (before === after) { return []; } const beforeLines = before.split('\n'); const afterLines = after.split('\n'); const beforeLength = beforeLines.length; const afterLength = afterLines.length; const ranges = []; if (beforeLength * afterLength > 1000000) { return computeAnchoredSerializedEditRanges(beforeLines, afterLines, 0, beforeLength, 0, afterLength); } const dp = Array.from({ length: beforeLength + 1 }, () => Array(afterLength + 1).fill(0)); for (let beforeIndex = 1; beforeIndex <= beforeLength; beforeIndex += 1) { for (let afterIndex = 1; afterIndex <= afterLength; afterIndex += 1) { dp[beforeIndex][afterIndex] = beforeLines[beforeIndex - 1] === afterLines[afterIndex - 1] ? dp[beforeIndex - 1][afterIndex - 1] + 1 : Math.max(dp[beforeIndex - 1][afterIndex], dp[beforeIndex][afterIndex - 1]); } } const matches = []; let beforeIndex = beforeLength; let afterIndex = afterLength; while (beforeIndex > 0 && afterIndex > 0) { if (beforeLines[beforeIndex - 1] === afterLines[afterIndex - 1]) { matches.unshift([beforeIndex - 1, afterIndex - 1]); beforeIndex -= 1; afterIndex -= 1; } else if (dp[beforeIndex - 1][afterIndex] >= dp[beforeIndex][afterIndex - 1]) { beforeIndex -= 1; } else { afterIndex -= 1; } } let previousBefore = 0; let previousAfter = 0; for (const [matchBefore, matchAfter] of matches) { if (matchBefore > previousBefore || matchAfter > previousAfter) { ranges.push({ fromLine: Math.max(1, previousAfter - 3), toLine: Math.max(previousAfter + 1, matchAfter + 3) }); } previousBefore = matchBefore + 1; previousAfter = matchAfter + 1; } if (previousBefore < beforeLength || previousAfter < afterLength) { ranges.push({ fromLine: Math.max(1, previousAfter - 3), toLine: Math.max(previousAfter + 1, afterLength + 3) }); } return ranges; } function computeAnchoredSerializedEditRanges(beforeLines, afterLines, beforeStart, beforeEnd, afterStart, afterEnd) { while (beforeStart < beforeEnd && afterStart < afterEnd && beforeLines[beforeStart] === afterLines[afterStart]) { beforeStart++; afterStart++; } while (beforeStart < beforeEnd && afterStart < afterEnd && beforeLines[beforeEnd - 1] === afterLines[afterEnd - 1]) { beforeEnd--; afterEnd--; } if (beforeStart === beforeEnd && afterStart === afterEnd) { return []; } const beforeLineCounts = new Map(); const afterLineCounts = new Map(); for (let index = beforeStart; index < beforeEnd; index += 1) { const current = beforeLineCounts.get(beforeLines[index]); beforeLineCounts.set(beforeLines[index], { count: (current?.count ?? 0) + 1, index }); } for (let index = afterStart; index < afterEnd; index += 1) { const current = afterLineCounts.get(afterLines[index]); afterLineCounts.set(afterLines[index], { count: (current?.count ?? 0) + 1, index }); } for (let beforeIndex = beforeStart; beforeIndex < beforeEnd; beforeIndex += 1) { const beforeEntry = beforeLineCounts.get(beforeLines[beforeIndex]); const afterEntry = afterLineCounts.get(beforeLines[beforeIndex]); if (beforeEntry?.count === 1 && afterEntry?.count === 1) { return [ ...computeAnchoredSerializedEditRanges(beforeLines, afterLines, beforeStart, beforeIndex, afterStart, afterEntry.index), ...computeAnchoredSerializedEditRanges(beforeLines, afterLines, beforeIndex + 1, beforeEnd, afterEntry.index + 1, afterEnd), ]; } } return [{ fromLine: Math.max(1, afterStart - 3), toLine: Math.max(afterStart + 1, afterEnd + 3), }]; } function shouldIgnoreBlur(shell, event) { const nextTarget = event.relatedTarget; const widgetShell = shell?.closest('.tool-shell'); return Boolean(nextTarget && (shell?.contains(nextTarget) || widgetShell?.contains(nextTarget))); } function renderFormattingButtons() { return ` <button class="markdown-format-button" type="button" data-format="bold"><strong>B</strong></button> <button class="markdown-format-button" type="button" data-format="italic"><em>I</em></button> <button class="markdown-format-button" type="button" data-format="strike"><span style="text-decoration:line-through">S</span></button> <span class="markdown-format-sep" aria-hidden="true"></span> <label class="markdown-format-size" title="Block style" aria-label="Block style"> <select id="markdown-block-style"> <option value="p" selected>Normal</option> <option value="h1">H1</option> <option value="h2">H2</option> <option value="h3">H3</option> </select> </label> <span class="markdown-format-sep" aria-hidden="true"></span> <button class="markdown-format-button" type="button" data-format="quote" title="Quote" aria-label="Quote">&#10077;</button> <button class="markdown-format-button" type="button" data-format="list" title="List" aria-label="List">&#8226;</button> <button class="markdown-format-button" type="button" data-format="link" title="Link" aria-label="Link">&#128279;</button> <button class="markdown-format-button" type="button" data-format="code" title="Code" aria-label="Code">&lsaquo;&rsaquo;</button> `; } function renderModeToggleIcon(view) { if (view === 'raw') { return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>'; } return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"></path><path d="M4 12h10"></path><path d="M4 17h7"></path></svg>'; } function renderHeadingOptionLabel(headings, heading) { const duplicateCount = headings.filter((candidate) => candidate.text === heading.text).length; if (duplicateCount <= 1) { return heading.text; } return `${heading.text} (#${heading.id})`; } export function renderMarkdownCopyButton() { return `<button class="markdown-editor-copy-button" type="button" id="copy-active-markdown" title="Copy" aria-label="Copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><span>Copy</span></button>`; } export function renderMarkdownModeToggle(view) { return ` <div class="markdown-editor-mode-toggle" role="tablist" aria-label="Editor mode"> <div class="markdown-editor-mode-toggle-indicator markdown-editor-mode-toggle-indicator--${view}" aria-hidden="true"></div> <button class="markdown-editor-mode-option${view === 'raw' ? ' is-active' : ''}" type="button" id="markdown-mode-raw" role="tab" aria-selected="${view === 'raw' ? 'true' : 'false'}" title="Raw" aria-label="Raw">${renderModeToggleIcon('raw')}<span>Raw</span></button> <button class="markdown-editor-mode-option${view === 'markdown' ? ' is-active' : ''}" type="button" id="markdown-mode-markdown" role="tab" aria-selected="${view === 'markdown' ? 'true' : 'false'}" title="Preview" aria-label="Preview">${renderModeToggleIcon('markdown')}<span>Preview</span></button> </div> `; } export function renderMarkdownEditorShell(options) { const isMarkdownView = options.view === 'markdown'; return ` <div class="markdown-editor-shell markdown-editor-shell--${options.view}"> <section class="markdown-editor-pane markdown-editor-pane--${options.view}" aria-label="Markdown editor"> ${isMarkdownView ? `<div id="markdown-editor-context-menu" class="markdown-editor-context-menu" hidden>${renderFormattingButtons()}</div><div id="markdown-link-modal" class="markdown-link-modal" hidden><div class="markdown-link-modal-card"><div class="markdown-link-mode-tabs"><button type="button" id="markdown-link-mode-file" class="markdown-link-mode-tab is-active">File</button><button type="button" id="markdown-link-mode-url" class="markdown-link-mode-tab">URL</button></div><div id="markdown-link-file-fields"><label class="markdown-link-modal-label" for="markdown-link-search">Find note</label><input id="markdown-link-search" class="markdown-link-modal-input" type="text" placeholder="Search files..." /><div id="markdown-link-results" class="markdown-link-results"></div><label class="markdown-link-modal-label" for="markdown-link-heading">Heading</label><select id="markdown-link-heading" class="markdown-link-modal-input markdown-link-modal-select"><option value="">None</option></select><label class="markdown-link-modal-label" for="markdown-link-alias">Alias</label><input id="markdown-link-alias" class="markdown-link-modal-input" type="text" placeholder="Optional label" /></div><div id="markdown-link-url-fields" hidden><label class="markdown-link-modal-label" for="markdown-link-input">URL</label><input id="markdown-link-input" class="markdown-link-modal-input" type="url" placeholder="https://example.com" /><label class="markdown-link-modal-label" for="markdown-link-label">Label</label><input id="markdown-link-label" class="markdown-link-modal-input" type="text" placeholder="Optional label" /></div><div class="markdown-link-modal-actions"><button type="button" id="markdown-link-cancel" class="markdown-link-modal-button">Cancel</button><button type="button" id="markdown-link-apply" class="markdown-link-modal-button markdown-link-modal-button--primary">Insert</button></div></div></div>` : ''} <div id="markdown-editor-root" class="markdown-editor-root"></div> </section> </div> `; } function applyRawTab(textarea) { const start = textarea.selectionStart; const end = textarea.selectionEnd; const nextValue = `${textarea.value.slice(0, start)}\t${textarea.value.slice(end)}`; textarea.value = nextValue; textarea.selectionStart = start + 1; textarea.selectionEnd = start + 1; } /** * Walk the prose-mirror DOM and assign slug-based id attributes to headings * so the outline's revealLine can scroll to them. Re-run after every update; * no-op writes are skipped so identical ids don't dirty the style engine. */ function syncHeadingIds(root) { const nextSlug = createSlugTracker(); const headings = Array.from(root.querySelectorAll('h1, h2, h3, h4, h5, h6')); for (const heading of headings) { const text = heading.textContent?.trim() ?? ''; if (!text) { if (heading.hasAttribute('id')) { heading.removeAttribute('id'); } if (heading.hasAttribute('data-heading-id')) { heading.removeAttribute('data-heading-id'); } continue; } const headingId = nextSlug(text); if (heading.id !== headingId) { heading.id = headingId; } if (heading.getAttribute('data-heading-id') !== headingId) { heading.setAttribute('data-heading-id', headingId); } } } export function mountMarkdownEditor(options) { const shell = options.target.closest('.markdown-editor-shell'); const contextMenu = shell?.querySelector('#markdown-editor-context-menu'); const formatButtons = shell ? Array.from(shell.querySelectorAll('[data-format]')) : []; const blockStyleSelect = shell?.querySelector('#markdown-block-style'); const linkModal = shell?.querySelector('#markdown-link-modal'); const linkModeFile = shell?.querySelector('#markdown-link-mode-file'); const linkModeUrl = shell?.querySelector('#markdown-link-mode-url'); const linkFileFields = shell?.querySelector('#markdown-link-file-fields'); const linkUrlFields = shell?.querySelector('#markdown-link-url-fields'); const linkSearchInput = shell?.querySelector('#markdown-link-search'); const linkResults = shell?.querySelector('#markdown-link-results'); const linkHeadingSelect = shell?.querySelector('#markdown-link-heading'); const linkAliasInput = shell?.querySelector('#markdown-link-alias'); const linkInput = shell?.querySelector('#markdown-link-input'); const linkLabelInput = shell?.querySelector('#markdown-link-label'); const linkApply = shell?.querySelector('#markdown-link-apply'); const linkCancel = shell?.querySelector('#markdown-link-cancel'); let linkMode = 'file'; let linkSearchResults = []; let selectedLinkItem = null; let linkResultsMessage = 'Search for a file to link'; let linkSearchRequestId = 0; let linkHeadingRequestId = 0; if (options.view === 'markdown') { options.target.replaceChildren(); let hasUserEdited = false; const markUserEdit = () => { hasUserEdited = true; }; // Pre-process the input once at mount; the captured context is // mirrored back into output by getTiptapMarkdown so trailing // newline / frontmatter / EOL are preserved. const { editorInput, context } = preprocessForEditor(options.value); const getTiptapMarkdown = () => { const storage = tiptap.storage; const serialized = storage.markdown?.getMarkdown() ?? ''; return applyPostProcess(serialized, context); }; let previousSerializedValue = ''; const tiptap = new Editor({ element: options.target, extensions: buildTiptapExtensions(), content: editorInput, editorProps: { attributes: { class: 'markdown-editor-surface markdown-editor-surface--markdown markdown markdown-doc', role: 'textbox', 'aria-multiline': 'true', }, }, onUpdate: ({ editor }) => { syncHeadingIds(editor.view.dom); if (!hasUserEdited) { return; } const value = getTiptapMarkdown(); const editRanges = computeSerializedEditRanges(previousSerializedValue, value); previousSerializedValue = value; options.onChange(value, editRanges); }, onSelectionUpdate: () => { updateContextMenu(); }, onBlur: ({ event }) => { if (shouldIgnoreBlur(shell, event)) { return; } if (contextMenu) { contextMenu.hidden = true; } options.onBlur?.(); }, }); previousSerializedValue = getTiptapMarkdown(); const editorDom = tiptap.view.dom; syncHeadingIds(editorDom); const updateContextMenu = () => { if (!contextMenu) { return; } const { from, to, empty } = tiptap.state.selection; if (empty || !tiptap.isFocused) { contextMenu.hidden = true; return; } const start = tiptap.view.coordsAtPos(from); const end = tiptap.view.coordsAtPos(to); const shellEl = shell; if (!shellEl) { return; } const shellRect = shellEl.getBoundingClientRect(); const midX = (start.left + end.right) / 2; contextMenu.hidden = false; const left = Math.max(12, midX - shellRect.left - contextMenu.offsetWidth / 2); const top = Math.max(12, start.top - shellRect.top - contextMenu.offsetHeight - 10); contextMenu.style.left = `${left}px`; contextMenu.style.top = `${top}px`; }; const setLinkHeadingOptions = (headings = [], placeholder = 'None') => { if (!linkHeadingSelect) { return; } linkHeadingSelect.replaceChildren(); const noneOption = document.createElement('option'); noneOption.value = ''; noneOption.textContent = placeholder; linkHeadingSelect.appendChild(noneOption); for (const heading of headings) { const option = document.createElement('option'); option.value = heading.id; option.textContent = renderHeadingOptionLabel(headings, heading); option.dataset.headingText = heading.text; linkHeadingSelect.appendChild(option); } }; const loadHeadingsForItem = async (item) => { if (!linkHeadingSelect) { return; } const requestId = ++linkHeadingRequestId; setLinkHeadingOptions([], 'Loading…'); try { const headings = await options.loadHeadings?.(item.path) ?? []; if (requestId !== linkHeadingRequestId || selectedLinkItem?.path !== item.path) { return; } setLinkHeadingOptions(headings); } catch { if (requestId !== linkHeadingRequestId || selectedLinkItem?.path !== item.path) { return; } setLinkHeadingOptions([], 'Failed to load headings'); } }; const renderLinkResults = () => { if (!linkResults) {