@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
569 lines (548 loc) • 23.5 kB
JavaScript
import { create, patch } from 'jsondiffpatch';
import { findExpandingRanges, hasExpandingRanges } from "./findExpandingRanges.mjs";
import { getInitialVisibleSourceLines } from "./getInitialVisibleSourceLines.mjs";
/**
* Async-friendly variant of {@link ParseSource}. The build-time diff path
* may wrap the synchronous highlighter with enhancers that need to run
* asynchronously, so `diffHast` accepts either return shape. `comments`
* is the post-transform comment map (1-indexed by line in the transformed
* source) when the transform repositions lines; wrappers that drive
* enhancers should prefer it over the source's own comment map so the
* enhanced frame structure aligns with the patched output.
*/
const differ = create({
omitRemovedValues: true,
cloneDiffValues: true,
// Give jsondiffpatch a stable identity per array item so it can't match a
// `<span class="line">` to a `<span class="collapse">` placeholder by
// position. Without this, the LCS matcher happily aligns the source
// wiped-line span at index N with the transform placeholder at index N
// and emits a noisy in-place mutation (rewrite className, swap
// `dataLn`→`dataLines`, wipe N children). With distinct hashes the
// placeholder becomes a clean insert and the wiped line spans become
// deletes.
objectHash: (value, index) => {
if (value === null || typeof value !== 'object') {
return `idx:${index}`;
}
const node = value;
if (node.type === 'element' && node.tagName === 'span') {
const cls = node.properties?.className;
const className = Array.isArray(cls) ? cls.join(' ') : cls;
// Collapse placeholders get a unique identity so jsondiffpatch
// can't morph a wiped line span into a placeholder in place.
if (className === 'collapse') {
return `collapse:${index}`;
}
}
// Everything else (frames, lines, text) falls back to positional
// identity — same as jsondiffpatch's default behavior — so we don't
// accidentally force unrelated nodes apart.
return `idx:${index}`;
}
});
/**
* `.line` element produced by addLineGutters. For non-empty lines the
* `\n` lives as a sibling text node outside the span, so an empty source
* line is either `<span.line></span>` (legacy) or `<span.line>\n</span>`
* — the latter form holds its own `\n` inside so block-level styles give
* the row a visible height without injecting invisible characters that
* would end up in the clipboard.
*/
function isEmptyLine(line) {
if (line.children.length === 0) {
return true;
}
if (line.children.length === 1) {
const only = line.children[0];
return only.type === 'text' && (only.value === '\n' || only.value === '\u200B');
}
return false;
}
function isLineElement(node) {
return !!node && node.type === 'element' && node.tagName === 'span' && node.properties != null && node.properties.className === 'line';
}
/**
* Strip `dataLn` from `.line` elements directly under each frame. Walks
* `root.children → frame.children` only — never descends into a line's
* highlighted spans (which are the bulk of the tree).
*/
function stripLineNumbersInPlace(root) {
if (root.type !== 'root') {
return;
}
const frames = root.children;
for (let f = 0; f < frames.length; f += 1) {
const frame = frames[f];
if (frame.type !== 'element') {
continue;
}
const lines = frame.children;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (line.type === 'element' && line.properties != null && line.properties.className === 'line' && line.properties.dataLn !== undefined) {
delete line.properties.dataLn;
}
}
}
}
/**
* Concatenate the plain text carried by a frame's `data.fallback` nodes
* (`addLineGutters` stores a single text node; `restructureFrames` may store
* several). Used to decide whether a transform actually rewrote a frame.
*/
function frameFallbackText(nodes) {
let out = '';
for (const node of nodes) {
if (node.type === 'text') {
out += node.value;
} else if (node.type === 'element') {
out += frameFallbackText(node.children);
}
}
return out;
}
/**
* Align the transform tree's frames with the source tree's frames (by document
* order) and reconcile their per-frame `data.fallback` so the diff records, per
* frame, *whether the transform rewrote it* — without ever leaking fallback
* text into the delta:
*
* - **Unchanged frame** (fallback text matches the source frame's): alias the
* source frame's fallback nodes onto the transform frame so the differ sees
* an identical value and emits no fallback op. The decoded tree's inherited
* fallback then survives the patch untouched.
* - **Rewritten frame** (text differs, or no source counterpart): delete the
* transform frame's fallback. Because the source frame keeps its own, the
* differ emits a content-less *delete* (it runs with `omitRemovedValues`).
*
* That delete is the build-time record of "this frame changed": on decode the
* applier removes the stale inherited fallback and regenerates it from the
* frame's post-transform spans, while untouched frames keep their precomputed
* fallback. Fallback text never enters the delta, so `patch` never applies
* fallback array operations against a regenerated frame (which would crash).
* Walks `root.children` only — never descends into a frame's lines.
*/
function reconcileFrameFallbacksForDiffInPlace(transformRoot, sourceRoot) {
if (transformRoot.type !== 'root' || sourceRoot.type !== 'root') {
return;
}
// Pair each transform frame with the source frame at the *same document
// position*. `differ` identifies frames by their array index (the
// `objectHash` returns `idx:${index}` for them), so the LCS matcher aligns
// transform child `i` with source child `i` — adding or removing a frame
// shifts the tail on *both* sides identically. Reconciling by that same
// index keeps this pass in lockstep with the diff: a transform frame is only
// aliased to a source fallback the differ will actually compare it against,
// so a frame the transform added (or that has no fallback-bearing source
// counterpart at its position) falls through to the delete branch and
// regenerates instead of inheriting an unrelated frame's fallback. A prior
// implementation walked a separate counter over only the fallback-bearing
// frames, which drifted out of alignment with the diff whenever a frame was
// inserted/removed or a non-fallback frame sat between two fallback frames.
const sourceChildren = sourceRoot.children;
const transformChildren = transformRoot.children;
for (let index = 0; index < transformChildren.length; index += 1) {
const child = transformChildren[index];
if (child.type !== 'element' || !child.data || child.data.fallback === undefined) {
continue;
}
const sourceChild = sourceChildren[index];
const sourceFallback = sourceChild?.type === 'element' ? sourceChild.data?.fallback : undefined;
if (sourceFallback !== undefined && frameFallbackText(sourceFallback) === frameFallbackText(child.data.fallback)) {
// Unchanged: alias the source nodes so the diff sees an identical
// fallback (even if the node split differs) and emits nothing.
child.data.fallback = sourceFallback;
} else {
// Rewritten (or no source counterpart): drop it so the diff emits a
// content-less delete that the applier turns into a regeneration.
delete child.data.fallback;
}
}
}
/**
* Reassign sequential 1-indexed `dataLn` values to every `.line` element
* directly under each frame. Used to restore numbering on `parsedSource`
* after the diff (addLineGutters always numbers 1..N in document order,
* so re-deriving is correct).
*/
function renumberLinesInPlace(root) {
if (root.type !== 'root') {
return;
}
let lineNumber = 0;
const frames = root.children;
for (let f = 0; f < frames.length; f += 1) {
const frame = frames[f];
if (frame.type !== 'element') {
continue;
}
const children = frame.children;
for (let i = 0; i < children.length; i += 1) {
const child = children[i];
if (child.type === 'element' && child.properties != null && child.properties.className === 'line') {
lineNumber += 1;
child.properties.dataLn = lineNumber;
}
}
}
if (root.data && 'totalLines' in root.data) {
root.data.totalLines = lineNumber;
}
}
/**
* One contiguous run of wiped lines inside a single frame, expressed in
* positions of `frame.children`. The slice `[start, endExclusive)` covers
* the wiped `.line` spans, their trailing `\n` text siblings, and any
* absorbed originally-blank line. `count` is the number of source rows
* the replacement placeholder should cover.
*/
/**
* Walk a frame's children and group consecutive wiped `.line` spans
* (together with their trailing `\n` siblings, plus optionally one
* trailing originally-blank line) into runs. `counter` is a shared
* 1-indexed line number advanced for every `.line` element encountered
* (addLineGutters numbers lines 1..N in document order across frames).
*/
function collectWipedRunsInFrame(frame, wiped, counter) {
const runs = [];
const children = frame.children;
let i = 0;
while (i < children.length) {
const child = children[i];
if (!isLineElement(child)) {
i += 1;
continue;
}
counter.value += 1;
if (!wiped.has(counter.value)) {
i += 1;
continue;
}
const runStart = i;
let count = 1;
i += 1;
// Consume the trailing `\n` text sibling of this wiped line.
if (i < children.length) {
const sibling = children[i];
if (sibling.type === 'text' && sibling.value === '\n') {
i += 1;
}
}
while (i < children.length) {
const peek = children[i];
if (!isLineElement(peek)) {
break;
}
counter.value += 1;
if (wiped.has(counter.value)) {
count += 1;
i += 1;
if (i < children.length) {
const sibling = children[i];
if (sibling.type === 'text' && sibling.value === '\n') {
i += 1;
}
}
continue;
}
// Absorb one trailing originally-blank line into the collapsed
// region so the boundary doesn't leave a stray blank row, plus its
// own trailing `\n` sibling.
if (isEmptyLine(peek)) {
count += 1;
i += 1;
if (i < children.length) {
const sibling = children[i];
if (sibling.type === 'text' && sibling.value === '\n') {
i += 1;
}
}
} else {
// Survivor: un-advance the line counter so the outer loop
// re-processes this child.
counter.value -= 1;
}
break;
}
runs.push({
start: runStart,
endExclusive: i,
count
});
}
return runs;
}
function makePlaceholder(count) {
// One empty `<span/>` child per collapsed line. Consumer CSS can
// give each child `display: block` + `height: var(--line-height)` to
// size the placeholder by intrinsic layout (no `attr()` or per-count
// fallback rules required) and animate per-line height independently.
// Both diff sides emit identical children, so jsondiffpatch still
// matches the placeholders structurally and produces an empty sub-
// delta for them.
const children = [];
for (let i = 0; i < count; i += 1) {
children.push({
type: 'element',
tagName: 'span',
properties: {},
children: []
});
}
return {
type: 'element',
tagName: 'span',
properties: {
className: 'collapse',
dataLines: count
},
children
};
}
/**
* For each frame, find wiped-line runs and splice them in place with a
* single `<span class="collapse" data-lines={count}>` placeholder
* (carrying `count` empty `<span/>` children, one per collapsed line).
* Run on *both* the source and transform trees before diffing — the
* placeholders line up structurally, so jsondiffpatch matches them and
* the resulting delta cleanly represents the collapse without us
* having to surgically rewrite array-position keys after the fact.
*
* The source tree's wiped runs are the originals being replaced; the
* transform tree's wiped runs are now-empty `.line` spans (the transform
* blanked their content). Both runs span the same 1-indexed line numbers,
* but at *different* `frame.children` positions because the empty-line
* encoding may differ between source and transform (e.g. the transform
* may emit `<span.line>\n</span>` with no sibling `\n`).
*/
function compactCollapseInTreeInPlace(tree, wiped) {
if (tree.type !== 'root') {
return;
}
const counter = {
value: 0
};
const frames = tree.children;
let lastFrame;
for (let f = 0; f < frames.length; f += 1) {
const frame = frames[f];
if (frame.type !== 'element') {
continue;
}
lastFrame = frame;
const runs = collectWipedRunsInFrame(frame, wiped, counter);
if (runs.length === 0) {
continue;
}
// Splice from the end so earlier indices stay valid.
for (let r = runs.length - 1; r >= 0; r -= 1) {
const run = runs[r];
frame.children.splice(run.start, run.endExclusive - run.start, makePlaceholder(run.count));
}
}
// Trailing wiped lines that the transform parser elided: when the
// transformed source ends with `\n` (because the blanked line carried no
// content), `addLineGutters` doesn't emit a `.line` span for that final
// blank row, so the counter walk above never reaches them. Append a
// single placeholder to the last frame covering all such trailing rows
// so the diff still produces a collapsed-lines span at the end.
let trailingCount = 0;
for (const lineNumber of wiped) {
if (lineNumber > counter.value) {
trailingCount += 1;
}
}
if (trailingCount > 0 && lastFrame) {
lastFrame.children.push(makePlaceholder(trailingCount));
}
}
/**
* Diffs each transformed variant against `parsedSource` and returns
* a `Transforms` map where every entry carries a `delta`.
*
* NOTE: `parsedSource` is temporarily mutated for the duration of this
* call — `dataLn` properties are stripped from line spans before diffing
* (so the always-sequential gutter numbering doesn't leak into the
* delta) and restored in `finally`. Callers must not read from the tree
* concurrently, and must not invoke `diffHast` against the same
* `parsedSource` in parallel. Today's only caller (`loadSingleFile`)
* runs sequentially per variant, so the constraint is satisfied; if
* that ever changes, clone the source array once up front instead.
*/
export async function diffHast(source, parsedSource, filename, transforms, parseSource) {
const originalLines = source.split('\n');
// Precompute which source lines are visible when the rendered code
// block is in its collapsed state. Used to derive
// `hasCollapseInFocus` per transform without re-walking the source
// tree for each entry.
const visibleSourceLines = getInitialVisibleSourceLines(parsedSource);
// Strip `dataLn` from `parsedSource` so the diff doesn't encode the
// always-sequential numbering. Restored in `finally`.
stripLineNumbersInPlace(parsedSource);
// `parsedSource` keeps its per-frame `data.fallback`: each transform's tree
// is reconciled against it (see `reconcileFrameFallbacksForDiffInPlace`) so
// the delta encodes a content-less fallback delete only for the frames that
// transform rewrote. The source fallback never enters the delta and stays on
// the tree for `buildRootFallback`.
try {
const transformed = await Promise.all(Object.entries(transforms).map(async ([key, transform]) => {
// Rename-only entries (no delta on the input manifest) have no
// source-level change to diff — pass them through untouched so
// the downstream embed step still preserves the rename in the
// variant-level manifest.
if (!transform.delta) {
return {
[key]: {
...transform
}
};
}
const patched = patch(originalLines.slice(), transform.delta);
if (!Array.isArray(patched)) {
throw new Error(`Patch for ${key} did not return an array`);
}
const patchedLines = patched;
const transformedSource = patchedLines.join('\n');
const parsedTransform = await parseSource(transformedSource, transform.fileName || filename, undefined, transform.comments);
// Wiped lines = 1-indexed *source* lines the transform blanked
// out in place. We can't trust positional alignment across the
// two arrays whenever the transform inserts or deletes lines —
// the shifted indices would make every unchanged line past the
// insertion look like a wipe of whatever non-blank source line
// happens to share its slot.
//
// The `@expanding*` markers tell us exactly which patched-side
// lines are transformer-inserts. By stepping through source and
// patched in lockstep and *skipping* the marked patched
// positions, the remaining patched positions line up 1:1 with
// the source. Any patched line at that aligned position which
// is blank while the matching source line is non-blank is a
// genuine wipe. Source lines past the end of the realigned
// patched stream are treated as deletes (not wipes) — wipes
// mean "blanked in place", which requires a partner slot in
// the patched output.
const collapsedPatchedLines = new Set();
for (const [startLine, endLine] of findExpandingRanges(transform.comments)) {
for (let line = startLine; line <= endLine; line += 1) {
collapsedPatchedLines.add(line);
}
}
const wiped = new Set();
let sourceIdx = 0;
for (let patchedIdx = 0; patchedIdx < patchedLines.length; patchedIdx += 1) {
// Patched-side line numbers are 1-indexed.
if (collapsedPatchedLines.has(patchedIdx + 1)) {
continue;
}
if (sourceIdx >= originalLines.length) {
break;
}
if (patchedLines[patchedIdx] === '' && originalLines[sourceIdx] !== '') {
wiped.add(sourceIdx + 1);
}
sourceIdx += 1;
}
stripLineNumbersInPlace(parsedTransform);
// Reconcile the transform tree's per-frame `data.fallback` against the
// source tree so the delta carries a content-less delete only for the
// frames this transform rewrote (and nothing for the rest). The
// applier regenerates the deleted ones from their post-transform spans.
reconcileFrameFallbacksForDiffInPlace(parsedTransform, parsedSource);
// Collapse wiped-line runs in the transform tree into a single
// `<span class="collapse" data-lines={count}>` placeholder per
// run (with one empty `<span/>` child per collapsed line) before
// diffing. The source tree is left intact (it's shared
// across transforms), so jsondiffpatch sees the now-empty source
// lines being replaced by the placeholder — a clean, minimal
// delta with no spurious LCS matches between unrelated lines.
if (wiped.size > 0) {
compactCollapseInTreeInPlace(parsedTransform, wiped);
}
// Diff 1:1. The caller is responsible for ensuring `parseSource`
// produces a frame structure aligned with `parsedSource` (e.g.
// by wrapping it to apply the same source enhancers); without
// that, the diff balloons at the frame level.
const delta = differ.diff(parsedSource, parsedTransform);
// `compactCollapseInTreeInPlace` is the only path that ever
// inserts a `.collapse` placeholder into the transformed tree,
// and it only runs when there were wiped lines to coalesce. So
// `wiped.size > 0` is exactly equivalent to "this delta inserts
// a `.collapse` element" — no tree walk needed. Persisting the
// flag here means the runtime classifier never has to inspect
// the delta (or decompress the embedded payload) to decide
// whether the swap is layout-affecting.
//
// `hasExpandingRanges(transform.comments)` covers the symmetric
// case: transformers that *add* lines (e.g. injecting an API
// key constant) flag those lines in their returned comments
// map with `@expanding-start`/`@expanding-end` markers; the
// applier turns them into `data-expanding=""` line attributes
// that animate via the same coordinated swap path.
const hasCollapse = wiped.size > 0 || hasExpandingRanges(transform.comments);
// `hasCollapseInFocus` mirrors `hasCollapse` but restricted to
// the source region visible when the surrounding code block is
// collapsed. A `.collapse` placeholder outside that region
// can't visibly shift layout for the user, so consumers that
// opt into `transformLayoutShift: 'focus'` can skip the
// coordinated phase 1 barrier for those swaps.
//
// Wiped lines are 1-indexed *source* line numbers and slot
// into `visibleSourceLines` directly. For transformer-inserted
// lines (the symmetric `@expanding*` case) we approximate the
// source-side anchor as the source line *immediately preceding*
// the patched-side insertion run. That line either is part of
// the visible region (so the inserted block lands inside the
// focus window) or sits outside it (so the user won't see the
// collapse animation while collapsed). The walk reuses the
// same `sourceIdx` advance rule as the wipe detection above.
let hasCollapseInFocus = false;
for (const wipedLine of wiped) {
if (visibleSourceLines.has(wipedLine)) {
hasCollapseInFocus = true;
break;
}
}
if (!hasCollapseInFocus && hasExpandingRanges(transform.comments)) {
const expandingRanges = findExpandingRanges(transform.comments);
// Map each expanding range's first patched line to the
// source line it follows. We re-walk source/patched in
// lockstep up to that point (cheap relative to the diff
// itself).
for (const [startLine] of expandingRanges) {
let srcAnchor = 0;
let sIdx = 0;
for (let pIdx = 0; pIdx < startLine - 1; pIdx += 1) {
if (collapsedPatchedLines.has(pIdx + 1)) {
continue;
}
if (sIdx >= originalLines.length) {
break;
}
sIdx += 1;
srcAnchor = sIdx;
}
// `srcAnchor === 0` means the insertion is at the very top
// of the file (before any source line). Treat that as
// "in focus" iff the first source line is visible.
const anchorLine = srcAnchor === 0 ? 1 : srcAnchor;
if (visibleSourceLines.has(anchorLine)) {
hasCollapseInFocus = true;
break;
}
}
}
return {
[key]: {
...transform,
delta,
hasCollapse,
hasCollapseInFocus
}
};
}));
return Object.assign({}, ...transformed);
} finally {
renumberLinesInPlace(parsedSource);
}
}