From 9ed7b45fe723f368cb58f7abf769c2224be3e63a Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 16:43:15 -0700 Subject: [PATCH 1/6] fix(editor): harden Markdown scanners and fixtures --- .../editor/details-markdown-html.ts | 3 +- .../editor/markdown-round-trip.test.ts | 164 ++---------------- .../components/editor/markdown-scan-ranges.ts | 16 ++ .../editor/rich-markdown-code-span-padding.ts | 24 ++- .../editor/rich-markdown-inline-math.ts | 2 +- 5 files changed, 49 insertions(+), 160 deletions(-) diff --git a/src/renderer/src/components/editor/details-markdown-html.ts b/src/renderer/src/components/editor/details-markdown-html.ts index 04b10b34fe4..d6be251b67b 100644 --- a/src/renderer/src/components/editor/details-markdown-html.ts +++ b/src/renderer/src/components/editor/details-markdown-html.ts @@ -112,7 +112,8 @@ export function findDetailsBlockStart(content: string): number { if (isInsideRange(index, fenceRanges) || isInsideRange(index, codeSpanRanges)) { continue } - const lineStart = content.lastIndexOf('\n', index - 1) + 1 + const lineStart = + Math.max(content.lastIndexOf('\n', index - 1), content.lastIndexOf('\r', index - 1)) + 1 const indent = content.slice(lineStart, index) if (indent.length <= 3 && /^ *$/.test(indent)) { return index diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 9464b926ff4..f6ea00e5911 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -7,166 +7,30 @@ import type { SlashCommandId } from './rich-markdown-slash-commands' import { slashCommands } from './rich-markdown-slash-commands' const REPORT_DOCUMENT_FIXTURE = [ - '# Fix report: details blocks disabling rich mode', + '# Markdown details', '', - '## Decision: option A (stop unconditionally writing the styling class into markdown output)', + 'A paragraph with `class=\"orca-details\"` and a nested toggle.', '', - 'Traced every consumer of `class="orca-details"` before choosing:', + '
', + 'Outer', '', - "- The rendered DOM node gets the class independently, from `OrcaDetails.configure({ HTMLAttributes: { class: 'orca-details' } })` in `rich-markdown-details-extension.ts` — this does not read the markdown source string at all.", - '- `renderDetailsAttributes` (`details-markdown-html.ts`) had exactly one call site: the markdown serializer itself. Nothing else in the pipeline reads the class from serialized markdown text.', - '- The tokenizer already treats the class as optional: `hasOnlySupportedDetailsAttributes` strips it if present but never requires it, and `isEditableDetailsHtmlBlock` accepts a bare `
` with no class. Any other class value (or any other unrecognized attribute, e.g. `id="x"`) fails that check entirely and the block is kept as passthrough HTML, whose raw source bytes are never touched by the details serializer.', - "- The read-only preview (`MarkdownPreviewBody.tsx`) allowlists `className: 'orca-details'` through `rehype-sanitize` *if the source HTML already has it* — it does not inject the class. A GitHub-authored file with no class already rendered unstyled in preview before this fix; that is unchanged, not something this fix touches.", + 'Body with **bold** text, a [link](https://example.com), and a list:', '', - "A newly created or user-authored `
` block should never have Orca write a styling class into its markdown source — GitHub-flavored files never carry that class, and the DOM styling path doesn't need it in the source. But a file already saved by an earlier Orca version does carry the class in its source, and dropping it unconditionally caused a second bug: the round-trip eligibility check compares serialized output against the source's literal bytes, so a legacy file that loses its class on save stops matching and regresses back to Source mode.", + '- one', + '- two', '', - 'The final design: `parseDetailsAttributes` records whether the block\'s opening tag had the legacy class (`hasLegacyStylingClass`), that flag is carried on the TipTap node as a genuine schema attribute (declared in `OrcaDetails.addAttributes`, alongside the existing `variant` attribute, so it survives edits and undo), and `renderDetailsAttributes` re-emits `class="orca-details"` only when the flag is set. A block with no class in its source stays classless through every subsequent save; a block that already had the class keeps it.', - '', - "User-provided attributes (a custom `class`, an `id`, etc.) don't need special handling under this design — they never reach the details tokenizer at all. `isEditableDetailsHtmlBlock` rejects any attribute outside its allowlist (`open`, the legacy class, `data-orca-toggle`), so those blocks are treated as opaque passthrough HTML and their source bytes round-trip verbatim, untouched by `renderDetailsAttributes`. Verified directly (see below).", - '', - '## Commits', - '', - '- `93f807fbfd59a9854ecb3bf24c0a3a1051a9b79f` — initial fix: serializer stops always writing the class.', - '- `6213aa9a99fb7717a5d968b6bbd21a00d66e2374` — follow-up: preserves the class when the source already had it, fixing a legacy-file regression the initial commit introduced.', - '', - 'Both on branch `fb/rtme-details-roundtrip` in `/Users/fbarthelemy/Code/orca/fb-rtme-details/`.', - '', - '## Review findings and verification', - '', - "Before making the follow-up change, ran three ad hoc probe tests against the first commit's code to check for regressions, all removed after verification:", - '', - '- Case — Source — Result before fix #2', - '- Legacy Orca-saved file — `
…` — `getMarkdownRichModeUnsupportedReason` returned `\'html-or-jsx\'` — **confirmed regression**, the file would fall back to Source mode.', - '- User custom class — `
…` — Round-tripped byte-identical to input; eligibility `null`. Passthrough HTML, unaffected.', - '- User `id` attribute — `
…` — Round-tripped byte-identical to input; eligibility `null`. Passthrough HTML, unaffected.', - '', - 'After the follow-up commit, re-ran the same three probes plus a bare-details control:', - '', - '- Case — Result after fix #2', - '- Legacy Orca-saved file — `getMarkdownRichModeUnsupportedReason` returns `null`; round-trips byte-identical to source.', - "- Bare `
` with no class — Still gets no class on save (fix #1's intent preserved).", - '- User custom class / `id` — Unchanged — still passthrough, still byte-identical.', - '', - 'All four passed. These are now permanent tests: `markdown-rich-mode.test.ts` ("allows a details block already carrying the legacy orca-details class"), `markdown-round-trip.test.ts` ("round-trips an orca-authored nested toggle unchanged", restored to its original byte-identical assertion, plus a new "does not backfill the legacy styling class onto a freshly nested toggle" case covering a legacy outer block wrapping a freshly authored inner block).', - '', - '## Diff summary', - '', - '**Commit 1** (`93f807fb`):', - '', - '- `details-markdown-html.ts`: `renderDetailsAttributes` stops prepending `class="orca-details"` unconditionally.', - '- `rich-markdown-details-extension.ts`: `renderMarkdown` only adds a leading space before the attribute string when attributes exist, avoiding a stray `
`.', - '- Test files updated to match the new (classless) serialized output.', - '', - '**Commit 2** (`6213aa9a`):', - '', - "- `rich-markdown-details-extension.ts`: `OrcaDetails.addAttributes` gains `hasLegacyStylingClass` (default `false`, not a DOM attribute — `parseHTML`/`renderHTML` are no-ops since it's markdown-only state).", - '- `details-markdown-html.ts`: `parseDetailsAttributes` sets `hasLegacyStylingClass` from a new `LEGACY_STYLING_CLASS_PATTERN` regex matching only the exact `class="orca-details"` form (the only class value `hasOnlySupportedDetailsAttributes` tolerates). `renderDetailsAttributes` re-emits the class when that flag is `true`.', - '- `markdown-rich-mode.test.ts`: added the legacy-class eligibility regression test.', - '- `markdown-round-trip.test.ts`: restored the nested-legacy-toggle test to its original byte-identical assertion (previously incorrectly rewritten in commit 1 to expect the class dropped); added the backfill-prevention test.', - '', - '## Commands run (final state, after both commits)', - '', - '- Command — Result', - '- `pnpm test src/renderer/src/components/editor` — pass — 208 files, 1416 tests, 2 skipped', - '- `pnpm tc` — pass — no type errors', - '- `npx oxlint` (full repo) — pass — exit 0, no findings', - '- `npx oxfmt --write` on changed files — applied, no changes needed on the second pass', - '- pre-commit hook (oxlint + oxfmt via lint-staged), both commits — pass, no manual fixes needed', - '', - '## Draft: GitHub issue', - '', - '**Title:** `[Bug]: Markdown files with
blocks always open in Source mode`', - '', - '**Body:**', - '', - '### Operating system', - '', - 'macOS', - '', - '### Orca version', - '', - '1.4.198', - '', - '### Details', - '', - 'Opening a markdown file that contains a `
` disclosure block always lands in Source mode with the "this file contains HTML, JSX, or MDX" banner, even for the smallest possible block and even though Orca has a dedicated rich-editor extension for details blocks that never gets a chance to run.', - '', - 'Minimal reproduction: create a markdown file containing exactly this and open it in Orca.', - '', - '```markdown', - '
', - 'x', - '', - 'body', - '', - '
', + '```text', + '
this is code', '```', '', - 'Expected: the file opens in the rich editor with the details block rendered as a collapsible toggle.', + '
', + 'Inner', '', - 'Actual: the file falls back to Source mode with the HTML/JSX/MDX banner.', + 'Nested body.', '', - 'The mechanism is that the markdown serializer always adds `class="orca-details"` to the `
` tag it writes out ([`renderDetailsAttributes`](https://github.com/stablyai/orca/blob/7dd183d82dafb768bb2506d161438a6d0894fd53/src/renderer/src/components/editor/details-markdown-html.ts#L79-L92)), and the rich-mode eligibility check compares that serialized output against the file\'s literal opening tag byte-for-byte ([`preservesEmbeddedHtml`](https://github.com/stablyai/orca/blob/7dd183d82dafb768bb2506d161438a6d0894fd53/src/renderer/src/components/editor/markdown-rich-mode.ts#L211-L221)), so a source file without that class never matches and the file is treated as unsupported HTML.', + '
', '', - '## Draft: PR body', - '', - '## ELI5', - '', - 'Any markdown file with a `
` disclosure block used to always open in Source mode instead of the rich editor, even for a two-line block with no attributes.', - '', - '## What Changed', - '', - 'The markdown serializer only writes `class="orca-details"` into a saved `
` tag when the file\'s own source already had that class. A block authored fresh, or one that came from a plain markdown file, never gets the class written into it, since the class is already applied to the rendered editor node independently through the extension\'s `HTMLAttributes` configuration.', - '', - '## Why', - '', - "Rich-mode eligibility detects embedded HTML and then verifies it survives a round trip through the editor by comparing the re-serialized output against the source's literal opening tag. The serializer used to always add a class the source didn't have, so that comparison failed for every `
` block that didn't already carry the class, and the file fell back to Source mode. Making the class conditional on the source removes the mismatch at its origin, and keeps files already saved by Orca (which do carry the class) round-tripping unchanged.", - '', - '## Linked Issue', - '', - 'Fixes #TBD', - '', - '## Visual Proof', - '', - '**Before:** opening a markdown file containing a details block lands in Source mode with the HTML banner, and the collapsible toggle is not interactive.', - '', - '**After:** the same file opens in the rich editor with the details block rendered as a collapsible block.', - '', - '## Testing', - '', - '- [ ] I manually tested these changes locally', - '- [x] Automated tests added/updated, or explained why not below', - '', - "Added regression tests in `markdown-rich-mode.test.ts` for a minimal bare details block, one with `open`, one already carrying the legacy styling class, and a full document shape (front matter, prose, a details block with bold text/link/list/fenced code, a second `
` block). Updated existing round-trip assertions in `markdown-round-trip.test.ts` and `rich-markdown-details-keyboard.test.ts` to match; added a test confirming a legacy-class outer block doesn't backfill the class onto a freshly authored nested block; confirmed by test that user-provided attributes like a custom `class` or `id` are unaffected, since those blocks are treated as passthrough HTML rather than going through this serializer. `pnpm test`, `pnpm tc`, and `oxlint` all pass.", - '', - '## AI Disclosure', - '', - 'Authored with Claude Code (Claude Fable 5.1 and Sonnet teammates) under my review.', - '', - '## Review', - '', - "Removes a source-mutating side effect from the details-block markdown serializer: it now writes the styling class into a saved file's `
` tag only when the file's source already carried that class, since the class is otherwise applied independently to the rendered DOM node. The unconditional injection was why any file with a details block failed the rich-mode round-trip check and fell back to Source mode; making it conditional both fixes new files and keeps files already saved by Orca round-tripping unchanged. User-provided attributes are unaffected, since blocks with attributes outside the small supported set are treated as opaque passthrough HTML rather than going through this serializer.", - '', - '## Agent skill upstream boundary', - '', - '- [x] Not applicable, or this change follows `docs/reference/agent-skill-sharing-upstream-boundary.md` and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.', - '', - '## Notes', - '', - 'Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance', - '', - '## Checklist', - '', - '- [x] This PR is small and focused', - '- [x] I explained what changed and why (including ELI5)', - '- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason', - '- [x] Self-reviewed for correctness, security, and performance', - '- [x] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)', - '- [x] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)', - '', - '## Author', - '', - 'X: [@fbartho](https://x.com/fbartho). I prefer Mastodon: [@fbartho@mastodon.social](https://mastodon.social/@fbartho).' + '
' ].join('\n') function roundTripMarkdown(content: string): string { diff --git a/src/renderer/src/components/editor/markdown-scan-ranges.ts b/src/renderer/src/components/editor/markdown-scan-ranges.ts index 62e841918cf..9f7c3db0c4f 100644 --- a/src/renderer/src/components/editor/markdown-scan-ranges.ts +++ b/src/renderer/src/components/editor/markdown-scan-ranges.ts @@ -23,6 +23,13 @@ export function markdownFenceRanges(content: string): MarkdownFenceRanges { } else { const openingFenceMatch = lineText.match(/^ {0,3}(`{3,}|~{3,})/u) if (openingFenceMatch?.[1]) { + if ( + openingFenceMatch[1][0] === '`' && + lineText.slice(openingFenceMatch[0].length).includes('`') + ) { + offset += line.length + continue + } openFence = { closingPattern: new RegExp( // CommonMark 4.5: a closing fence may be followed only by spaces or @@ -82,6 +89,15 @@ export function markdownCodeSpanRanges( continue } + let backslashes = 0 + for (let cursor = index - 1; cursor >= 0 && content[cursor] === '\\'; cursor -= 1) { + backslashes += 1 + } + if (backslashes % 2 === 1) { + index += tickCount + continue + } + let tickCount = 0 while (content[index + tickCount] === '`') { tickCount += 1 diff --git a/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts b/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts index bc7202d2310..46f7a3af0f8 100644 --- a/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts +++ b/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts @@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core' // Why: a private-use code point cannot appear in a markdown document, so it can stand // in for padding while the mark-boundary walk runs and be restored afterwards. -const PADDING_PLACEHOLDER = String.fromCharCode(0xe000) +const PADDING_PLACEHOLDERS = ['\uE000', '\uE001', '\uE002', '\uE003'] type MarkdownNodeLike = { type?: string @@ -33,6 +33,9 @@ function hasCodeMark(node: MarkdownNodeLike): boolean { * span, where CommonMark strips one pad on render and the source keeps its bytes. */ export function maskCodeSpanPadding(nodes: MarkdownNodeLike[]): MarkdownNodeLike[] { + const source = nodes.map((node) => node.text ?? '').join('') + const placeholder = + PADDING_PLACEHOLDERS.find((candidate) => !source.includes(candidate)) ?? '\uE000\uE001' return nodes.map((node) => { if (node?.type !== 'text' || !hasCodeMark(node)) { return node @@ -46,16 +49,13 @@ export function maskCodeSpanPadding(nodes: MarkdownNodeLike[]): MarkdownNodeLike const body = text.slice(leading.length, trailing ? text.length - trailing.length : text.length) return { ...node, - text: - PADDING_PLACEHOLDER.repeat(leading.length) + - body + - PADDING_PLACEHOLDER.repeat(trailing.length) + text: placeholder.repeat(leading.length) + body + placeholder.repeat(trailing.length) } }) } -export function restoreCodeSpanPadding(markdown: string): string { - return markdown.split(PADDING_PLACEHOLDER).join(' ') +export function restoreCodeSpanPadding(markdown: string, placeholder: string): string { + return markdown.split(placeholder).join(' ') } /** @@ -83,7 +83,15 @@ export const RichMarkdownCodeSpanPadding = Extension.create({ ...rest: unknown[] ) { const rendered = walk.call(this, maskCodeSpanPadding(nodes ?? []), ...rest) - return typeof rendered === 'string' ? restoreCodeSpanPadding(rendered) : rendered + const placeholder = + PADDING_PLACEHOLDERS.find( + (candidate) => + !nodes + .map((node) => node.text ?? '') + .join('') + .includes(candidate) + ) ?? '\uE000\uE001' + return typeof rendered === 'string' ? restoreCodeSpanPadding(rendered, placeholder) : rendered } } }) diff --git a/src/renderer/src/components/editor/rich-markdown-inline-math.ts b/src/renderer/src/components/editor/rich-markdown-inline-math.ts index 4083d70e243..820fdd31370 100644 --- a/src/renderer/src/components/editor/rich-markdown-inline-math.ts +++ b/src/renderer/src/components/editor/rich-markdown-inline-math.ts @@ -2,7 +2,7 @@ import { InlineMath } from '@tiptap/extension-mathematics' // Why: the common dialect requires a non-space next to each delimiter and forbids a // newline inside, which is what keeps `US$ 5,000 and R$ 40,000` out of a math span. -const INLINE_MATH = /^\$(?![\s$])((?:[^$\n]*[^\s$])?)\$(?!\$)/ +const INLINE_MATH = /^\$(?![\s$])((?:\\[^\n]|[^$\\\n])*?)(? Date: Fri, 18 Sep 2026 16:48:28 -0700 Subject: [PATCH 2/6] fix(editor): preserve code contents and validate reference candidates --- .../editor/markdown-rich-mode.test.ts | 4 +-- .../components/editor/markdown-rich-mode.ts | 9 ++++- .../editor/markdown-round-trip.test.ts | 11 ++++-- .../editor/markdown-scan-ranges.test.ts | 36 +++++++++++-------- .../components/editor/markdown-scan-ranges.ts | 2 +- .../rich-markdown-code-span-padding.test.ts | 2 ++ .../editor/rich-markdown-code-span-padding.ts | 36 +++++++++---------- .../rich-markdown-literal-serialization.ts | 4 +-- .../editor/rich-markdown-table-markdown.ts | 9 ++++- 9 files changed, 71 insertions(+), 42 deletions(-) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.test.ts b/src/renderer/src/components/editor/markdown-rich-mode.test.ts index 73478820b63..1c05d3ef1b9 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.test.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.test.ts @@ -320,10 +320,10 @@ describe('getMarkdownRichModeUnsupportedMessage', () => { ).toBeNull() }) - it('conservatively blocks a pre-filter match in a document past the parse-size guard', () => { + it('does not infer a reference definition from document size', () => { const content = `${'a'.repeat(50_001)}\n[Bug]: text with spaces\n` - expect(getMarkdownRichModeUnsupportedMessage(content)).not.toBeNull() + expect(getMarkdownRichModeUnsupportedMessage(content)).toBeNull() }) it('still parses to confirm a pre-filter match at or under the parse-size guard', () => { diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index 37915f0a9e6..73a565db071 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -200,7 +200,14 @@ function hasLinkReferenceDefinition(content: string): boolean { return false } if (commentStripped.length > 50_000) { - return true + // Probe candidate paragraphs separately; document size alone is not syntax evidence. + return commentStripped + .split(/\r?\n[ \t]*\r?\n/) + .some( + (block) => + /^[ \t>*+\-\d.)]*\[[^\]]+\]:/m.test(block) && + containsDefinitionNode(linkReferenceDefinitionProcessor.parse(block)) + ) } const tree = linkReferenceDefinitionProcessor.parse(commentStripped) return containsDefinitionNode(tree) diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index f6ea00e5911..3cf7110282a 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -9,9 +9,9 @@ import { slashCommands } from './rich-markdown-slash-commands' const REPORT_DOCUMENT_FIXTURE = [ '# Markdown details', '', - 'A paragraph with `class=\"orca-details\"` and a nested toggle.', + 'A paragraph with `class="orca-details"` and a nested toggle.', '', - '
', + '
', 'Outer', '', 'Body with **bold** text, a [link](https://example.com), and a list:', @@ -619,3 +619,10 @@ describe('rich markdown round trip', () => { expect(roundTripMarkdown(input)).toBe('```\n[[not-a-link]]\n```') }) }) + +it('preserves code spacing inside a table across repeated saves', () => { + const source = '| code |\n| --- |\n| `a b` |' + const once = roundTripMarkdown(source) + expect(once).toContain('`a b`') + expect(roundTripMarkdown(once)).toBe(once) +}) diff --git a/src/renderer/src/components/editor/markdown-scan-ranges.test.ts b/src/renderer/src/components/editor/markdown-scan-ranges.test.ts index c28e975c8c8..a6a726534b2 100644 --- a/src/renderer/src/components/editor/markdown-scan-ranges.test.ts +++ b/src/renderer/src/components/editor/markdown-scan-ranges.test.ts @@ -124,22 +124,25 @@ describe('findDetailsBlockStart cost on documents without a toggle', () => { expect(codeSpanSpy).toHaveBeenCalledTimes(paragraphs.length) }) - it.skipIf(process.env.ORCA_DETAILS_SCAN_BENCH !== '1')('benchmarks a large toggle-free document', () => { - const paragraphs = Array.from( - { length: 300 }, - (_, index) => `Paragraph ${index} ${'lorem ipsum dolor sit amet '.repeat(25)}` - ) - const document = paragraphs.join('\n\n') + it.skipIf(process.env.ORCA_DETAILS_SCAN_BENCH !== '1')( + 'benchmarks a large toggle-free document', + () => { + const paragraphs = Array.from( + { length: 300 }, + (_, index) => `Paragraph ${index} ${'lorem ipsum dolor sit amet '.repeat(25)}` + ) + const document = paragraphs.join('\n\n') - const started = performance.now() - let offset = 0 - for (const paragraph of paragraphs) { - expect(findDetailsBlockStart(document.slice(offset))).toBe(-1) - offset += paragraph.length + 2 + const started = performance.now() + let offset = 0 + for (const paragraph of paragraphs) { + expect(findDetailsBlockStart(document.slice(offset))).toBe(-1) + offset += paragraph.length + 2 + } + + process.stdout.write(`${JSON.stringify({ elapsedMs: performance.now() - started })}\n`) } - - process.stdout.write(`${JSON.stringify({ elapsedMs: performance.now() - started })}\n`) - }) + ) it('still finds a toggle that follows a long run of prose', () => { const prose = Array.from({ length: 300 }, (_, index) => `Paragraph ${index}.`).join('\n\n') @@ -195,3 +198,8 @@ describe('findDetailsBlockStart with a non-breaking space after a fence closer', expect(findDetailsBlockStart(content)).toBe(-1) }) }) + +it('does not hide text behind escaped backticks or invalid fence info', () => { + expect(markdownCodeSpanRanges('\\`literal\\`', [])).toEqual([]) + expect(markdownFenceRanges('```bad`info\nbody')).toEqual([]) +}) diff --git a/src/renderer/src/components/editor/markdown-scan-ranges.ts b/src/renderer/src/components/editor/markdown-scan-ranges.ts index 9f7c3db0c4f..3e471be72d9 100644 --- a/src/renderer/src/components/editor/markdown-scan-ranges.ts +++ b/src/renderer/src/components/editor/markdown-scan-ranges.ts @@ -94,7 +94,7 @@ export function markdownCodeSpanRanges( backslashes += 1 } if (backslashes % 2 === 1) { - index += tickCount + index += 1 continue } diff --git a/src/renderer/src/components/editor/rich-markdown-code-span-padding.test.ts b/src/renderer/src/components/editor/rich-markdown-code-span-padding.test.ts index 132380f6322..8946ea4efbb 100644 --- a/src/renderer/src/components/editor/rich-markdown-code-span-padding.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-code-span-padding.test.ts @@ -45,6 +45,8 @@ describe('code span padding round trip', () => { ['Read `Anexo v2.docx ` and write up.'], ['Read ` Anexo v2.docx` and write up.'], ['Plain `code` here.'], + ['Authored \uE000\uE001\uE002\uE003 and ` padded` text'], + ['`a\uE000b` and ` padded`'], ['**bold** and `code` and *it*'], ['`a` and `b`'], ['[`label`](https://example.com)'] diff --git a/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts b/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts index 46f7a3af0f8..6042dc743b5 100644 --- a/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts +++ b/src/renderer/src/components/editor/rich-markdown-code-span-padding.ts @@ -1,9 +1,5 @@ import { Extension } from '@tiptap/core' -// Why: a private-use code point cannot appear in a markdown document, so it can stand -// in for padding while the mark-boundary walk runs and be restored afterwards. -const PADDING_PLACEHOLDERS = ['\uE000', '\uE001', '\uE002', '\uE003'] - type MarkdownNodeLike = { type?: string text?: string @@ -32,17 +28,26 @@ function hasCodeMark(node: MarkdownNodeLike): boolean { * which is right for emphasis (`** text **` is not emphasis) and wrong for a code * span, where CommonMark strips one pad on render and the source keeps its bytes. */ -export function maskCodeSpanPadding(nodes: MarkdownNodeLike[]): MarkdownNodeLike[] { - const source = nodes.map((node) => node.text ?? '').join('') - const placeholder = - PADDING_PLACEHOLDERS.find((candidate) => !source.includes(candidate)) ?? '\uE000\uE001' +function paddingPlaceholder(nodes: MarkdownNodeLike[]): string { + const source = JSON.stringify(nodes) + let placeholder = '\uE000' + while (source.includes(placeholder)) { + placeholder += '\uE000' + } + return placeholder +} + +export function maskCodeSpanPadding( + nodes: MarkdownNodeLike[], + placeholder = paddingPlaceholder(nodes) +): MarkdownNodeLike[] { return nodes.map((node) => { if (node?.type !== 'text' || !hasCodeMark(node)) { return node } const text = node.text ?? '' const leading = text.match(/^(\s+)/)?.[1] ?? '' - const trailing = text.match(/(\s+)$/)?.[1] ?? '' + const trailing = text.slice(leading.length).match(/(\s+)$/)?.[1] ?? '' if (!leading && !trailing) { return node } @@ -54,7 +59,7 @@ export function maskCodeSpanPadding(nodes: MarkdownNodeLike[]): MarkdownNodeLike }) } -export function restoreCodeSpanPadding(markdown: string, placeholder: string): string { +export function restoreCodeSpanPadding(markdown: string, placeholder = '\uE000'): string { return markdown.split(placeholder).join(' ') } @@ -82,15 +87,8 @@ export const RichMarkdownCodeSpanPadding = Extension.create({ nodes: MarkdownNodeLike[], ...rest: unknown[] ) { - const rendered = walk.call(this, maskCodeSpanPadding(nodes ?? []), ...rest) - const placeholder = - PADDING_PLACEHOLDERS.find( - (candidate) => - !nodes - .map((node) => node.text ?? '') - .join('') - .includes(candidate) - ) ?? '\uE000\uE001' + const placeholder = paddingPlaceholder(nodes ?? []) + const rendered = walk.call(this, maskCodeSpanPadding(nodes ?? [], placeholder), ...rest) return typeof rendered === 'string' ? restoreCodeSpanPadding(rendered, placeholder) : rendered } } diff --git a/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts index 58b4f5025a6..0cbdde67c75 100644 --- a/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts +++ b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts @@ -5,7 +5,7 @@ import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' const DOLLAR_SKIP_TYPES = new Set(['inlineMath', 'rawMarkdownHtmlInline']) -const CHEAP_NEEDS_WORK = /\$|\||\\[[\]]|^( {0,3})(#|-|\d+\.)( |$)/m +const CHEAP_NEEDS_WORK = /\$|\||\\[[\]]|^( {0,3})(#|[+-]|\d+\.)( |$)/m const REF_DEF = /^ {0,3}\[[^\n]*\]:/m type BlockInfo = { block: ProseMirrorNode; inTableCell: boolean } @@ -79,7 +79,7 @@ function escapeLineLeading(markdown: string): string { // writes paragraph text "1." and tests assert getMarkdown() === '1.\n\n'. return markdown .replace(/^( {0,3})#(?= |$)/gm, '$1\\#') - .replace(/^( {0,3})-(?= )/gm, '$1\\-') + .replace(/^( {0,3})([+-])(?= )/gm, '$1\\$2') .replace(/^( {0,3})(\d+)\.(?= )/gm, '$1$2\\.') } diff --git a/src/renderer/src/components/editor/rich-markdown-table-markdown.ts b/src/renderer/src/components/editor/rich-markdown-table-markdown.ts index 3515d772d5a..8c77b4acc16 100644 --- a/src/renderer/src/components/editor/rich-markdown-table-markdown.ts +++ b/src/renderer/src/components/editor/rich-markdown-table-markdown.ts @@ -1,4 +1,5 @@ import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/core' +import { markdownCodeSpanRanges } from './markdown-scan-ranges' type TableCellAlign = 'left' | 'right' | 'center' | null type TableCell = { text: string; isHeader: boolean; align: TableCellAlign } @@ -9,7 +10,13 @@ const MAX_COLUMN_WIDTH = 60 const MAX_ALIGNED_TABLE_WIDTH = 160 function collapseWhitespace(value: string): string { - return value.replace(/\s+/g, ' ').trim() + let offset = 0 + let result = '' + for (const [start, end] of markdownCodeSpanRanges(value, [])) { + result += value.slice(offset, start).replace(/\s+/g, ' ') + value.slice(start, end) + offset = end + } + return (result + value.slice(offset).replace(/\s+/g, ' ')).trim() } function normalizeAlign(attrs: Record | undefined): TableCellAlign { From a9d6e3a5bb39a221e857bee65e5077af2b0bcc78 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 16:48:55 -0700 Subject: [PATCH 3/6] fix(editor): support CJK emphasis in preview --- package.json | 1 + pnpm-lock.yaml | 81 +++++++++++++++++++ .../editor/MarkdownPreviewBody.test.tsx | 67 +++++++++++++++ .../components/editor/MarkdownPreviewBody.tsx | 2 + .../editor/markdown-table-of-contents.test.ts | 9 +++ .../editor/markdown-table-of-contents.ts | 6 +- 6 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx diff --git a/package.json b/package.json index 23d1ecf3e34..370f3f2f53a 100644 --- a/package.json +++ b/package.json @@ -287,6 +287,7 @@ "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "remark-breaks": "^4.0.0", + "remark-cjk-friendly": "^2.3.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b3d847c1bf..8cf68047aca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -468,6 +468,9 @@ importers: remark-breaks: specifier: ^4.0.0 version: 4.0.0 + remark-cjk-friendly: + specifier: ^2.3.1 + version: 2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) remark-frontmatter: specifier: ^5.0.0 version: 5.0.0(supports-color@7.2.0) @@ -5715,6 +5718,15 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-markdown-cjk-friendly@1.0.0: + resolution: {integrity: sha512-BoaAm8mlJ+LAYz0Qs532Y3ciTuQYgBUPZcSFbvC/ZKmEMAKgulw84YvQK1gI34t/vL2euSfuaWlqczkTBgamkw==} + engines: {node: '>=18'} + peerDependencies: + '@types/mdast': '*' + peerDependenciesMeta: + '@types/mdast': + optional: true + mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -5742,6 +5754,25 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-cjk-friendly-util@3.0.1: + resolution: {integrity: sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w==} + engines: {node: '>=18'} + peerDependencies: + micromark-util-types: '*' + peerDependenciesMeta: + micromark-util-types: + optional: true + + micromark-extension-cjk-friendly@2.0.1: + resolution: {integrity: sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw==} + engines: {node: '>=18'} + peerDependencies: + micromark: ^4.0.0 + micromark-util-types: ^2.0.0 + peerDependenciesMeta: + micromark-util-types: + optional: true + micromark-extension-frontmatter@2.0.0: resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} @@ -6525,6 +6556,16 @@ packages: remark-breaks@4.0.0: resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + remark-cjk-friendly@2.3.1: + resolution: {integrity: sha512-f+pKZRxCRwNEGFBKNRAZAqU91GIK1SAo3ZyFHWRUgC9zcxRR0BXKd6YwqgSsxtW0rNpUDtONj7H5nje2WL3fcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/mdast': ^4.0.0 + unified: ^11.0.0 + peerDependenciesMeta: + '@types/mdast': + optional: true + remark-frontmatter@5.0.0: resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} @@ -12619,6 +12660,16 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + mdast-util-to-markdown-cjk-friendly@1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2): + dependencies: + mdast-util-to-markdown: 2.1.2 + micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) + micromark-util-symbol: 2.0.1 + optionalDependencies: + '@types/mdast': 4.0.4 + transitivePeerDependencies: + - micromark-util-types + mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -12687,6 +12738,25 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-cjk-friendly-util@3.0.1(micromark-util-types@2.0.2): + dependencies: + get-east-asian-width: 1.5.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + optionalDependencies: + micromark-util-types: 2.0.2 + + micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): + dependencies: + devlop: 1.1.0 + micromark: 4.0.2(supports-color@7.2.0) + micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) + micromark-util-chunked: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + optionalDependencies: + micromark-util-types: 2.0.2 + micromark-extension-frontmatter@2.0.0: dependencies: fault: 2.0.1 @@ -13755,6 +13825,17 @@ snapshots: mdast-util-newline-to-break: 2.0.0 unified: 11.0.5 + remark-cjk-friendly@2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): + dependencies: + mdast-util-to-markdown-cjk-friendly: 1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2) + micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) + unified: 11.0.5 + optionalDependencies: + '@types/mdast': 4.0.4 + transitivePeerDependencies: + - micromark + - micromark-util-types + remark-frontmatter@5.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 diff --git a/src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx b/src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx new file mode 100644 index 00000000000..5554dba47ff --- /dev/null +++ b/src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx @@ -0,0 +1,67 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import remarkCjkFriendly from 'remark-cjk-friendly/parseOnly' +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import { unified } from 'unified' +import { describe, expect, it } from 'vitest' +import { MarkdownPreviewBody } from './MarkdownPreviewBody' + +const cjkEmphasisExamples = [ + { + name: 'Korean text after a quoted phrase', + markdown: '**"이런"**것은 강조됩니다.', + expectedHtml: '"이런"것은 강조됩니다.' + }, + { + name: 'Korean text after parentheses', + markdown: '**(강조)**입니다.', + expectedHtml: '(강조)입니다.' + }, + { + name: 'Korean text on both delimiter boundaries', + markdown: '문장은**"여기"**에서 이어집니다.', + expectedHtml: '문장은"여기"에서 이어집니다.' + }, + { + name: 'Japanese text after corner brackets', + markdown: '**「強調」**です。', + expectedHtml: '「強調」です。' + }, + { + name: 'Chinese text after quotation marks', + markdown: '**“强调”**文本', + expectedHtml: '“强调”文本' + } +] + +const unchangedMarkdownExamples = [ + '# Heading', + '**bold** text and *italic* text', + 'foo_bar_baz', + '`**literal**`', + '[link](https://example.com)', + '- [x] completed task', + '| left | right |\n| --- | --- |\n| a | b |', + '~~deleted~~ text', + '~~"삭제"~~문장' +] + +function parseMarkdown(markdown: string) { + return unified().use(remarkParse).use(remarkGfm).parse(markdown) +} + +function parseCjkFriendlyMarkdown(markdown: string) { + return unified().use(remarkParse).use(remarkGfm).use(remarkCjkFriendly).parse(markdown) +} + +describe('MarkdownPreviewBody', () => { + it.each(cjkEmphasisExamples)('renders emphasis for $name', ({ markdown, expectedHtml }) => { + const html = renderToStaticMarkup() + + expect(html).toContain(expectedHtml) + }) + + it.each(unchangedMarkdownExamples)('keeps existing parsing for %s', (markdown) => { + expect(parseCjkFriendlyMarkdown(markdown)).toEqual(parseMarkdown(markdown)) + }) +}) diff --git a/src/renderer/src/components/editor/MarkdownPreviewBody.tsx b/src/renderer/src/components/editor/MarkdownPreviewBody.tsx index b29185f0321..3125405127b 100644 --- a/src/renderer/src/components/editor/MarkdownPreviewBody.tsx +++ b/src/renderer/src/components/editor/MarkdownPreviewBody.tsx @@ -7,6 +7,7 @@ import rehypeRaw from 'rehype-raw' import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' import rehypeSlug from 'rehype-slug' import remarkBreaks from 'remark-breaks' +import remarkCjkFriendly from 'remark-cjk-friendly/parseOnly' import remarkFrontmatter from 'remark-frontmatter' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' @@ -55,6 +56,7 @@ const markdownPreviewSanitizeSchema = { type MarkdownPluginList = NonNullable const MARKDOWN_REMARK_PLUGINS: MarkdownPluginList = [ remarkGfm, + remarkCjkFriendly, remarkBreaks, remarkFrontmatter, remarkMath, diff --git a/src/renderer/src/components/editor/markdown-table-of-contents.test.ts b/src/renderer/src/components/editor/markdown-table-of-contents.test.ts index ec0acbf2785..74b245085d4 100644 --- a/src/renderer/src/components/editor/markdown-table-of-contents.test.ts +++ b/src/renderer/src/components/editor/markdown-table-of-contents.test.ts @@ -98,6 +98,15 @@ describe('markdown table of contents', () => { ]) }) + it('extracts emphasized headings next to Korean text', () => { + const toc = buildMarkdownTableOfContents('# **"이런"**것은 강조됩니다') + + expect(toc[0]).toMatchObject({ + id: '이런것은-강조됩니다', + title: '"이런"것은 강조됩니다' + }) + }) + it('uses GitHub-compatible duplicate slugs', () => { const toc = buildMarkdownTableOfContents('# Repeat\n# Repeat') diff --git a/src/renderer/src/components/editor/markdown-table-of-contents.ts b/src/renderer/src/components/editor/markdown-table-of-contents.ts index e1bc30497e8..3b95e080781 100644 --- a/src/renderer/src/components/editor/markdown-table-of-contents.ts +++ b/src/renderer/src/components/editor/markdown-table-of-contents.ts @@ -1,3 +1,4 @@ +import remarkCjkFriendly from 'remark-cjk-friendly/parseOnly' import remarkFrontmatter from 'remark-frontmatter' import remarkGfm from 'remark-gfm' import remarkParse from 'remark-parse' @@ -112,7 +113,7 @@ function appendTocItem(stack: MarkdownTocItem[], item: MarkdownTocItem): void { } type MarkdownAstNode = { - alt?: string + alt?: string | null children?: MarkdownAstNode[] depth?: number type?: string @@ -142,8 +143,9 @@ export function buildMarkdownTableOfContents(markdown: string): MarkdownTocItem[ const tree = unified() .use(remarkParse) .use(remarkGfm) + .use(remarkCjkFriendly) .use(remarkFrontmatter, ['yaml', 'toml']) - .parse(markdown) as MarkdownAstNode + .parse(markdown) function visit(node: MarkdownAstNode): void { if ( From 24cbbd6c2b4b829e7ceb0991b5e13a46bd9792cb Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 16:48:57 -0700 Subject: [PATCH 4/6] refactor(editor): add shared Markdown scanners --- .../editor/markdown-code-span-scanner.ts | 222 ++++++++++++++++++ .../editor/markdown-code-stripping.ts | 63 +++++ .../editor/markdown-fence-scanner.ts | 126 ++++++++++ .../components/editor/markdown-scan-ranges.ts | 138 +++++++++++ 4 files changed, 549 insertions(+) create mode 100644 src/renderer/src/components/editor/markdown-code-span-scanner.ts create mode 100644 src/renderer/src/components/editor/markdown-code-stripping.ts create mode 100644 src/renderer/src/components/editor/markdown-fence-scanner.ts create mode 100644 src/renderer/src/components/editor/markdown-scan-ranges.ts diff --git a/src/renderer/src/components/editor/markdown-code-span-scanner.ts b/src/renderer/src/components/editor/markdown-code-span-scanner.ts new file mode 100644 index 00000000000..c1323c9ce0e --- /dev/null +++ b/src/renderer/src/components/editor/markdown-code-span-scanner.ts @@ -0,0 +1,222 @@ +import { + createMarkdownFenceTracker, + findMarkdownLineEnd, + forEachMarkdownLine +} from './markdown-fence-scanner' + +const BLANK_LINE = /^[ \t\r]*$/ +const BLOCKQUOTE_PREFIX = /^(?: {0,3}>[ \t]?)+/ + +// marked's block-level tag list, which is what makes a line an HTML block rather +// than inline HTML: `
` ends the paragraph above it, `
` does not. +const HTML_BLOCK_TAG = + 'address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul' + +// Headings, thematic breaks, and setext underlines are one line each, so the line +// after them always opens another block. +const SINGLE_LINE_BLOCK = + /^ {0,3}(?:#{1,6}(?:\s|$)|(?:-[\t ]*){3,}$|(?:_[ \t]*){3,}$|(?:\*[ \t]*){3,}$|(?:=+|-+)[ \t]*$)/ + +// Mirrors the rest of marked's paragraph-interruption rule. Backtick runs on +// opposite sides of one of these lines sit in different leaf blocks, so marked +// never pairs them. +const LEAF_BLOCK_START = new RegExp( + [ + '^ {0,3}(?:', + // Any ordinal, though only `1.` interrupts a paragraph: later ordinals still open + // the next item of a list, and splitting one block too many only costs a check. + '(?:[*+-]|\\d{1,9}[.)])(?:[ \\t]|$)', + `||$)`, + '|<(?:script|pre|style|textarea|!--)', // a declaration or PI does not interrupt + ')' + ].join(''), + 'i' +) + +// The row of dashes under a GFM table header. marked ends the paragraph above the +// header on this line alone, before it checks that the cell counts agree. +const TABLE_DELIMITER_ROW = /^ {0,3}(?:\| *)?:?-+:? *(?:\| *:?-+:? *)*(?:\| *)?$/ + +/** Blockquote marker count opening `line`, and the content that follows it. */ +function splitBlockquotePrefix(line: string): { depth: number; rest: string } { + const prefix = BLOCKQUOTE_PREFIX.exec(line)?.[0] + if (!prefix) { + return { depth: 0, rest: line } + } + let depth = 0 + for (const character of prefix) { + if (character === '>') { + depth += 1 + } + } + return { depth, rest: line.slice(prefix.length) } +} + +/** True when the line at `start` is a delimiter row, making the line above a header. */ +function hasTableDelimiterAhead(content: string, start: number): boolean { + if (start >= content.length) { + return false + } + const next = content.slice(start, findMarkdownLineEnd(content, start)) + return TABLE_DELIMITER_ROW.test(splitBlockquotePrefix(next).rest) +} + +/** True when an odd run of backslashes escapes the character at `index`. */ +function isEscapedAt(content: string, index: number, lineStart: number): boolean { + let cursor = index + while (cursor > lineStart && content[cursor - 1] === '\\') { + cursor -= 1 + } + return (index - cursor) % 2 === 1 +} + +export type MarkdownCodeSpanScanner = { + /** + * End of the inline code span opening at `index`, or null when the backtick run + * never closes. CommonMark closes a span on a run of exactly the opening length, + * so a longer run is content rather than a delimiter. + */ + findSpanEnd: (index: number) => number | null +} + +/** + * Indexes every backtick run in one pass, so a lookup is a binary search rather + * than a fresh scan of the rest of the document. + */ +export function createMarkdownCodeSpanScanner(content: string): MarkdownCodeSpanScanner { + const starts: number[] = [] + const lengths: number[] = [] + // Marked parses inline content one leaf block at a time, so a span cannot pair + // across a blank line, a fence delimiter, or the start of the next leaf block. + const blocks: number[] = [] + const escaped: boolean[] = [] + const runsByLength = new Map() + + const fence = createMarkdownFenceTracker() + let blockId = 0 + let previousWasBoundary = true + let previousQuoteDepth = 0 + let insideTable = false + + forEachMarkdownLine(content, (lineStart, lineEnd, nextLineStart) => { + const line = content.slice(lineStart, lineEnd) + const wasInsideFence = fence.insideFence + const isBoundary = fence.consume(line) || wasInsideFence || BLANK_LINE.test(line) + if (isBoundary) { + insideTable = false + } + const quote = isBoundary ? null : splitBlockquotePrefix(line) + const quoteDepth = quote ? quote.depth : previousQuoteDepth + const isDelimiterRow = quote !== null && TABLE_DELIMITER_ROW.test(quote.rest) + const isTableHeader = + quote !== null && !isDelimiterRow && hasTableDelimiterAhead(content, nextLineStart) + insideTable = insideTable || isDelimiterRow + // marked parses every cell on its own, so each row is its own inline context. + const endsWithLine = + quote !== null && (SINGLE_LINE_BLOCK.test(quote.rest) || isTableHeader || insideTable) + // A deeper quote opens a block; a shallower one can be a lazy continuation. + const startsLeafBlock = + quote !== null && + (endsWithLine || quote.depth > previousQuoteDepth || LEAF_BLOCK_START.test(quote.rest)) + if (isBoundary || previousWasBoundary || startsLeafBlock) { + blockId += 1 + } + previousWasBoundary = isBoundary || endsWithLine + previousQuoteDepth = quoteDepth + + let index = lineStart + while (index < lineEnd) { + if (content[index] !== '`') { + index += 1 + continue + } + const runStart = index + while (index < lineEnd && content[index] === '`') { + index += 1 + } + const runLength = index - runStart + const runsOfLength = runsByLength.get(runLength) + if (runsOfLength) { + runsOfLength.push(starts.length) + } else { + runsByLength.set(runLength, [starts.length]) + } + starts.push(runStart) + lengths.push(runLength) + blocks.push(blockId) + escaped.push(isEscapedAt(content, runStart, lineStart)) + } + }) + + /** Index of the run covering `index`, or -1. */ + function findRunAt(index: number): number { + let low = 0 + let high = starts.length - 1 + let atOrBefore = -1 + while (low <= high) { + const mid = (low + high) >> 1 + if (starts[mid] <= index) { + atOrBefore = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + if (atOrBefore === -1 || index >= starts[atOrBefore] + lengths[atOrBefore]) { + return -1 + } + return atOrBefore + } + + /** First run in `candidates` starting at or after `offset`, or -1. */ + function findFirstRunFrom(candidates: number[], offset: number): number { + let low = 0 + let high = candidates.length - 1 + let match = -1 + while (low <= high) { + const mid = (low + high) >> 1 + if (starts[candidates[mid]] >= offset) { + match = candidates[mid] + high = mid - 1 + } else { + low = mid + 1 + } + } + return match + } + + return { + findSpanEnd(index: number): number | null { + const opener = findRunAt(index) + if (opener === -1) { + return null + } + // A caller may resume inside a run it already rejected; only the tail opens. + // A backslashed backtick is literal text and cannot open, though marked + // still lets it close: `` `foo\` `` is a span ending on the escaped run. + const openStart = escaped[opener] ? Math.max(index, starts[opener] + 1) : index + const openerEnd = starts[opener] + lengths[opener] + if (openStart >= openerEnd) { + return null + } + const candidates = runsByLength.get(openerEnd - openStart) + if (!candidates) { + return null + } + const closer = findFirstRunFrom(candidates, openerEnd) + if (closer === -1 || blocks[closer] !== blocks[opener]) { + return null + } + return starts[closer] + lengths[closer] + } + } +} + +/** Offsets of the run of backticks at `index`, which closes no span. */ +export function skipMarkdownBacktickRun(content: string, index: number): number { + let end = index + while (content[end] === '`') { + end += 1 + } + return end +} diff --git a/src/renderer/src/components/editor/markdown-code-stripping.ts b/src/renderer/src/components/editor/markdown-code-stripping.ts new file mode 100644 index 00000000000..47015eb5ee8 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-code-stripping.ts @@ -0,0 +1,63 @@ +import { + createMarkdownCodeSpanScanner, + skipMarkdownBacktickRun +} from './markdown-code-span-scanner' +import { forEachMarkdownLine, getMarkdownFenceRanges } from './markdown-fence-scanner' + +export function stripMarkdownCode(content: string): string { + const ranges = getMarkdownCodeRanges(content) + let rangeIndex = 0 + let sanitized = '' + + forEachMarkdownLine(content, (lineStart, lineEnd) => { + while (rangeIndex < ranges.length && ranges[rangeIndex][1] <= lineStart) { + rangeIndex += 1 + } + // Emit the gaps between code ranges; the line break follows either way. + let cursor = lineStart + for (let i = rangeIndex; i < ranges.length && ranges[i][0] < lineEnd; i += 1) { + sanitized += content.slice(cursor, Math.max(cursor, ranges[i][0])) + cursor = Math.max(cursor, Math.min(ranges[i][1], lineEnd)) + } + sanitized += content.slice(cursor, lineEnd) + if (lineEnd < content.length) { + sanitized += '\n' + } + }) + + return sanitized +} + +/** Fenced blocks and inline code spans, sorted and non-overlapping. */ +function getMarkdownCodeRanges(content: string): [number, number][] { + const fences = getMarkdownFenceRanges(content) + const spans = createMarkdownCodeSpanScanner(content) + const ranges: [number, number][] = [] + let fenceIndex = 0 + let index = 0 + + while (index < content.length) { + while (fenceIndex < fences.length && fences[fenceIndex][1] <= index) { + fenceIndex += 1 + } + if (fenceIndex < fences.length && index >= fences[fenceIndex][0]) { + ranges.push([index, fences[fenceIndex][1]]) + index = fences[fenceIndex][1] + continue + } + if (content[index] !== '`') { + index += 1 + continue + } + // A run that never closes is literal text, not a delimiter. + const spanEnd = spans.findSpanEnd(index) + if (spanEnd === null) { + index = skipMarkdownBacktickRun(content, index) + continue + } + ranges.push([index, spanEnd]) + index = spanEnd + } + + return ranges +} diff --git a/src/renderer/src/components/editor/markdown-fence-scanner.ts b/src/renderer/src/components/editor/markdown-fence-scanner.ts new file mode 100644 index 00000000000..4afed0185a4 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-fence-scanner.ts @@ -0,0 +1,126 @@ +export type MarkdownFenceRanges = readonly (readonly [number, number])[] + +export type MarkdownFenceTracker = { + readonly insideFence: boolean + // Returns true when the line was consumed as a fence delimiter. + consume: (line: string) => boolean +} + +// A top-level opener may be indented by at most three spaces. Closers are matched +// separately below because marked allows them to be indented within the fence. +const FENCE_LINE = /^[ ]{0,3}(`{3,}|~{3,})/ +const INDENTED_FENCE_LINE = /^[ \t]*(`{3,}|~{3,})/ +// marked lets a closer trail a run of fence characters, e.g. ```~~~ closes a ``` block. +const CLOSING_FENCE_SUFFIX = /^[~`]*[ \t\r]*$/ + +/** Tracks CommonMark fenced code blocks across the lines of one document. */ +export function createMarkdownFenceTracker(): MarkdownFenceTracker { + let marker = '' + let length = 0 + + return { + get insideFence(): boolean { + return length > 0 + }, + consume(line: string): boolean { + const match = length > 0 ? INDENTED_FENCE_LINE.exec(line) : FENCE_LINE.exec(line) + if (!match) { + return false + } + const lineMarker = match[1][0] + const lineLength = match[1].length + const suffix = line.slice(match[0].length) + + if (length > 0) { + if (lineMarker === marker && lineLength >= length && CLOSING_FENCE_SUFFIX.test(suffix)) { + marker = '' + length = 0 + } + return true + } + + if (lineMarker === '`' && suffix.includes('`')) { + return false + } + marker = lineMarker + length = lineLength + return true + } + } +} + +// Native scan: these run over whole documents, so per-character JS is too costly. +const LINE_BREAK = /[\n\r]/g + +/** + * End of the line at `start`, excluding its terminator. marked normalizes + * `/\r\n|\r/g` to `\n` before parsing, so a lone CR ends a line here too. + */ +export function findMarkdownLineEnd(content: string, start: number): number { + LINE_BREAK.lastIndex = start + return LINE_BREAK.exec(content)?.index ?? content.length +} + +/** Start of the line following the terminator at `lineEnd`. */ +export function skipMarkdownLineBreak(content: string, lineEnd: number): number { + if (content.charCodeAt(lineEnd) === 13 && content.charCodeAt(lineEnd + 1) === 10) { + return lineEnd + 2 + } + return lineEnd < content.length ? lineEnd + 1 : lineEnd +} + +export function forEachMarkdownLine( + content: string, + visit: (lineStart: number, lineEnd: number, nextLineStart: number) => void +): void { + let lineStart = 0 + for (;;) { + const lineEnd = findMarkdownLineEnd(content, lineStart) + const nextLineStart = skipMarkdownLineBreak(content, lineEnd) + visit(lineStart, lineEnd, nextLineStart) + if (lineEnd >= content.length) { + return + } + lineStart = nextLineStart + } +} + +/** Offsets of every fenced code block, including its delimiter lines. */ +export function getMarkdownFenceRanges(content: string): MarkdownFenceRanges { + const ranges: [number, number][] = [] + const tracker = createMarkdownFenceTracker() + let openStart = -1 + + forEachMarkdownLine(content, (lineStart, lineEnd, nextLineStart) => { + const wasInside = tracker.insideFence + const isFenceLine = tracker.consume(content.slice(lineStart, lineEnd)) + if (!wasInside && isFenceLine) { + openStart = lineStart + } else if (wasInside && !tracker.insideFence) { + ranges.push([openStart, nextLineStart]) + openStart = -1 + } + }) + + if (openStart !== -1) { + ranges.push([openStart, content.length]) + } + return ranges +} + +export function isInsideMarkdownFenceRange(index: number, ranges: MarkdownFenceRanges): boolean { + return ranges.some(([start, end]) => index >= start && index < end) +} + +/** Same test for callers that probe non-decreasing offsets, in linear total time. */ +export function createMarkdownFenceRangeCursor( + ranges: MarkdownFenceRanges +): (index: number) => boolean { + let cursor = 0 + return (index: number): boolean => { + while (cursor < ranges.length && index >= ranges[cursor][1]) { + cursor += 1 + } + return cursor < ranges.length && index >= ranges[cursor][0] + } +} diff --git a/src/renderer/src/components/editor/markdown-scan-ranges.ts b/src/renderer/src/components/editor/markdown-scan-ranges.ts new file mode 100644 index 00000000000..3e471be72d9 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-scan-ranges.ts @@ -0,0 +1,138 @@ +// Fence ranges depend only on the scanned string, so callers scanning one body +// repeatedly compute them once and share them across sibling matches. +export type MarkdownFenceRanges = readonly (readonly [number, number])[] + +export function markdownFenceRanges(content: string): MarkdownFenceRanges { + const ranges: [number, number][] = [] + let offset = 0 + let openFence: { closingPattern: RegExp; start: number } | null = null + + for (const lineMatch of content.matchAll(/[^\r\n]*(?:\r\n|\n|\r|$)/g)) { + const line = lineMatch[0] + if (line === '') { + break + } + + const lineText = line.replace(/(?:\r\n|\n|\r)$/u, '') + if (openFence) { + // Built once per fence: rebuilding it per line recompiled the same regex for every fenced line. + if (openFence.closingPattern.test(lineText)) { + ranges.push([openFence.start, offset + line.length]) + openFence = null + } + } else { + const openingFenceMatch = lineText.match(/^ {0,3}(`{3,}|~{3,})/u) + if (openingFenceMatch?.[1]) { + if ( + openingFenceMatch[1][0] === '`' && + lineText.slice(openingFenceMatch[0].length).includes('`') + ) { + offset += line.length + continue + } + openFence = { + closingPattern: new RegExp( + // CommonMark 4.5: a closing fence may be followed only by spaces or + // tabs, unlike `\s`, which also matches non-ASCII whitespace. + `^ {0,3}${openingFenceMatch[1][0]}{${openingFenceMatch[1].length},}[ \\t]*$` + ), + start: offset + } + } + } + + offset += line.length + } + + if (openFence) { + ranges.push([openFence.start, content.length]) + } + + return ranges +} + +export function isInsideRange(index: number, ranges: MarkdownFenceRanges): boolean { + return ranges.some(([start, end]) => index >= start && index < end) +} + +function rangeEndAt(index: number, ranges: MarkdownFenceRanges): number { + for (const [start, end] of ranges) { + if (index >= start && index < end) { + return end + } + } + return -1 +} + +// CommonMark code spans: a backtick run only closes on a run of the same +// length, so `` `
` `` is one span even though `
` alone +// isn't. Mirrors the tick-matching in raw-markdown-html.ts's inline scan. +// Fenced blocks are skipped whole — their delimiters and content are not +// inline code, and scanning them pairs a fence backtick with a later prose +// one, swallowing everything between. Blank lines are not span boundaries. +export function markdownCodeSpanRanges( + content: string, + fenceRanges: MarkdownFenceRanges = markdownFenceRanges(content) +): MarkdownFenceRanges { + const ranges: [number, number][] = [] + let index = 0 + + while (index < content.length) { + const fenceEnd = rangeEndAt(index, fenceRanges) + if (fenceEnd !== -1) { + index = fenceEnd + continue + } + + if (content[index] !== '`') { + index += 1 + continue + } + + let backslashes = 0 + for (let cursor = index - 1; cursor >= 0 && content[cursor] === '\\'; cursor -= 1) { + backslashes += 1 + } + if (backslashes % 2 === 1) { + index += 1 + continue + } + + let tickCount = 0 + while (content[index + tickCount] === '`') { + tickCount += 1 + } + + const spanStart = index + let searchFrom = index + tickCount + let closingIndex = -1 + while (searchFrom < content.length) { + const candidate = content.indexOf('`'.repeat(tickCount), searchFrom) + if (candidate === -1) { + break + } + if (rangeEndAt(candidate, fenceRanges) !== -1) { + searchFrom = candidate + 1 + continue + } + if ( + (candidate === 0 || content[candidate - 1] !== '`') && + content[candidate + tickCount] !== '`' + ) { + closingIndex = candidate + break + } + searchFrom = candidate + 1 + } + + if (closingIndex === -1) { + index += tickCount + continue + } + + ranges.push([spanStart, closingIndex + tickCount]) + index = closingIndex + tickCount + } + + return ranges +} From 6afe452eb0b7a1d87ac6518cc42e1902d9ce59e7 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 16:50:23 -0700 Subject: [PATCH 5/6] fix(editor): protect fence info and unsaved preview boundaries --- .../src/components/editor/EditorPanelShell.tsx | 2 +- .../editor/editor-panel-render-model.test.ts | 12 +++++++++++- .../components/editor/editor-panel-render-model.ts | 8 +++++++- .../rich-markdown-code-block-markdown.test.ts | 13 +++++++++++++ .../editor/rich-markdown-code-block-markdown.ts | 12 ++++++++---- .../editor/rich-markdown-heading-split.ts | 5 +++++ 6 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-code-block-markdown.test.ts diff --git a/src/renderer/src/components/editor/EditorPanelShell.tsx b/src/renderer/src/components/editor/EditorPanelShell.tsx index a01d618efa5..48cfca89a2b 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.tsx @@ -163,7 +163,7 @@ export function EditorPanelShell({ showMarkdownFrontmatter={markdownFrontmatterVisible} onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents} markdownAnnotationsEnabled={markdownAnnotationsEnabled} - onOpenMarkdownPreview={onOpenMarkdownPreview} + onOpenMarkdownPreview={model.canOpenPreviewToSide ? onOpenMarkdownPreview : undefined} /> = {}): OpenFile { describe('getEditorPanelRenderModel rich-mode fallback toggle', () => { it('offers Preview once rich mode falls back for this content', () => { const model = renderModel({ - editorDrafts: { '/repo/README.md': '[reference]: https://example.com' } + fileContents: { + '/repo/README.md': textContent({ content: '[reference]: https://example.com' }) + } }) expect(model.availableEditorToggleModes).toEqual(['source', 'rich', 'preview', 'changes']) @@ -265,3 +267,11 @@ describe('getEditorPanelRenderModel markdown export affordance', () => { ).toBe(false) }) }) + +it('hides disk preview while a fallback draft is unsaved', () => { + const model = renderModel({ + editorDrafts: { '/repo/README.md': '[reference]: https://example.com' } + }) + expect(model.availableEditorToggleModes).not.toContain('preview') + expect(model.canOpenPreviewToSide).toBe(false) +}) diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 0ccf9053c3f..177c83d89ec 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -140,11 +140,16 @@ export function getEditorPanelRenderModel({ const richModeFallsBackToSource = richModeEligibility !== null && (richModeEligibility.exceedsSizeLimit || richModeUnsupportedMessage !== null) + const hasUnsavedMarkdownDraft = + viewerLanguage === 'markdown' && + activeFile.mode === 'edit' && + editorDrafts[activeFile.id] !== undefined && + editorDrafts[activeFile.id] !== inlineFileContent?.content const editorToggleModes = getEditorToggleModes({ language: viewerLanguage, mode: activeFile.mode, diffSource: activeFile.diffSource, - richModeFallsBackToSource + richModeFallsBackToSource: richModeFallsBackToSource && !hasUnsavedMarkdownDraft }) const availableEditorToggleModes = isBinaryEditSurface || !canUseChangesModeForFile(activeFile) @@ -196,6 +201,7 @@ export function getEditorPanelRenderModel({ // files and commit diffs whose content may not match the working tree). canOpenPreviewToSide: canOpenWorkspaceFileBrowser && + !hasUnsavedMarkdownDraft && canPreviewLanguage(viewerLanguage) && (activeFile.mode === 'edit' || (isSingleDiff && openFileState.canOpen)), mdViewMode, diff --git a/src/renderer/src/components/editor/rich-markdown-code-block-markdown.test.ts b/src/renderer/src/components/editor/rich-markdown-code-block-markdown.test.ts new file mode 100644 index 00000000000..2a006c5ae8c --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-code-block-markdown.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { renderRichMarkdownCodeBlock } from './rich-markdown-code-block-markdown' + +describe('code fence info boundaries', () => { + it.each([ + ['foo`bar', 'body', '~~~foo`bar\nbody\n~~~'], + ['~foo', 'x```y', '~~~ ~foo\nx```y\n~~~'] + ])('preserves language %s', (language, body, expected) => { + expect( + renderRichMarkdownCodeBlock({ attrs: { language } }, { renderChildren: () => body }) + ).toBe(expected) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-code-block-markdown.ts b/src/renderer/src/components/editor/rich-markdown-code-block-markdown.ts index d03acf924fb..ee2a088c949 100644 --- a/src/renderer/src/components/editor/rich-markdown-code-block-markdown.ts +++ b/src/renderer/src/components/editor/rich-markdown-code-block-markdown.ts @@ -18,10 +18,10 @@ function longestFenceRun(text: string, character: '`' | '~'): number { return longest } -function chooseFence(text: string): { character: '`' | '~'; length: number } { +function chooseFence(text: string, language: string): { character: '`' | '~'; length: number } { const backtickLength = Math.max(3, longestFenceRun(text, '`') + 1) const tildeLength = Math.max(3, longestFenceRun(text, '~') + 1) - return tildeLength < backtickLength + return language.includes('`') || tildeLength < backtickLength ? { character: '~', length: tildeLength } : { character: '`', length: backtickLength } } @@ -32,7 +32,11 @@ export function renderRichMarkdownCodeBlock( ): string { const language = typeof node.attrs?.language === 'string' ? node.attrs.language : '' const body = helpers.renderChildren(node.content ?? []) - const fence = chooseFence(body) + const fence = chooseFence(body, language) const marker = fence.character.repeat(fence.length) - return [`${marker}${language}`, body, marker].join('\n') + return [ + `${marker}${language.startsWith(fence.character) ? ' ' : ''}${language}`, + body, + marker + ].join('\n') } diff --git a/src/renderer/src/components/editor/rich-markdown-heading-split.ts b/src/renderer/src/components/editor/rich-markdown-heading-split.ts index 9a914174714..6580afc00fc 100644 --- a/src/renderer/src/components/editor/rich-markdown-heading-split.ts +++ b/src/renderer/src/components/editor/rich-markdown-heading-split.ts @@ -31,6 +31,11 @@ export function splitRichMarkdownHeading(editor: Editor): boolean { if (!empty || $from.parent.type.name !== 'heading') { return false } + for (let depth = $from.depth; depth > 0; depth -= 1) { + if (['tableCell', 'tableHeader'].includes($from.node(depth).type.name)) { + return false + } + } if ($from.parentOffset === 0 || $from.parentOffset === $from.parent.content.size) { return false } From 21a68a56f98fe272f8a9517c4046c8ed5f67ceb2 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 16:54:01 -0700 Subject: [PATCH 6/6] fix(editor): scope escape encoding to text nodes --- .../editor/editor-panel-render-model.test.ts | 4 +- .../markdown-rich-mode-comments.test.ts | 6 ++ .../components/editor/markdown-rich-mode.ts | 10 +- .../editor/rich-markdown-destinations.ts | 73 ++++++++++++++ .../rich-markdown-escape-round-trip.test.ts | 22 ++++- .../editor/rich-markdown-escaped-character.ts | 18 +--- .../editor/rich-markdown-extensions.ts | 79 +-------------- .../rich-markdown-literal-serialization.ts | 96 ++++--------------- .../editor/rich-markdown-prose-entities.ts | 14 ++- 9 files changed, 143 insertions(+), 179 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-destinations.ts diff --git a/src/renderer/src/components/editor/editor-panel-render-model.test.ts b/src/renderer/src/components/editor/editor-panel-render-model.test.ts index 13dea862455..682d8df326d 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.test.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.test.ts @@ -77,7 +77,9 @@ describe('getEditorPanelRenderModel rich-mode fallback toggle', () => { it('offers Preview in Source view once a stored fault matches the current content', () => { const model = renderModel({ markdownViewMode: { '/repo/README.md': 'source' }, - editorDrafts: { '/repo/README.md': '[reference]: https://example.com' }, + fileContents: { + '/repo/README.md': textContent({ content: '[reference]: https://example.com' }) + }, markdownRichModeFaultedContent: { '/repo/README.md': '[reference]: https://example.com' } diff --git a/src/renderer/src/components/editor/markdown-rich-mode-comments.test.ts b/src/renderer/src/components/editor/markdown-rich-mode-comments.test.ts index 1a18c7883a1..6d5c3444ac6 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode-comments.test.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode-comments.test.ts @@ -173,3 +173,9 @@ describe('rich editing of Markdown documents with HTML comments', () => { ) }) }) + +it('does not let image-alt comments bypass reference definitions', () => { + expect( + getMarkdownRichModeUnsupportedReason('![](image.png)\n\n[id]: https://example.com') + ).toBe('reference-links') +}) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index 2f7790f4058..c087b38ffb0 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -131,12 +131,6 @@ export function getMarkdownRichModeUnsupportedReason( const htmlMatcher = UNSUPPORTED_PATTERNS.find((m) => m.reason === 'html-or-jsx') const hasHtml = htmlMatcher && hasHtmlOrJsx(contentWithoutCode, htmlMatcher.pattern) - // HTML comments inside image alt text are Markdown label content, not embedded - // document HTML; they remain literal through the rich serializer. - if (hasHtml && hasOnlyImageAltComments(contentWithoutCode)) { - return null - } - for (const matcher of UNSUPPORTED_PATTERNS) { if (matcher.reason === 'html-or-jsx') { continue @@ -150,7 +144,9 @@ export function getMarkdownRichModeUnsupportedReason( return matcher.reason } - if (hasHtml) { + // HTML comments inside image alt text are Markdown label content, not embedded + // document HTML; they remain literal through the rich serializer. + if (hasHtml && !hasOnlyImageAltComments(contentWithoutCode)) { // The source codec recognizes multiline code spans that the cheap scan can misclassify. const htmlOutput = getRichMarkdownHtmlValidationOutput(body) if (htmlOutput && preservesEmbeddedHtml(body, htmlOutput)) { diff --git a/src/renderer/src/components/editor/rich-markdown-destinations.ts b/src/renderer/src/components/editor/rich-markdown-destinations.ts new file mode 100644 index 00000000000..a7ad7080c50 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-destinations.ts @@ -0,0 +1,73 @@ +import Link from '@tiptap/extension-link' +import Image from '@tiptap/extension-image' + +export const RichMarkdownLink = Link.extend({ + addAttributes() { + return { + ...this.parent?.(), + rawHref: { default: null, rendered: false }, + originalHref: { default: null, rendered: false } + } + }, + parseMarkdown: (token, helpers) => + helpers.applyMark('link', helpers.parseInline(token.tokens || []), { + href: token.href, + title: token.title || null, + rawHref: extractRawDestination(token.raw, token.href), + originalHref: token.href + }), + renderMarkdown: (node, helpers) => { + const href = + node.attrs?.href === node.attrs?.originalHref + ? (node.attrs?.rawHref ?? node.attrs?.href ?? '') + : (node.attrs?.href ?? '') + const title = node.attrs?.title ?? '' + const text = helpers.renderChildren(node) + return title ? `[${text}](${href} "${title}")` : `[${text}](${href})` + } +}) + +function extractRawDestination(raw: string | undefined, href: string | undefined): string | null { + if (!raw) { + return null + } + const open = raw.indexOf('](') + const close = raw.lastIndexOf(')') + if (open === -1 || close <= open + 2) { + return null + } + const destination = raw.slice(open + 2, close).trim() + const titleStart = destination.search(/\s+["']|\s+\(/) + const candidate = titleStart >= 0 ? destination.slice(0, titleStart) : destination + const decoded = candidate + .replace(/^<(.*)>$/, '$1') + .replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/g, '$1') + return decoded === href ? candidate : null +} + +export const RichMarkdownImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + rawSrc: { default: null, rendered: false }, + originalSrc: { default: null, rendered: false } + } + }, + parseMarkdown: (token, helpers) => + helpers.createNode('image', { + src: token.href, + alt: token.text || '', + title: token.title, + rawSrc: extractRawDestination(token.raw, token.href), + originalSrc: token.href + }), + renderMarkdown: (node) => { + const src = + node.attrs?.src === node.attrs?.originalSrc + ? (node.attrs?.rawSrc ?? node.attrs?.src ?? '') + : (node.attrs?.src ?? '') + const alt = node.attrs?.alt ?? '' + const title = node.attrs?.title ?? '' + return title ? `![${alt}](${src} "${title}")` : `![${alt}](${src})` + } +}) diff --git a/src/renderer/src/components/editor/rich-markdown-escape-round-trip.test.ts b/src/renderer/src/components/editor/rich-markdown-escape-round-trip.test.ts index 3d09f59a5ed..22b42cf8cae 100644 --- a/src/renderer/src/components/editor/rich-markdown-escape-round-trip.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-escape-round-trip.test.ts @@ -117,7 +117,7 @@ describe('rich markdown escape round trip', () => { it('does not invent a backslash on money-like $5', () => { expect(expectStable('cost $5\n')).toBe('cost $5') - expect(expectStable('cost \\$5\n')).not.toContain('\\$') + expect(expectStable('cost \\$5\n')).toBe('cost \\$5') expect(inspect('cost $5\n').inlineMath).toBe(0) }) @@ -260,3 +260,23 @@ describe('rich markdown escape round trip', () => { } }) }) + +it.each([ + '\\* `$HOME`', + '\\* `a & b`', + '\\* Copyright © 2026', + 'cost \\$5\n\n```\nx && y < z\n```', + 'a \\& b and [x](http://h/?a=1&b=2)', + '[`[text](url)`](https://docs)' +])('preserves mixed escaped prose through repeated saves: %s', (source) => { + const before = inspect(source) + let output = source + for (let cycle = 0; cycle < 3; cycle += 1) { + const next = inspect(output) + expect(next.text).toBe(before.text) + expect(next.href).toBe(before.href) + expect(next.types).toEqual(before.types) + output = next.markdown + } + expect(roundTrip(output)).toBe(output) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-escaped-character.ts b/src/renderer/src/components/editor/rich-markdown-escaped-character.ts index f71594e3120..c101e8ff4aa 100644 --- a/src/renderer/src/components/editor/rich-markdown-escaped-character.ts +++ b/src/renderer/src/components/editor/rich-markdown-escaped-character.ts @@ -42,22 +42,8 @@ export const RichMarkdownEscapedCharacter = Mark.create({ { type: 'text', text: character } ]) }, - // Why: a mark's markdown is one prefix for the whole run, so `\*\*` would come out as `\**`; - // this is only the fallback for a serializer that bypasses getMarkdown. - renderMarkdown: (node, helpers) => { - const rendered = helpers.renderChildren(node) - const plain = rendered.replace(/^\\+/, '') - if (node.marks?.some((mark) => mark.type === 'code')) { - return plain - } - if (plain === '&') { - return '&' - } - if (plain === '<') { - return '<' - } - return escapedCharacterSourceText(plain, false) - }, + // Mark renderers receive a delimiter probe, so text escaping belongs in the text encoder. + renderMarkdown: (node, helpers) => helpers.renderChildren(node), parseHTML() { return [{ tag: `span[${MARKER_ATTRIBUTE}]` }] diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index e2e2c353633..54f5a6e5d98 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -1,15 +1,14 @@ import type { AnyExtension } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' -import Link from '@tiptap/extension-link' import { Code } from '@tiptap/extension-code' -import Image from '@tiptap/extension-image' +import { RichMarkdownLink, RichMarkdownImage } from './rich-markdown-destinations' import Placeholder from '@tiptap/extension-placeholder' import TaskItem from '@tiptap/extension-task-item' import { createRichMarkdownTable } from './rich-markdown-table' import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import { TableRow } from '@tiptap/extension-table-row' -import { BlockMath, InlineMath } from '@tiptap/extension-mathematics' +import { BlockMath } from '@tiptap/extension-mathematics' import { createRichMarkdownExtension } from './rich-markdown-extension' import { createLowlight, common } from 'lowlight' import { @@ -42,6 +41,7 @@ import { RichMarkdownListItem } from './rich-markdown-list-item' import { RichMarkdownProseEntities } from './rich-markdown-prose-entities' import { RichMarkdownParagraph } from './rich-markdown-paragraph' import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight' +import { RichMarkdownInlineMath } from './rich-markdown-inline-math' import { RichMarkdownEscapedCharacter } from './rich-markdown-escaped-character' import { RichMarkdownTaskList } from './rich-markdown-task-list' import { createCachedLowlight } from './rich-markdown-lowlight-cache' @@ -49,58 +49,6 @@ import { renderRichMarkdownCodeBlock } from './rich-markdown-code-block-markdown const lowlight = createCachedLowlight(createLowlight(common)) -const RichMarkdownLink = Link.extend({ - addAttributes() { - return { - ...this.parent?.(), - rawHref: { default: null, rendered: false } - } - }, - parseMarkdown: (token, helpers) => - helpers.applyMark('link', helpers.parseInline(token.tokens || []), { - href: token.href, - title: token.title || null, - rawHref: extractRawDestination(token.raw) - }), - renderMarkdown: (node, helpers) => { - const href = node.attrs?.rawHref ?? node.attrs?.href ?? '' - const title = node.attrs?.title ?? '' - const text = helpers.renderChildren(node) - return title ? `[${text}](${href} "${title}")` : `[${text}](${href})` - } -}) - -function extractRawDestination(raw: string | undefined): string | null { - if (!raw) { - return null - } - const open = raw.indexOf('](') - const close = raw.lastIndexOf(')') - if (open === -1 || close <= open + 2) { - return null - } - const destination = raw.slice(open + 2, close).trim() - const titleStart = destination.search(/\s+["']|\s+\(/) - return titleStart >= 0 ? destination.slice(0, titleStart) : destination -} - -// Why: Pandoc's rule keeps money as text — both `$` must touch the formula, the closing one -// must not be followed by a digit, and an escaped `\$` never closes. -const INLINE_MATH_PATTERN = /^\$(?![\s$])((?:\\[\s\S]|[^$\\])*?)(? src.indexOf('$'), - tokenize: (src: string) => { - const match = src.match(INLINE_MATH_PATTERN) - if (!match) { - return undefined - } - return { type: 'inlineMath', raw: match[0], latex: match[1] } - } - } -}) const BLOCK_MATH_START_PATTERN = /\n[ \t]*\$\$/ const BLOCK_MATH_PATTERN = /^[ \t]*\$\$((?:(?!\$\$)[\s\S])+?)\$\$/ const RichMarkdownBlockMath = BlockMath.extend({ @@ -175,26 +123,7 @@ export function createRichMarkdownExtensions({ // file:// URLs in tags are blocked by cross-origin restrictions. // A nodeView loads local images via IPC → blob URL, which bypasses this // and works identically in dev and production modes. - Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - rawSrc: { default: null, rendered: false } - } - }, - parseMarkdown: (token, helpers) => - helpers.createNode('image', { - src: token.href, - alt: token.text || '', - title: token.title, - rawSrc: extractRawDestination(token.raw) - }), - renderMarkdown: (node) => { - const src = node.attrs?.rawSrc ?? node.attrs?.src ?? '' - const alt = node.attrs?.alt ?? '' - const title = node.attrs?.title ?? '' - return title ? `![${alt}](${src} "${title}")` : `![${alt}](${src})` - }, + RichMarkdownImage.extend({ addStorage() { return { contextVersion: 0, diff --git a/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts index adcfdec15d1..523316ffce2 100644 --- a/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts +++ b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts @@ -2,7 +2,6 @@ import type { Editor, JSONContent } from '@tiptap/core' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' -import { RICH_MARKDOWN_ESCAPED_CHARACTER_MARK } from './rich-markdown-escaped-character' const DOLLAR_SKIP_TYPES = new Set(['inlineMath', 'rawMarkdownHtmlInline']) @@ -54,6 +53,9 @@ function hasBare(markdown: string, chars: string): boolean { } function destNeedsEscape(dest: string): boolean { + if (/\s/.test(dest)) { + return true + } let depth = 0 let oddBackslash = false for (let i = 0; i < dest.length; i += 1) { @@ -170,12 +172,19 @@ function escapeLinkAndImageAttributes(node: JSONContent): void { forEachLinkOrImage(node, (attrs, kind) => { const destKey = kind === 'image' ? 'src' : 'href' const dest = attrs[destKey] - const rawDest = kind === 'link' ? attrs.rawHref : attrs.rawSrc - if (typeof rawDest === 'string') { - attrs[destKey] = rawDest + const rawKey = kind === 'image' ? 'rawSrc' : 'rawHref' + const originalKey = kind === 'image' ? 'originalSrc' : 'originalHref' + const raw = attrs[rawKey] + const keepRaw = typeof raw === 'string' && dest === attrs[originalKey] + if (keepRaw) { + attrs[destKey] = raw + attrs[originalKey] = raw } - if (typeof dest === 'string' && destNeedsEscape(dest)) { - attrs[destKey] = escapeBare(dest, '()') + if (!keepRaw && typeof dest === 'string' && destNeedsEscape(dest)) { + attrs[destKey] = escapeBare( + dest.replace(/\s/g, (character) => encodeURIComponent(character)), + '()' + ) } if (typeof attrs.title === 'string' && hasBare(attrs.title, '"')) { attrs.title = escapeBare(attrs.title, '"') @@ -255,23 +264,10 @@ export function preserveLiteralMarkdownSource( return cached.result } let result = markdown - const preservesEscapedCharacters = blockHasEscapedCharacters(info.block) - const isPlainEscapedBlock = preservesEscapedCharacters && blockHasOnlyEscapeMarks(info.block) - if (isPlainEscapedBlock && !info.inTableCell) { - result = result.replace(/\\\$(?=\d)/g, '$') - } - if (preservesEscapedCharacters) { - result = info.inTableCell - ? result.replace(/(? mark.type === RICH_MARKDOWN_ESCAPED_CHARACTER_MARK) || - node.content?.some((child) => hasEscapedEntityMark(child)) - ) -} - -function blockHasEscapedCharacters(block: ProseMirrorNode): boolean { - let found = false - block.descendants((node) => { - if (node.marks.some((mark) => mark.type.name === RICH_MARKDOWN_ESCAPED_CHARACTER_MARK)) { - found = true - } - }) - return found -} - -function blockHasInlineMath(block: ProseMirrorNode): boolean { - let found = false - block.descendants((node) => { - if (node.type.name === 'inlineMath') { - found = true - } - }) - return found -} - -function blockHasOnlyEscapeMarks(block: ProseMirrorNode): boolean { - let onlyEscapeMarks = true - block.descendants((node) => { - if ( - node.isText && - node.marks.some((mark) => mark.type.name !== RICH_MARKDOWN_ESCAPED_CHARACTER_MARK) - ) { - onlyEscapeMarks = false - } - }) - return onlyEscapeMarks -} diff --git a/src/renderer/src/components/editor/rich-markdown-prose-entities.ts b/src/renderer/src/components/editor/rich-markdown-prose-entities.ts index 38440f3e353..5f882f3b47a 100644 --- a/src/renderer/src/components/editor/rich-markdown-prose-entities.ts +++ b/src/renderer/src/components/editor/rich-markdown-prose-entities.ts @@ -1,9 +1,14 @@ -import { Extension } from '@tiptap/core' +import { Extension, type JSONContent } from '@tiptap/core' + +import { + escapedCharacterSourceText, + RICH_MARKDOWN_ESCAPED_CHARACTER_MARK +} from './rich-markdown-escaped-character' const TAG_OPENING = /^<(?:[a-zA-Z][a-zA-Z0-9-]*|\/[a-zA-Z][a-zA-Z0-9-]*|!|\?)/ type MarkdownTextEncoder = { - encodeTextForMarkdown?: (text: string, node: unknown, parentNode?: unknown) => string + encodeTextForMarkdown?: (text: string, node: JSONContent, parentNode?: JSONContent) => string } function isMarkdownTextEncoder(value: unknown): value is MarkdownTextEncoder { @@ -53,6 +58,11 @@ export const RichMarkdownProseEntities = Extension.create({ return } managerValue.encodeTextForMarkdown = (text, node, parentNode) => { + if (node.marks?.some((mark) => mark.type === RICH_MARKDOWN_ESCAPED_CHARACTER_MARK)) { + const insideCode = + parentNode?.type === 'codeBlock' || node.marks.some((mark) => mark.type === 'code') + return escapedCharacterSourceText(text, insideCode) + } const encoded = base.call(managerValue, text, node, parentNode) return encoded === text ? text : encodeProseTextForMarkdown(text) }