UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

405 lines (386 loc) 18.1 kB
// The transform-application core. Split out of `applyCodeTransform` so the // decode step is *injected* rather than statically imported: the client-side // transform engine (`useCode/TransformEngine`) reaches this through a dynamic // `import()` and passes the shell's already-loaded `decodeHastSource`, so the // engine chunk never statically pulls `decodeHastSource` (and its // `hastDecompress` dependency) — those stay counted in the `useCode` shell // instead of being hoisted into their own chunks. The standalone server/build // pipeline keeps its original API through the thin `applyCodeTransform` wrapper, // which binds `decodeHastSource` for callers that don't inject one. import { patch, clone } from 'jsondiffpatch'; import { findExpandingRanges } from "./findExpandingRanges.mjs"; import { isFrameSpan } from "../parseSource/isFrameSpan.mjs"; /** * Decodes a `VariantSource` to a live `HastRoot` (or `null` for string / * unrecognized shapes). */ /** Rebuilds a frame's lazy fallback from its post-transform spans. */ /** * Hast helpers the transform core needs but must NOT import statically, so the * client `useCode/TransformEngine` chunk doesn't pull `decodeHastSource` / * `frameFallbackFromSpans` (and their `hastDecompress` dependency). The * always-loaded `useCode` shell already has both and injects them; the * standalone `applyCodeTransform` wrapper binds the built-ins for server/build * callers. */ /** * Reassign sequential 1-indexed `dataLn` values to every `.line` element in * the tree. The build-side `diffHast` strips line numbers before computing * the delta (so adding/removing a line doesn't make every subsequent line * differ), and we restore them here on the patched output. Also refreshes * `data.totalLines` on the root if present. * * Walks `root.children → frame.children` directly — never descends into a * line's syntax-highlighted content (the bulk of the tree's nodes), since * `addLineGutters` always emits lines as direct children of frames. * * Returns a map from each surviving line's original `dataLn` (preserved * through `patch` because the diff was computed on a stripped tree) to * the new sequential 1-indexed `dataLn` written here. Caller uses it to * shift any 1-indexed payload keyed by source line number (e.g. the * variant's `comments` map) so it lines up with the renumbered tree. */ function renumberLines(root) { const lineMap = new Map(); if (root.type !== 'root') { return lineMap; } 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; const previous = child.properties.dataLn; if (typeof previous === 'number') { lineMap.set(previous, lineNumber); } child.properties.dataLn = lineNumber; } } } if (root.data && 'totalLines' in root.data) { root.data.totalLines = lineNumber; } return lineMap; } /** * Rewrite a 1-indexed comments map so each entry moves from the source * line it was attached to onto the line that source line now occupies in * the renumbered tree. Comments attached to lines the transform wiped * (no entry in `lineMap`) are dropped — the line they annotated is gone. */ function remapComments(comments, lineMap) { const remapped = {}; for (const [key, value] of Object.entries(comments)) { const oldLine = Number(key); const newLine = lineMap.get(oldLine); if (newLine !== undefined) { remapped[newLine] = value; } } return remapped; } /** * Walk a freshly-renumbered hast tree and set `dataExpanding: ''` on * every `.line` element whose 1-indexed `dataLn` falls inside one of * `ranges`. The attribute is the hook the runtime CSS uses to animate * transformer-added lines in (entry: height 0 → line-height) and out * (exit: line-height → 0). The `.collapse` placeholder element family * is reserved for transformer-removed lines. * * Like `renumberLines`, walks only `root.children → frame.children` * — line elements are always direct children of frames in the trees * produced by `addLineGutters`, so we never descend into syntax- * highlighted content. No-op when `ranges` is empty. */ function markAddedLinesInPlace(root, ranges) { if (ranges.length === 0 || 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 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') { continue; } const lineNumber = child.properties.dataLn; if (typeof lineNumber !== 'number') { continue; } for (const [start, end] of ranges) { if (lineNumber >= start && lineNumber <= end) { child.properties.dataExpanding = ''; break; } } } } } /** * Regenerate `data.fallback` for every frame the transform rewrote. * * `diffHast` encodes each rewritten frame as a content-less fallback *delete* * (and leaves untouched frames' fallback alone), so after `patch` exactly the * changed frames are missing their fallback while the rest keep the inherited * one. For each missing frame we rebuild the fallback the same way the renderer * derives it lazily — `stripHighlightingSpans` over the frame's post-transform * children — so the pre-hydration render matches the highlighted output * (including `.collapse` placeholders) without a layout shift, and any consumer * reading `data.fallback` (e.g. `buildRootFallback`) sees the post-transform * text rather than the stale original. * * Walks `root.children` only; descends into a changed frame's children once via * `stripHighlightingSpans`. Untouched frames are skipped entirely. */ function regenerateMissingFrameFallbacksInPlace(root, frameFallbackFromSpans) { 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' || !isFrameSpan(frame) || frame.data?.fallback !== undefined) { continue; } if (!frame.data) { frame.data = {}; } frame.data.fallback = frameFallbackFromSpans(frame.children); } } /** * Applies a specific transform to a variant source and returns the transformed source * along with a remapped copy of the supplied `comments` map (when any) shifted to * line up with the renumbered `dataLn` values in the transformed tree. * * **Return shape, by input shape:** * - `string` input → `string` output. * - HAST-backed input (`HastRoot`, `{ hastJson }`, or `{ hastCompressed }`) * that actually applies a delta → live `HastRoot` output, regardless of the * input wire shape. The serialized wire shapes are *not* re-emitted: every * downstream reader in this package funnels through `decodeHastSource`, * which accepts a live root directly, so re-stringifying / re-compressing * here would just be undone by the next consumer (and would defeat the * shared decode cache, which is keyed on payload identity). Callers * outside this package that need a serialized payload must re-encode * the returned root themselves. * - Rename-only entries (`hasDelta: false`) and unknown-transform passthrough * return the original `source` object untouched (same shape and identity). * * @param source - The original variant source (string, `HastRoot`, * `{ hastJson }`, or `{ hastCompressed }`) * @param transforms - Object containing all available transforms * @param transformKey - The key of the specific transform to apply * @param deps - Hast helpers (`decode`, `frameFallbackFromSpans`) injected so * this module never statically imports them; see {@link TransformRuntimeDeps}. * @param comments - Optional 1-indexed comment map keyed by the source's original * line numbers. Returned shifted so each entry now sits on the line its * original source line occupies in the transformed tree; entries whose * source line was wiped by the transform are dropped. * @returns `{ source, comments }` where `source` is the transformed variant * source (see "Return shape" above) and `comments` is the remapped map * (or `undefined` when no comments were passed). * @throws Error if the transform key doesn't exist or patching fails */ export function applyCodeTransformWithComments(source, transforms, transformKey, deps, comments, fallback) { const transform = transforms[transformKey]; if (!transform) { throw new Error(`Transform "${transformKey}" not found in transforms`); } // Determine the format of the source and apply the appropriate transform strategy if (typeof source === 'string') { if (!transform.delta) { // Rename-only transform (manifest entry with `hasDelta: false`): no // source change to apply. Surface the explicit `comments` map if // the transformer provided one, otherwise pass the input through. return { source, comments: transform.comments ?? comments }; } // For string sources, deltas are typically line-array based (from transformSource) const sourceLines = source.split('\n'); const patched = patch(sourceLines, transform.delta); if (!Array.isArray(patched)) { throw new Error(`Patch for transform "${transformKey}" did not return an array`); } // String transforms only wipe lines (never insert/reorder), so the // 1-indexed mapping is identity for surviving non-empty lines and // dropped for wiped ones. Build the map by walking both arrays. // If the transformer supplied an explicit `comments` map for this // entry, use it verbatim instead of auto-shifting. let remappedComments; if (transform.comments) { remappedComments = transform.comments; } else if (comments) { const lineMap = new Map(); const limit = Math.min(sourceLines.length, patched.length); for (let i = 0; i < limit; i += 1) { if (patched[i] !== '' || sourceLines[i] === '') { lineMap.set(i + 1, i + 1); } } remappedComments = remapComments(comments, lineMap); } return { source: patched.join('\n'), comments: remappedComments }; } // For Hast node sources, deltas are typically node-based (from diffHast). // The patched tree is returned as a live `HastRoot` regardless of the input // wire shape (`hastJson` / `hastCompressed` / live root). Re-serializing // and re-compressing here would just be undone by the very next consumer: // every reader funnels through `decodeHastSource`, which already accepts // live roots, and the `decodedHastCache` it maintains is keyed on the // source-payload identity — a freshly re-encoded payload would be a brand // new object that the cache couldn't help anyway. The original input // payload stays compressed in memory; only the transformed working copy // lives as a tree. const sourceRoot = deps.decode(source, fallback); if (!sourceRoot) { // `decodeHastSource` returns `null` when a `hastCompressed` payload can't be // decompressed — almost always a missing/mismatched `fallback` dictionary // (e.g. an extra file whose fallback wasn't threaded through). Fail with a // clear message instead of a downstream "Cannot read properties of null". throw new Error(`Cannot apply transform "${transformKey}": failed to decode the source. A compressed payload needs its fallback dictionary to decompress.`); } // For serialized sources, the transform deltas are embedded inside // `root.data.transforms` (so they ride inside the compressed payload and // stay out of the rendered HTML). The variant-level `transforms` arg may // be a manifest with no `delta` field — fall back to the embedded copy. const embeddedTransforms = sourceRoot.data?.transforms; const delta = transform.delta ?? embeddedTransforms?.[transformKey]?.delta; if (!delta) { // Rename-only transform (manifest entry with `hasDelta: false`): no // delta exists on the manifest entry or embedded in the source's // `data.transforms`. Return the source untouched (in the same wire // shape we received it) and surface the transformer's explicit // `comments` map if one was provided. No patching happened, so the // original payload is still the cheapest thing to hand back. return { source, comments: transform.comments ?? comments }; } // Apply the node-based delta const patchedNodes = patch(clone(sourceRoot), delta); if (!patchedNodes) { throw new Error(`Patch for transform "${transformKey}" returned null/undefined`); } // Strip embedded transforms from the patched root so the output doesn't // re-embed deltas that have already been applied — and so subsequent // applies against the patched root start from a clean slate. const patchedRoot = patchedNodes; if (patchedRoot.data?.transforms) { const { transforms: droppedTransforms, ...restData } = patchedRoot.data; patchedRoot.data = Object.keys(restData).length > 0 ? restData : undefined; } // Regenerate the per-frame fallback for any frame the transform rewrote. The // delta carries a content-less delete for those frames (built by `diffHast`), // so `patch` left them without a fallback; rebuild it from the live // post-transform spans. Untouched frames keep their inherited fallback. regenerateMissingFrameFallbacksInPlace(patchedRoot, deps.frameFallbackFromSpans); // Reassign 1..N line numbers — `diffHast` stripped them before diffing, // so each surviving line's `dataLn` still holds its original source // line number. Capture that mapping while overwriting it so we can shift // the caller's comments map onto the new numbering. When the transform // entry carries an explicit `comments` map (set by a transformer that // adds lines or fully replaces the file), use it verbatim instead. const lineMap = renumberLines(patchedRoot); let remappedComments; if (transform.comments) { remappedComments = transform.comments; } else if (comments) { remappedComments = remapComments(comments, lineMap); } // Decorate transformer-added lines with `data-expanding=""` so the // runtime CSS can animate them in (entry, post-swap) and out (exit, // pre-swap). The `.collapse` placeholder family is reserved for // transformer-removed lines. Markers are paired // `@expanding-start`/`@expanding-end` substrings inside the comments // map the transformer returned; the map has already been renumbered // above so the ranges point at the final post-transform line numbers. const addedLineRanges = findExpandingRanges(remappedComments); if (addedLineRanges.length > 0) { markAddedLinesInPlace(patchedRoot, addedLineRanges); } return { source: patchedNodes, comments: remappedComments }; } /** * Applies multiple transforms to a variant source in sequence. Comments are * shifted by each transform in turn so the returned map lines up with the * fully-transformed source. * * @param source - The original variant source * @param transforms - Object containing all available transforms * @param transformKeys - Array of transform keys to apply in order * @param deps - Hast helpers (`decode`, `frameFallbackFromSpans`) injected so * this module never statically imports them; see {@link TransformRuntimeDeps}. * @param comments - Optional 1-indexed comment map for the original source * @returns `{ source, comments }` after applying every transform in order * @throws Error if any transform key doesn't exist or patching fails */ export function applyCodeTransformsWithComments(source, transforms, transformKeys, deps, comments, fallback) { // The single-call helper strips `data.transforms` from each patched // root so subsequent applies start from a clean slate AND so the final // output doesn't re-embed deltas that have already been applied. That // means a manifest-only call chain (`transforms` arg carries no // `delta` fields, deltas live inside `source.data.transforms`) would // break on the second hop: the first hop reads the embedded delta, // strips the map, and the second hop has nowhere left to look. Pull // the embedded deltas once up front and merge them into a resolved // transforms map so every hop sees inline deltas. let resolvedTransforms = transforms; if (transformKeys.length > 1 && typeof source !== 'string') { const sourceRoot = deps.decode(source, fallback); const embeddedTransforms = sourceRoot?.data?.transforms; if (embeddedTransforms) { const merged = { ...transforms }; for (const [key, embeddedEntry] of Object.entries(embeddedTransforms)) { const manifestEntry = merged[key]; if (manifestEntry && !manifestEntry.delta && embeddedEntry?.delta) { merged[key] = { ...manifestEntry, delta: embeddedEntry.delta }; } else if (!manifestEntry && embeddedEntry) { merged[key] = embeddedEntry; } } resolvedTransforms = merged; } } let currentSource = source; let currentComments = comments; for (const transformKey of transformKeys) { const result = applyCodeTransformWithComments(currentSource, resolvedTransforms, transformKey, deps, currentComments, fallback); currentSource = result.source; currentComments = result.comments; } return { source: currentSource, comments: currentComments }; }