From baf1e0fae77aa705c3c898678db0bc44c264246b Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:05:39 +0800 Subject: [PATCH] perf(preview): process and sanitize only the blocks that changed After every typing pause the preview took the whole document through processMarkdownHtml, DOMPurify and a diff that serialised every block, so a pause cost the size of the document: about 0.8s at 1MB and 4.5s at 5MB on the main thread to change one paragraph. The preview now asks render_markdown for its split form and patchPreview diffs the renderer's own blocks, keyed on their markup without source ranges. Only the blocks that changed are processed (processBlockMarkup, the per-block half of processMarkdownHtml), sanitized with the same shared policy, and inserted. Kept blocks that moved get their new data-sourcepos written onto them. The heading wrappers and fold keys are kept on the live tree with DOM calls; a block goes after the one before it, and only adding, removing or re-levelling a top-level heading regroups. richContent records what it replaced a block with (replacedBy), so the patch can follow a code block into its shell or a diagram into its container. blockPatch.ts is gone; its tests are ported to previewPatch.spec.ts, and previewPatchParity.spec.ts holds the new path to the old one over the renderer's real output for samples/markdown-syntax.md, after eight kinds of edit and after undoing each. --- scripts/blockPatch.spec.ts | 285 --------------- scripts/blockPatchRenumber.spec.ts | 144 -------- scripts/diagramThemeRefresh.spec.ts | 13 +- scripts/fixtures/markdown-syntax.blocks.json | 179 ++++++++++ scripts/previewPatch.spec.ts | 288 +++++++++++++++ scripts/previewPatchParity.spec.ts | 98 ++++++ scripts/previewSanitize.spec.ts | 18 +- scripts/previewSanitize.test.ts | 83 ++--- scripts/tabSwitchKeepsPreviewDom.spec.ts | 29 +- scripts/tocRenderSignal.spec.ts | 2 +- scripts/viewModeWithoutSaving.spec.ts | 32 +- src-tauri/src/markdown.rs | 23 ++ src/lib/MarkdownViewer.svelte | 55 ++- src/lib/sessions/documentSession.svelte.ts | 26 +- src/lib/utils/blockPatch.ts | 347 ------------------- src/lib/utils/export.ts | 5 +- src/lib/utils/foldLayout.ts | 2 +- src/lib/utils/markdown.ts | 142 +++++--- src/lib/utils/previewAnchor.ts | 6 +- src/lib/utils/previewPatch.ts | 262 ++++++++++++++ src/lib/utils/richContent.ts | 16 +- src/lib/utils/sanitize.ts | 8 +- 22 files changed, 1072 insertions(+), 991 deletions(-) delete mode 100644 scripts/blockPatch.spec.ts delete mode 100644 scripts/blockPatchRenumber.spec.ts create mode 100644 scripts/fixtures/markdown-syntax.blocks.json create mode 100644 scripts/previewPatch.spec.ts create mode 100644 scripts/previewPatchParity.spec.ts delete mode 100644 src/lib/utils/blockPatch.ts create mode 100644 src/lib/utils/previewPatch.ts diff --git a/scripts/blockPatch.spec.ts b/scripts/blockPatch.spec.ts deleted file mode 100644 index 6e7287aa..00000000 --- a/scripts/blockPatch.spec.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { beforeEach, describe, expect, test } from 'vitest'; - -import { patchPreviewBlocks as patchNodes } from '../src/lib/utils/blockPatch.js'; - -/** The patch takes the sanitizer's nodes; these tests start from markup. */ -function patchPreviewBlocks(host: HTMLElement, html: string) { - const template = document.createElement('template'); - template.innerHTML = html; - return patchNodes(host, template.content); -} -import { asRendererLine } from '../src/lib/utils/lineCoordinates.js'; -import { - findAnchorElement, - getPreviewOffsetForSourceLine, - type AnchorBox, - type AnchorNode, -} from '../src/lib/utils/previewAnchor.js'; - -/** - * The shape `processMarkdownHtml` actually produces: a heading, then a - * `.foldable-content-wrapper > .content-inner` holding everything under it. It - * matters that the fixtures look like this rather than like a flat list of - * paragraphs — a flat document has no second level, and the second level is - * where every real edit lands. - */ -function section(paragraphs: { line: number; text: string }[]): string { - const blocks = paragraphs - .map(({ line, text }) => `

${text}

`) - .join(''); - return `

Title

${blocks}
`; -} - -let host: HTMLElement; - -beforeEach(() => { - document.body.innerHTML = ''; - host = document.createElement('div'); - document.body.appendChild(host); -}); - -function paragraphs(): HTMLElement[] { - return Array.from(host.querySelectorAll('.content-inner > p')); -} - -describe('the first render', () => { - test('inserts every block and reports it as the work to enrich', () => { - const html = section([ - { line: 3, text: 'alpha' }, - { line: 5, text: 'beta' }, - ]); - const patch = patchPreviewBlocks(host, html); - - expect(host.children.length).toBe(2); - expect(patch.inserted).toEqual([...host.children]); - expect(patch.kept).toBe(0); - }); - - test('drops the whitespace between blocks, so children and childNodes stay one list', () => { - patchPreviewBlocks(host, '

a

\n

b

\n'); - expect(host.childNodes.length).toBe(host.children.length); - }); -}); - -describe('typing one character in the middle of a document', () => { - const before = section([ - { line: 3, text: 'alpha' }, - { line: 5, text: 'beta' }, - { line: 7, text: 'gamma' }, - ]); - const after = section([ - { line: 3, text: 'alpha' }, - { line: 5, text: 'betaX' }, - { line: 7, text: 'gamma' }, - ]); - - test('replaces exactly the block that changed and keeps the rest of the nodes', () => { - patchPreviewBlocks(host, before); - const [alpha, beta, gamma] = paragraphs(); - - const patch = patchPreviewBlocks(host, after); - const [alphaAfter, betaAfter, gammaAfter] = paragraphs(); - - expect(alphaAfter).toBe(alpha); - expect(gammaAfter).toBe(gamma); - expect(betaAfter).not.toBe(beta); - expect(betaAfter.textContent).toBe('betaX'); - }); - - test('hands renderRichContent only the changed block', () => { - patchPreviewBlocks(host, before); - const patch = patchPreviewBlocks(host, after); - - expect(patch.inserted.map((element) => element.textContent)).toEqual(['betaX']); - expect(patch.replaced).toBe(1); - }); - - test('descends through the fold wrapper instead of replacing the whole section', () => { - patchPreviewBlocks(host, before); - const wrapper = host.querySelector('.foldable-content-wrapper'); - const inner = host.querySelector('.content-inner'); - - patchPreviewBlocks(host, after); - - expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); - expect(host.querySelector('.content-inner')).toBe(inner); - }); -}); - -describe('inserting a line above everything', () => { - test('keeps the blocks that only moved, and refreshes their source ranges', () => { - patchPreviewBlocks( - host, - section([ - { line: 3, text: 'alpha' }, - { line: 5, text: 'beta' }, - ]), - ); - const [alpha, beta] = paragraphs(); - - // Every line below the insertion is renumbered. This is the case a - // `data-sourcepos` key gets wrong: nothing about the rendering changed. - const patch = patchPreviewBlocks( - host, - section([ - { line: 4, text: 'alpha' }, - { line: 6, text: 'beta' }, - ]), - ); - - expect(paragraphs()).toEqual([alpha, beta]); - expect(patch.replaced).toBe(0); - expect(alpha.getAttribute('data-sourcepos')).toBe('4:1-4:5'); - expect(beta.getAttribute('data-sourcepos')).toBe('6:1-6:4'); - }); -}); - -describe('typing in a heading', () => { - test('replaces the heading and looks inside the wrapper holding the rest of the file', () => { - patchPreviewBlocks(host, section([{ line: 3, text: 'alpha' }])); - const [alpha] = paragraphs(); - - const patch = patchPreviewBlocks( - host, - section([{ line: 3, text: 'alpha' }]).replace('>Title<', '>Titles<'), - ); - - expect(paragraphs()).toEqual([alpha]); - expect(patch.inserted.map((element) => element.tagName)).toEqual(['H1']); - }); -}); - -describe('blocks that renderRichContent rewrote', () => { - test('survive a patch, because the diff keys off what was inserted, not what is there now', () => { - const before = section([ - { line: 3, text: 'alpha' }, - { line: 5, text: 'beta' }, - ]); - patchPreviewBlocks(host, before); - - // What the code-block pass does to a `
`: a new parent, a new sibling,
-		// and markup that no longer resembles what the renderer emitted.
-		const alpha = paragraphs()[0];
-		const shell = document.createElement('div');
-		shell.className = 'code-block-shell';
-		alpha.replaceWith(shell);
-		shell.appendChild(alpha);
-		shell.appendChild(document.createElement('button'));
-
-		const patch = patchPreviewBlocks(
-			host,
-			section([
-				{ line: 3, text: 'alpha' },
-				{ line: 5, text: 'betaX' },
-			]),
-		);
-
-		expect(host.querySelector('.code-block-shell')).toBe(shell);
-		expect(shell.contains(alpha)).toBe(true);
-		expect(patch.inserted.map((element) => element.textContent)).toEqual(['betaX']);
-	});
-
-	test('still get their source range refreshed, through the node that replaced them', () => {
-		patchPreviewBlocks(host, section([{ line: 3, text: 'alpha' }]));
-
-		const alpha = paragraphs()[0];
-		const diagram = document.createElement('div');
-		diagram.className = 'mermaid-diagram';
-		diagram.setAttribute('data-sourcepos', alpha.getAttribute('data-sourcepos')!);
-		alpha.replaceWith(diagram);
-
-		patchPreviewBlocks(host, section([{ line: 4, text: 'alpha' }]));
-
-		expect(host.querySelector('.mermaid-diagram')).toBe(diagram);
-		expect(diagram.getAttribute('data-sourcepos')).toBe('4:1-4:5');
-	});
-});
-
-/**
- * The memos in `previewAnchor.ts` were written against a preview that was
- * rebuilt wholesale, where a block whose source lines changed was necessarily a
- * new node. This module keeps the node and rewrites the attribute on it, so the
- * two halves only agree if the patch drops those memos — and the way to show
- * that is to ask `previewAnchor` its own questions across a patch, not to watch
- * for the invalidation call.
- *
- * There is no layout in jsdom, so the boxes are supplied. They are the ones the
- * defect was first measured with: two blocks, 100px each, stacked from 0.
- */
-describe('the source ranges previewAnchor has already read', () => {
-	test('follow a range rewritten in place on a node that was kept', () => {
-		const block = (line: number, text: string) =>
-			`

${text}

`; - - patchPreviewBlocks(host, block(1, 'alpha') + block(3, 'beta')); - const [alpha, beta] = Array.from(host.children) as HTMLElement[]; - - const boxes = new Map([ - [alpha, { top: 0, height: 100 }], - [beta, { top: 100, height: 100 }], - ]); - const measure = (node: AnchorNode): AnchorBox => boxes.get(node) ?? { top: NaN, height: NaN }; - - // Ask first, so the answers for lines 1 and 3 are memoised against these - // two nodes. Scroll sync does this many times a second. - expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(100); - - // Two lines inserted above the document. Identical rendering, so both - // paragraphs are kept and only their `data-sourcepos` is rewritten. - patchPreviewBlocks(host, block(3, 'alpha') + block(5, 'beta')); - expect(Array.from(host.children)).toEqual([alpha, beta]); - - // A memo keyed on a surviving node answers 100 and 300 here, and goes on - // answering that for the life of the node. - expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(0); - expect(getPreviewOffsetForSourceLine(host, asRendererLine(5), measure)).toBe(100); - }); - - test('follow a replaced child up into the span of the container that kept it', () => { - patchPreviewBlocks( - host, - section([ - { line: 3, text: 'alpha' }, - { line: 5, text: 'beta' }, - ]), - ); - const wrapper = host.querySelector('.foldable-content-wrapper'); - const inner = host.querySelector('.content-inner'); - - // The wrapper and the `.content-inner` carry no range of their own; the - // answer for both is derived from the paragraphs inside them and memoised - // on the container. This is the call that memoises it. - expect(findAnchorElement(host, 5)?.element).toBe(paragraphs()[1]); - - // Both paragraphs are rewritten and moved down five lines. The containers - // are kept and descended into — so nothing touches them, and their spans - // are stale even though every node holding a range is new. - patchPreviewBlocks( - host, - section([ - { line: 8, text: 'alphaX' }, - { line: 10, text: 'betaX' }, - ]), - ); - expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); - expect(host.querySelector('.content-inner')).toBe(inner); - - // A stale span says the wrapper covers lines 3 to 5, so line 10 is inside - // nothing and the descent — which is strict here — gives up at the top - // level and hands the caller a null it has to guess its way out of. - expect(findAnchorElement(host, 10)?.element).toBe(paragraphs()[1]); - expect(findAnchorElement(host, 8)?.element).toBe(paragraphs()[0]); - }); -}); - -describe('a container somebody else rewrote', () => { - test('is replaced wholesale rather than diffed against a stale record', () => { - patchPreviewBlocks(host, '

a

b

'); - host.appendChild(document.createElement('p')); - - const patch = patchPreviewBlocks(host, '

a

b

'); - - expect(patch.replaced).toBe(2); - expect(host.children.length).toBe(2); - }); -}); diff --git a/scripts/blockPatchRenumber.spec.ts b/scripts/blockPatchRenumber.spec.ts deleted file mode 100644 index 8e9257db..00000000 --- a/scripts/blockPatchRenumber.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { beforeEach, describe, expect, test } from 'vitest'; - -import { patchPreviewBlocks as patchNodes } from '../src/lib/utils/blockPatch.js'; - -/** The patch takes the sanitizer's nodes; these tests start from markup. */ -function patchPreviewBlocks(host: HTMLElement, html: string) { - const template = document.createElement('template'); - template.innerHTML = html; - return patchNodes(host, template.content); -} - -/** - * Renaming a heading, which is the edit #632 could not collapse. - * - * comrak deduplicates heading ids by numbering them, so a document with - * repeated headings — `## Objectives` under every section, which is what a - * template-driven document looks like — renumbers every later duplicate when - * one of them is renamed. Those headings really did change: the id is in the - * markup, it is what `#anchor` links, the outline and `data-fold-key` name the - * heading by, and no key can call them unchanged without lying about it. - * - * What was NOT forced is everything BETWEEN them. The changed run at that - * level of the document is `h2, wrapper, h2, wrapper, …`, and the wrappers - * hold the whole body of the document; the diff descended into containers from - * each end of the run and replaced the rest of it wholesale, so one renamed - * heading took a third of `samples/stress-test.md` with it. A renumbering adds - * and removes no blocks, so the two runs are the same length and line up pair - * for pair — which is all it takes to ask each pair the question the ends were - * already being asked. - */ - -let host: HTMLElement; - -beforeEach(() => { - document.body.innerHTML = ''; - host = document.createElement('div'); - document.body.appendChild(host); -}); - -/** - * Sections in the shape `processMarkdownHtml` builds them, with comrak's own - * id numbering applied to the headings. - */ -function sections(headings: string[]): string { - const taken = new Map(); - return headings - .map((heading, index) => { - const slug = heading.toLowerCase(); - const seen = taken.get(slug) ?? 0; - taken.set(slug, seen + 1); - const id = seen === 0 ? slug : `${slug}-${seen}`; - const line = index * 4 + 1; - return ( - `

${heading}

` + - '
' + - `

body ${index}

` + - '
' - ); - }) - .join(''); -} - -const bodies = () => Array.from(host.querySelectorAll('.content-inner > p')); - -describe('renaming one of many identical headings', () => { - const before = sections(['Objectives', 'Objectives', 'Objectives', 'Objectives', 'Objectives']); - const after = sections(['Goals', 'Objectives', 'Objectives', 'Objectives', 'Objectives']); - - test('renumbers every later heading, which is why they cannot be kept', () => { - expect(before).toContain('id="objectives-4"'); - // Every heading's id moved up one, so all five blocks really are different - // markup. This is the premise, not the defect. - expect(after).toContain('id="goals"'); - expect(after).toContain('id="objectives-3"'); - expect(after).not.toContain('id="objectives-4"'); - }); - - test('replaces the headings and nothing else', () => { - patchPreviewBlocks(host, before); - const kept = bodies(); - - const patch = patchPreviewBlocks(host, after); - - expect(patch.inserted.map((element) => element.tagName)).toEqual(['H2', 'H2', 'H2', 'H2', 'H2']); - expect(patch.replaced).toBe(5); - // Identity, not equality: every paragraph in this document renders to the - // same markup, so "the same node" is the only question worth asking. - expect(bodies().every((body, index) => body === kept[index])).toBe(true); - }); - - test('leaves the wrappers holding the document in place', () => { - patchPreviewBlocks(host, before); - const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); - - patchPreviewBlocks(host, after); - - expect( - Array.from(host.querySelectorAll('.foldable-content-wrapper')).every( - (wrapper, index) => wrapper === wrappers[index], - ), - ).toBe(true); - }); - - test('gives the kept blocks the new heading ids to be anchors for', () => { - patchPreviewBlocks(host, before); - patchPreviewBlocks(host, after); - - expect(Array.from(host.querySelectorAll('h2')).map((h) => h.id)).toEqual([ - 'goals', - 'objectives', - 'objectives-1', - 'objectives-2', - 'objectives-3', - ]); - }); -}); - -describe('an edit that adds a block to the changed run', () => { - test('still replaces the run, rather than pairing blocks that do not correspond', () => { - patchPreviewBlocks(host, sections(['Alpha', 'Beta'])); - - // One section became two: the runs are different lengths, so there is no - // pairing to be had and the diff falls back to swapping the run. - const patch = patchPreviewBlocks(host, sections(['Alpha', 'Beta', 'Gamma'])); - - expect(host.querySelectorAll('h2').length).toBe(3); - expect(Array.from(host.querySelectorAll('h2')).map((h) => h.id)).toEqual(['alpha', 'beta', 'gamma']); - expect(patch.inserted.length).toBeGreaterThan(0); - }); -}); - -describe('the one-character edit #632 exists for', () => { - test('is still one block, with the run walk in the way', () => { - patchPreviewBlocks(host, sections(['Alpha', 'Beta', 'Gamma'])); - const kept = bodies(); - - const after = sections(['Alpha', 'Beta', 'Gamma']).replace('>body 1<', '>body 1X<'); - const patch = patchPreviewBlocks(host, after); - - expect(patch.inserted.map((element) => element.textContent)).toEqual(['body 1X']); - expect(bodies()[0]).toBe(kept[0]); - expect(bodies()[2]).toBe(kept[2]); - }); -}); diff --git a/scripts/diagramThemeRefresh.spec.ts b/scripts/diagramThemeRefresh.spec.ts index 2c3bfa1d..18e82083 100644 --- a/scripts/diagramThemeRefresh.spec.ts +++ b/scripts/diagramThemeRefresh.spec.ts @@ -11,7 +11,7 @@ * it was replaced by the `.mermaid-diagram` container holding the SVG. * * That was invisible while the preview was `{@html sanitizedHtml}`, because - * every render put the whole document back as source. `blockPatch.ts` (#632) + * every render put the whole document back as source. the block patch (#632) * keeps enriched nodes across renders instead, and from then on a theme change * reached no diagram at all. The `
` is gone, `patch.inserted` is empty on
  * a re-render of the same document, and typing does not help either: the
@@ -24,14 +24,7 @@
 
 import { describe, expect, test } from 'vitest';
 
-import { patchPreviewBlocks as patchNodes } from '../src/lib/utils/blockPatch.js';
-
-/** The patch takes the sanitizer's nodes; these tests start from markup. */
-function patchPreviewBlocks(host: HTMLElement, html: string) {
-	const template = document.createElement('template');
-	template.innerHTML = html;
-	return patchNodes(host, template.content);
-}
+import { patchPreview } from '../src/lib/utils/previewPatch.js';
 import { readDiagramSource } from '../src/lib/utils/mermaidPrint.js';
 import { renderRichContent, type RichContentLibraries } from '../src/lib/utils/richContent.js';
 import { resetDiagramCache } from '../src/lib/utils/diagramCache.js';
@@ -130,7 +123,7 @@ describe('a pass that is not a theme change', () => {
 		// The keystroke path: the patch replaces the paragraph and hands only
 		// that block to the enrichment. The diagram is not in it, and a theme it
 		// no longer matches must not drag it into the pass.
-		const patch = patchPreviewBlocks(document.createElement('div'), '

x

'); + const patch = patchPreview(document.createElement('div'), '

x

', '', new Set()); await renderRichContent({ roots: patch.inserted, libraries: libraries(draws), diff --git a/scripts/fixtures/markdown-syntax.blocks.json b/scripts/fixtures/markdown-syntax.blocks.json new file mode 100644 index 00000000..a3d5e0bc --- /dev/null +++ b/scripts/fixtures/markdown-syntax.blocks.json @@ -0,0 +1,179 @@ +[ + "
\n", + "\n

title: "Markdown in Markpad"
\ndescription: "Every syntax Markpad renders, what it looks like, and what happens to it everywhere else."

\n", + "\n

Markdown in Markpad

\n", + "\n

Open this file in Markpad, and read it twice.

\n", + "\n
    \n
  • In preview, it shows you what Markpad can do — every feature below is the real thing rather than a description of one.
  • \n
  • In the editor (Ctrl/Cmd + E, or the Preview, Split View and Editor buttons in the title bar), the same page shows you how each one is written.
  • \n
\n", + "\n

That is the whole design of this file: the answer to "can it do X?" and the answer to "how do I write X?" are the same paragraph, seen from two sides.

\n", + "\n

There is a third use for it: give the file to an AI. Attach it to a chat and the assistant knows exactly what Markpad can render. Ask it to reformat a document you already have, or to write a new one that uses the whole range — callouts, task lists, tables, footnotes, maths, Mermaid diagrams. Save the reply as a .md, open it here, and it looks the way it was meant to.

\n", + "\n

If you are reading it on GitHub instead, that is useful too — the difference between what you see there and what you see in Markpad is exactly what the compatibility column below is about.

\n", + "\n
\n", + "\n

What is supported, and what travels

\n", + "\n

Markdown has a small core that every reader agrees on and a large fringe that nobody standardised. Markpad's rule for the fringe:

\n", + "\n
\n

Accept a spelling only where it cannot misread ordinary text — and never emit one the reader did not ask for.

\n
\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
SyntaxMarkpadOn GitHubAlso used by
**bold** *italic* `code`✅✅CommonMark — everyone
# Heading, lists, > quotes, ---✅✅CommonMark — everyone
~~strikethrough~~✅✅GFM
- [x] task lists✅✅GFM
Pipe tables✅✅GFM
Bare URLs (https://…)✅✅GFM
[^1] footnotes✅✅GFM
> [!NOTE] alerts✅✅GitHub, Obsidian
$x$ and $$x$$ math✅✅GitHub, Pandoc, Obsidian, Quarto
```mermaid diagrams✅✅GitHub, GitLab
YAML front matter✅ shown as a panel⚠️ shown as a tableJekyll, Hugo, Obsidian, Quarto
A single newline⚠️ is a line break⚠️ is a spaceObsidian, Typora, most note apps
==highlight==✅❌ literal textObsidian, Pandoc, Discourse
^[an inline footnote]✅❌ literal textPandoc, Obsidian
++inserted++✅❌ literal textPandoc, CriticMarkup
Definition lists✅❌ literal textPandoc, PHP Markdown Extra
[[Note#Heading]] wikilinks✅❌ literal textObsidian, Logseq, Foam, Roam
![[image.png]] embeds✅❌ literal textObsidian, Logseq
^block-id✅❌ literal textObsidian
A YouTube link on its own✅ becomes a thumbnail⚠️ stays a link—
![](clip.mp4)✅ becomes a player❌ broken image—
![](sound.mp3)✅ becomes a player❌ broken image—
A link to another .md✅ opens it in a tab✅ navigates—
~subscript~❌ — it is strikethrough❌ — it is strikethroughPandoc, Typora (opt-in)
^superscript^❌ literal text❌ literal textPandoc, Typora (opt-in)
||spoiler||❌ literal text❌ literal textDiscord, Obsidian
:emoji: shortcodes❌ literal text✅GitHub
\n", + "\n

The three ❌ at the bottom are deliberate, and the reasons are worth knowing because they are the same reason:

\n", + "\n
    \n
  • ~x~ — GFM defines strikethrough as one or two tildes. ~x~ is struck-through text on GitHub, and here. Reading it as a subscript would take a spelling GitHub already uses, and H~2~O would then look different in the two places. Write H<sub>2</sub>O, which works in both.
  • \n
  • ^x^ — two carets in one paragraph pair up, so a^2 + b^2 = c^2 — ordinary prose that renders correctly today — would become a<sup>2 + b</sup>2 = c^2.
  • \n
  • ||x|| — that is also how an empty table cell is written. | 1 || 3 | would collapse into one cell reading 1 || 3.
  • \n
\n", + "\n

And one difference that is not a syntax at all, but changes how every paragraph looks:

\n", + "\n
    \n
  • A single newline is a line break here. In CommonMark — and on GitHub — a line break inside a paragraph is just a space, and the text reflows. Markpad breaks where you broke, which is what a note-taking app is usually expected to do, and what Obsidian and Typora do. A paragraph you wrap by hand will therefore look different on GitHub. Leave a blank line between paragraphs and the two agree.
  • \n
\n", + "\n
\n", + "\n

1. Text

\n", + "\n

Bold, italic, both, struck through, inline code, underlined (HTML, since Markdown has no syntax for it), and highlighted.

\n", + "\n

An escaped *asterisk* stays an asterisk. So does a \\backslash.

\n", + "\n

Emphasis does not fire inside a word, so snake_case_names and 234 survive: snake_case_names and 234.

\n", + "\n

Line breaks are literal: this line
\nand this one are two lines, not one paragraph reflowed. Two trailing spaces or a trailing backslash — the CommonMark spellings — work as well, and are what to use if the document also has to render on GitHub.
\nThis line followed a backslash.

\n", + "\n

Escaping

\n", + "\n

Anything above can be escaped with a backslash, which is how this document shows syntax without triggering it: *not italic*, ==not highlighted==, [[not a wikilink]].

\n", + "\n

Inside `code spans` nothing is interpreted at all — **bold**, [[Notes]], $x$ and ==mark== are all literal there. That is the usual way to write about Markdown in Markdown.

\n", + "\n

HTML entities work: © — … → &

\n", + "\n

2. Headings

\n", + "\n

Every heading gets an anchor. Hover one in the preview and a link icon appears; right-click it for Copy Reference, which writes a link in whichever style this document already uses.

\n", + "\n

A heading too long for one line wraps into two lines of about the same length, rather than leaving a word or two alone on the second.

\n", + "\n

A third-level heading

\n", + "\n

A fourth

\n", + "\n
A fifth
\n", + "\n
A sixth
\n", + "\n

A first- and second-level heading can also be underlined instead, which is the other CommonMark spelling:

\n", + "\n

An underlined H1

\n", + "\n

An underlined H2

\n", + "\n

3. Lists

\n", + "\n
    \n
  • A bullet
  • \n
  • Another\n
      \n
    • Nested\n
        \n
      • Deeper
      • \n
      \n
    • \n
    \n
  • \n
  • Back to the top level
  • \n
\n", + "\n
    \n
  1. First
  2. \n
  3. Second\n
      \n
    1. Nested and numbered
    2. \n
    \n
  4. \n
  5. Third
  6. \n
\n", + "\n

A list can start anywhere, and ) works as well as .:

\n", + "\n
    \n
  1. Seven
  2. \n
  3. Eight
  4. \n
  5. Nine
  6. \n
\n", + "\n

Items separated by blank lines get paragraph spacing — "loose" rather than "tight":

\n", + "\n
    \n
  • \n

    A loose item, with room around it.

    \n
  • \n
  • \n

    Another one.

    \n
  • \n
\n", + "\n

A list item can hold anything:

\n", + "\n
    \n
  1. \n

    A step with its own code block:

    \n
    npm run tauri dev\n
    \n
  2. \n
  3. \n

    And its own quote:

    \n
    \n

    Including this.

    \n
    \n
  4. \n
\n", + "\n
    \n
  • A finished task — click the box in the preview and the file is edited
  • \n
  • An unfinished one\n
      \n
    • Nested, and also clickable
    • \n
    \n
  • \n
\n", + "\n

The editor writes the markers. Press Enter inside an item and the next line carries the same one: a bullet keeps the character it was written with, a numbered item increments, a task item arrives unticked. Tab and Shift+Tab change the level: the item moves to its parent's content column, and both numbered lists renumber — the one it joins and the one it left behind. Enter on an item with nothing in it clears the marker and leaves the list, which is how you get out.

\n", + "\n

A list can also be started from the keyboard: Ctrl/Cmd + Shift + 8 for bullets, 7 for numbers, 9 for a task list.

\n", + "\n

4. Quotes and alerts

\n", + "\n
\n

An ordinary blockquote.

\n

With a second paragraph.

\n
\n", + "\n
\n

Quotes nest:

\n
\n

and the inner one is indented again,

\n
\n

three deep.

\n
\n
\n
\n", + "\n
\n

A quote can contain anything else:

\n
    \n
  • a list
  • \n
  • with items
  • \n
\n
// and a code block\n
\n
\n", + "\n
\n

[!NOTE]
\nAlerts are GitHub's, and Markpad renders the same five.

\n
\n", + "\n
\n

[!TIP]
\nUseful advice.

\n
\n", + "\n
\n

[!IMPORTANT]
\nSomething you should not miss.

\n
\n", + "\n
\n

[!WARNING]
\nSomething that could go wrong.

\n
\n", + "\n
\n

[!CAUTION]
\nSomething that could go badly wrong.

\n
\n", + "\n

Markpad adds five more that GitHub does not have — [!INFO], [!TODO], [!FAQ], [!QUESTION], [!EXAMPLE] — and a foldable form, [!NOTE]+ for open and [!NOTE]- for closed:

\n", + "\n
\n

[!TODO]
\nOne of the five extra kinds.

\n
\n", + "\n
\n

[!EXAMPLE]-
\nFolded to begin with. Click the title to open it.

\n
\n", + "\n

The editor writes the > as well. Press Enter inside a quote and the next line carries the marker at the same depth, nesting included. An empty quoted line clears in one keystroke — one, however deep it is — which is how you leave the quote.

\n", + "\n

5. Code

\n", + "\n

Inline const answer = 42, and fenced blocks with a language:

\n", + "\n
const features = ['tables', 'math', 'diagrams'];\nconsole.log(`Markpad renders ${features.length} of these`);\n
\n", + "\n
from pathlib import Path\n\ndef headings(document: Path) -> list[str]:\n    return [line for line in document.read_text().splitlines() if line.startswith("#")]\n
\n", + "\n
fn main() {\n    println!("Syntax highlighting comes from highlight.js");\n}\n
\n", + "\n

A block with no language is left unhighlighted, which is usually what you want for output:

\n", + "\n
$ markpad --help\nUsage: markpad [FILE]\n
\n", + "\n

Fences can be written with tildes instead, which is handy when the block itself contains backticks:

\n", + "\n
```js\nconst nested = true;\n```\n
\n", + "\n

Four spaces of indentation is the older spelling, and still works:

\n", + "\n
an indented code block\nwith two lines\n
\n", + "\n

6. Tables

\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FeatureRendersNotes
Alignment✅:---, :---:, ---:
Long cells✅wrap rather than clip
Inline markup✅bold, code, links
\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
LeftCentreRight
ab1
ccdd22
eeefff333
\n", + "\n

A literal pipe inside a cell is escaped:

\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ExpressionMeans
a | ba or b
x || yx if it is set, else y
\n", + "\n

An empty cell is written with two pipes — | 1 || 3 | — which is why the spoiler syntax is not supported:

\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
OneTwoThree
13
\n", + "\n

A table wider than the text grows into the side margins instead of scrolling inside the column, and the paragraphs around it keep their width. Digits in a cell all take the same width, so a column of numbers lines up even in a font whose numerals are proportional:

\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
YearJanFebMarAprMayJunJulAugSepOctNovDecTotal
20251,2041,1181,3671,4501,5211,4891,6021,7111,6881,7451,8031,91118,609
20262,0161,9742,2082,3112,3902,4472,5192,6062,688———21,159
\n", + "\n

A table inside a list, a quote or a callout stays in the column, and export and PDF keep every table at the column width.

\n", + "\n

None of that has to be typed by hand. With the caret in a table:

\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
KeyDoes
Tab / Shift+Tabmove to the next / previous cell — Tab in the last one appends a row
Ctrl/Cmd + Enterinsert a row below, Shift for above
Ctrl/Cmd + Shift + Cinsert a column
Ctrl/Cmd + Shift + Backspacedelete the column
Ctrl/Cmd + Alt/Option + Tinsert a table to begin with
\n", + "\n

Every edit reformats the table, so the pipes stay lined up — including in CJK text, where a character is two columns wide. To delete a row, delete its line. The rest of the table commands are in the command palette (F1).

\n", + "\n

7. Thematic breaks

\n", + "\n

Three or more of -, * or _ on their own line, all the same rule:

\n", + "\n
\n", + "\n
\n", + "\n
\n", + "\n

8. Links

\n", + "\n

An ordinary link, one with a title, a bare URL https://spec.commonmark.org, and one in parentheses (https://github.github.com/gfm/) — the closing bracket is not swallowed.

\n", + "\n

Angle brackets make a link out of anything: https://commonmark.org and someone@example.com.

\n", + "\n

Reference-style links keep the URL out of the sentence, which is easier to read in the source:

\n", + "\n

The CommonMark spec and the GFM spec disagree in about twenty places.

\n", + "\n

A link to a heading in this document: back to the table. Type ](# in the editor and Markpad completes the headings for you.

\n", + "\n

Ctrl/Cmd + K inserts a link around whatever is selected.

\n", + "\n

Wikilinks

\n", + "\n

Markpad understands Obsidian's spelling and rewrites it before rendering:

\n", + "\n\n", + "\n

Type [[# and the headings complete here too. A wikilink without a heading is deliberately left as literal text: [[Notes]] is also how citation numbering and CommonMark reference links are written.

\n", + "\n

Block ids

\n", + "\n

A paragraph can be given an id and linked to.

\n", + "\n

That paragraph ends with ^demo-block, and ^demo-block points at it.

\n", + "\n

9. Images and embeds

\n", + "\n

A local image, resolved relative to this file:

\n", + "\n

\"Markpad

\n", + "\n

With a title, which most readers show on hover:

\n", + "\n

\"The

\n", + "\n

A reference-style image, where the destination lives elsewhere in the document:

\n", + "\n

\"Drag

\n", + "\n

Obsidian's embed syntax works for local files too — ![[lightmode.png]] — resolved the same way. Dragging an image into the editor writes the reference for you; dragging a .md file onto either pane opens it in a tab. An image file copied in Finder or Explorer pastes as an image too — it is saved beside the document and referenced, rather than arriving as a path.

\n", + "\n

Where a pasted or dropped image lands is a setting: a folder name, created next to the document. ${filename} in it stands for the document's own name without its extension, so ${filename}.assets beside notes/trip.md writes into notes/trip.assets, and a folder of notes stops sharing one flat img/. The token is spelled the way Typora and SoloMD spell it. It expands to a folder name rather than a path — separators, .. and absolute paths are refused — so the images stay beside the document they belong to.

\n", + "\n

Video and audio

\n", + "\n

An image reference whose file is a video or a sound becomes a player, with controls. Nothing new to learn — it is the ![](…) you already know, pointing at a different kind of file:

\n", + "\n
![A screen recording](demo.mp4)\n![The interview](episode.mp3)\n
\n", + "\n

Video: mp4, webm, ogg, mov. Audio: mp3, wav, aac, flac, m4a. A width or height written in HTML carries over to the player.

\n", + "\n

YouTube

\n", + "\n

A YouTube link alone in its paragraph becomes a thumbnail that opens in your browser:

\n", + "\n

https://www.youtube.com/watch?v=dQw4w9WgXcQ

\n", + "\n

The image form does the same thing:

\n", + "\n
![Any caption](https://youtu.be/dQw4w9WgXcQ)\n
\n", + "\n

A YouTube link in the middle of a sentence — like https://youtu.be/dQw4w9WgXcQ here — stays an ordinary link, because replacing it would break the sentence around it.

\n", + "\n\n", + "\n

A link to another Markdown file opens it in a new tab rather than leaving the app: the stress test, and a heading inside it. Back and forward work as they do in a browser.

\n", + "\n

The path is not limited to this folder. It is resolved against the document you are reading, the way a relative path works anywhere else:

\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
WrittenResolves to (from /notes/project/index.md)
[x](other.md)/notes/project/other.md — a sibling
[x](sub/deep.md)/notes/project/sub/deep.md — into a folder
[x](../sibling.md)/notes/sibling.md — up one
[x](../../up.md)/up.md — and up again
[x](/abs/root.md)/abs/root.md — an absolute path
[x](C:/win/abs.md)C:/win/abs.md — Windows, with a drive letter
[x](with%20space.md)/notes/project/with space.md — percent-encoding is decoded
[x](other.md#a-heading)that file, scrolled to that heading
\n", + "\n

Which files are claimed: .md, .markdown, .mdown, .mkd, .txt. Anything else — a .pdf, an image, a folder — is handed to your system to open with whatever it normally uses.

\n", + "\n

Two things are deliberately not claimed, even when they end in .md:

\n", + "\n
    \n
  • Anything with a scheme. https://example.com/notes.md is a web address and opens in your browser, not as a tab.
  • \n
  • A protocol-relative URL. //example.com/notes.md looks like a path and is one of these: it is an address on another host.
  • \n
\n", + "\n

If the file does not exist, the tab you are in stays exactly as it was and the error is reported — the document you were reading is not closed or replaced.

\n", + "\n

The wikilink spelling resolves the same way, and appends .md for you: [[sub/deep#Setup]] reaches sub/deep.md.

\n", + "\n

10. Footnotes

\n", + "\n

The usual kind1, and Pandoc's inline kind2. A footnote can be referenced more than once1, and both references point at the same note.

\n", + "\n

11. Maths

\n", + "\n

Inline: $e^{i\\pi} + 1 = 0$, and $\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$. A price like \\$5 needs escaping, or the next $ would close a formula around it.

\n", + "\n

Display:

\n", + "\n

$$
\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}
\n$$

\n", + "\n

$$
\nA = \\begin{bmatrix} 1 & 2 \\\\ 3 & 4 \\end{bmatrix}
\n$$

\n", + "\n

Multi-line environments keep their rows:

\n", + "\n

$$
\n\\begin{aligned}
\nf(x) &= (x + 1)^2 \\\\
\n&= x^2 + 2x + 1
\n\\end{aligned}
\n$$

\n", + "\n

Chemistry, through KaTeX's mhchem extension:

\n", + "\n

$$
\n\\ce{CO2 + C -> 2 CO}
\n$$

\n", + "\n

$$
\n\\ce{SO4^2- + Ba^2+ -> BaSO4 v}
\n$$

\n", + "\n

Inline as well: $\\ce{H2O}$, and a unit written as $\\pu{123 kJ//mol}$.

\n", + "\n

Rendered by KaTeX, with mhchem loaded on the same instance, so \\ce and \\pu work wherever a formula does. An escaped \\$5 stays a dollar sign rather than opening a formula.

\n", + "\n

12. Diagrams

\n", + "\n
flowchart LR\n    A[Markdown] --> B{Markpad}\n    B --> C[Preview]\n    B --> D[Export HTML]\n    B --> E[Print PDF]\n
\n", + "\n
sequenceDiagram\n    participant You\n    participant Markpad\n    You->>Markpad: type\n    Markpad-->>You: rendered, on the next frame\n
\n", + "\n
pie title Where a render happens\n    "comrak (Rust)" : 60\n    "frontend passes" : 25\n    "KaTeX and Mermaid" : 15\n
\n", + "\n
gantt\n    title Anything Mermaid draws, Markpad draws\n    dateFormat YYYY-MM-DD\n    section Reading\n    Open a file      :done, a1, 2026-08-01, 1d\n    Scroll it        :active, a2, after a1, 2d\n
\n", + "\n

13. Definition lists

\n", + "\n
\n
Markdown
\n
A plain-text format that stays readable unrendered.
\n
Markpad
\n
A reader and editor for it, which is what you are using.
\n
A term can have more than one definition.
\n
\n", + "\n

14. Inserted text

\n", + "\n

Pandoc and CriticMarkup mark added text with ++: this sentence has an insertion in it.

\n", + "\n

15. Other scripts and symbols

\n", + "\n

这是一段中文,用来检查字体、行高和换行。中文的粗体、行内代码 和链接都应该正常。

\n", + "\n

日本語のテキストもここで確認できます。太字とコード。

\n", + "\n

한국어 문장도 마찬가지입니다. 굵게와 코드.

\n", + "\n

Where Chinese or Japanese meets Latin letters or digits, the preview adds a small gap — 用Rust写的编辑器,第2版 — without a space being written into the file. Text that already has a space is not spaced twice, and code keeps its exact width: 用Rust写.

\n", + "\n

In the editor those three paragraphs are treated as prose rather than as code: Alt/Option + ←/→ and double-click stop at word boundaries inside a Chinese or Japanese clause instead of swallowing the whole sentence, and neither the fullwidth punctuation nor the ideographic space an IME produces is outlined as a suspicious character.

\n", + "\n

Symbols and emoji: → ← ↔ ⇒ ∑ ∏ √ ∞ ≈ ≠ ≤ ≥ ± × ÷ ° 🚀 📘 ✅ ⚠️

\n", + "\n

16. Raw HTML

\n", + "\n
\nA collapsible section\n

Markdown inside an HTML block still renders — bold, code, and a link.

\n
\n", + "\n

Ctrl + S saves. Marked text and subscript / superscript are available as HTML, which is the spelling that works everywhere.

\n", + "\n
\n", + "\n

17. Things this page does that are not syntax

\n", + "\n

Some of what Markpad adds is not a spelling to learn but behaviour you get for free. They are easiest to try right here:

\n", + "\n
    \n
  • Click a task box in the preview and the file is edited. The [ ] becomes [x] on disk, on the right line, even in a nested list.
  • \n
  • Hover a heading and an anchor appears. Right-click it for Copy Reference, which writes a link in whichever style this document already uses — [[…]] if the document uses wikilinks, […](#…) if it uses standard links.
  • \n
  • Right-click any paragraph, list item or heading and choose Edit: the editor opens on exactly those lines, with them selected. Ctrl/Cmd + E does the same for whatever you have selected in the preview.
  • \n
  • Fold a heading with the chevron beside it, and everything under it collapses. The folds are remembered per document. The editor folds the same headings from its own gutter, alongside the list folding it already had.
  • \n
  • Scroll either pane of the split view and the other follows by source line rather than by ratio, so the two stay together even this far down the document. The two panes can trade sides, in Settings.
  • \n
  • Copy from the preview and the formatting comes with it: headings, emphasis, lists, tables and links arrive as themselves in Word, Mail or Docs, and as the Markdown behind them anywhere that takes plain text.
  • \n
  • Sticky headings keep the heading you are currently inside pinned to the top of the editor. Toggle them in Settings.
  • \n
  • Open the table of contents and it follows wherever you scroll, in the preview or in the editor, keeping the current heading centred. Unpinned, it gets out of the way rather than sitting on the text: it collapses when you pick an entry, and when you reach past it to touch what it was covering.
  • \n
  • Type ](# or [[# in the editor and every heading in the document is offered as a completion.
  • \n
  • The front matter at the top of this file — the title and description between --- lines — is shown as a panel rather than as text, and is editable there.
  • \n
  • Export this page to HTML, or print it to PDF, and the maths and diagrams come with it.
  • \n
\n", + "\n
\n", + "\n

Where each of these is rendered

\n", + "\n

Not a user-facing detail, but useful if something looks wrong and you are deciding where to report it:

\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
StageDoes
Rust (comrak)CommonMark, GFM, footnotes, highlight, insert, definition lists, and the wikilink / embed / block-id rewrites
FrontendAlerts, front matter panel, heading folds and anchors, media players, YouTube thumbnails, local link resolution
Frontend, asyncKaTeX for maths, Mermaid for diagrams, highlight.js for code
\n", + "\n

Raw HTML is allowed and sanitised — <style> is the one tag that is dropped, since a document should not be able to restyle the app around it.

\n", + "\n

Everything above is exercised by the test suite. If a row in the compatibility table is wrong, that is a bug worth reporting.

\n", + "\n
\n
    \n
  1. \n

    The definition can live anywhere in the document — Markpad collects them at the bottom, numbered in the order they are referenced.

    \n

    A footnote can also run to several paragraphs, as long as the continuation is indented. ↩ ↩2

    \n
  2. \n
  3. \n

    which is written where it is read, with no label to invent ↩

    \n
  4. \n
\n
\n" +] diff --git a/scripts/previewPatch.spec.ts b/scripts/previewPatch.spec.ts new file mode 100644 index 00000000..3ad3f8d0 --- /dev/null +++ b/scripts/previewPatch.spec.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, test } from 'vitest'; + +import { asRendererLine } from '../src/lib/utils/lineCoordinates.js'; +import { + findAnchorElement, + getPreviewOffsetForSourceLine, + type AnchorBox, + type AnchorNode, +} from '../src/lib/utils/previewAnchor.js'; +import { patchPreview } from '../src/lib/utils/previewPatch.js'; +import { replacedBy } from '../src/lib/utils/richContent.js'; + +/** + * The block patch, from the renderer's split output to the live article. + * `previewPatchParity.spec.ts` holds it to the whole-document pipeline over a + * real document; these pin which nodes it keeps, which it replaces, and what + * it hands on, one edit at a time. + */ + +let host: HTMLElement; + +beforeEach(() => { + document.body.innerHTML = ''; + host = document.createElement('div'); + document.body.appendChild(host); +}); + +const patch = (html: string, folds = new Set()) => patchPreview(host, html, '/docs/a.md', folds); +const split = (...parts: string[]) => parts.join('\0\n'); +const p = (line: number, text: string) => `

${text}

`; +const h = (level: number, line: number, text: string, id = text.toLowerCase()) => + `${text}`; +const bodies = () => Array.from(host.querySelectorAll('p')) as HTMLElement[]; + +describe('the first render', () => { + test('inserts every block, folds each section under its heading, and hands all of it on', () => { + const result = patch(split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'))); + + expect(Array.from(host.children).map((e) => e.className)).toEqual(['foldable-header', 'foldable-content-wrapper']); + expect(host.querySelectorAll('.foldable-content-wrapper > .content-inner > p').length).toBe(2); + expect(result.inserted.map((e) => e.tagName)).toEqual(['H1', 'P', 'P']); + expect(result.wrappers.length).toBe(1); + }); + + test('carries no whitespace between blocks, so children and childNodes stay one list', () => { + patch(split(p(1, 'a'), p(3, 'b')) + '\n'); + expect(host.childNodes.length).toBe(host.children.length); + }); +}); + +describe('typing one character in the middle of a document', () => { + const before = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'), p(7, 'gamma')); + const after = split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'betaX'), p(7, 'gamma')); + + test('replaces exactly that block, in place, and keeps every other node', () => { + patch(before); + const [alpha, beta, gamma] = bodies(); + const heading = host.querySelector('h1'); + const wrapper = host.querySelector('.foldable-content-wrapper'); + + const result = patch(after); + + const [alphaAfter, betaAfter, gammaAfter] = bodies(); + expect(alphaAfter).toBe(alpha); + expect(gammaAfter).toBe(gamma); + expect(betaAfter).not.toBe(beta); + expect(betaAfter.textContent).toBe('betaX'); + expect(host.querySelector('h1')).toBe(heading); + expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); + expect(result.inserted).toEqual([betaAfter]); + expect(result.wrappers).toEqual([]); + }); +}); + +describe('inserting a line above everything', () => { + test('keeps the blocks that only moved and gives them their new source ranges', () => { + patch(split(p(3, 'alpha'), `
    \n
  • item
  • \n
`)); + const [alpha] = bodies(); + const item = host.querySelector('li')!; + + const result = patch(split(p(4, 'alpha'), `
    \n
  • item
  • \n
`)); + + expect(result.inserted).toEqual([]); + expect(bodies()[0]).toBe(alpha); + expect(host.querySelector('li')).toBe(item); + expect(alpha.getAttribute('data-sourcepos')).toBe('4:1-4:5'); + // The task checkbox toggle writes to the line it reads here. + expect(item.getAttribute('data-sourcepos')).toBe('6:1-6:9'); + }); + + test('moves the line anchors this side added by the same amount', () => { + const long = (line: number) => + `

a
\nb
\nc

`; + patch(long(1)); + const anchors = () => Array.from(host.querySelectorAll('.source-line-anchor')).map((a) => a.getAttribute('data-sourcepos')); + expect(anchors()).toEqual(['2:1-2:1', '3:1-3:1']); + + patch(long(4)); + expect(anchors()).toEqual(['5:1-5:1', '6:1-6:1']); + }); +}); + +describe('headings', () => { + test('typing in one replaces the heading and keeps its wrapper and everything in it', () => { + patch(split(h(1, 1, 'Title'), p(3, 'alpha'))); + const [alpha] = bodies(); + const wrapper = host.querySelector('.foldable-content-wrapper')!; + + const result = patch(split(h(1, 1, 'Titles'), p(3, 'alpha'))); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H1']); + expect(bodies()[0]).toBe(alpha); + expect(host.querySelector('.foldable-content-wrapper')).toBe(wrapper); + expect(host.querySelector('h1')!.getAttribute('data-fold-target')).toBe(wrapper.id); + }); + + test('renaming one of many identical ones replaces the renumbered headings and nothing else', () => { + const sections = (names: string[]) => { + const seen = new Map(); + return split( + ...names.flatMap((name, index) => { + const slug = name.toLowerCase(); + const n = seen.get(slug) ?? 0; + seen.set(slug, n + 1); + return [h(2, index * 4 + 1, name, n ? `${slug}-${n}` : slug), p(index * 4 + 3, `body ${index}`)]; + }), + ); + }; + patch(sections(['Objectives', 'Objectives', 'Objectives'])); + const kept = bodies(); + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + + const result = patch(sections(['Goals', 'Objectives', 'Objectives'])); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H2', 'H2', 'H2']); + expect(bodies()).toEqual(kept); + bodies().forEach((body, index) => expect(body).toBe(kept[index])); + expect(Array.from(host.querySelectorAll('.foldable-content-wrapper'))).toEqual(wrappers); + expect(Array.from(host.querySelectorAll('h2')).map((e) => e.id)).toEqual(['goals', 'objectives', 'objectives-1']); + }); + + test('a new section regroups the blocks after it under the new heading, without rebuilding them', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), p(5, 'beta'))); + const [alpha, beta] = bodies(); + + const result = patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'))); + + expect(result.inserted.map((e) => e.tagName)).toEqual(['H2']); + expect(result.wrappers.length).toBe(2); + const sections = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(sections[0].contains(alpha)).toBe(true); + expect(sections[1].contains(beta)).toBe(true); + }); + + test('a new paragraph right under a heading goes into that heading\'s section, first', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'))); + patch(split(h(2, 1, 'One'), p(3, 'new'), p(5, 'alpha'))); + + const inner = host.querySelector('.content-inner')!; + expect(Array.from(inner.children).map((e) => e.textContent)).toEqual(['new', 'alpha']); + }); + + test('a new paragraph above the first heading stays outside every section', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'))); + patch(split(p(1, 'lead'), h(2, 3, 'One'), p(5, 'alpha'))); + + expect(host.firstElementChild!.textContent).toBe('lead'); + }); +}); + +describe('folds', () => { + const doc = split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta')); + + test('a fold the document is told to keep closed is closed on the first render', () => { + patch(doc, new Set(['two'])); + expect(host.querySelector('#two')!.classList.contains('is-collapsed')).toBe(true); + expect(host.querySelector('#two')!.nextElementSibling!.classList.contains('is-collapsed')).toBe(true); + }); + + test('a fold the reader closed stays closed through an edit somewhere else', () => { + patch(doc); + const two = host.querySelector('#two')!; + two.classList.add('is-collapsed'); + + patch(doc.replace('>alpha<', '>alphaX<')); + + expect(host.querySelector('#two')).toBe(two); + expect(two.classList.contains('is-collapsed')).toBe(true); + }); + + test('callouts with the same title get keys of their own', () => { + const callout = (line: number) => + `
\n

[!note]- Same
\nx

\n
`; + patch(split(callout(1), callout(4))); + expect(Array.from(host.querySelectorAll('.callout-foldable')).map((e) => e.getAttribute('data-fold-key'))).toEqual([ + 'callout:Same', + 'callout:Same~1', + ]); + }); +}); + +describe('blocks that renderRichContent replaced', () => { + const pre = (line: number) => `
x\n
`; + + /** What the code-block pass does: a new parent, recorded for the patch. */ + function shell(element: Element): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.className = 'code-block-shell'; + element.replaceWith(wrapper); + wrapper.appendChild(element); + replacedBy.set(element, wrapper); + return wrapper; + } + + test('are kept through an edit elsewhere, and removed whole when their block goes', () => { + patch(split(pre(1), p(5, 'alpha'))); + const wrapper = shell(host.querySelector('pre')!); + + patch(split(pre(1), p(5, 'alphaX'))); + expect(host.firstElementChild).toBe(wrapper); + + patch(split(p(1, 'alphaX'))); + expect(host.querySelector('.code-block-shell')).toBeNull(); + expect(host.children.length).toBe(1); + }); + + test('get their new source range through the node that replaced them', () => { + patch(split(pre(1))); + const original = host.querySelector('pre')!; + const diagram = document.createElement('div'); + diagram.className = 'mermaid-diagram'; + diagram.setAttribute('data-sourcepos', original.getAttribute('data-sourcepos')!); + original.replaceWith(diagram); + replacedBy.set(original, diagram); + + patch(split(pre(2))); + + expect(host.firstElementChild).toBe(diagram); + expect(diagram.getAttribute('data-sourcepos')).toBe('2:1-4:3'); + }); +}); + +/** + * The memos in `previewAnchor.ts` assume a node whose source lines changed is a + * new node. A kept node with rewritten ranges is not, so the patch has to drop + * them — shown by asking `previewAnchor` its own questions across a patch. + * jsdom has no layout, so the boxes are supplied. + */ +describe('the source ranges previewAnchor has already read', () => { + test('follow a range rewritten on a node that was kept', () => { + patch(split(p(1, 'alpha'), p(3, 'beta'))); + const [alpha, beta] = bodies(); + const boxes = new Map([ + [alpha, { top: 0, height: 100 }], + [beta, { top: 100, height: 100 }], + ]); + const measure = (node: AnchorNode): AnchorBox => boxes.get(node) ?? { top: NaN, height: NaN }; + expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(100); + + patch(split(p(3, 'alpha'), p(5, 'beta'))); + + expect(bodies()).toEqual([alpha, beta]); + expect(getPreviewOffsetForSourceLine(host, asRendererLine(3), measure)).toBe(0); + expect(getPreviewOffsetForSourceLine(host, asRendererLine(5), measure)).toBe(100); + }); + + test('follow kept blocks inside a kept section', () => { + patch(split(h(1, 1, 'Title'), p(3, 'alpha'), p(5, 'beta'))); + expect(findAnchorElement(host, 5)?.element).toBe(bodies()[1]); + + patch(split(h(1, 1, 'Title'), p(8, 'alpha'), p(10, 'beta'))); + + expect(findAnchorElement(host, 10)?.element).toBe(bodies()[1]); + expect(findAnchorElement(host, 8)?.element).toBe(bodies()[0]); + }); +}); + +describe('an article somebody else rewrote', () => { + test('is rebuilt rather than diffed against a stale record', () => { + patch(split(p(1, 'a'), p(3, 'b'))); + host.appendChild(document.createElement('p')); + + const result = patch(split(p(1, 'a'), p(3, 'b'))); + + expect(result.inserted.length).toBe(2); + expect(host.children.length).toBe(2); + }); +}); diff --git a/scripts/previewPatchParity.spec.ts b/scripts/previewPatchParity.spec.ts new file mode 100644 index 00000000..94292e2a --- /dev/null +++ b/scripts/previewPatchParity.spec.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, test } from 'vitest'; + +import { processMarkdownHtml } from '../src/lib/utils/markdown.js'; +import { patchPreview } from '../src/lib/utils/previewPatch.js'; +import { sanitizeMarkdownFragment } from '../src/lib/utils/sanitize.js'; +import blocks from './fixtures/markdown-syntax.blocks.json'; + +/** + * The block patch against the whole-document pipeline it replaced, over the + * renderer's real output for samples/markdown-syntax.md (every construct the + * preview has: callouts, folds, footnotes, math, Mermaid, task lists, raw + * `
` around Markdown). `preview_block_fixture_is_current` in + * markdown.rs keeps the fixture in step with the renderer. + * + * Three properties: + * - a first render is the document the old pipeline produced; + * - after an edit, the patched article is what a first render of the edited + * document would be — the incremental path loses nothing; + * - undoing the edit puts the original document back. + */ + +const SPLIT = blocks.join('\0'); +const PATH = '/docs/markdown-syntax.md'; + +/** `wrap-N` ids only have to be unique; the two paths hand them out in a different order. */ +const normalized = (host: Element) => host.innerHTML.replace(/wrap-\d+/g, 'wrap-N'); + +function oldPipeline(split: string): string { + const host = document.createElement('div'); + host.append(sanitizeMarkdownFragment(processMarkdownHtml(split.replaceAll('\0\n', ''), PATH, new Set()))); + // The old patch carried elements only, never the whitespace between them. + for (const node of Array.from(host.childNodes)) if (node.nodeType !== 1) node.remove(); + return normalized(host); +} + +function cold(split: string): string { + const host = document.createElement('div'); + patchPreview(host, split, PATH, new Set()); + return normalized(host); +} + +/** Every `data-sourcepos` line moved down by one: a line inserted at the top. */ +const enterAtTop = (split: string) => + split.replace(/data-sourcepos="(\d+):(\d+)-(\d+):(\d+)"/g, (_, a, b, c, d) => `data-sourcepos="${+a + 1}:${b}-${+c + 1}:${d}"`); + +const at = (split: string, index: number, edit: (block: string) => string) => { + const parts = split.split('\0'); + parts[index] = edit(parts[index]); + return parts.join('\0'); +}; +const firstIndex = (pattern: RegExp) => blocks.findIndex((block) => pattern.test(block)); +const middleParagraph = blocks.findIndex((block, index) => index > blocks.length / 2 && /^\n?

/); + +const edits: Record string> = { + 'a keystroke in a paragraph': (split) => at(split, middleParagraph, (b) => b.replace('

', 'x

')), + 'a line inserted at the top': enterAtTop, + 'a renamed heading': (split) => at(split, firstH2, (b) => b.replace(/(

]*>)/, '$1Renamed ')), + 'a new paragraph': (split) => at(split, middleParagraph, (b) => `${b}\0\n

new

\n`), + 'a new section': (split) => at(split, middleParagraph, (b) => `${b}\0\n

Added

\n`), + 'a deleted heading': (split) => at(split, firstH2, () => ''), + 'an edited diagram': (split) => at(split, mermaid, (b) => b.replace('', 'C --> D\n')), + 'an edited raw HTML block': (split) => at(split, details, (b) => b.replace('', ' edited')), +}; + +let host: HTMLElement; +beforeEach(() => { + document.body.innerHTML = ''; + host = document.createElement('div'); + document.body.appendChild(host); +}); + +test('the fixture has the constructs the edits below aim at', () => { + expect([middleParagraph, firstH2, mermaid, details].every((index) => index > 0)).toBe(true); +}); + +test('a first render is what the whole-document pipeline produced', () => { + expect(cold(SPLIT)).toBe(oldPipeline(SPLIT)); +}); + +describe.each(Object.entries(edits))('%s', (_, edit) => { + test('patches to what a first render of the edited document is', () => { + const edited = edit(SPLIT); + expect(edited).not.toBe(SPLIT); + patchPreview(host, SPLIT, PATH, new Set()); + patchPreview(host, edited, PATH, new Set()); + expect(normalized(host)).toBe(cold(edited)); + }); + + test('and back to the original when it is undone', () => { + patchPreview(host, SPLIT, PATH, new Set()); + patchPreview(host, edit(SPLIT), PATH, new Set()); + patchPreview(host, SPLIT, PATH, new Set()); + expect(normalized(host)).toBe(cold(SPLIT)); + }); +}); diff --git a/scripts/previewSanitize.spec.ts b/scripts/previewSanitize.spec.ts index ce811f8f..5f225555 100644 --- a/scripts/previewSanitize.spec.ts +++ b/scripts/previewSanitize.spec.ts @@ -41,8 +41,8 @@ import assert from 'node:assert/strict'; import DOMPurify from 'dompurify'; import { afterEach, test } from 'vitest'; -import { patchPreviewBlocks } from '../src/lib/utils/blockPatch.js'; -import { sanitizeMarkdownFragment, sanitizeMarkdownHtml, ALLOWED_MARKDOWN_URI_REGEXP } from '../src/lib/utils/sanitize.js'; +import { patchPreview } from '../src/lib/utils/previewPatch.js'; +import { sanitizeMarkdownHtml, ALLOWED_MARKDOWN_URI_REGEXP } from '../src/lib/utils/sanitize.js'; /** The document author's payload, as it arrives from comrak (`render.unsafe_ = true`). */ const POC_STYLE = ''; @@ -128,16 +128,14 @@ test('the inline style attribute is still allowed, because documents use it', () }); test('the preview path patches in the filtered nodes, with the same policy', () => { - // The preview hands DOMPurify's own nodes to the patch instead of a string, - // so this is the path the article is actually filled through. + // The patch takes the renderer's own output and sanitizes each block it + // inserts, so this is the path the article is actually filled through — + // on the first render and on an edit. const { titlebar, preview } = mountApp(); + const payload = '

x

\0\n'; - patchPreviewBlocks( - preview, - sanitizeMarkdownFragment( - `${RENDERED}\n

x

`, - ), - ); + patchPreview(preview, `${RENDERED}\0\n${payload}`, '/docs/a.md', new Set()); + patchPreview(preview, `${RENDERED}\0\n

edit

\0\n${payload}`, '/docs/a.md', new Set()); assert.equal(preview.querySelector('p')?.textContent, 'An ordinary paragraph of prose.'); assert.equal(preview.querySelector('style, script, [onerror], [href^="javascript"]'), null); diff --git a/scripts/previewSanitize.test.ts b/scripts/previewSanitize.test.ts index e20fd05c..ba329b14 100644 --- a/scripts/previewSanitize.test.ts +++ b/scripts/previewSanitize.test.ts @@ -37,30 +37,13 @@ const SOURCES = readSourceFiles('src'); const viewerSource = readSource('src/lib/MarkdownViewer.svelte'); const sanitizeSource = readSource('src/lib/utils/sanitize.ts'); const richContentSource = readSource('src/lib/utils/richContent.ts'); - -/** - * The identifier the preview injects with `{@html}`, proved to be the shared - * sanitizer's output. - * - * The chain is what matters — something is declared from - * `sanitizeMarkdownFragment(...)` and *that* something is what reaches `{@html}` — - * so this reads the name out of the source instead of pinning it. The previous - * form spelled the whole declaration out (`let sanitizedHtml = $derived(...)`), - * which made the private binding name, the `let`, and the `$derived` wrapper - * into contract; renaming the variable or switching how it is computed would - * have failed the suite while the security property held. - */ -function sanitizedSinkName(): string { - const declaration = viewerSource.match(/(?:let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*[^;\n]*\bsanitizeMarkdownFragment\(/); - assert.ok(declaration, 'the preview must derive what it injects from sanitizeMarkdownFragment'); - return declaration![1]; -} +const previewPatchSource = readSource('src/lib/utils/previewPatch.ts'); test('the preview sanitizes through the shared policy, not a local config', () => { assert.match( - viewerSource, + previewPatchSource, /import\s*\{[^}]*\bsanitizeMarkdownFragment\b[^}]*\}\s*from\s*'[^']*\/sanitize\.js'/, - 'the viewer must import the shared sanitizer', + 'the preview patch must import the shared sanitizer', ); // The sinks: every `{@html …}` in the viewer, whatever the expression looks @@ -83,7 +66,7 @@ test('the preview sanitizes through the shared policy, not a local config', () = // // The document's own sink is no longer one of them. `{@html sanitizedHtml}` // rebuilt the whole article per keystroke, so the preview now patches in only - // the blocks that changed (see utils/blockPatch.ts) — which means the string + // the blocks that changed (see utils/previewPatch.ts) — which means the string // becomes nodes inside that module instead of inside the compiler's `@html`. // The security property is unchanged and the chain is the same length: the // sanitizer's output, and nothing else, is what is parsed. The two @@ -101,25 +84,28 @@ test('the preview sanitizes through the shared policy, not a local config', () = 'unexpected {@html} sink — what the preview injects is the shared sanitizer output', ); - // The document sink, in the form it takes now: one call, and what it is - // handed is the sanitized string. `patchPreviewBlocks` is the only way into - // the preview's article, so pinning its argument pins the whole path. - const patchArguments = [...viewerSource.matchAll(/patchPreviewBlocks\(\s*[\w.]+\s*,\s*([\w.]+)\s*\)/g)].map( - (match) => match[1], - ); - assert.deepEqual( - patchArguments, - [sanitizedSinkName()], - 'the preview must patch in the shared sanitizer output, once, and nothing else', + // The document sink, in the form it takes now: one call, handed the + // renderer's output. `patchPreview` is the only way into the preview's + // article, so what it does with that string is the whole path. + assert.equal( + [...viewerSource.matchAll(/\bpatchPreview\(\s*host\s*,\s*htmlContent\s*,/g)].length, + 1, + 'the preview must reach the article through patchPreview, once', ); - // And that the module it is handed to has no way of making nodes out of a - // string: it moves the sanitizer's own nodes and parses nothing. - const blockPatchSource = readSource('src/lib/utils/blockPatch.ts'); - const parses = [...blockPatchSource.matchAll(/\.(?:innerHTML|outerHTML|insertAdjacentHTML)\s*=\s*([^;]+);/g)].map( - (m) => m[1].trim(), + // Inside it: the one place markup is parsed is a block of the renderer's + // output, into a detached document of its own, before the filter — and what + // a block puts into the article is the filter's own nodes. Nothing is + // parsed after `sanitizeMarkdownFragment`, and no markup is assembled. + const parses = [...previewPatchSource.matchAll(/(\w+)\.(?:innerHTML|outerHTML|insertAdjacentHTML)\s*=\s*([^;]+);/g)].map( + (m) => `${m[1]} = ${m[2].trim()}`, ); - assert.deepEqual(parses, [], 'blockPatch.ts must move the sanitized nodes and never assemble markup of its own'); + assert.deepEqual(parses, ['box = raw'], 'previewPatch.ts parses only a raw block, before the filter'); + assert.match(previewPatchSource, /const box = scratch\.createElement\('div'\);/, 'into the detached scratch document'); + const renderBlock = sliceBetween(previewPatchSource, 'function renderBlock(', '\n}\n'); + assert.match(renderBlock, /const clean = sanitizeMarkdownFragment\(box\.innerHTML\);\n\tconst top = Array\.from\(clean\.children\)/); + assert.match(renderBlock, /return top;$/, 'a block is inserted as the filter returned it'); + assert.doesNotMatch(previewPatchSource, /insertAdjacentHTML|outerHTML|createContextualFragment/); // Why the footnote sink is allowed, pinned as a direction rather than as a // spelling: the tooltip body is read out of the rendered document, never @@ -127,7 +113,7 @@ test('the preview sanitizes through the shared policy, not a local config', () = // // `previewBlocks` and not `markdownBody`, and the difference is the whole // point of the assertion rather than a rename. The article now holds one - // host per open tab, and `previewBlocks` is the one `patchPreviewBlocks` was + // host per open tab, and `previewBlocks` is the one `patchPreview` was // handed above — so this pins the tooltip to the exact element the sanitized // string was parsed into, where the previous spelling only reached the box // containing it. @@ -149,7 +135,7 @@ test('the preview sanitizes through the shared policy, not a local config', () = }); test('the shared policy the preview now gets is the one that forbids author stylesheets', () => { - // Read as one chain: the preview calls sanitizeMarkdownFragment (test + // Read as one chain: the preview patch calls sanitizeMarkdownFragment (test // above), which passes MARKDOWN_SANITIZE_CONFIG and only asks for nodes // back, as the export's sanitizeMarkdownHtml passes it, and that config // forbids the tag the payload needs. @@ -222,8 +208,8 @@ test('the diagram sanitizer stays separate from the document policy', () => { test('the two paths keep their opposite orders on purpose', () => { // The export sanitizes the renderer output and processes afterwards // (scripts/exportSanitize.test.ts pins that). The preview processes first - // and sanitizes at the sink, so the string that reaches `{@html}` is exactly - // the sanitizer's output — there is no parse/serialize round trip after the + // and sanitizes at the sink, so what reaches the article is exactly the + // sanitizer's output — there is no parse/serialize round trip after the // filter has run. Pin the order so the difference cannot be "tidied up" // into a weaker one without reading why. // @@ -232,14 +218,17 @@ test('the two paths keep their opposite orders on purpose', () => { // argument list `(html, filePath, collapsedHeaders)` and the trailing // semicolon — which pinned three private parameter names and a formatting // choice as the price of checking which call happens where. + // The preview processes each block it inserts in `renderBlock`, and + // nowhere else; the viewer hands the renderer's output straight to the patch. + assert.deepEqual(callSiteOffsets(viewerSource, 'processMarkdownHtml'), [], 'the viewer processes nothing itself'); assert.deepEqual( - callSiteOffsets(viewerSource, 'processMarkdownHtml').map((offset) => enclosingFunctionName(viewerSource, offset)), - ['renderMarkdownPreview'], - 'renderMarkdownPreview must be the one place that processes the renderer output', + callSiteOffsets(previewPatchSource, 'processBlockMarkup').map((offset) => enclosingFunctionName(previewPatchSource, offset)), + ['renderBlock'], + 'renderBlock must be the one place that processes the renderer output', ); - assert.doesNotMatch( - viewerSource, - /processMarkdownHtml\(\s*sanitizeMarkdown/, + const renderBlock = sliceBetween(previewPatchSource, 'function renderBlock(', '\n}\n'); + assert.ok( + renderBlock.indexOf('processBlockMarkup(') < renderBlock.indexOf('sanitizeMarkdownFragment('), 'the preview must not move the filter ahead of processing without revisiting the note above', ); // The sink half — that what `{@html}` injects is the sanitizer's output — is diff --git a/scripts/tabSwitchKeepsPreviewDom.spec.ts b/scripts/tabSwitchKeepsPreviewDom.spec.ts index 192e55fc..d30aa57b 100644 --- a/scripts/tabSwitchKeepsPreviewDom.spec.ts +++ b/scripts/tabSwitchKeepsPreviewDom.spec.ts @@ -1,12 +1,9 @@ import { beforeEach, expect, test } from 'vitest'; -import { patchPreviewBlocks as patchNodes } from '../src/lib/utils/blockPatch.js'; +import { patchPreview } from '../src/lib/utils/previewPatch.js'; -/** The patch takes the sanitizer's nodes; these tests start from markup. */ function patchPreviewBlocks(host: HTMLElement, html: string) { - const template = document.createElement('template'); - template.innerHTML = html; - return patchNodes(host, template.content); + return patchPreview(host, html, '/docs/a.md', new Set()); } /** @@ -21,26 +18,24 @@ function patchPreviewBlocks(host: HTMLElement, html: string) { * moved after it had been restored. * * There is a host per tab now, and this is the property that makes the switch - * free. It is a property of `patchPreviewBlocks` plus the per-host memo it - * keeps (`renderedKeys`, a `WeakMap` keyed on the container), not of any new - * module: re-patching a host that already holds that document matches every key - * and changes nothing. + * free. It is a property of `patchPreview` plus the per-host record it keeps + * (`states`, a `WeakMap` keyed on the container), not of any new module: + * re-patching a host that already holds that document matches every key and + * changes nothing. * * What this cannot check is the wiring in `MarkdownViewer.svelte` that gives * each tab its own host — that component cannot be mounted outside Tauri. What * it does check is the mechanism the wiring depends on, and it is the half that - * would fail silently: a keying change in `blockPatch.ts` would still look + * would fail silently: a keying change in `previewPatch.ts` would still look * correct on screen and quietly put the whole cost back. */ +/** The renderer's output with `splitBlocks`: one block per `\0`. */ function doc(title: string, paragraphs: string[]): string { - const blocks = paragraphs - .map((text, i) => `

${text}

`) - .join(''); - return ( - `

${title}

` + - `
${blocks}
` - ); + return [ + `

${title}

`, + ...paragraphs.map((text, i) => `

${text}

`), + ].join('\0\n'); } const A = doc('alpha', ['one', 'two', 'three']); diff --git a/scripts/tocRenderSignal.spec.ts b/scripts/tocRenderSignal.spec.ts index 5bff8c04..4dd76966 100644 --- a/scripts/tocRenderSignal.spec.ts +++ b/scripts/tocRenderSignal.spec.ts @@ -59,7 +59,7 @@ test('the revision is published by the patch, not by the render', () => { // than from the write to the DOM is the bug, spelled differently. const viewer = readSource('src/lib/MarkdownViewer.svelte'); - const patchEffect = viewer.slice(viewer.indexOf('patchPreviewBlocks(host, ')); + const patchEffect = viewer.slice(viewer.indexOf('patchPreview(host, ')); const bump = patchEffect.indexOf('previewRevision = ++previewPatches;'); expect(bump).toBeGreaterThan(-1); expect(bump).toBeLessThan(patchEffect.indexOf('\n\t});')); diff --git a/scripts/viewModeWithoutSaving.spec.ts b/scripts/viewModeWithoutSaving.spec.ts index 5dd58d6a..aeb751ea 100644 --- a/scripts/viewModeWithoutSaving.spec.ts +++ b/scripts/viewModeWithoutSaving.spec.ts @@ -98,7 +98,7 @@ type Fakes = { askCustomCalls: string[]; saveCalls: string[]; loadCalls: string[]; - renderedFrom: Array<{ raw: string; path: string }>; + renderedFrom: string[]; toasts: string[]; saveFails: boolean; /** Text the "user" appends while `saveContent` is awaiting (TOCTOU). */ @@ -106,7 +106,7 @@ type Fakes = { }; /** `renderMarkdownPreview`'s stand-in: the output names what it was given. */ -const rendered = (raw: string, path: string) => `${raw}`; +const rendered = (raw: string) => `${raw}`; function buildHarness(fakes: Fakes, isEditing: boolean, panes: Record = {}): Harness { const source = [ @@ -163,9 +163,9 @@ function buildHarness(fakes: Fakes, isEditing: boolean, panes: Record {}, - renderMarkdownPreview: async (raw: string, path: string) => { - fakes.renderedFrom.push({ raw, path }); - return rendered(raw, path); + renderMarkdownPreview: async (raw: string) => { + fakes.renderedFrom.push(raw); + return rendered(raw); }, // The disk route, with the real function's dirty short-circuit. loadMarkdown: async (path: string, options: any = {}) => { @@ -174,8 +174,8 @@ function buildHarness(fakes: Fakes, isEditing: boolean, panes: Record item.id === activeId)!; if (receiving.isDirty && receiving.path === path && !options.discardUnsavedBuffer) return; const content = fakes.disk.get(path) ?? ''; - fakes.renderedFrom.push({ raw: content, path }); - tabManager.updateTabContent(activeId, rendered(content, path)); + fakes.renderedFrom.push(content); + tabManager.updateTabContent(activeId, rendered(content)); tabManager.setTabRawContent(activeId, content); }, documentSession: { ensureFullContent: async () => true, isLossySaveRefused: () => false }, @@ -261,11 +261,11 @@ test('reading mode shows the buffer, not the file', async () => { await harness.toggleEdit(); // The whole point of the bug: with a disk read here, this is `ON_DISK`. - assert.equal(tab.content, rendered(IN_BUFFER, tab.path)); + assert.equal(tab.content, rendered(IN_BUFFER)); assert.deepEqual( fakes.renderedFrom, - [{ raw: IN_BUFFER, path: tab.path }], - 'the preview is rendered once, from the buffer, under the tab\'s own path', + [IN_BUFFER], + 'the preview is rendered once, from the buffer', ); assert.deepEqual(fakes.loadCalls, [], 'the file is not re-read to leave the editor'); }); @@ -284,7 +284,7 @@ test('an untitled buffer never reaches the Save dialog on a view toggle', async assert.deepEqual(fakes.saveCalls, [], 'saveContent would open the Save dialog for a pathless tab'); assert.equal(tab.isEditing, false); - assert.equal(tab.content, rendered('# untitled\n', '')); + assert.equal(tab.content, rendered('# untitled\n')); }); test('auto-save still flushes on the way out, because the debounce is about to be dropped', async () => { @@ -300,7 +300,7 @@ test('auto-save still flushes on the way out, because the debounce is about to b assert.deepEqual(fakes.askCustomCalls, [], 'the flush is silent — it is the user\'s own setting'); assert.equal(tab.isDirty, false); assert.equal(tab.isEditing, false); - assert.equal(tab.content, rendered(IN_BUFFER, tab.path)); + assert.equal(tab.content, rendered(IN_BUFFER)); }); test('a file that cannot be written no longer traps the user in the editor', async () => { @@ -316,7 +316,7 @@ test('a file that cannot be written no longer traps the user in the editor', asy assert.deepEqual(fakes.saveCalls, [tab.id], 'the flush was attempted'); assert.ok(fakes.toasts.includes('toast.autoSaveFailed'), 'and the failure was reported'); assert.equal(tab.isEditing, false, 'but the view still switches'); - assert.equal(tab.content, rendered(IN_BUFFER, tab.path)); + assert.equal(tab.content, rendered(IN_BUFFER)); assert.equal(tab.isDirty, true, 'the buffer is still there to be rescued'); }); @@ -332,7 +332,7 @@ test('edits typed during the flush are shown, and reported as not yet on disk', assert.equal(tab.isEditing, false, 'the TOCTOU case is no longer a reason to stay in the editor'); assert.equal( tab.content, - rendered(IN_BUFFER + 'typed while saving\n', tab.path), + rendered(IN_BUFFER + 'typed while saving\n'), 'the preview shows the newest text, which is exactly what is NOT on disk', ); }); @@ -351,7 +351,7 @@ test('closing split view on a dirty file neither asks nor writes', async () => { assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); assert.equal(tab.isSplit, false); assert.equal(tab.isDirty, true); - assert.equal(tab.content, rendered(IN_BUFFER, tab.path), 'the surviving pane keeps showing the buffer'); + assert.equal(tab.content, rendered(IN_BUFFER), 'the surviving pane keeps showing the buffer'); }); test('closing split view honours auto-save the same way leaving edit mode does', async () => { @@ -363,7 +363,7 @@ test('closing split view honours auto-save the same way leaving edit mode does', assert.deepEqual(fakes.saveCalls, [tab.id]); assert.deepEqual(fakes.askCustomCalls, []); assert.equal(tab.isSplit, false); - assert.equal(tab.content, rendered(IN_BUFFER, tab.path)); + assert.equal(tab.content, rendered(IN_BUFFER)); }); // ------------------------------------------------------------- the boundary diff --git a/src-tauri/src/markdown.rs b/src-tauri/src/markdown.rs index fff3351e..f4f41f2c 100644 --- a/src-tauri/src/markdown.rs +++ b/src-tauri/src/markdown.rs @@ -3804,6 +3804,29 @@ mod split_blocks_tests { assert_eq!(blocks("\n\na\n\n
\n\nb\n").len(), 4); } + /// `scripts/previewPatchParity.spec.ts` replays the preview's block patch + /// over this sample's blocks, so the fixture has to be what the renderer + /// says today. `MARKPAD_UPDATE_FIXTURES=1 cargo test` rewrites it. + #[test] + fn preview_block_fixture_is_current() { + let blocks = blocks(include_str!("../../samples/markdown-syntax.md")); + let json = serde_json::to_string_pretty(&blocks).unwrap() + "\n"; + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../scripts/fixtures/markdown-syntax.blocks.json" + ); + if std::env::var_os("MARKPAD_UPDATE_FIXTURES").is_some() { + std::fs::write(path, &json).unwrap(); + } + let fixture = std::fs::read_to_string(path) + .unwrap_or_default() + .replace("\r\n", "\n"); + assert!( + fixture == json, + "{path} is stale: run `MARKPAD_UPDATE_FIXTURES=1 cargo test preview_block_fixture`" + ); + } + #[test] fn no_document_can_write_the_separator() { let html = convert_markdown_with("a\0b\n\n$x\0y$\n\n`c\0d`\n", true); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 5dfe2281..e3c92e79 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -31,9 +31,7 @@ import { type ViewMode, viewModeOf } from './utils/titlebarToolbar.js'; import { buildMenuModel, installAppMenu, type MenuAction } from './utils/appMenu.js'; import ZoomOverlay from './components/ZoomOverlay.svelte'; -import { processMarkdownHtml } from './utils/markdown'; import { MARKDOWN_LINK_EXTENSIONS } from './utils/markdownLinks.js'; -import { sanitizeMarkdownFragment } from './utils/sanitize.js'; import { resolveMermaidTheme, } from './utils/mermaidPrint.js'; @@ -42,7 +40,7 @@ import { renderRichContent as renderRichContentInto, } from './utils/richContent.js'; import { observeFoldLayout, type FoldLayoutObservation } from './utils/foldLayout.js'; -import { patchPreviewBlocks } from './utils/blockPatch.js'; +import { patchPreview } from './utils/previewPatch.js'; import { revealFold, toggleFold, @@ -147,7 +145,7 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from let markdownBody: HTMLElement | null = $state(null); let layoutContainerEl: HTMLElement | null = $state(null); /** - * One host per tab, holding that tab's rendered document. `blockPatch.ts` + * One host per tab, holding that tab's rendered document. `previewPatch.ts` * owns their children, not Svelte: an `{@html}` here would rebuild the * article on every keystroke. * @@ -1207,30 +1205,23 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from // exported document carries a CSP as the second line of defence. // // The preview has no second line: what it produces becomes nodes in the live - // application document, so the filter runs last — the processed HTML is - // cached in `tab.content` and sanitized at the sink (see the patch effect) - // into DOMPurify's own nodes, which `patchPreviewBlocks` moves into the - // article. No parse/serialize round trip happens after the sanitizer has - // had its say. - // Moving the call here instead would inject nodes the sanitizer never saw. + // application document, so the filter runs last. `tab.content` holds the + // renderer's own output, split into top-level blocks, and the patch effect + // hands it to `patchPreview`, which processes and sanitizes only the blocks + // that changed and moves DOMPurify's own nodes into the article. No + // parse/serialize round trip happens after the sanitizer has had its say. // - // `folds` is a parameter rather than a read of the active tab because this - // renders documents that are NOT on screen: a window restore renders every - // restored tab, a cross-window arrival renders itself, the background - // completion of a large file lands long after the user may have switched - // away, and the first-stage read in `documentSession` resolves after three - // awaits a switch can happen inside — the four `previewHosts` lists. Each of - // those must fold the document it is rendering, not whichever one happens to - // be active when the promise resolves. - async function renderMarkdownPreview(raw: string, filePath: string, folds: Set) { + // The document's path and folds are read when the patch runs, from the tab + // on screen, which is the document being patched: a navigation has already + // moved the tab to the new file and cleared its folds by then. + async function renderMarkdownPreview(raw: string) { const body = getMarkdownBodyWithoutFrontMatter(raw); - const html = (await invoke('render_markdown', { content: body })) as string; - return processMarkdownHtml(html, filePath, folds); + return (await invoke('render_markdown', { content: body, splitBlocks: true })) as string; } async function renderTabPreviewFromRaw(tab: Tab) { - const processed = await renderMarkdownPreview(tab.rawContent, tab.path, tab.foldOverrides); - tabManager.updateTabContent(tab.id, processed); + const rendered = await renderMarkdownPreview(tab.rawContent); + tabManager.updateTabContent(tab.id, rendered); tab.previewedRawContent = tab.rawContent; // The patch effect turns the new `tab.content` into DOM and enriches what // it changed, so this only has to wait for the flush. It used to call @@ -1508,14 +1499,20 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from const unchanged = patchedHtml.get(host) === htmlContent; patchedHtml.set(host, htmlContent); if (unchanged) invalidateAnchorMemos(); - const sanitized = unchanged ? null : sanitizeMarkdownFragment(htmlContent); - const patch = sanitized ? patchPreviewBlocks(host, sanitized) : { inserted: [] }; + // Untracked: a fold toggle or a rename is not a new document. + const patch = unchanged + ? { inserted: [], wrappers: [] } + : untrack(() => { + const tab = tabManager.activeTab; + return patchPreview(host, htmlContent, tab?.path ?? '', tab?.foldOverrides ?? NO_FOLD_OVERRIDES); + }); // Only the new blocks. `ResizeObserver.observe` on a target it is already // watching re-registers it rather than doing nothing, and a fresh // registration delivers an initial observation — so handing it the whole // host would put back exactly the per-keystroke re-measure of every fold // that keeping the observation alive removed. for (const block of patch.inserted) foldLayout?.observe(block); + for (const wrapper of patch.wrappers) foldLayout?.observe(wrapper); // A host's first enrichment is the cold start above: it waits for // whichever libraries the document needs. A tab being re-activated // patches to nothing, and its host is in the set from that first render. @@ -1529,7 +1526,7 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from /** * The hosts whose document has been through `renderRichContent` at least * once. A `WeakMap`/`WeakSet` keyed on the container for the same reason - * `renderedKeys` in `blockPatch.ts` is: a host that Svelte destroys with its + * `states` in `previewPatch.ts` is: a host that Svelte destroys with its * tab takes its entry with it, so this cannot drift from the set of live * tabs the way a hand-kept registry can. */ @@ -3412,15 +3409,15 @@ import { claimTabForRead, createDocumentSession, type LoadMarkdownOptions } from // word costs one render, and below the pause before a reader looks at // the preview. const timer = setTimeout(() => { - renderMarkdownPreview(rawContent, tab.path, tab.foldOverrides) - .then((processed) => { + renderMarkdownPreview(rawContent) + .then((rendered) => { const currentTab = tabManager.activeTab; if ( previewRenderRevision !== renderRevision || tabManager.activeTabId !== tabId || currentTab?.rawContent !== rawContent ) return; - tabManager.updateTabContent(tabId, processed); + tabManager.updateTabContent(tabId, rendered); currentTab.previewedRawContent = rawContent; // No `tick().then(renderRichContent)`: the new content is a // dependency of the patch effect, which patches the blocks diff --git a/src/lib/sessions/documentSession.svelte.ts b/src/lib/sessions/documentSession.svelte.ts index c13b2ceb..b3856ad2 100644 --- a/src/lib/sessions/documentSession.svelte.ts +++ b/src/lib/sessions/documentSession.svelte.ts @@ -63,7 +63,7 @@ type DocumentSessionOptions = { setShowHome: (value: boolean) => void; currentFile: () => string; resetScrollHistory: () => void; - renderMarkdown: (raw: string, path: string, foldOverrides: Set) => Promise; + renderMarkdown: (raw: string) => Promise; afterLoad: () => Promise; saveRecentFile: (path: string) => void; deleteRecentFile: (path: string) => void; @@ -90,18 +90,6 @@ type DocumentSessionOptions = { onPartialCopySaved: () => void; }; -/** - * Which folds the tab being loaded has flipped from the opening position its - * own source asks for, read at render time rather than captured up front. A - * large file is rendered twice — the 5MB preview, then the whole document once - * the background read lands — and the user can fold something in between; the - * second render has to honour that. An unknown tab flips nothing, which is what - * a load of a tab that has since been closed should hand a renderer. - */ -function foldsForTab(tabId: string): Set { - return tabManager.tabs.find((item) => item.id === tabId)?.foldOverrides ?? new Set(); -} - /** * Per tab: the revision of the newest disk read aimed at it. Module scope * rather than per session, because the reads it orders are not all the @@ -628,9 +616,7 @@ export function createDocumentSession(options: DocumentSessionOptions) { [content, isFull, lossy, encoding] = (await invoke('open_markdown_preview', { path: filePath, maxBytes: 5_000_000 })) as [string, boolean, boolean, string]; } if (!mayApply()) return; - // A navigation drops the folds of the document being left, so the - // file it moves to renders with none. - const processed = await options.renderMarkdown(content, filePath, pendingNavigateTabId ? new Set() : foldsForTab(activeId)); + const rendered = await options.renderMarkdown(content); // Every write this stage makes comes after this one test, in one // synchronous block. The encoding included: a prefix's detected // encoding can differ from the whole file's, and `tab.encoding` is @@ -646,7 +632,7 @@ export function createDocumentSession(options: DocumentSessionOptions) { tabManager.setTabEncoding(activeId, encoding); lossySaveWarnedTabs.delete(activeId); if (pendingNavigateTabId) tabManager.navigate(pendingNavigateTabId, filePath, pathKey); - tabManager.updateTabContent(activeId, processed); + tabManager.updateTabContent(activeId, rendered); // `isFull === false` means this is only the leading slice of a // large file. Marking the tab keeps anything downstream from // mistaking it for the whole document and writing it back. @@ -679,10 +665,10 @@ export function createDocumentSession(options: DocumentSessionOptions) { try { if (options.isScrolling()) return void setTimeout(applyFull, 100); if (!canApplyFullLoad()) return updateLoading(activeId, false); - options.renderMarkdown(fullContent, filePath, foldsForTab(activeId)) - .then((fullProcessed) => { + options.renderMarkdown(fullContent) + .then((fullRendered) => { if (!canApplyFullLoad()) return updateLoading(activeId, false); - tabManager.updateTabContent(activeId, fullProcessed); + tabManager.updateTabContent(activeId, fullRendered); tabManager.setTabRawContent(activeId, fullContent); // This buffer REPLACES the preview's, so it // carries its own verdict — the preview only diff --git a/src/lib/utils/blockPatch.ts b/src/lib/utils/blockPatch.ts deleted file mode 100644 index 0c9a0db8..00000000 --- a/src/lib/utils/blockPatch.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { invalidateAnchorMemos } from './previewAnchor.js'; -import { carrySourcepos } from './richContent.js'; - -/** - * Putting the newly rendered document into the preview by replacing only the - * blocks that changed. - * - * The preview used to be `{@html sanitizedHtml}`: every keystroke destroyed the - * whole article and built a new one. Three rounds of fixes attacked the cost of - * rebuilding it — memoising KaTeX and highlight.js (#614), debouncing the - * pipeline against a pause rather than a frame (#614), measuring folds from - * layout height rather than scrollable extent (#617) — and the report never went - * away, because the remaining defect is not cost. `scrollTop` was measured - * unchanged at Δ0.0 while the text still moved: a rebuilt tree is not required - * to lay out to the same pixels as the one it replaced. Inline maths (KaTeX's - * auto-renderer, which has no memo), font metrics, a re-attached - * ResizeObserver, an image whose intrinsic size is not known again until it - * decodes — any one of them is a frame of difference, and there is no end to - * that list. The only stable tree is the one that is not rebuilt. - * - * So this module keeps the nodes whose rendering did not change and swaps the - * ones that did, which is what VS Code and Obsidian do for the same reason. - * - * Measured in Chromium over the real pipeline (comrak → `processMarkdownHtml` → - * `sanitizeMarkdownHtml` → here), typing one character in the middle of a - * paragraph, against the same edit applied by rebuilding the article: - * - * samples/markdown-syntax.md 1000 -> 2 elements re-created (of 1733) - * samples/katex-stress.md 230 -> 1 (of 4641) - * samples/stress-test.md 6985 -> 1 (of 7819) - * - * and the whole apply — patch plus highlighting, typesetting and diagrams over - * what it changed — 23.8 -> 5.9ms, 27.7 -> 5.3ms and 51.1 -> 17.5ms - * respectively. What the reader is looking at is the node they were looking at - * before, not a copy of it: a fixed reference block sampled at frames 0, 1, 2, 5 - * and 11 after the edit is at +0.000px in every case. - * - * Renaming a heading is the one edit that does not collapse to a block or two. - * comrak deduplicates heading ids (`objectives-24`, `objectives-25`, …), so - * renaming one renumbers every later duplicate, and those headings really are - * different markup: the id is what an `#anchor` link, the outline and - * `data-fold-key` name the heading by, so no key can call them unchanged - * without lying. What is not forced is the content BETWEEN them, and that was - * where the cost sat — the changed run at that level of the document is - * `h2, wrapper, h2, wrapper, …`, the wrappers hold the body of the file, and - * the run was swapped whole. On stress-test.md that was 71 blocks, a third of - * the document, for one renamed heading. The run is now walked pairwise when - * both sides have the same length, so the headings are replaced and the - * wrappers are descended into. - * - * Keeping the heading nodes as well and rewriting their ids — the trick - * `refreshSourcepos` plays with source ranges — is the tempting next step, and - * it does not work. That trick is sound only because every node - * `renderRichContent` replaces it replaces singly, so the annotated nodes line - * up one for one on both sides after enrichment. Ids have no such property: - * enrichment INVENTS them, a Mermaid SVG carrying dozens where the `
` it
- * replaced had none, so the two `[id]` lists do not correspond and the
- * length-mismatch bail leaves a kept block wearing an id nothing links to any
- * more. A stale anchor target is a wrong answer; replacing a heading is only
- * work. Dropping ids from the key without rewriting them at all is the same
- * defect with none of the machinery.
- *
- * ## The key is the markup, not the source position
- *
- * comrak stamps every block with `data-sourcepos`, and it is tempting to diff
- * on that. It is exactly wrong: inserting one line renumbers every block below
- * the cursor, so a source-position key says "everything after the caret is
- * new" — the whole document, on every keystroke, which is the thing being
- * fixed. The key here is the block's own markup with every source range removed,
- * so a paragraph that merely moved down two lines is recognised as the same
- * paragraph. It is not hashed: the string *is* the key, which costs one map
- * comparison per block and cannot collide.
- *
- * `data-sourcepos` still has to be *right* on a kept node — `lineCoordinates.ts`
- * and `previewAnchor.ts` resolve every scroll-sync and anchor-restore question
- * through it, and split-view scroll sync is running while the user types. So the
- * ranges are refreshed onto kept nodes from the new tree (`refreshSourcepos`).
- * That is an attribute write no CSS rule reads: no style invalidation, no
- * layout, no paint — the class of work this module exists to preserve.
- *
- * It is not, however, a write nothing reads. `previewAnchor.ts` memoises the
- * range it parses off a node and the span it derives from a node's subtree, and
- * both memos were built on the guarantee this module removes: that a node whose
- * source lines changed is a *new* node, so a stale entry is unreachable. Keeping
- * the node keeps the entry, and the memo goes on answering with the lines the
- * block was on before the edit, for as long as the node lives. So the patch owes
- * `invalidateAnchorMemos` a call — the whole of it, not per node, for the reason
- * spelled out where those memos are declared.
- *
- * ## Keys are remembered, never read back off the live tree
- *
- * `renderRichContent` rewrites what it touches: a `
` gains a
- * `.code-block-shell` parent and a copy button, a Mermaid `
` becomes a
- * `
`, a formula's `innerHTML` becomes KaTeX's. The - * live markup therefore has nothing to do with the markup that produced it, and - * a diff that re-serialised the live tree would find every enriched block - * "changed" forever. `renderedKeys` holds the keys of what this module last - * *inserted*, per container, positionally. Every one of those rewrites is - * one-node-for-one-node, so the positions still line up. - * - * A container whose live child count no longer matches its remembered keys is - * one somebody else rewrote; it gets replaced wholesale rather than guessed at. - * - * ## Why this descends - * - * "Top-level block" is not a useful unit in this pipeline. `processMarkdownHtml` - * re-parents everything under a heading into a `.foldable-content-wrapper`, so a - * document that opens with `# Title` has exactly two top-level children and the - * second one is the rest of the file. The diff therefore recurses into the - * containers Markpad builds itself (`DESCENDABLE`) — and only those. Their - * children are always whole blocks, which is what makes it safe to compare them - * as a list and to drop the inter-block whitespace. Descending into arbitrary - * elements is not: a `

` whose element children are unchanged can still have - * different text between them, and an element-wise diff would keep the old - * words. - */ - -/** - * Source ranges, removed before a block is keyed. The values comrak writes are - * `line:col-line:col`, so there is no quote to escape and no need to parse. - */ -const SOURCEPOS_ATTRIBUTE = /\s+data-sourcepos="[^"]*"/g; - -/** - * The containers this diff may look inside instead of replacing. - * - * Every one is built by `processMarkdownHtml` out of `appendChild` calls on - * whole blocks, which is the property that makes a positional child diff sound. - */ -const DESCENDABLE = - '.foldable-content-wrapper, .content-inner, .markdown-alert, .markdown-alert-content'; - -/** What this module last put into each container, in order. */ -const renderedKeys = new WeakMap(); - -export interface BlockPatch { - /** - * The elements that were newly inserted — everything, and only what, - * `renderRichContent` has to visit. Never nested inside one another. - */ - inserted: HTMLElement[]; - /** Elements inserted, at every depth the diff reached. Diagnostics. */ - replaced: number; - /** Elements left in place, at every depth the diff reached. Diagnostics. */ - kept: number; -} - -function blockKey(element: Element): string { - return element.outerHTML.replace(SOURCEPOS_ATTRIBUTE, ''); -} - -function childKeys(container: ParentNode): string[] { - return Array.from(container.children).map(blockKey); -} - -/** - * Bring the preview's blocks up to date with `sanitized`, replacing as few of - * them as possible. - * - * `sanitized` is `sanitizeMarkdownFragment`'s output: DOMPurify's own tree, - * which lives in an inert document, so the blocks that turn out to be - * unchanged are discarded without ever having loaded an image or run - * anything. This module parses no markup: the nodes the filter approved are - * the nodes that reach the document. - */ -export function patchPreviewBlocks(host: HTMLElement, sanitized: DocumentFragment): BlockPatch { - // Before the tree moves, not after: a patch that throws half-way through has - // still changed some of it, and an empty memo is only ever a slow answer - // where a stale one is a wrong one. - invalidateAnchorMemos(); - - const patch: BlockPatch = { inserted: [], replaced: 0, kept: 0 }; - patchContainer(host, sanitized, patch); - return patch; -} - -function patchContainer(live: HTMLElement, next: ParentNode, patch: BlockPatch): void { - const nextElements = Array.from(next.children) as HTMLElement[]; - const newKeys = childKeys(next); - const liveElements = Array.from(live.children) as HTMLElement[]; - const oldKeys = renderedKeys.get(live); - - renderedKeys.set(live, newKeys); - - // First render into this container, or one whose children somebody else - // changed. Only elements are carried over: the whitespace between blocks is - // comrak's line breaks, invisible between block boxes, and keeping it out - // means `children` and `childNodes` stay the same list forever. - if (!oldKeys || oldKeys.length !== liveElements.length) { - live.replaceChildren(...nextElements); - for (const element of nextElements) rememberSubtree(element); - patch.inserted.push(...nextElements); - patch.replaced += nextElements.length; - return; - } - - // Common prefix and suffix rather than a full edit-distance: the edit this - // exists for is a keystroke, which produces one changed block with an - // unchanged run on either side of it. A block that *moved* (cut here, pasted - // far away) falls outside the run and is replaced, which is correct and only - // costs the incrementality nobody is typing to get. - const shortest = Math.min(oldKeys.length, newKeys.length); - let head = 0; - while (head < shortest && oldKeys[head] === newKeys[head]) head += 1; - let tail = 0; - while ( - tail < shortest - head && - oldKeys[oldKeys.length - 1 - tail] === newKeys[newKeys.length - 1 - tail] - ) { - tail += 1; - } - patch.kept += head + tail; - - let oldFrom = head; - let oldTo = oldKeys.length - tail; - let newFrom = head; - let newTo = newKeys.length - tail; - - // The changed run is usually a single container holding the edit. Descending - // from both ends rather than only the front is what keeps typing inside a - // heading from being a whole-document replacement: the `

` really did - // change and gets swapped, and the wrapper carrying the rest of the file is - // reached from the other side and looked inside. - while (oldFrom < oldTo && newFrom < newTo && canDescend(liveElements[oldFrom], nextElements[newFrom])) { - descend(liveElements[oldFrom], nextElements[newFrom], patch); - oldFrom += 1; - newFrom += 1; - } - while (oldTo > oldFrom && newTo > newFrom && canDescend(liveElements[oldTo - 1], nextElements[newTo - 1])) { - descend(liveElements[oldTo - 1], nextElements[newTo - 1], patch); - oldTo -= 1; - newTo -= 1; - } - - // What is left is the run that really changed. When both sides have the same - // number of blocks in it, the two line up pair for pair, and each pair can - // be asked the question the ends were asked instead of the run being swapped - // wholesale. That is the difference between "a heading changed" and "a third - // of the document changed": renumbering `objectives-24` renames every later - // duplicate, and the run that spans them is `h2, wrapper, h2, wrapper, …` - // where only the headings differ. The wrappers hold the body of the file. - // - // Same length is the whole condition, and it is not a heuristic: it is what - // says the blocks correspond. An edit that adds or removes one shifts - // everything after it, there is no pairing to be had, and the run is swapped - // as before — which is also the case the prefix/suffix diff was chosen for. - if (oldTo - oldFrom === newTo - newFrom) { - for (let index = 0; index < oldTo - oldFrom; index += 1) { - const liveElement = liveElements[oldFrom + index]; - const nextElement = nextElements[newFrom + index]; - if (canDescend(liveElement, nextElement)) { - descend(liveElement, nextElement, patch); - continue; - } - liveElement.replaceWith(nextElement); - rememberSubtree(nextElement); - patch.inserted.push(nextElement); - patch.replaced += 1; - } - } else { - // Read before anything is removed: still a live node, and still in place - // afterwards, because everything removed sits before it. - const before = liveElements[oldTo] ?? null; - for (let index = oldFrom; index < oldTo; index += 1) liveElements[index].remove(); - - const incoming = nextElements.slice(newFrom, newTo); - if (incoming.length > 0) { - const fragment = live.ownerDocument.createDocumentFragment(); - for (const element of incoming) fragment.appendChild(element); - live.insertBefore(fragment, before); - for (const element of incoming) rememberSubtree(element); - patch.inserted.push(...incoming); - patch.replaced += incoming.length; - } - } - - // Kept blocks moved in the source even when they did not move on screen, and - // a nested container's whole child list shifts when a line is added above the - // heading that owns it — so the prefix needs this as much as the suffix does. - for (let index = 0; index < head; index += 1) { - refreshSourcepos(liveElements[index], nextElements[index]); - } - for (let index = 1; index <= tail; index += 1) { - refreshSourcepos(liveElements[liveElements.length - index], nextElements[nextElements.length - index]); - } -} - -function descend(live: HTMLElement, next: HTMLElement, patch: BlockPatch): void { - // The container is kept, so it keeps its own source range too — a callout is - // annotated, and scroll sync reads the outermost annotation it can find. - carrySourcepos(next, live); - patchContainer(live, next, patch); -} - -function canDescend(live: Element, next: Element): boolean { - return ( - live.tagName === next.tagName && - // A fold that was toggled has `is-collapsed` on one side and not the - // other; that is a replacement, not a container to look inside. - live.className === next.className && - next.childElementCount > 0 && - next.matches(DESCENDABLE) - ); -} - -/** - * Record the keys of every descendable container inside a freshly inserted - * block, so the *next* diff can look inside it instead of replacing it. - * - * Must run before `renderRichContent` touches these nodes, which is why the - * patch and the enrichment are ordered rather than independent. - */ -function rememberSubtree(element: Element): void { - if (element.matches(DESCENDABLE)) renderedKeys.set(element, childKeys(element)); - for (const container of element.querySelectorAll(DESCENDABLE)) { - renderedKeys.set(container, childKeys(container)); - } -} - -function annotatedNodes(element: Element): Element[] { - const nodes = element.hasAttribute('data-sourcepos') ? [element] : []; - nodes.push(...element.querySelectorAll('[data-sourcepos]')); - return nodes; -} - -/** - * Copy the new source ranges onto a block that is being kept. - * - * The two subtrees are the same markup apart from the ranges the key strips, so - * their annotated elements line up one for one — including after enrichment, - * because every node `renderRichContent` replaces it replaces singly and - * `carrySourcepos` moves the range across. A length mismatch means that - * assumption does not hold for this block; leaving it untouched is the safe - * answer, and it costs a stale range rather than a wrong one somewhere else. - */ -function refreshSourcepos(live: Element, next: Element): void { - const liveNodes = annotatedNodes(live); - const nextNodes = annotatedNodes(next); - if (liveNodes.length !== nextNodes.length) return; - - for (let index = 0; index < liveNodes.length; index += 1) { - const range = nextNodes[index].getAttribute('data-sourcepos'); - if (range !== null && liveNodes[index].getAttribute('data-sourcepos') !== range) { - liveNodes[index].setAttribute('data-sourcepos', range); - } - } -} diff --git a/src/lib/utils/export.ts b/src/lib/utils/export.ts index c1667943..d7432c4d 100644 --- a/src/lib/utils/export.ts +++ b/src/lib/utils/export.ts @@ -347,9 +347,8 @@ async function renderExportRichContent(root: HTMLElement, ctx: ExportContext): P // One root, and always the whole thing: an exported file is a string, // so there is no previous DOM to keep any of. The preview's split from // this — same render, same filter, same detached parse, then either - // serialize it or diff it into the article — is the whole reason - // `blockPatch.ts` takes an already-sanitized string and produces nodes - // rather than owning a render of its own. + // serialize it or patch it into the article block by block, which is + // `previewPatch.ts`. roots: [root], libraries: ctx.libraries, mermaidTheme: ctx.mermaidTheme, diff --git a/src/lib/utils/foldLayout.ts b/src/lib/utils/foldLayout.ts index 33700022..0ff6161a 100644 --- a/src/lib/utils/foldLayout.ts +++ b/src/lib/utils/foldLayout.ts @@ -71,7 +71,7 @@ export interface FoldLayoutObservation { /** * Start watching the fold contents inside newly rendered markup. * - * Called with the blocks `blockPatch.ts` just inserted, rather than with the + * Called with the blocks `previewPatch.ts` just inserted, rather than with the * whole article. The observation used to be torn down and rebuilt on every * keystroke, which meant a document's every fold got a fresh * ResizeObserver registration per character — and a fresh registration diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts index 0a9ae618..099b658e 100644 --- a/src/lib/utils/markdown.ts +++ b/src/lib/utils/markdown.ts @@ -689,12 +689,23 @@ export function processMarkdownHtml( ): string { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); + processBlockMarkup(doc.body, filePath); + wrapHeadings(Array.from(doc.body.querySelectorAll(".foldable-header")), { next: 0 }); + applyFolds(doc.body, foldOverrides); + return doc.body.innerHTML; +} - // The keys handed out in THIS render, shared by the callout pass and the - // heading pass below so that neither can hand out a key the other used. - const foldKeys = new Set(); +/** `+` or `-`, on a foldable callout: what the document opens it as. */ +const CALLOUT_FOLD_ATTR = "data-callout-fold"; - for (const img of doc.querySelectorAll("img")) { +/** + * Everything `processMarkdownHtml` does that only looks inside one top-level + * block. Fold keys and the heading wrappers need the whole document and are + * `applyFolds` and `wrapHeadings`. + */ +export function processBlockMarkup(root: Element, filePath: string): void { + const doc = root.ownerDocument; + for (const img of root.querySelectorAll("img")) { const src = img.getAttribute("src"); let finalSrc = src; if (src && !src.startsWith("http") && !src.startsWith("data:")) { @@ -750,7 +761,7 @@ export function processMarkdownHtml( } } - for (const a of doc.querySelectorAll("a")) { + for (const a of root.querySelectorAll("a")) { const href = a.getAttribute("href"); if (href && isYoutubeLink(href)) { const parent = a.parentElement; @@ -799,7 +810,7 @@ export function processMarkdownHtml( } }; - for (const bq of Array.from(doc.querySelectorAll("blockquote"))) { + for (const bq of Array.from(root.querySelectorAll("blockquote"))) { // Only the text the quote's first paragraph opens with is a marker, as // in Obsidian and GitHub: `[!note]` in code or a nested quote is text. let first = bq.firstChild; @@ -887,36 +898,21 @@ export function processMarkdownHtml( if (contentInner.childNodes.length === 0) { container.classList.add("callout-title-only"); } else { - // A callout the reader folds is remembered exactly like a - // heading, and `> [!note]-` is the document's opening position - // rather than a state of its own — see `foldState.ts`. A - // title-only callout hides nothing and so is not a fold at all, - // which is why the key is assigned in this branch. - if (isFoldable) { - const key = assignFoldKey(container, foldKeys); - if (isFolded(foldOverrides, key, fold === "-")) { - contentWrapper.classList.add("is-collapsed"); - container.classList.add("is-collapsed"); - } - } + // The fold key is the document's to hand out: see `applyFolds`. + if (isFoldable) container.setAttribute(CALLOUT_FOLD_ATTR, fold); container.appendChild(contentWrapper); } bq.replaceWith(container); } } - processDisplayMathBlocks(doc.body, doc); - processBlockIds(doc.body, doc); - processTaskItems(doc.body); - processInlineMath(doc.body); - processSoftLineAnchors(doc.body, doc); - - const headings = Array.from(doc.querySelectorAll("h1, h2, h3, h4, h5, h6")); - // The heading↔wrapper pairing only needs to be unique within this - // render's output (the previous DOM is replaced wholesale), so a - // counter keeps the HTML deterministic for identical input. - let nextFoldId = 0; - for (const h of headings) { + processDisplayMathBlocks(root, doc); + processBlockIds(root, doc); + processTaskItems(root); + processInlineMath(root); + processSoftLineAnchors(root, doc); + + for (const h of Array.from(root.querySelectorAll("h1, h2, h3, h4, h5, h6"))) { // comrak puts the deduplicated heading id ("title", "title-1", ...) // on an empty inner , so h.id is empty and the fold // key falls all the way back to the heading text — which collides for @@ -934,13 +930,26 @@ export function processMarkdownHtml( chevron.innerHTML = ``; h.insertBefore(chevron, h.firstChild); h.classList.add("foldable-header"); + } - const wrapper = doc.createElement("div"); - wrapper.className = "foldable-content-wrapper"; - const inner = doc.createElement("div"); - inner.className = "content-inner"; - wrapper.appendChild(inner); + // Clean up empty paragraphs that might be leftovers from blank lines + Array.from(root.querySelectorAll("p")).forEach((p) => { + if (isBlank(p.innerHTML)) { + p.remove(); + } + }); +} +/** + * Moves what follows each heading, up to the next heading of the same or a + * higher level, into the heading's `.foldable-content-wrapper`. + * + * `heads` in document order. Returns the wrappers it made. + */ +export function wrapHeadings(heads: Element[], ids: FoldIds): HTMLElement[] { + const made: HTMLElement[] = []; + for (const h of heads) { + const { wrapper, inner } = makeFoldWrapper(h.ownerDocument, h, ids); let current = h.nextElementSibling; const level = parseInt(h.tagName[1], 10); while (current) { @@ -954,24 +963,55 @@ export function processMarkdownHtml( current = next; } if (h.parentNode) h.parentNode.insertBefore(wrapper, h.nextSibling); + made.push(wrapper); + } + return made; +} - const mappingId = "wrap-" + nextFoldId++; - h.setAttribute("data-fold-target", mappingId); - wrapper.id = mappingId; +/** Where the `wrap-N` ids of one document have got to. */ +export interface FoldIds { next: number } + +export function makeFoldWrapper(doc: Document, h: Element, ids: FoldIds) { + const wrapper = doc.createElement("div"); + wrapper.className = "foldable-content-wrapper"; + const inner = doc.createElement("div"); + inner.className = "content-inner"; + wrapper.appendChild(inner); + // Only has to be unique within the live document. + const mappingId = "wrap-" + ids.next++; + h.setAttribute("data-fold-target", mappingId); + wrapper.id = mappingId; + return { wrapper, inner }; +} +/** + * Hands out every fold key in `root`, callouts first and then headings, in + * document order, and sets each fold's collapsed state from `foldOverrides`. + * With `onlyChanged`, a fold whose key did not change keeps the state it has, + * which may be one the reader toggled. + */ +export function applyFolds(root: Element, foldOverrides: Set, onlyChanged = false): void { + const foldKeys = new Set(); + for (const container of Array.from(root.querySelectorAll(`.callout-foldable[${CALLOUT_FOLD_ATTR}]`))) { + // A title-only callout hides nothing, so it is not a fold. + const content = Array.from(container.childNodes).find( + (child): child is Element => + child.nodeType === 1 && (child as Element).classList.contains("markdown-alert-content"), + ); + if (!content) continue; + const before = container.getAttribute("data-fold-key"); + const key = assignFoldKey(container, foldKeys); + if (onlyChanged && before === key) continue; + const folded = isFolded(foldOverrides, key, container.getAttribute(CALLOUT_FOLD_ATTR) === "-"); + container.classList.toggle("is-collapsed", folded); + content.classList.toggle("is-collapsed", folded); + } + for (const h of Array.from(root.querySelectorAll(".foldable-header[data-fold-target]"))) { + const before = h.getAttribute("data-fold-key"); const key = assignFoldKey(h, foldKeys); - if (isFolded(foldOverrides, key)) { - h.classList.add("is-collapsed"); - wrapper.classList.add("is-collapsed"); - } + if (onlyChanged && before === key) continue; + const folded = isFolded(foldOverrides, key); + h.classList.toggle("is-collapsed", folded); + h.nextElementSibling?.classList.toggle("is-collapsed", folded); } - - // Clean up empty paragraphs that might be leftovers from blank lines - Array.from(doc.querySelectorAll("p")).forEach((p) => { - if (isBlank(p.innerHTML)) { - p.remove(); - } - }); - - return doc.body.innerHTML; } diff --git a/src/lib/utils/previewAnchor.ts b/src/lib/utils/previewAnchor.ts index ae45a64c..26c72b08 100644 --- a/src/lib/utils/previewAnchor.ts +++ b/src/lib/utils/previewAnchor.ts @@ -288,7 +288,7 @@ function elementChildren(node: AnchorNode): AnchorNode[] { * every candidate sibling on every call, and on a 13,000-line document that is * ~6ms per mapping — measured, in Chrome, over this pipeline's own output. * - * WHAT INVALIDATES THEM: `invalidateAnchorMemos`, which `patchPreviewBlocks` + * WHAT INVALIDATES THEM: `invalidateAnchorMemos`, which `patchPreview` * calls before it touches the tree. It has to, because these answers are no * longer a function of a node's identity. * @@ -296,9 +296,9 @@ function elementChildren(node: AnchorNode): AnchorNode[] { * wholesale by `bind:innerHTML` on every render pass, so a changed document was * a new set of nodes and the entries describing the old ones were collected * with them. Nothing invalidated these memos because nothing could. - * `blockPatch.ts` breaks that premise on purpose — it keeps the nodes whose + * `previewPatch.ts` breaks that premise on purpose — it keeps the nodes whose * rendering did not change, and rewrites `data-sourcepos` ON THOSE KEPT NODES - * from the new tree (`refreshSourcepos`, `carrySourcepos`). A kept node is the + * from the new markup (`moveSourcepos`). A kept node is the * same key, so its entry survives the edit that changed its range, and the memo * keeps answering with the lines the block used to be on — permanently, because * nothing is ever going to replace that node either. Every edit that shifts a diff --git a/src/lib/utils/previewPatch.ts b/src/lib/utils/previewPatch.ts new file mode 100644 index 00000000..6677a747 --- /dev/null +++ b/src/lib/utils/previewPatch.ts @@ -0,0 +1,262 @@ +import { applyFolds, processBlockMarkup, wrapHeadings, type FoldIds } from './markdown.js'; +import { invalidateAnchorMemos } from './previewAnchor.js'; +import { replacedBy } from './richContent.js'; +import { sanitizeMarkdownFragment } from './sanitize.js'; + +/** + * Bringing the preview up to date block by block, from the renderer's HTML. + * + * After every typing pause the preview used to take the whole document + * through `processMarkdownHtml` (DOMParser plus a dozen passes), DOMPurify, + * and a diff that serialised every block to compare it. All three scale with + * the document, not the edit: about 0.8s per pause at 1MB and 4.5s at 5MB, + * on the main thread, to change one paragraph. + * + * `render_markdown` with `splitBlocks` writes `\0` between top-level blocks + * (`BLOCK_SEPARATOR` in markdown.rs). comrak still parses the whole document, + * so footnote numbers, reference links and heading-id dedupe are already in + * each block's markup, and a block whose markup did not change needs nothing + * done to it. Only the blocks that did change are processed, sanitized and + * inserted. StackEdit's preview has the same shape. + * + * ## The key is the markup, not the source position + * + * Inserting a line renumbers every `data-sourcepos` below it, so a key that + * included them would call the rest of the document new. The key is the + * block's markup with its ranges removed; a kept block whose ranges moved gets + * the new ones written onto it (`moveSourcepos`), because scroll sync, anchor + * restore and the task checkboxes all read them. + * + * ## Keys are remembered, never read back off the live tree + * + * `renderRichContent` rewrites what it touches (a code block's shell, a + * Mermaid diagram in place of its `
`), so the live markup has nothing to
+ * do with the renderer's. The record is of the renderer's markup and of the
+ * elements each block was inserted as; `replacedBy` leads from an element
+ * enrichment replaced to what stands there now.
+ *
+ * ## What is document-wide on this side
+ *
+ * The heading wrappers and the fold keys. Both are made on the live tree with
+ * DOM calls only — nothing here parses markup after `sanitizeMarkdownFragment`
+ * has seen it. A block is inserted after the one before it, or at the top of
+ * that heading's wrapper; only an edit that adds, removes or re-levels a
+ * top-level heading changes which wrapper a block belongs to, and that one
+ * regroups.
+ */
+
+const SEPARATOR = '\0';
+const SOURCEPOS_ATTRIBUTE = /\s+data-sourcepos="[^"]*"/g;
+const FIRST_LINE = /data-sourcepos="(\d+):/;
+const FOLD_HEADS = '.foldable-header, .callout-foldable';
+
+interface PreviewState {
+	/** The renderer's markup of each block, as last applied. */
+	raws: string[];
+	/** The same without source ranges: a block that only moved keeps its key. */
+	keys: string[];
+	/** What each block put into the preview: none, one, or a few elements. */
+	els: Element[][];
+	ids: FoldIds;
+	/**
+	 * `host.childElementCount` after the patch. Anything else next time means
+	 * somebody else rewrote the article, and it is rebuilt rather than diffed
+	 * against a stale record.
+	 */
+	topCount: number;
+}
+
+const states = new WeakMap();
+
+export interface PreviewPatch {
+	/** New blocks, for `renderRichContent`. Never nested inside one another. */
+	inserted: HTMLElement[];
+	/** New heading wrappers, for the fold layout observer only. */
+	wrappers: HTMLElement[];
+	replaced: number;
+	kept: number;
+}
+
+let scratch: Document | null = null;
+
+function renderBlock(raw: string, filePath: string, ids: FoldIds): HTMLElement[] {
+	// An inert document: nothing in it runs or loads.
+	scratch ??= document.implementation.createHTMLDocument('');
+	const box = scratch.createElement('div');
+	box.innerHTML = raw;
+	processBlockMarkup(box, filePath);
+	const clean = sanitizeMarkdownFragment(box.innerHTML);
+	const top = Array.from(clean.children) as HTMLElement[];
+	// Headings inside a block (a callout, a list item) fold among their own
+	// siblings. The block's top-level heading is the patch's to wrap.
+	wrapHeadings(
+		Array.from(clean.querySelectorAll('.foldable-header')).filter((h) => h.parentNode !== clean),
+		ids,
+	);
+	return top;
+}
+
+function live(element: Element): Element {
+	for (let next = replacedBy.get(element); next; next = replacedBy.get(element)) element = next;
+	return element;
+}
+
+function isTopHeading(element: Element): boolean {
+	return /^H[1-6]$/.test(element.tagName) && element.classList.contains('foldable-header');
+}
+
+function firstLine(raw: string): number {
+	const match = FIRST_LINE.exec(raw);
+	return match ? Number(match[1]) : 0;
+}
+
+const SOURCEPOS_VALUE = /data-sourcepos="([^"]*)"/g;
+
+/**
+ * A kept block whose markup differs only in its source ranges: the text above
+ * it gained or lost lines. Each range the renderer wrote is replaced by its
+ * counterpart in the new markup (same markup, so same order); the ones this
+ * side derived (`source-line-anchor`) move by the block's own shift.
+ */
+function moveSourcepos(elements: Element[], before: string, after: string): void {
+	const from = Array.from(before.matchAll(SOURCEPOS_VALUE), (m) => m[1]);
+	const to = Array.from(after.matchAll(SOURCEPOS_VALUE), (m) => m[1]);
+	const moved = new Map(from.map((range, index) => [range, to[index]]));
+	const delta = firstLine(after) - firstLine(before);
+	const shift = (range: string) =>
+		range.replace(/^(\d+)(:\d+-)(\d+)/, (_, a, mid, b) => `${Number(a) + delta}${mid}${Number(b) + delta}`);
+	for (const element of elements) {
+		const nodes = element.hasAttribute('data-sourcepos') ? [element] : [];
+		nodes.push(...element.querySelectorAll('[data-sourcepos]'));
+		for (const node of nodes) {
+			const range = node.getAttribute('data-sourcepos')!;
+			const next = moved.get(range) ?? shift(range);
+			if (next !== range) node.setAttribute('data-sourcepos', next);
+		}
+	}
+}
+
+export function patchPreview(
+	host: HTMLElement,
+	html: string,
+	filePath: string,
+	foldOverrides: Set,
+): PreviewPatch {
+	invalidateAnchorMemos();
+	const raws = html.split(SEPARATOR);
+	const keys = raws.map((raw) => raw.replace(SOURCEPOS_ATTRIBUTE, ''));
+	const patch: PreviewPatch = { inserted: [], wrappers: [], replaced: 0, kept: 0 };
+	const recorded = states.get(host);
+	const old = recorded?.topCount === host.childElementCount ? recorded : undefined;
+
+	if (!old) {
+		const ids: FoldIds = { next: 0 };
+		const els = raws.map((raw) => renderBlock(raw, filePath, ids));
+		const flat = els.flat() as HTMLElement[];
+		host.replaceChildren(...flat);
+		patch.wrappers = wrapHeadings(flat.filter(isTopHeading), ids);
+		applyFolds(host, foldOverrides);
+		states.set(host, { raws, keys, els, ids, topCount: host.childElementCount });
+		patch.inserted = flat;
+		patch.replaced = flat.length;
+		return patch;
+	}
+
+	const oldLength = old.keys.length;
+	const shortest = Math.min(oldLength, keys.length);
+	let head = 0;
+	while (head < shortest && old.keys[head] === keys[head]) head += 1;
+	let tail = 0;
+	while (tail < shortest - head && old.keys[oldLength - 1 - tail] === keys[keys.length - 1 - tail]) tail += 1;
+
+	const els: Element[][] = new Array(raws.length);
+	const keep = (index: number, oldIndex: number) => {
+		els[index] = old.els[oldIndex].map(live);
+		patch.kept += 1;
+		if (old.raws[oldIndex] !== raws[index]) moveSourcepos(els[index], old.raws[oldIndex], raws[index]);
+	};
+	for (let index = 0; index < head; index += 1) keep(index, index);
+	for (let back = 1; back <= tail; back += 1) keep(raws.length - back, oldLength - back);
+
+	// The changed run. Same length on both sides means the blocks correspond
+	// pair for pair (a renamed heading renumbering later duplicates), so only
+	// the pairs that really differ are rendered.
+	const oldTo = oldLength - tail;
+	const newTo = raws.length - tail;
+	const pairwise = oldTo - head === newTo - head;
+	const removed: Element[] = [];
+	const render: number[] = [];
+	for (let index = head; index < newTo; index += 1) {
+		if (pairwise && old.keys[index] === keys[index]) keep(index, index);
+		else render.push(index);
+	}
+	for (let index = head; index < oldTo; index += 1) {
+		if (!(pairwise && old.keys[index] === keys[index])) removed.push(...old.els[index].map(live));
+	}
+	for (const index of render) els[index] = renderBlock(raws[index], filePath, old.ids);
+	const added = render.flatMap((index) => els[index]) as HTMLElement[];
+
+	// A renamed heading of the same level keeps its wrapper. Any other change
+	// to the top-level headings changes which blocks belong to which heading.
+	const regroup =
+		removed.some(isTopHeading) || added.some(isTopHeading)
+			? !(
+					pairwise &&
+					render.every((index) => {
+						const before = old.els[index].map(live);
+						const after = els[index];
+						return before.length === 1 && after.length === 1
+							? isTopHeading(before[0]) === isTopHeading(after[0]) && before[0].tagName === after[0].tagName
+							: !before.some(isTopHeading) && !after.some(isTopHeading);
+					})
+				)
+			: false;
+	const touchesFolds =
+		removed.some((e) => e.matches(FOLD_HEADS) || e.querySelector(FOLD_HEADS)) ||
+		added.some((e) => e.matches(FOLD_HEADS) || e.querySelector(FOLD_HEADS));
+
+	if (regroup) {
+		// Regroup: every block back to the top level, then wrapped again. The
+		// blocks are moved, not rebuilt.
+		for (const element of removed) element.remove();
+		const flat = els.flat() as HTMLElement[];
+		host.replaceChildren(...flat);
+		patch.wrappers = wrapHeadings(flat.filter(isTopHeading), old.ids);
+		applyFolds(host, foldOverrides);
+	} else {
+		for (const index of render) {
+			const before = pairwise ? old.els[index].map(live) : [];
+			const after = els[index] as HTMLElement[];
+			if (pairwise && before.length === 1 && after.length === 1 && isTopHeading(before[0])) {
+				after[0].setAttribute('data-fold-target', before[0].getAttribute('data-fold-target') ?? '');
+				before[0].replaceWith(after[0]);
+				continue;
+			}
+			for (const element of before) element.remove();
+			if (after.length > 0) insertBlock(host, els, index, after);
+		}
+		for (const element of removed) element.remove();
+		if (touchesFolds) applyFolds(host, foldOverrides, true);
+	}
+
+	states.set(host, { raws, keys, els, ids: old.ids, topCount: host.childElementCount });
+	patch.inserted = added;
+	patch.replaced = added.length;
+	return patch;
+}
+
+/** Puts `nodes` where block `index` belongs: after the previous block's last element. */
+function insertBlock(host: HTMLElement, els: Element[][], index: number, nodes: HTMLElement[]) {
+	for (let before = index - 1; before >= 0; before -= 1) {
+		const previous = els[before];
+		if (!previous || previous.length === 0) continue;
+		const last = previous[previous.length - 1];
+		if (isTopHeading(last)) {
+			last.nextElementSibling!.querySelector(':scope > .content-inner')!.prepend(...nodes);
+		} else {
+			last.after(...nodes);
+		}
+		return;
+	}
+	host.prepend(...nodes);
+}
diff --git a/src/lib/utils/richContent.ts b/src/lib/utils/richContent.ts
index 57b67848..c1921d9d 100644
--- a/src/lib/utils/richContent.ts
+++ b/src/lib/utils/richContent.ts
@@ -220,7 +220,7 @@ export interface RenderRichContentOptions {
 	 * The elements holding processed Markdown. Live or detached, both work.
 	 *
 	 * A list, not one element, because the preview no longer rebuilds its
-	 * article: `blockPatch.ts` replaces the blocks that changed and hands their
+	 * article: `previewPatch.ts` replaces the blocks that changed and hands their
 	 * new nodes here, so a keystroke typesets one paragraph instead of the
 	 * document. The export passes the single detached element it is building.
 	 * The roots must not contain one another — each is visited on its own, so a
@@ -264,6 +264,13 @@ function defaultIdFactory(index: number): string {
  * `` becomes a thumbnail link — and the same
  * gap costs the same thing there.
  */
+/**
+ * Enrichment replaces some blocks with a node of its own (a Mermaid diagram)
+ * or moves them into one (a code block's shell). `previewPatch.ts` holds on to
+ * the block it inserted and follows this to what stands in its place now.
+ */
+export const replacedBy = new WeakMap();
+
 export function carrySourcepos(from: Element, to: Element) {
 	const sourcepos = from.getAttribute('data-sourcepos');
 	if (sourcepos) to.setAttribute('data-sourcepos', sourcepos);
@@ -360,7 +367,7 @@ export async function renderRichContent(options: RenderRichContentOptions): Prom
 	 * by `pre code`, and a diagram that has been drawn once has no `
` left:
 	 * this function replaced it with the `.mermaid-diagram` container. While the
 	 * preview was `{@html sanitizedHtml}` that was invisible, because every
-	 * render put the whole document back as source; `blockPatch.ts` keeps the
+	 * render put the whole document back as source; the block patch keeps the
 	 * enriched nodes instead, so from #632 on a theme change reached no diagram
 	 * at all, in any mode. The source is on the container already —
 	 * `mermaidPrint.ts` keeps it there so the PDF path can rebuild the diagram
@@ -372,7 +379,7 @@ export async function renderRichContent(options: RenderRichContentOptions): Prom
 	 * no rendered diagram in them, so this is one selector that matches nothing.
 	 *
 	 * What a theme change costs, measured in Chromium over the real pipeline
-	 * (comrak -> `processMarkdownHtml` -> `sanitizeMarkdownHtml` -> `blockPatch`
+	 * (comrak -> `processMarkdownHtml` -> `sanitizeMarkdownHtml` -> the block patch
 	 * -> here) in a 1000x800 preview box, as one whole-article
 	 * `renderRichContent` with highlight.js and the display-maths memo already
 	 * warm, two runs:
@@ -460,6 +467,7 @@ export async function renderRichContent(options: RenderRichContentOptions): Prom
 				wrapper.className = 'code-block-shell';
 				preEl.replaceWith(wrapper);
 				wrapper.appendChild(preEl);
+				replacedBy.set(preEl, wrapper);
 			}
 
 			const label = doc.createElement('button');
@@ -594,6 +602,7 @@ export async function renderRichContent(options: RenderRichContentOptions): Prom
 			rememberDiagramSource(container, mermaidCode, options.mermaidTheme);
 			container.innerHTML = sanitizeDiagramSvg(svg);
 			preEl.replaceWith(container);
+			replacedBy.set(preEl, container);
 		} catch (error) {
 			options.onError?.(error);
 			console.error('Failed to render Mermaid diagram:', error);
@@ -604,6 +613,7 @@ export async function renderRichContent(options: RenderRichContentOptions): Prom
 			errorDiv.style.padding = '1em';
 			errorDiv.textContent = `Error rendering Mermaid diagram: ${error}`;
 			preEl.replaceWith(errorDiv);
+			replacedBy.set(preEl, errorDiv);
 		}
 		// Scroll sync's sample table may still hold the `
`.
 		invalidateAnchorMemos();
diff --git a/src/lib/utils/sanitize.ts b/src/lib/utils/sanitize.ts
index 332c1a3f..53aa3614 100644
--- a/src/lib/utils/sanitize.ts
+++ b/src/lib/utils/sanitize.ts
@@ -64,10 +64,10 @@ export function sanitizeMarkdownHtml(html: string): string {
 /**
  * The same policy, answering with the filtered nodes instead of a string.
  *
- * For the preview, where they go straight into the article: serializing
- * DOMPurify's tree only for `blockPatch.ts` to parse it again cost two passes
- * over the whole document per render, and a round trip after the filter is
- * the one place a parser could still rewrite what the filter approved.
+ * For the preview, where they go straight into the article
+ * (`previewPatch.ts`, one changed block at a time): a round trip after the
+ * filter is the one place a parser could still rewrite what the filter
+ * approved.
  */
 export function sanitizeMarkdownFragment(html: string): DocumentFragment {
 	return DOMPurify.sanitize(html, { ...MARKDOWN_SANITIZE_CONFIG, RETURN_DOM_FRAGMENT: true });