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\nnew
\n`),
'a new section': (split) => at(split, middleParagraph, (b) => `${b}\0\nAdded
\n`),
'a deleted heading': (split) => at(split, firstH2, () => ''),
+ 'a new subsection': (split) => at(split, middleParagraph, (b) => `${b}\0\nSub
\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) => ``).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) {