@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
668 lines (632 loc) • 20 kB
JavaScript
/**
* Extracts JSDoc comments from highlighted type declaration HAST trees.
*
* Operates on a `pre > code > span.frame > span.line*` HAST structure produced
* by `formatDetailedTypeAsHast`. Walks the HAST span elements (pl-v, pl-k, pl-en,
* pl-s, pl-c, etc.) to structurally identify property declarations, extract their
* types, and associate pending JSDoc comments.
*
* Splits the single frame into multiple alternating frames at the `code.children`
* level — comment frames carry `data-comment` for CSS hiding, non-comment frames
* are normal.
*
* Supports deep extraction for nested object types using dot-notation
* property paths (e.g., `appearance.theme`).
*/
import { restructureFrames } from "../parseSource/restructureFrames.mjs";
import { getHastTextContent, getShallowTextContent, hasClass, isCommentSpan, isEntityNameSpan, isKeywordSpan, isLineSpan, isPropertyNameSpan, isStringLiteralSpan } from "./hastTypeUtils.mjs";
/**
* Information extracted from a property declaration, optionally with JSDoc.
*/
/**
* Result of extracting type comments from a highlighted HAST.
*/
/**
* Checks if a line element contains a JSDoc comment (`pl-c` classed spans).
*/
function isCommentLine(lineElement) {
return lineElement.children.some(child => child.type === 'element' && isCommentSpan(child));
}
/**
* Extracts the full text content of a comment line.
*
* We extract ALL text from the line (not just `pl-c` spans) because the
* syntax highlighter applies different classes to JSDoc tags (e.g. `@default`
* may be in a `pl-k` span). Since `isCommentLine` already confirmed
* this line is a comment, all its text belongs to the comment.
*/
function extractCommentText(lineElement) {
return getHastTextContent(lineElement);
}
/**
* Parses accumulated JSDoc lines into structured comment data.
*
* Handles both single-line and multi-line JSDoc formats.
* Strips JSDoc markers and leading asterisks.
*/
function parseJSDocLines(commentTexts) {
// First, extract the content lines from JSDoc syntax
const contentLines = [];
for (const raw of commentTexts) {
const trimmed = raw.trim();
// Single-line: /** text */
if (trimmed.startsWith('/**') && trimmed.endsWith('*/')) {
const inner = trimmed.slice(3, -2).trim();
if (inner) {
contentLines.push(inner);
}
continue;
}
// Start: /**
if (trimmed.startsWith('/**')) {
const rest = trimmed.slice(3).trim();
if (rest) {
contentLines.push(rest);
}
continue;
}
// End: */
if (trimmed.endsWith('*/')) {
let text = trimmed.slice(0, -2);
if (text.startsWith('*')) {
text = text.slice(1);
}
text = text.trimStart();
if (text) {
contentLines.push(text);
}
continue;
}
// Middle: * text
let text = trimmed;
if (text.startsWith('*')) {
text = text.slice(1);
// Remove exactly one leading space after the *, preserving further indentation
if (text.startsWith(' ')) {
text = text.slice(1);
}
}
contentLines.push(text);
}
// Parse tags from content lines
const see = [];
let description = '';
let defaultValue;
let deprecated;
let example;
let currentTag = null;
let currentTagLines = [];
const descriptionLines = [];
function flushTag() {
if (!currentTag) {
return;
}
const value = currentTagLines.join('\n').trim();
switch (currentTag) {
case 'default':
defaultValue = value;
break;
case 'deprecated':
deprecated = value;
break;
case 'see':
if (value) {
see.push(value);
}
break;
case 'example':
example = value;
break;
default:
break;
}
currentTag = null;
currentTagLines = [];
}
for (const line of contentLines) {
const tagMatch = line.match(/^@(\w+)\s*(.*)?$/);
if (tagMatch) {
flushTag();
currentTag = tagMatch[1];
currentTagLines = tagMatch[2] ? [tagMatch[2]] : [];
} else if (currentTag) {
currentTagLines.push(line);
} else {
descriptionLines.push(line);
}
}
flushTag();
description = descriptionLines.join('\n').trim();
return {
description,
defaultValue,
deprecated,
see,
example
};
}
/**
* Parsed property data extracted from HAST line tokens.
* Each property carries an array of union branches (split at top-level `|`
* keywords in the HAST) so that merge/dedupe operates on individual members
* rather than pre-joined strings.
*/
/**
* Internal accumulator for merging the same property across union branches.
* Each branch's type text is stored as a separate entry to allow whole-branch
* dedupe without ever splitting a merged string.
*/
/**
* Extracts property declarations from a line's HAST children by walking span tokens.
*
* Recognizes the patterns produced by Starry Night:
* - Regular: `<pl-v>"name"` → `<pl-k>":"` → type tokens → `";"`
* - Optional: `<pl-v>"name"` → `<pl-k>"?:"` → type tokens → `";"`
* - Quoted: `<pl-s>"name"` → `<pl-k>":"` or `<pl-k>"?:"` → type tokens
* - Fn name: `<pl-en>"name"` → `<pl-k>":"` → type tokens
*
* For inline union branches (e.g., `| { a: string; b: number }`), multiple
* properties can be extracted from a single line — the function re-enters
* property detection after each `;` inside a `{ }` block.
*/
function extractPropertiesFromLine(children) {
const results = [];
let i = 0;
while (i < children.length) {
const node = children[i];
// Skip text nodes (brace tracking happens inside collectTypeTokens)
if (node.type === 'text') {
i += 1;
continue;
}
if (node.type !== 'element') {
i += 1;
continue;
}
// Detect a property name: pl-v, pl-s (quoted), or pl-en (function-style)
const isName = isPropertyNameSpan(node) || isStringLiteralSpan(node) || isEntityNameSpan(node);
if (isName) {
const name = getShallowTextContent(node);
// Look ahead for the colon keyword (pl-k containing ":" or "?:")
const colonIdx = findColonKeyword(children, i + 1);
if (colonIdx >= 0) {
const colonText = getShallowTextContent(children[colonIdx]);
const optional = colonText.startsWith('?');
// Collect type tokens starting after the colon
const {
typeBranches,
opensObject,
endIndex
} = collectTypeTokens(children, colonIdx + 1);
results.push({
name,
optional,
typeBranches,
opensObject
});
i = endIndex;
continue;
}
}
i += 1;
}
return results;
}
/**
* Finds the next `<pl-k>` containing `:` or `?:` after `startIdx`,
* skipping only whitespace text nodes. Returns -1 if no colon found
* before a non-whitespace, non-keyword token.
*/
function findColonKeyword(children, startIdx) {
for (let j = startIdx; j < children.length; j += 1) {
const node = children[j];
if (node.type === 'text') {
// Allow whitespace between name and colon
if (node.value.trim() === '') {
continue;
}
return -1;
}
if (node.type === 'element' && isKeywordSpan(node)) {
const text = getShallowTextContent(node);
if (text === ':' || text === '?:') {
return j;
}
}
return -1;
}
return -1;
}
/**
* Collects type tokens after a colon, splitting at top-level `|` keywords.
*
* Tracks depth through `{}`, `()`, `[]` (in text nodes) and `<>` (in `pl-k`
* keyword spans). A `pl-k` element containing just `|` at all-zero depth is
* treated as a union separator — the tokens before and after it become
* separate branches.
*
* Stops at `;` at all-zero depth, or `}` making brace depth negative.
* Returns the branches, whether the type opens a nested object (`{` at end),
* and the index to resume scanning from.
*/
function collectTypeTokens(children, startIdx) {
const allBranches = [[]];
let braceDepth = 0;
let parenDepth = 0;
let bracketDepth = 0;
let genericDepth = 0;
let endIndex = children.length;
for (let j = startIdx; j < children.length; j += 1) {
const node = children[j];
const current = allBranches[allBranches.length - 1];
if (node.type === 'text') {
const value = node.value;
for (let k = 0; k < value.length; k += 1) {
const ch = value[k];
if (ch === '{') {
braceDepth += 1;
} else if (ch === '}') {
braceDepth -= 1;
if (braceDepth < 0) {
// Exiting the enclosing inline branch object — stop here
const before = value.substring(0, k).trimEnd();
if (before) {
current.push(before);
}
endIndex = j + 1;
return {
typeBranches: allBranches.map(b => b.join('').trim()),
opensObject: false,
endIndex
};
}
} else if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
} else if (ch === '[') {
bracketDepth += 1;
} else if (ch === ']') {
bracketDepth -= 1;
} else if (ch === ';' && braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && genericDepth === 0) {
// End of property at top level — trim and return
const before = value.substring(0, k).trimEnd();
if (before) {
current.push(before);
}
endIndex = j + 1;
return {
typeBranches: allBranches.map(b => b.join('').trim()),
opensObject: false,
endIndex
};
}
}
current.push(value);
} else if (node.type === 'element') {
const text = getTokenText(node);
// Detect top-level | keyword → start a new branch
if (isKeywordSpan(node) && text === '|' && braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && genericDepth === 0) {
allBranches.push([]);
continue;
}
// Track generic depth for < and > keywords (not => or >=)
if (isKeywordSpan(node)) {
if (text === '<') {
genericDepth += 1;
} else if (text === '>') {
genericDepth -= 1;
}
}
// Track braces in element text (existing safety net)
for (const ch of text) {
if (ch === '{') {
braceDepth += 1;
} else if (ch === '}') {
braceDepth -= 1;
}
}
current.push(text);
}
}
endIndex = children.length;
const lastText = allBranches[allBranches.length - 1].join('').trim();
// Check if single-branch type ends with `{` (opens a nested object)
const opensObject = allBranches.length === 1 && lastText.endsWith('{') && braceDepth > 0;
if (opensObject) {
allBranches[allBranches.length - 1] = [lastText.slice(0, -1).trim()];
}
return {
typeBranches: allBranches.map(b => b.join('').trim()),
opensObject,
endIndex
};
}
/**
* Gets the text content of a HAST element token for type building.
* For string literal spans (pl-s), wraps the content in quotes to
* preserve the original form.
*/
function getTokenText(node) {
const text = getShallowTextContent(node);
if (isStringLiteralSpan(node)) {
// Starry Night strips quotes from pl-s content; restore them
// Check if the text already has quotes
if (text.startsWith("'") && text.endsWith("'") || text.startsWith('"') && text.endsWith('"')) {
return text;
}
return `'${text}'`;
}
return text;
}
/**
* Adds property branches to the accumulator map.
* Each call contributes the union branches from a single property occurrence
* (which may itself be a union like `MouseEvent | KeyboardEvent`).
* Dedupe is exact string comparison on each branch — no splitting needed.
*/
function mergeProperty(accumulators, path, typeBranches, comment) {
if (path in accumulators) {
const acc = accumulators[path];
for (const branch of typeBranches) {
if (branch && !acc.branchKeys.has(branch)) {
acc.branches.push(branch);
acc.branchKeys.add(branch);
}
}
if (comment.optional) {
acc.optional = true;
}
} else {
const nonEmpty = typeBranches.filter(Boolean);
accumulators[path] = {
description: comment.description,
defaultValue: comment.defaultValue,
deprecated: comment.deprecated,
see: comment.see,
example: comment.example,
optional: comment.optional ?? false,
branches: nonEmpty,
branchKeys: new Set(nonEmpty)
};
}
}
/**
* Converts the internal accumulator map to the public ExtractedTypeComment map.
* Branches are joined with ` | ` to produce the final typeText.
*/
function finalizeProperties(accumulators) {
const properties = {};
for (const [path, acc] of Object.entries(accumulators)) {
const extracted = {
typeText: acc.branches.join(' | '),
optional: acc.optional
};
if (acc.description) {
extracted.description = acc.description;
}
if (acc.defaultValue !== undefined) {
extracted.defaultValue = acc.defaultValue;
}
if (acc.deprecated !== undefined) {
extracted.deprecated = acc.deprecated;
}
if (acc.see && acc.see.length > 0) {
extracted.see = acc.see;
}
if (acc.example !== undefined) {
extracted.example = acc.example;
}
properties[path] = extracted;
}
return properties;
}
/**
* Navigates from a HAST root to the frame element containing all line spans.
*/
function findFrameElement(hast) {
const pre = hast.children[0];
if (!pre || pre.type !== 'element' || pre.tagName !== 'pre') {
return undefined;
}
const code = pre.children[0];
if (!code || code.type !== 'element' || code.tagName !== 'code') {
return undefined;
}
const frame = code.children[0];
if (!frame || frame.type !== 'element' || !hasClass(frame, 'frame')) {
return undefined;
}
return {
frame,
code
};
}
/**
* Walks the line elements of the frame, identifies which lines are JSDoc comments,
* and returns the line numbers for comment vs non-comment ranges.
*/
function classifyLines(frame) {
const commentLines = new Set();
let totalLines = 0;
for (const child of frame.children) {
if (!isLineSpan(child)) {
continue;
}
const ln = child.properties?.dataLn;
if (typeof ln !== 'number') {
continue;
}
totalLines = Math.max(totalLines, ln);
if (isCommentLine(child)) {
commentLines.add(ln);
}
}
return {
commentLines,
totalLines
};
}
/**
* Builds an ordered array of FrameRange objects that split lines into
* alternating comment and non-comment ranges.
*/
function buildCommentFrameRanges(commentLines, totalLines) {
if (totalLines === 0) {
return [];
}
const ranges = [];
let rangeStart = 1;
let isComment = commentLines.has(1);
for (let ln = 2; ln <= totalLines; ln += 1) {
const lineIsComment = commentLines.has(ln);
if (lineIsComment !== isComment) {
ranges.push({
startLine: rangeStart,
endLine: ln - 1,
type: isComment ? 'comment' : 'normal'
});
rangeStart = ln;
isComment = lineIsComment;
}
}
// Close the last range
ranges.push({
startLine: rangeStart,
endLine: totalLines,
type: isComment ? 'comment' : 'normal'
});
return ranges;
}
/**
* Extracts JSDoc comments from a highlighted type declaration HAST and returns
* a restructured HAST (single frame split into alternating comment/non-comment frames
* via `restructureFrames`) plus a map of property paths to extracted data.
*
* Property paths use dot-notation for nested object types, such as
* "appearance.theme" for a property "theme" nested inside "appearance".
*/
export function extractTypeProps(hast) {
const result = findFrameElement(hast);
if (!result) {
return {
hast,
properties: {}
};
}
const {
frame,
code
} = result;
const accumulators = {};
// Track JSDoc comment accumulation
let pendingCommentTexts = null;
// Track nesting path for deep extraction
const pathStack = [];
// Walk lines to extract property data and classify comment lines
const children = frame.children;
for (const child of children) {
if (!isLineSpan(child)) {
continue;
}
if (isCommentLine(child)) {
if (pendingCommentTexts === null) {
pendingCommentTexts = [];
}
pendingCommentTexts.push(extractCommentText(child));
continue;
}
// Non-comment line: extract properties from HAST tokens
const props = extractPropertiesFromLine(child.children);
if (pendingCommentTexts !== null && props.length > 0) {
// Attach JSDoc comment to the first property on this line
const firstProp = props[0];
const parsed = parseJSDocLines(pendingCommentTexts);
const path = pathStack.length > 0 ? [...pathStack, firstProp.name].join('.') : firstProp.name;
mergeProperty(accumulators, path, firstProp.typeBranches, {
description: parsed.description,
defaultValue: parsed.defaultValue,
deprecated: parsed.deprecated,
see: parsed.see.length > 0 ? parsed.see : undefined,
example: parsed.example,
optional: firstProp.optional
});
if (firstProp.opensObject) {
pathStack.push(firstProp.name);
}
// Remaining properties on the same line (e.g., inline union: `| { a: T; b: U }`)
for (let pi = 1; pi < props.length; pi += 1) {
const prop = props[pi];
const propPath = pathStack.length > 0 ? [...pathStack, prop.name].join('.') : prop.name;
mergeProperty(accumulators, propPath, prop.typeBranches, {
optional: prop.optional
});
}
pendingCommentTexts = null;
} else {
// No pending comment
for (const prop of props) {
const path = pathStack.length > 0 ? [...pathStack, prop.name].join('.') : prop.name;
mergeProperty(accumulators, path, prop.typeBranches, {
optional: prop.optional
});
if (prop.opensObject) {
pathStack.push(prop.name);
}
}
if (pendingCommentTexts !== null) {
pendingCommentTexts = null;
}
}
// Track closing braces to pop path stack.
// Walk HAST children to count braces, skipping string
// literals (pl-s) where braces are part of the string value.
if (pathStack.length > 0) {
let depth = 0;
for (const span of child.children) {
if (span.type === 'element' && isStringLiteralSpan(span)) {
continue;
}
let text = '';
if (span.type === 'text') {
text = span.value;
} else if (span.type === 'element') {
text = getHastTextContent(span);
}
for (const ch of text) {
if (ch === '{') {
depth += 1;
} else if (ch === '}') {
depth -= 1;
}
}
}
for (let d = depth; d < 0 && pathStack.length > 0; d += 1) {
pathStack.pop();
}
}
}
// Build frame ranges from comment classification and restructure via restructureFrames
const {
commentLines,
totalLines
} = classifyLines(frame);
if (commentLines.size > 0) {
const frameRanges = buildCommentFrameRanges(commentLines, totalLines);
// Create a virtual HastRoot wrapping code.children so restructureFrames can operate on it
const virtualRoot = {
type: 'root',
children: code.children,
data: {
totalLines
}
};
restructureFrames(virtualRoot, frameRanges, new Map());
// Apply the restructured frames back to code
code.children = virtualRoot.children;
}
return {
hast,
properties: finalizeProperties(accumulators)
};
}