Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions scripts/previewPatch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div>a</div></section><section data-preview-block="guess"><p>b</p>';
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'));
Expand Down Expand Up @@ -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')));
Expand Down
3 changes: 3 additions & 0 deletions scripts/previewPatchParity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ const edits: Record<string, (split: string) => string> = {
'a new paragraph': (split) => at(split, middleParagraph, (b) => `${b}\0\n<p data-sourcepos="900:1-900:3">new</p>\n`),
'a new section': (split) => at(split, middleParagraph, (b) => `${b}\0\n<h2 id="added" data-sourcepos="900:1-900:8">Added</h2>\n`),
'a deleted heading': (split) => at(split, firstH2, () => ''),
'a new subsection': (split) => at(split, middleParagraph, (b) => `${b}\0\n<h3 id="sub" data-sourcepos="900:1-900:7">Sub</h3>\n`),
'a new top-level heading': (split) => at(split, firstH2, (b) => `<h1 id="top" data-sourcepos="900:1-900:5">Top</h1>\n\0${b}`),
'a heading changed level': (split) => at(split, firstH2, (b) => b.replace(/^(\n?)<h2 /, '$1<h3 ').replace(/<\/h2>/, '</h3>')),
'an edited diagram': (split) => at(split, mermaid, (b) => b.replace('</code>', 'C --&gt; D\n</code>')),
'an edited raw HTML block': (split) => at(split, details, (b) => b.replace('</summary>', ' edited</summary>')),
};
Expand Down
27 changes: 18 additions & 9 deletions scripts/previewSanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
142 changes: 124 additions & 18 deletions src/lib/utils/previewPatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 `<section>` 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) => `<section ${BLOCK_MARK}="${nonce}">${raw}</section>`).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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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) : [];
Expand All @@ -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) {
Expand Down
Loading