UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

212 lines (199 loc) 7.9 kB
// Example copied from https://github.com/wooorm/starry-night#example-adding-line-numbers import { createFrame } from "./createFrame.mjs"; import { isFrameSpan } from "./isFrameSpan.mjs"; /** * Counts the number of lines in a HAST tree without mutating it. * Uses the same logic as starryNightGutter but only returns the count. * @param tree - The HAST tree to count lines in * @returns The number of lines in the tree */ export function countLines(tree) { const search = /\r?\n|\r/g; let index = -1; let start = 0; let startTextRemainder = ''; let lineNumber = 0; while (index + 1 < tree.children.length) { index += 1; const child = tree.children[index]; if (child.type === 'text') { let textStart = 0; let match = search.exec(child.value); while (match) { lineNumber += 1; start = index + 1; textStart = match.index + match[0].length; match = search.exec(child.value); } // If we matched, make sure to not drop the text after the last line ending. if (start === index + 1) { startTextRemainder = child.value.slice(textStart); } } } // Check if we have remaining content to process as a line if (start < tree.children.length || startTextRemainder) { lineNumber += 1; } return lineNumber; } export function starryNightGutter(tree, sourceLines, frameSize = 120) { /** @type {Array<RootContent>} */ const replacement = []; const search = /\r?\n|\r/g; let index = -1; let start = 0; let startTextRemainder = ''; let lineNumber = 0; let frameLines = []; while (index + 1 < tree.children.length) { index += 1; const child = tree.children[index]; if (child.type === 'text') { let textStart = 0; let match = search.exec(child.value); while (match) { // Nodes in this line. const line = tree.children.slice(start, index); // Prepend text from a partial matched earlier text. if (startTextRemainder) { line.unshift({ type: 'text', value: startTextRemainder }); startTextRemainder = ''; } // Append text from this text. if (match.index > textStart) { line.push({ type: 'text', value: child.value.slice(textStart, match.index) }); } // Add a line, and the eol. lineNumber += 1; // If the line is empty, include the newline inside the span to avoid empty spans if (line.length === 0) { line.push({ type: 'text', value: match[0] }); frameLines.push(createLine(line, lineNumber)); } else { frameLines.push(createLine(line, lineNumber), { type: 'text', value: match[0] }); } // Check if we need to create a frame (only if sourceLines provided, otherwise keep everything in one frame) if (sourceLines && lineNumber % frameSize === 0) { replacement.push(createFrame(frameLines)); frameLines = []; } start = index + 1; textStart = match.index + match[0].length; match = search.exec(child.value); } // If we matched, make sure to not drop the text after the last line ending. if (start === index + 1) { startTextRemainder = child.value.slice(textStart); } } } const line = tree.children.slice(start); // Prepend text from a partial matched earlier text. if (startTextRemainder) { line.unshift({ type: 'text', value: startTextRemainder }); startTextRemainder = ''; } if (line.length > 0) { lineNumber += 1; frameLines.push(createLine(line, lineNumber)); } // Add any remaining lines as the final frame if (frameLines.length > 0) { replacement.push(createFrame(frameLines)); } // When `sourceLines` is provided, attach a precomputed `fallback` hast to // every frame (including single-frame output). // // `frame.data.fallback` is a basic hast node array (single `{ type: 'text' }` // node) carrying the same text the highlighted hast would render. It is set // here, before the enhancer pipeline runs, so enhancers can read and keep it // in sync. Enhancers only modify spans (never the plain-text output), so a // frame's fallback stays valid unless the frame is restructured — in which // case `restructureFrames` shifts the corresponding lines between frames. // // Downstream, these per-frame fallbacks are collected into the variant-level // root fallback and also let the renderer skip running // `stripHighlightingSpans` per frame at render time. Every frame except the // last covers `frameSize` source lines, each of which was followed by a // newline separator in the original source, so its text ends with a trailing // '\n'. The final frame only carries a trailing newline if the source itself // ends with one. Without this trailing '\n', the plain-text fallback and the // highlighted render disagree by exactly one newline per non-final frame, // which causes a layout shift during lazy hydration when a frame toggles // between the two. if (sourceLines) { const lastIndex = replacement.length - 1; for (let frameIndex = 0; frameIndex < replacement.length; frameIndex += 1) { const frame = replacement[frameIndex]; if (frame.type === 'element' && frame.tagName === 'span' && isFrameSpan(frame)) { // Extract line range from child .line elements const lineChildren = frame.children.filter(c => c.type === 'element' && c.properties?.className === 'line' && typeof c.properties.dataLn === 'number'); if (lineChildren.length > 0) { const startLine = Number(lineChildren[0].properties.dataLn) - 1; const endLine = Number(lineChildren[lineChildren.length - 1].properties.dataLn); const joined = sourceLines.slice(startLine, endLine).join('\n'); // Non-final frames are always followed by more content, so their text // ends with the line separator. The final frame's text ends with a // newline only when the source itself does — mirroring the highlighted // render, whose last `.line` span is then followed by a trailing `\n` // text node. `sourceLines` has one more entry than `lineNumber` exactly // when the source ended with a newline (the empty segment after it // never became a line). This keeps the plain-text fallback the same // height as the highlighted render (no hydration jump) AND makes the // root fallback dictionary an exact match for the raw source text. const sourceEndsWithNewline = sourceLines.length > lineNumber; const text = frameIndex < lastIndex || sourceEndsWithNewline ? `${joined}\n` : joined; // Cast to `ElementData` because `hast-util-from-parse5` augments // it with a required `position` field (upstream bug — should be // optional). We're not running through that parser here, so the // field never exists at runtime. if (!frame.data) { frame.data = {}; } frame.data.fallback = [{ type: 'text', value: text }]; } } } } // Replace children with new array. tree.children = replacement; // Add total line count to root data if (!tree.data) { tree.data = {}; } tree.data.totalLines = lineNumber; // Store the frame size used for splitting so downstream enhancers can match it if (replacement.length > 1) { tree.data.frameSize = frameSize; } } function createLine(children, line) { return { type: 'element', tagName: 'span', properties: { className: 'line', dataLn: line }, children }; }