UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

1,398 lines (1,328 loc) 52.7 kB
import { getHastTextContent } from "../loadServerTypes/hastTypeUtils.mjs"; import { calculateFrameRanges } from "../parseSource/calculateFrameRanges.mjs"; import { calculateFrameIndent } from "./calculateFrameIndent.mjs"; import { restructureFrames } from "../parseSource/restructureFrames.mjs"; /** * The prefix used to identify emphasis comments in source code. * Comments starting with this prefix will be processed for emphasis. */ export const EMPHASIS_COMMENT_PREFIX = '@highlight'; /** * The prefix used to identify focus-only comments in source code. * Comments starting with this prefix will mark the region as focused without highlighting. */ export const FOCUS_COMMENT_PREFIX = '@focus'; /** * Modifier token used inside `@highlight` / `@focus` comments * to override padding for that directive. * Example: @highlight @padding 2. */ export const PADDING_COMMENT_PREFIX = '@padding'; /** * Modifier token used inside `@highlight` / `@focus` comments * to override focus max size for that directive. * Example: @highlight @min 6. */ export const MIN_COMMENT_PREFIX = '@min'; /** * Parsed emphasis directive from a comment. */ /** * Replaces quoted content with underscores of the same length so that * regex matching only finds tokens in unquoted territory. * Supports double ("...") and single ('...') quotes. */ function maskQuotedContent(content) { let result = ''; let quoteChar; for (let i = 0; i < content.length; i += 1) { const char = content[i]; if (quoteChar) { result += '_'; if (char === quoteChar) { quoteChar = undefined; } } else if (char === '"' || char === "'") { quoteChar = char; result += '_'; } else { result += char; } } return result; } /** * Returns `true` when `ch` is an ASCII whitespace character. */ function isWhitespace(char) { return char === ' ' || char === '\t' || char === '\n' || char === '\r'; } /** * Finds the first occurrence of `token` in `masked` that sits at a word * boundary (preceded by start-of-string or whitespace, followed by * end-of-string or whitespace). Returns the index or -1. */ function findToken(masked, token, startFrom = 0) { let idx = masked.indexOf(token, startFrom); while (idx !== -1) { const before = idx === 0 || isWhitespace(masked[idx - 1]); const after = idx + token.length >= masked.length || isWhitespace(masked[idx + token.length]); if (before && after) { return idx; } idx = masked.indexOf(token, idx + 1); } return -1; } /** * Reads consecutive ASCII digits starting at `pos`. * Returns the substring of digits (may be empty). */ function readDigits(str, pos) { let end = pos; while (end < str.length && str[end] >= '0' && str[end] <= '9') { end += 1; } return str.slice(pos, end); } /** * Collapses runs of whitespace into a single space and trims. */ function collapseWhitespace(str) { let result = ''; let prevSpace = false; for (let i = 0; i < str.length; i += 1) { if (isWhitespace(str[i])) { prevSpace = true; } else { if (prevSpace && result.length > 0) { result += ' '; } prevSpace = false; result += str[i]; } } return result; } /** * Extracts a number from content (e.g., "3" from "@padding 3 @focus"). * Returns the parsed number or undefined if not found or invalid. * Only matches unquoted tokens — "@padding 2" inside quotes is ignored. */ function extractPaddingValue(content) { const masked = maskQuotedContent(content); const idx = findToken(masked, '@padding'); if (idx === -1) { return undefined; } // Skip whitespace after "@padding" let pos = idx + '@padding'.length; while (pos < masked.length && isWhitespace(masked[pos])) { pos += 1; } const digits = readDigits(masked, pos); if (digits.length === 0) { return undefined; } // Must be followed by whitespace or end-of-string const afterDigits = pos + digits.length; if (afterDigits < masked.length && !isWhitespace(masked[afterDigits])) { return undefined; } const value = parseInt(digits, 10); return Number.isNaN(value) ? undefined : value; } /** * Removes the `@padding N` directive from content (if present). * Only removes unquoted tokens — "@padding 2" inside quotes is preserved. */ function removePaddingDirective(content) { const masked = maskQuotedContent(content); const idx = findToken(masked, '@padding'); if (idx === -1) { return content; } // Find the full span: leading whitespace + "@padding" + optional whitespace + digits + trailing whitespace let start = idx; while (start > 0 && isWhitespace(content[start - 1])) { start -= 1; } let end = idx + '@padding'.length; while (end < content.length && isWhitespace(content[end])) { end += 1; } // Skip digits while (end < content.length && content[end] >= '0' && content[end] <= '9') { end += 1; } // Skip trailing whitespace while (end < content.length && isWhitespace(content[end])) { end += 1; } return collapseWhitespace(`${content.slice(0, start)} ${content.slice(end)}`); } /** * Extracts a number from content (e.g., "6" from "@min 6 @focus"). * Returns the parsed number or undefined if not found or invalid. * Only matches unquoted tokens — "@min 6" inside quotes is ignored. */ function extractMinValue(content) { const masked = maskQuotedContent(content); const idx = findToken(masked, '@min'); if (idx === -1) { return undefined; } // Skip whitespace after "@min" let pos = idx + '@min'.length; while (pos < masked.length && isWhitespace(masked[pos])) { pos += 1; } const digits = readDigits(masked, pos); if (digits.length === 0) { return undefined; } // Must be followed by whitespace or end-of-string const afterDigits = pos + digits.length; if (afterDigits < masked.length && !isWhitespace(masked[afterDigits])) { return undefined; } const value = parseInt(digits, 10); return Number.isNaN(value) || value < 1 ? undefined : value; } /** * Removes the `@min` directive (and its optional value) from content. * Only removes unquoted tokens — "@min 6" inside quotes is preserved. */ function removeMinDirective(content) { const masked = maskQuotedContent(content); const idx = findToken(masked, '@min'); if (idx === -1) { return content; } // Find the full span: leading whitespace + "@min" + optional (whitespace + non-whitespace value) + trailing whitespace let start = idx; while (start > 0 && isWhitespace(content[start - 1])) { start -= 1; } let end = idx + '@min'.length; // Skip whitespace after @min let ws = end; while (ws < content.length && isWhitespace(content[ws])) { ws += 1; } // If there's a non-whitespace, non-quote value after @min, skip it if (ws < content.length && content[ws] !== '"' && content[ws] !== "'" && !isWhitespace(content[ws])) { end = ws; while (end < content.length && !isWhitespace(content[end])) { end += 1; } } // Skip trailing whitespace while (end < content.length && isWhitespace(content[end])) { end += 1; } return collapseWhitespace(`${content.slice(0, start)} ${content.slice(end)}`); } /** * Extracts a quoted string from content. * Supports both double quotes ("...") and single quotes ('...'). * If the entire content is a single quoted string, returns its inner text. * Otherwise returns the first quoted substring found. */ function extractQuotedString(content) { const trimmed = content.trim(); // Check if the entire content is a single quoted string if (trimmed.length >= 2) { const first = trimmed[0]; if ((first === '"' || first === "'") && trimmed[trimmed.length - 1] === first) { return trimmed.slice(1, -1); } } // Find first quoted substring anywhere for (let i = 0; i < content.length; i += 1) { const char = content[i]; if (char === '"' || char === "'") { const close = content.indexOf(char, i + 1); if (close !== -1 && close > i + 1) { return content.slice(i + 1, close); } } } return undefined; } /** * Extracts all quoted strings from content. * Supports both double quotes ("...") and single quotes ('...'). */ function extractAllQuotedStrings(content) { const results = []; let i = 0; while (i < content.length) { const char = content[i]; if (char === '"' || char === "'") { const close = content.indexOf(char, i + 1); if (close !== -1 && close > i + 1) { results.push(content.slice(i + 1, close)); i = close + 1; continue; } } i += 1; } return results; } /** * Extracts and removes the `@focus` keyword from content. */ function extractFocus(content) { const masked = maskQuotedContent(content); const idx = findToken(masked, '@focus'); if (idx === -1) { return { focus: false, remaining: content }; } const remaining = (content.slice(0, idx) + content.slice(idx + '@focus'.length)).trim(); return { focus: true, remaining }; } /** * Parses emphasis comments and returns structured directives. * * Supported formats: * - Single line: `@highlight` or `@highlight "description"` * - Single line focused: `@highlight @focus` or `@highlight @focus "description"` * - Multiline start: `@highlight-start` or `@highlight-start "description"` * - Multiline start focused: `@highlight-start @focus` or `@highlight-start @focus "description"` * - Multiline end: `@highlight-end` * - Text highlight: `@highlight-text "text to highlight"` or `@highlight-text "one" "two"` * - Text highlight focused: `@highlight-text @focus "text to highlight"` * - Focus only (single line): `@focus` * - Focus only (multiline): `@focus-start` / `@focus-end` * * @param comments - Source comments keyed by line number * @returns Array of parsed emphasis directives */ function parseEmphasisDirectives(comments) { const directives = []; for (const [lineStr, commentArray] of Object.entries(comments)) { const line = parseInt(lineStr, 10); for (const comment of commentArray) { // Check if this is a @highlight comment if (comment.startsWith(EMPHASIS_COMMENT_PREFIX)) { const content = comment.slice(EMPHASIS_COMMENT_PREFIX.length); parseHighlightDirective(directives, line, content); continue; } // Check if this is a @focus comment (focus-only, no highlight) if (comment.startsWith(FOCUS_COMMENT_PREFIX)) { const content = comment.slice(FOCUS_COMMENT_PREFIX.length); parseFocusDirective(directives, line, content); } } } return directives; } /** * Parses a `@highlight` comment into one or more directives. */ function parseHighlightDirective(directives, line, content) { // Extract @padding if present const paddingFrameMaxSize = extractPaddingValue(content); const focusFramesMaxSize = extractMinValue(content); const contentWithoutModifiers = removeMinDirective(removePaddingDirective(content)); if (contentWithoutModifiers.startsWith('-end')) { directives.push({ line, type: 'end', lineHighlight: true, paddingFrameMaxSize, focusFramesMaxSize }); } else if (contentWithoutModifiers.startsWith('-start')) { const afterStart = contentWithoutModifiers.slice('-start'.length).trim(); const { focus, remaining: remainingStart } = extractFocus(afterStart); const description = extractQuotedString(remainingStart); directives.push({ line, type: 'start', description, focus, lineHighlight: true, paddingFrameMaxSize, focusFramesMaxSize }); } else if (contentWithoutModifiers.startsWith('-text')) { const afterText = contentWithoutModifiers.slice('-text'.length).trim(); const { focus, remaining: remainingText } = extractFocus(afterText); const highlightTexts = extractAllQuotedStrings(remainingText); if (highlightTexts.length > 0) { // Text-only markers should not influence region padding, so // @padding/@min modifiers are intentionally omitted here. // lineHighlight is false because @highlight-text only highlights // inline text, it does NOT highlight the line itself. directives.push({ line, type: 'text', highlightTexts, focus, lineHighlight: false, paddingFrameMaxSize: undefined, focusFramesMaxSize: undefined }); } } else { const afterHighlight = contentWithoutModifiers.trim(); const { focus, remaining: remainingSingle } = extractFocus(afterHighlight); const description = extractQuotedString(remainingSingle) || undefined; directives.push({ line, type: 'single', description, focus, lineHighlight: true, paddingFrameMaxSize, focusFramesMaxSize }); } } /** * Parses a `@focus` comment into a focus-only directive (no line highlight). */ function parseFocusDirective(directives, line, content) { // Extract @padding if present const paddingFrameMaxSize = extractPaddingValue(content); const focusFramesMaxSize = extractMinValue(content); const contentWithoutModifiers = removeMinDirective(removePaddingDirective(content)); if (contentWithoutModifiers.startsWith('-end')) { directives.push({ line, type: 'end', lineHighlight: false, paddingFrameMaxSize, focusFramesMaxSize }); } else if (contentWithoutModifiers.startsWith('-start')) { const afterStart = contentWithoutModifiers.slice('-start'.length).trim(); const description = extractQuotedString(afterStart); directives.push({ line, type: 'start', description, focus: true, lineHighlight: false, paddingFrameMaxSize, focusFramesMaxSize }); } else { // Single line: @focus or @focus "description" const afterFocus = contentWithoutModifiers.trim(); const description = extractQuotedString(afterFocus) || undefined; directives.push({ line, type: 'single', description, focus: true, lineHighlight: false, paddingFrameMaxSize, focusFramesMaxSize }); } } /** * Capitalizes the first letter of a string. * * @param str - The string to capitalize * @returns The string with the first letter capitalized */ function capitalize(str) { if (!str) { return str; } return str.charAt(0).toUpperCase() + str.slice(1); } /** * Builds a map of line numbers to their line elements from the HAST tree. * This allows O(1) lookups instead of traversing the tree for each lookup. * * @param node - The HAST node to search * @returns Map of line numbers to their line elements */ function buildLineElementMap(node) { const map = new Map(); function traverse(n) { if (!('children' in n) || !n.children) { return; } for (const child of n.children) { if (child.type !== 'element') { continue; } // Check if this is a line element if (child.tagName === 'span' && child.properties?.className === 'line' && typeof child.properties.dataLn === 'number') { map.set(child.properties.dataLn, child); } // Recurse into children traverse(child); } } traverse(node); return map; } /** * Checks if a line element contains only a comment with the given text. * A line is considered "comment-only" if it contains only whitespace, a .pl-c element, * and optionally .pl-pse elements (JSX comment braces like `{` and `}`). * * @param lineElement - The line element to check * @param commentText - The text the comment should contain (e.g., "@highlight-start") * @returns True if the line contains only a comment with the specified text */ function isCommentOnlyLine(lineElement, commentText) { if (!lineElement.children) { return false; } // Check if the line contains a .pl-c element with the expected text let hasMatchingComment = false; let hasNonWhitespaceContent = false; for (const child of lineElement.children) { if (child.type === 'text') { // Check if this is non-whitespace text if (child.value.trim() !== '') { hasNonWhitespaceContent = true; } } else if (child.type === 'element') { const className = child.properties?.className; const classNames = Array.isArray(className) ? className : [className]; if (classNames.includes('pl-c')) { // This is a comment element - check if it contains the expected text const text = getHastTextContent(child); if (text.includes(commentText)) { hasMatchingComment = true; } else { // Some other comment hasNonWhitespaceContent = true; } } else if (classNames.includes('pl-pse')) { // This is punctuation for special expressions (JSX braces for comments) // Check if it's just `{` or `}` which are used for JSX comment syntax const text = getHastTextContent(child); if (text !== '{' && text !== '}') { hasNonWhitespaceContent = true; } // Otherwise ignore - these are just JSX comment syntax } else { // Non-comment element - check if it has non-whitespace content const text = getHastTextContent(child); if (text.trim() !== '') { hasNonWhitespaceContent = true; } } } } return hasMatchingComment && !hasNonWhitespaceContent; } /** * Calculates which lines should be emphasized based on parsed directives. * * @param directives - Parsed emphasis directives * @param lineElements - Map of line numbers to their HAST elements * @returns Map of line numbers to their emphasis metadata */ function calculateEmphasizedLines(directives, lineElements) { const emphasizedLines = new Map(); // Sort directives by line number for proper pairing const sortedDirectives = [...directives].sort((a, b) => a.line - b.line); // Process single line and text directives for (const directive of sortedDirectives) { if (directive.type === 'single') { const strong = directive.description?.endsWith('!') ?? false; // Strip trailing ! from description since it's just a signal for strong emphasis const description = capitalize(strong ? directive.description?.slice(0, -1).trimEnd() : directive.description); emphasizedLines.set(directive.line, { description, strong, position: 'single', lineHighlight: directive.lineHighlight, focus: directive.focus, paddingFrameMaxSize: directive.paddingFrameMaxSize, focusFramesMaxSize: directive.focusFramesMaxSize, // Treat a single `@highlight` as a containing highlight range of depth 1 // so that an outer multiline range wrapping this line is detected as // nesting and promoted to `strong`. containingRangeDepth: directive.lineHighlight ? 1 : undefined }); } else if (directive.type === 'text') { // Text highlight - emphasize specific text(s) within the line. // Merge with any existing entry (e.g. when @highlight and @highlight-text // map to the same line after comment removal). const existing = emphasizedLines.get(directive.line); // Concatenate highlight texts when multiple directives target the same line. const mergedTexts = existing?.highlightTexts ? [...existing.highlightTexts, ...(directive.highlightTexts ?? [])] : directive.highlightTexts; emphasizedLines.set(directive.line, { ...existing, position: existing?.position ?? 'single', lineHighlight: existing?.lineHighlight ?? directive.lineHighlight, highlightTexts: mergedTexts, focus: directive.focus || existing?.focus, paddingFrameMaxSize: directive.paddingFrameMaxSize ?? existing?.paddingFrameMaxSize, focusFramesMaxSize: directive.focusFramesMaxSize ?? existing?.focusFramesMaxSize }); } } // Process multiline directives by pairing starts with ends const startStack = []; for (const directive of sortedDirectives) { if (directive.type === 'start') { startStack.push(directive); } else if (directive.type === 'end' && startStack.length > 0) { const startDirective = startStack.pop(); // Check if the start directive's line contains only a comment (displayComments mode). // If so, the content to highlight starts on the NEXT line. // If the comment was stripped, the line number already points to the first content line. const startLineElement = lineElements.get(startDirective.line); const isStartCommentOnly = startLineElement && (isCommentOnlyLine(startLineElement, '@highlight-start') || isCommentOnlyLine(startLineElement, '@focus-start')); const startLine = isStartCommentOnly ? startDirective.line + 1 : startDirective.line; const endLine = directive.line - 1; // Skip if no lines to emphasize (e.g., adjacent comments with no content between) if (startLine > endLine) { continue; } // Check if this is a strong emphasis (description ends with !) const strong = startDirective.description?.endsWith('!') ?? false; // Strip trailing ! from description since it's just a signal for strong emphasis const description = capitalize(strong ? startDirective.description?.slice(0, -1).trimEnd() : startDirective.description); // Add all lines between start and end for (let line = startLine; line <= endLine; line += 1) { const existing = emphasizedLines.get(line); // Determine position for this line in the current range let position; if (line === startLine && line === endLine) { // A multiline range that resolves to a single content line (e.g. // when comment-only lines are stripped) should be treated as a // standalone single-line highlight. position = 'single'; } else if (line === startLine && line !== endLine) { position = 'start'; } else if (line === endLine && line !== startLine) { position = 'end'; } // If this line is already emphasized (from an inner range), mark it as strong // only when both ranges have lineHighlight (true nesting of highlights). // A focus range overlapping with a highlight is not nesting — it just // merges focus into the existing entry. const meta = existing ? { // Nested highlight ranges are strong; focus+highlight overlap is not. // Detect true nesting via `containingRangeDepth` rather than // `existing.lineHighlight`. This works when the existing entry // came from a `@highlight-text` directive on a line that's also // wrapped in a highlight range — `existing.lineHighlight` is true // after the first range merge, so a second wrapping range needs to // see the depth to know nesting occurred. strong: (existing.containingRangeDepth ?? 0) >= 1 && startDirective.lineHighlight || existing.strong || strong, description: existing.description ?? (line === startLine ? description : undefined), // Inner range position takes precedence, but 'single' from a standalone // @highlight-text should be replaced by the multiline range's position. // Keep 'single' from a real @highlight (lineHighlight is set), even when // the line also carries @highlight-text. position: existing.position && !(existing.position === 'single' && existing.highlightTexts && !existing.lineHighlight) ? existing.position : position, highlightTexts: existing.highlightTexts, // Preserve text highlights from @highlight-text lineHighlight: existing.lineHighlight || startDirective.lineHighlight, focus: existing.focus || startDirective.focus, // When the outer range is focus and the inner is not, the focus // range's overrides win (focus tier > non-focus). When both // are the same tier, the inner (existing) wins since it was // placed by a more specific directive. paddingFrameMaxSize: startDirective.focus && !existing.focus ? startDirective.paddingFrameMaxSize ?? existing.paddingFrameMaxSize : existing.paddingFrameMaxSize ?? startDirective.paddingFrameMaxSize, focusFramesMaxSize: startDirective.focus && !existing.focus ? startDirective.focusFramesMaxSize ?? existing.focusFramesMaxSize : existing.focusFramesMaxSize ?? startDirective.focusFramesMaxSize, // Propagated when existing had no overrides of its own (all come // from the range), or when existing was itself propagated. propagatedOverride: existing.paddingFrameMaxSize !== undefined || existing.focusFramesMaxSize !== undefined ? existing.propagatedOverride : true, // Track how many containing highlight ranges wrap this line // so that inline <mark> elements can receive the right data-hl tier. containingRangeDepth: startDirective.lineHighlight ? (existing.containingRangeDepth ?? 0) + 1 : existing.containingRangeDepth } : { strong, description: line === startLine ? description : undefined, position, lineHighlight: startDirective.lineHighlight, focus: startDirective.focus, paddingFrameMaxSize: startDirective.paddingFrameMaxSize, focusFramesMaxSize: startDirective.focusFramesMaxSize, propagatedOverride: true, // Track depth so a wrapping outer range can detect nesting // (used by the `strong` calculation above). containingRangeDepth: startDirective.lineHighlight ? 1 : undefined }; emphasizedLines.set(line, meta); } } } return emphasizedLines; } /** * Converts a group of nodes into a `<mark>` element. * * When the group contains exactly one `<span>` child, we replace that * element in-place — changing its `tagName` to `mark` and merging the * highlight properties — instead of wrapping it in an extra `<mark>`. * This keeps the output flat (e.g. `<mark class="pl-e">config</mark>` * instead of `<mark><span class="pl-e">config</span></mark>`). */ function groupToMark(nodes, props) { if (nodes.length === 1 && nodes[0].type === 'element' && nodes[0].tagName === 'span') { const child = nodes[0]; return { ...child, tagName: 'mark', properties: { ...child.properties, ...props } }; } return { type: 'element', tagName: 'mark', properties: props, children: nodes }; } /** * Like {@link getHastTextContent} but replaces any text inside a * `<mark>` element with sentinel null characters so that those * regions are invisible to the text search in `wrapTextInHighlightSpan`. * This prevents nesting highlights when successive tokens overlap. */ function getSearchableText(node) { if (node.type === 'text') { return node.value; } if (node.type === 'element') { if (node.tagName === 'mark') { return '\0'.repeat(getHastTextContent(node).length); } if (node.children) { return node.children.map(getSearchableText).join(''); } } return ''; } /** * Recursively walks children and adds `data-hl` to any `<mark>` elements * so they inherit the highlight level of their parent line. */ function propagateHlToMarks(children, hlValue) { for (const child of children) { if (child.type === 'element') { if (child.tagName === 'mark') { child.properties = child.properties || {}; child.properties.dataHl = hlValue; } if (child.children) { propagateHlToMarks(child.children, hlValue); } } } } /** * Injects a `data-hl` highlight span for a character range inside an * element's children, without splitting the element itself. * Used when a semantic element partially overlaps a match — the element * stays intact and the highlight is placed inside it. * * Uses a plan-based approach (like {@link wrapTextInHighlightSpan}) to * detect when the range produces multiple highlight fragments. When the * caller already provides a `part` value, every fragment inherits it. * Otherwise, if multiple fragments are detected, `data-hl-part` values * (`"start"`, `"middle"`, `"end"`) are computed locally. * * @param children - The children of the element to modify * @param from - Start offset within the element's text content * @param to - End offset within the element's text content * @param part - Optional `data-hl-part` value inherited from the parent */ function injectHighlightInChildren(children, from, to, part, strict, textToHighlight) { const before = []; const after = []; const plan = []; let currentGroup = []; let offset = 0; let pastRange = false; // Precompute text lengths to avoid repeated recursive walks per child. const childLengths = children.map(child => getHastTextContent(child).length); function flushGroup() { if (currentGroup.length > 0) { plan.push({ kind: 'group', nodes: currentGroup }); currentGroup = []; } } for (let i = 0; i < children.length; i += 1) { const child = children[i]; const len = childLengths[i]; const childStart = offset; const childEnd = offset + len; offset = childEnd; if (pastRange) { after.push(child); continue; } if (childEnd <= from) { before.push(child); continue; } if (childStart >= to) { flushGroup(); after.push(child); pastRange = true; continue; } if (childStart >= from && childEnd <= to) { currentGroup.push(child); continue; } // Straddling const overlapFrom = Math.max(from, childStart) - childStart; const overlapTo = Math.min(to, childEnd) - childStart; if (child.type === 'text') { const beforeText = child.value.slice(0, overlapFrom); const matchedText = child.value.slice(overlapFrom, overlapTo); const afterText = child.value.slice(overlapTo); if (beforeText) { flushGroup(); before.push({ type: 'text', value: beforeText }); } if (matchedText) { currentGroup.push({ type: 'text', value: matchedText }); } if (afterText) { flushGroup(); after.push({ type: 'text', value: afterText }); pastRange = true; } } else if (child.type === 'element' && child.children) { // Nested element straddling — this is fragmentation across an element boundary if (strict) { throw new Error(`Base UI: @highlight-text "${textToHighlight}" straddles an element boundary. ` + 'In strict mode, highlighted text must not be fragmented across elements. ' + 'Adjust the highlighted text so it aligns with syntax token boundaries.'); } flushGroup(); plan.push({ kind: 'inject', element: child, from: overlapFrom, to: overlapTo }); if (childEnd > to) { pastRange = true; } } else { currentGroup.push(child); } } flushGroup(); // When the caller already supplied a part, every fragment inherits it. // Otherwise, when multiple fragments exist, compute parts locally. const needsParts = part === undefined && plan.length > 1; const highlighted = []; for (let i = 0; i < plan.length; i += 1) { const item = plan[i]; let effectivePart = part; if (needsParts) { if (i === 0) { effectivePart = 'start'; } else if (i === plan.length - 1) { effectivePart = 'end'; } else { effectivePart = 'middle'; } } if (item.kind === 'group') { const props = {}; if (effectivePart !== undefined) { props.dataHlPart = effectivePart; } highlighted.push(groupToMark(item.nodes, props)); } else { highlighted.push({ ...item.element, children: injectHighlightInChildren(item.element.children, item.from, item.to, effectivePart, strict, textToHighlight) }); } } return [...before, ...highlighted, ...after]; } /** * Wraps all occurrences of a specific text within a line's children in * `<mark>` elements. * * Semantic element nodes (syntax-highlighting spans) are never split or * cloned. When a match partially overlaps an element, the highlight is * injected *inside* the element via {@link injectHighlightInChildren}. * When a match covers entire elements, a single wrapper `<mark>` * groups them all. * * If a match is fragmented (spans a partial element boundary), each * fragment gets a `data-hl-part` attribute (`"start"`, `"middle"`, or * `"end"`) so the segments can be styled together (e.g. border-radius). * * Already-highlighted nodes (`<mark>`) are excluded from matching so that * successive calls for different tokens don't nest or double-highlight. */ function wrapTextInHighlightSpan(children, textToHighlight, strict) { // Build searchable text, masking already-highlighted regions with sentinels. const segments = children.map(getSearchableText); const fullText = segments.join(''); const matchIndex = fullText.indexOf(textToHighlight); if (matchIndex === -1) { return children; } const matchEnd = matchIndex + textToHighlight.length; // Classify each child relative to [matchIndex, matchEnd). // "group" items are fully-contained nodes wrapped in a single data-hl span. // "inject" items are elements that straddle a boundary — the highlight goes // inside them, preserving the semantic element. const before = []; const after = []; const plan = []; let currentGroup = []; let offset = 0; let pastMatch = false; function flushGroup() { if (currentGroup.length > 0) { plan.push({ kind: 'group', nodes: currentGroup }); currentGroup = []; } } for (let i = 0; i < children.length; i += 1) { const child = children[i]; const len = segments[i].length; const childStart = offset; const childEnd = offset + len; offset = childEnd; if (pastMatch) { after.push(child); continue; } // Entirely before match if (childEnd <= matchIndex) { before.push(child); continue; } // Entirely after match if (childStart >= matchEnd) { flushGroup(); after.push(child); pastMatch = true; continue; } // Entirely within match if (childStart >= matchIndex && childEnd <= matchEnd) { currentGroup.push(child); continue; } // Straddling a boundary const overlapFrom = Math.max(matchIndex, childStart) - childStart; const overlapTo = Math.min(matchEnd, childEnd) - childStart; if (child.type === 'text') { // Text nodes can be split freely — they carry no semantic class. const beforeText = child.value.slice(0, overlapFrom); const matchedText = child.value.slice(overlapFrom, overlapTo); const afterText = child.value.slice(overlapTo); if (beforeText) { flushGroup(); before.push({ type: 'text', value: beforeText }); } if (matchedText) { currentGroup.push({ type: 'text', value: matchedText }); } if (afterText) { flushGroup(); after.push({ type: 'text', value: afterText }); pastMatch = true; } } else if (child.type === 'element' && child.children) { // Element nodes are never split — inject highlight inside them. flushGroup(); plan.push({ kind: 'inject', element: child, from: overlapFrom, to: overlapTo }); if (childEnd > matchEnd) { pastMatch = true; } } else { currentGroup.push(child); } } flushGroup(); // When multiple highlight pieces exist (due to element boundary straddling), // mark each with data-hl-part so they can be styled as a group. const needsParts = plan.length > 1; if (strict && needsParts) { throw new Error(`Base UI: @highlight-text "${textToHighlight}" straddles an element boundary. ` + 'In strict mode, highlighted text must not be fragmented across elements. ' + 'Adjust the highlighted text so it aligns with syntax token boundaries.'); } const highlighted = []; for (let i = 0; i < plan.length; i += 1) { const item = plan[i]; let part; if (needsParts) { if (i === 0) { part = 'start'; } else if (i === plan.length - 1) { part = 'end'; } else { part = 'middle'; } } if (item.kind === 'group') { const props = {}; if (part !== undefined) { props.dataHlPart = part; } highlighted.push(groupToMark(item.nodes, props)); } else { const injectedChildren = injectHighlightInChildren(item.element.children, item.from, item.to, part, strict, textToHighlight); highlighted.push({ ...item.element, // Re-scan: the injected element may contain additional occurrences of the // text beyond the just-highlighted region (e.g. repeated tokens in its tail). children: wrapTextInHighlightSpan(injectedChildren, textToHighlight, strict) }); } } return [...before, ...highlighted, // Recursively process the remainder for additional occurrences ...wrapTextInHighlightSpan(after, textToHighlight, strict)]; } /** * Single-pass traversal that applies emphasis attributes to line elements * AND collects leading whitespace for indent calculation on highlighted lines. * * This merges what would otherwise be two separate traversals into one. * * @param node - The node to process * @param emphasizedLines - Map of line numbers to their emphasis metadata * @returns Array of line elements that are highlighted, grouped by region */ function applyEmphasisAndCollectHighlightedElements(node, emphasizedLines, options) { const highlightedLineElements = []; function traverse(n) { if (!('children' in n) || !n.children) { return; } for (let i = 0; i < n.children.length; i += 1) { const child = n.children[i]; if (child.type !== 'element') { continue; } // Check if this is a line element if (child.tagName === 'span' && child.properties?.className === 'line' && typeof child.properties.dataLn === 'number') { const lineNumber = child.properties.dataLn; const meta = emphasizedLines.get(lineNumber); if (meta !== undefined) { // Determine whether line-level data-hl should be applied. // Line-level highlighting is only needed when highlight lines appear // inside a focus frame (highlight + focus), or when highlights are nested // (strong). Simple standalone highlights don't need line-level marks // because the frame itself handles the visual emphasis. const shouldApplyLineHl = meta.lineHighlight && (meta.focus === true || meta.strong === true); if (meta.highlightTexts) { // For text highlight, wrap the specific text(s) in a <mark> element let children = child.children; for (const text of meta.highlightTexts) { children = wrapTextInHighlightSpan(children, text, options.strictHighlightText === true); } child.children = children; // Propagate data-hl to inline <mark> elements based on how many // containing highlight ranges wrap this line. This gives marks // 3 visual tiers: bare (standalone), data-hl="" (inside 1 range), // and data-hl="strong" (inside 2+ nested ranges). if (meta.containingRangeDepth && meta.containingRangeDepth > 0) { const markHlValue = meta.containingRangeDepth >= 2 ? 'strong' : ''; propagateHlToMarks(child.children, markHlValue); } // Only mark the line with data-hl when the highlight is nested // (inside a focus frame or strong from nesting). // Standalone @highlight-text lines should not get line-level marks // because the frame itself handles the visual emphasis. if (shouldApplyLineHl) { const hlValue = meta.strong ? 'strong' : ''; child.properties.dataHl = hlValue; if (meta.description) { child.properties.dataHlDescription = meta.description; } if (meta.position) { child.properties.dataHlPosition = meta.position; } } } else if (shouldApplyLineHl) { // Use data-hl with optional "strong" value on the line child.properties.dataHl = meta.strong ? 'strong' : ''; if (meta.description) { child.properties.dataHlDescription = meta.description; } if (meta.position) { child.properties.dataHlPosition = meta.position; } } // Collect this line element for indent calculation highlightedLineElements.push(child); } } // Recurse into children (for frames containing lines) traverse(child); } } traverse(node); return highlightedLineElements; } /** * Groups highlighted line elements by their highlight regions and calculates * the indent level for each region. * * @param highlightedElements - Line elements that are highlighted, in order * @param emphasizedLines - The emphasis metadata map * @returns Map from region index to indent level */ function calculateRegionIndentLevels(highlightedElements, emphasizedLines) { const regionIndentLevels = new Map(); if (highlightedElements.length === 0) { return regionIndentLevels; } // Group elements by consecutive regions const sortedLines = Array.from(emphasizedLines.keys()).sort((a, b) => a - b); let regionIndex = 0; let regionElements = []; let prevLine = -1; // Build a quick lookup from lineNumber to element const elementByLine = new Map(); for (const el of highlightedElements) { const ln = el.properties?.dataLn; elementByLine.set(ln, el); } for (const line of sortedLines) { const el = elementByLine.get(line); if (!el) { continue; } if (prevLine >= 0 && line !== prevLine + 1) { // Gap: close current region regionIndentLevels.set(regionIndex, calculateFrameIndent(regionElements)); regionIndex += 1; regionElements = []; } regionElements.push(el); prevLine = line; } // Close the last region if (regionElements.length > 0) { regionIndentLevels.set(regionIndex, calculateFrameIndent(regionElements)); } return regionIndentLevels; } /** * Post-restructure pass that reconciles line-level `data-hl` attributes with * frame-level types. * * When a frame's type is `highlighted` or `highlighted-unfocused`, the frame * itself already communicates the highlight — so line-level `data-hl` (empty * value) is redundant and gets stripped. `data-hl="strong"` is preserved * because it communicates deeper nesting that the frame type alone can't convey. * * Descriptions from stripped lines are promoted to the frame element as * `data-frame-description`. For lines that never received `data-hl` in the first * place (standalone highlights without focus), descriptions are also promoted. */ function reconcileLineAndFrameEmphasis(root, emphasizedLines) { for (const frame of root.children) { if (frame.type !== 'element') { continue; } const frameType = frame.properties?.dataFrameType; const isHighlightedFrame = frameType === 'highlighted' || frameType === 'highlighted-unfocused'; for (const child of frame.children) { if (child.type !== 'element' || child.tagName !== 'span' || child.properties?.className !== 'line' || typeof child.properties.dataLn !== 'number') { continue; } const meta = emphasizedLines.get(child.properties.dataLn); if (!meta) { continue; } // In highlighted/highlighted-unfocused frames, strip redundant line-level // data-hl (empty value only). The frame already communicates the highlight. // Keep data-hl="strong" — it conveys deeper nesting the frame can't express. if (isHighlightedFrame && 'dataHl' in (child.properties ?? {}) && child.properties.dataHl !== 'strong') { delete child.properties.dataHl; // Move description and position to the frame since line-level attrs are gone if (child.properties.dataHlDescription) { frame.properties ??= {}; frame.properties.dataFrameDescription = child.properties.dataHlDescription; delete child.properties.dataHlDescription; } if (child.properties.dataHlPosition) { delete child.properties.dataHlPosition; } continue; } // For non-highlighted frames: promote descriptions to the frame when the // line doesn't have data-hl (standalone highlights without focus). if (meta.description && !('dataHl' in (child.properties ?? {}))) { frame.properties ??= {}; frame.properties.dataFrameDescription = meta.description; } } } } /** * Creates a source enhancer that adds emphasis to code lines based on `@highlight` comments * and restructures frames around highlighted regions. * * Supports five patterns: * * 1. **Single line emphasis** - emphasizes the line containing the comment: * ```jsx * <h1>Heading 1</h1> {/* @highlight *\/} * ``` * * 2. **Multiline emphasis** - emphasizes all lines between start and end: * ```jsx * // @highlight-start * <div> * <h1>Heading 1</h1> * </div> * // @highlight-end * ``` * * 3. **Multiline with description**: * ```jsx * // @highlight-start "we add a heading" * <div> * <h1>Heading 1</h1> * </div> * // @highlight-end * ``` * * 4. **Text highlight** - highlights specific text within a line: * ```jsx * <h1>Heading 1</h1> {/* @highlight-text "Heading 1" *\/} * ``` * * 5. **Focus override** - mark a region for padding focus: * ```jsx * <h1>Heading 1</h1> {/* @highlight @focus *\/} * ``` * * Emphasized lines receive a `data-hl` attribute on their `<span class="line">` element. * When highlights exist, frames are restructured with `data-frame-type` attributes * (`highlighted`, `padding-top`, `padding-bottom`, or omitted for normal). * Highlighted frames also receive `data-frame-indent` with the shared indent level. * * @param options - Optional configuration for padding frames * @returns A `SourceEnhancer` function * * @example * ```ts * import { createEnhanceCodeEmphasis } from '@mui/internal-docs-infra/pipeline/enhanceCodeEmphasis'; * * const enhancers = [createEnhanceCodeEmphasis({ paddingFrameMaxSize: 5, focusFramesMaxSize: 8 })]; * ``` */ export function createEnhanceCodeEmphasis(options = {}) { const enhancer = (root, comments) => { // Helper: mark root as collapsible when hidden and visible emphasis frames coexist function markCollapsible(frameRanges) { let hasHidden = false; let hasVisible = false; for (const range of frameRanges) { if (range.type === 'normal' || range.type === 'highlighted-unfocused' || range.type === 'focus-unfocused') { hasHidden = true; } else if (range.type === 'highlighted' || range.type === 'focus' || range.type === 'padding-top' || range.type === 'padding-bottom') { hasVisible = true; } if (hasHidden && hasVisible) { root.data = { ...root.data, collapsible: true }; return; } } } // Helper: record the focused-window size (lines visible when collapsed) // alongside `totalLines`. Mirrors the visibility rule in `<Pre>` / // `hasCollapseInFocus`: frame types `'highlighted' | 'focus' | // 'padding-top' | 'padding-bottom'` make up the focused window. // // When `oversizedFocus: 'hide'` suppressed the focus window, no focused // frames exist (`focusedLines === 0`) yet there is hidden content to // expand into. Force `collapsible` so the block collapses to nothing // rather than reading as a non-collapsible (always-expanded) block. function recordFocusedLines(frameRanges) { let focusedLines = 0; for (const range of frameRanges) { if (range.type === 'highlighted' || range.type === 'focus' || range.type === 'padding-top' || range.type === 'padding-bottom') { focusedLines += range.endLine - range.startLine + 1; } } root.data = { ...root.data, focusedLines }; if (options.oversizedFocus === 'hide' && focusedLines === 0 && frameRanges.length > 0) { root.data = { ...root.data, collapsible: true }; } } // Step 1: Parse directives from comments (no tree traversal) const directives = comments && Object.keys(comments).length > 0 ? parseEmphasisDirectives(comments) : []; const effectiveOptions = options; const hasDirectives = directives.length > 0; // Step 2 (Traversal 1): Build line element map const lineElements = buildLineElementMap(root); const totalLines = root.data?.totalLines ?? lineElements.size; // Read frameSize from HAST (set by starryNightGutter when it splits frames) // so emphasis reframing matches the original gutter split size const normalFrameMaxSize = root.data?.frameSize; if (!hasDirectives) { // Auto-focus path: no