diff --git a/scripts/previewPatch.spec.ts b/scripts/previewPatch.spec.ts index 3ad3f8d0..c44f4294 100644 --- a/scripts/previewPatch.spec.ts +++ b/scripts/previewPatch.spec.ts @@ -48,6 +48,19 @@ describe('the first render', () => { }); }); +describe('raw HTML that breaks out of the batch a first render parses blocks in', () => { + test('is rendered block by block instead, with nothing attributed to the wrong block', () => { + const forged = '
a

b

'; + const result = patch(split(p(1, 'alpha'), forged, p(5, 'beta'))); + + expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alpha', 'a', 'b', 'beta']); + expect(result.inserted.length).toBe(4); + + patch(split(p(1, 'alphaX'), forged, p(5, 'beta'))); + expect(Array.from(host.children).map((e) => e.textContent)).toEqual(['alphaX', 'a', 'b', 'beta']); + }); +}); + 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')); @@ -152,6 +165,45 @@ describe('headings', () => { expect(sections[1].contains(beta)).toBe(true); }); + test('a new subsection leaves the sections around it where they were', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Three'), p(11, 'gamma'))); + const [one, twoBody, three] = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + const two = host.querySelector('#two')!; + two.classList.add('is-collapsed'); + two.nextElementSibling!.classList.add('is-collapsed'); + + const result = patch( + split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(3, 9, 'Sub'), p(11, 'x'), h(2, 13, 'Three'), p(15, 'gamma')), + ); + + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(wrappers[0]).toBe(one); + expect(wrappers[1]).toBe(twoBody); + expect(wrappers[wrappers.length - 1]).toBe(three); + // Only the new subsection is wrapped: the section around it ends where it did. + expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['sub']); + // The section the reader closed is still closed, and now holds the new one. + expect(two.nextElementSibling!.classList.contains('is-collapsed')).toBe(true); + expect(two.nextElementSibling!.querySelector('#sub')).not.toBeNull(); + }); + + test('a new section of the same level re-wraps only the section it splits', () => { + patch(split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), p(9, 'b2'), h(2, 11, 'Three'), p(13, 'gamma'))); + const [one, , three] = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + const kept = bodies(); + + const result = patch( + split(h(2, 1, 'One'), p(3, 'alpha'), h(2, 5, 'Two'), p(7, 'beta'), h(2, 9, 'Mid'), p(11, 'b2'), h(2, 13, 'Three'), p(15, 'gamma')), + ); + + expect(result.wrappers.map((w) => w.previousElementSibling!.id)).toEqual(['two', 'mid']); + const wrappers = Array.from(host.querySelectorAll('.foldable-content-wrapper')); + expect(wrappers[0]).toBe(one); + expect(wrappers[3]).toBe(three); + expect(bodies().every((body, index) => body === kept[index])).toBe(true); + expect(wrappers[2].textContent).toBe('b2'); + }); + 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'))); diff --git a/scripts/previewPatchParity.spec.ts b/scripts/previewPatchParity.spec.ts index 94292e2a..50bea4c8 100644 --- a/scripts/previewPatchParity.spec.ts +++ b/scripts/previewPatchParity.spec.ts @@ -61,6 +61,9 @@ const edits: Record string> = { '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, () => ''), + 'a new subsection': (split) => at(split, middleParagraph, (b) => `${b}\0\n

Sub

\n`), + 'a new top-level heading': (split) => at(split, firstH2, (b) => `

Top

\n\0${b}`), + 'a heading changed level': (split) => at(split, firstH2, (b) => b.replace(/^(\n?)

/, '

')), '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')), }; diff --git a/scripts/previewSanitize.test.ts b/scripts/previewSanitize.test.ts index ba329b14..bf3ab7cb 100644 --- a/scripts/previewSanitize.test.ts +++ b/scripts/previewSanitize.test.ts @@ -100,11 +100,20 @@ test('the preview sanitizes through the shared policy, not a local config', () = const parses = [...previewPatchSource.matchAll(/(\w+)\.(?:innerHTML|outerHTML|insertAdjacentHTML)\s*=\s*([^;]+);/g)].map( (m) => `${m[1]} = ${m[2].trim()}`, ); - assert.deepEqual(parses, ['box = raw'], 'previewPatch.ts parses only a raw block, before the filter'); + assert.deepEqual(parses, ['box = markup'], 'previewPatch.ts parses only the renderer output, 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'); + const processAndSanitize = sliceBetween(previewPatchSource, 'function processAndSanitize(', '\n}\n'); + assert.match(processAndSanitize, /return sanitizeMarkdownFragment\(box\.innerHTML\);$/, 'and the filter has the last word'); + // What a block is inserted as is always the filter's own nodes. + const renderBlocks = sliceBetween(previewPatchSource, 'function renderBlocks(', '\n}\n'); + assert.deepEqual( + [...renderBlocks.matchAll(/Array\.from\((\w+)\.children\)/g)].map((m) => m[1]), + ['clean', 'lone', 'mark'], + ); + assert.match(renderBlocks, /const clean = processAndSanitize\(/); + assert.match(renderBlocks, /const lone = processAndSanitize\(/); + assert.match(renderBlocks, /const marks = Array\.from\(clean\.children\);/); + assert.match(renderBlocks, /return marks\.map\(\(mark\) =>/); assert.doesNotMatch(previewPatchSource, /insertAdjacentHTML|outerHTML|createContextualFragment/); // Why the footnote sink is allowed, pinned as a direction rather than as a @@ -218,17 +227,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 + // The preview processes the blocks it inserts in `processAndSanitize`, 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(previewPatchSource, 'processBlockMarkup').map((offset) => enclosingFunctionName(previewPatchSource, offset)), - ['renderBlock'], - 'renderBlock must be the one place that processes the renderer output', + ['processAndSanitize'], + 'processAndSanitize must be the one place that processes the renderer output', ); - const renderBlock = sliceBetween(previewPatchSource, 'function renderBlock(', '\n}\n'); + const processAndSanitize = sliceBetween(previewPatchSource, 'function processAndSanitize(', '\n}\n'); assert.ok( - renderBlock.indexOf('processBlockMarkup(') < renderBlock.indexOf('sanitizeMarkdownFragment('), + processAndSanitize.indexOf('processBlockMarkup(') < processAndSanitize.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/src/lib/utils/previewPatch.ts b/src/lib/utils/previewPatch.ts index 6677a747..6ad9b4d6 100644 --- a/src/lib/utils/previewPatch.ts +++ b/src/lib/utils/previewPatch.ts @@ -42,7 +42,7 @@ import { sanitizeMarkdownFragment } from './sanitize.js'; * 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. + * regroups the sections it touched (`regroupSections`). */ const SEPARATOR = '\0'; @@ -79,21 +79,60 @@ export interface PreviewPatch { let scratch: Document | null = null; -function renderBlock(raw: string, filePath: string, ids: FoldIds): HTMLElement[] { +/** Processes and sanitizes `markup` in one pass, answering with the filter's own nodes. */ +function processAndSanitize(markup: string, filePath: string): DocumentFragment { // An inert document: nothing in it runs or loads. scratch ??= document.implementation.createHTMLDocument(''); const box = scratch.createElement('div'); - box.innerHTML = raw; + box.innerHTML = markup; 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. + return sanitizeMarkdownFragment(box.innerHTML); +} + +/** Headings inside a block (a callout, a list item) fold among their own siblings. */ +function wrapNestedHeadings(block: ParentNode, ids: FoldIds): void { + wrapHeadings( + Array.from(block.querySelectorAll('.foldable-header')).filter((h) => h.parentNode !== block), + ids, + ); +} + +const BLOCK_MARK = 'data-preview-block'; + +/** + * The elements each of `raws` becomes, in one parse and one DOMPurify pass + * however many blocks there are: a call per block costs a document of + * DOMPurify's own each time, which made a first render of 50,000 blocks a + * second slower than the whole-document pipeline. + * + * Each block goes in inside a `
` carrying a nonce, and comes back + * out of it. Markup that closed one early or forged one cannot know the + * nonce, so the count or a nonce is wrong and the blocks are done one at a + * time instead. + */ +function renderBlocks(raws: string[], filePath: string, ids: FoldIds): HTMLElement[][] { + if (raws.length === 0) return []; + const nonce = Math.random().toString(36).slice(2); + const clean = processAndSanitize( + raws.map((raw) => `
${raw}
`).join(''), + filePath, + ); + const marks = Array.from(clean.children); + const intact = + marks.length === raws.length && + marks.every((mark) => mark.tagName === 'SECTION' && mark.getAttribute(BLOCK_MARK) === nonce); + if (!intact) { + if (raws.length > 1) return raws.map((raw) => renderBlocks([raw], filePath, ids)[0]); + const lone = processAndSanitize(raws[0], filePath); + wrapNestedHeadings(lone, ids); + return [Array.from(lone.children) as HTMLElement[]]; + } + // One query for the whole batch, not one per block. wrapHeadings( - Array.from(clean.querySelectorAll('.foldable-header')).filter((h) => h.parentNode !== clean), + Array.from(clean.querySelectorAll('.foldable-header')).filter((h) => h.parentNode?.parentNode !== clean), ids, ); - return top; + return marks.map((mark) => Array.from(mark.children) as HTMLElement[]); } function live(element: Element): Element { @@ -151,7 +190,7 @@ export function patchPreview( if (!old) { const ids: FoldIds = { next: 0 }; - const els = raws.map((raw) => renderBlock(raw, filePath, ids)); + const els = renderBlocks(raws, filePath, ids); const flat = els.flat() as HTMLElement[]; host.replaceChildren(...flat); patch.wrappers = wrapHeadings(flat.filter(isTopHeading), ids); @@ -193,7 +232,11 @@ export function patchPreview( 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); + renderBlocks( + render.map((index) => raws[index]), + filePath, + old.ids, + ).forEach((elements, at) => (els[render[at]] = elements)); const added = render.flatMap((index) => els[index]) as HTMLElement[]; // A renamed heading of the same level keeps its wrapper. Any other change @@ -216,13 +259,12 @@ export function patchPreview( 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); + patch.wrappers = regroupSections(host, els, head, newTo, removed, added, old.ids); + applyFolds(host, foldOverrides, true); + // A kept heading keeps its state, which its new wrapper takes on. + for (const wrapper of patch.wrappers) { + wrapper.classList.toggle('is-collapsed', wrapper.previousElementSibling!.classList.contains('is-collapsed')); + } } else { for (const index of render) { const before = pairwise ? old.els[index].map(live) : []; @@ -245,6 +287,70 @@ export function patchPreview( return patch; } +const levelOf = (heading: Element) => Number(heading.tagName[1]); +const topHeadingOf = (elements: Element[] | undefined) => elements?.find(isTopHeading); + +/** + * Re-wraps the part of the document whose sections an edit to its top-level + * headings changed, and nothing else. With `L` the highest level the edit + * added or removed, that part runs from the nearest heading of level `L` or + * higher before the edit to the next one after it: a section of a higher + * level contains it unchanged, and one of the same level ends where it did. + * The blocks are moved, not rebuilt. Returns the new wrappers. + */ +function regroupSections( + host: HTMLElement, + els: Element[][], + from: number, + to: number, + removed: Element[], + added: Element[], + ids: FoldIds, +): HTMLElement[] { + const level = Math.min(...[...removed, ...added].filter(isTopHeading).map(levelOf)); + const wrapperOf = (heading: Element) => { + const next = heading.nextElementSibling; + return next?.classList.contains('foldable-content-wrapper') ? next : null; + }; + + // Where the regrouped run goes: a placeholder, set before anything moves. + const marker = host.ownerDocument.createComment(''); + let start = 0; + for (let index = from - 1; index >= 0; index -= 1) { + const heading = topHeadingOf(els[index]); + if (!heading || levelOf(heading) > level) continue; + if (levelOf(heading) === level) { + start = index; + heading.before(marker); + } else { + start = index + 1; + const inner = Array.from(wrapperOf(heading)?.children ?? []).find((child) => + child.classList.contains('content-inner'), + ); + (inner ?? host).prepend(marker); + } + break; + } + if (!marker.parentNode) host.prepend(marker); + let end = els.length; + for (let index = to; index < els.length; index += 1) { + const heading = topHeadingOf(els[index]); + if (heading && levelOf(heading) <= level) { + end = index; + break; + } + } + + const stale: Element[] = []; + for (const element of removed) if (isTopHeading(element)) stale.push(...[wrapperOf(element)].filter((w) => w !== null)); + const run = els.slice(start, end).flat() as HTMLElement[]; + for (const element of run) if (isTopHeading(element)) stale.push(...[wrapperOf(element)].filter((w) => w !== null)); + for (const element of removed) element.remove(); + marker.replaceWith(...run); + for (const wrapper of stale) wrapper.remove(); + return wrapHeadings(run.filter(isTopHeading), ids); +} + /** 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) {