@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
483 lines (426 loc) • 14.1 kB
JavaScript
/**
* HAST type detection utilities for analyzing syntax-highlighted TypeScript types.
*
* These utilities work on already-highlighted HAST nodes to detect type patterns
* (unions, functions, objects) and derive shortType/detailedType variants.
*/
import { compressHast, getHastTextContent, getShallowTextContent } from "../hastUtils/index.mjs";
export { getHastTextContent, getShallowTextContent };
/**
* Checks if a HAST element has a specific CSS class.
* Handles both string and array class representations.
*/
export function hasClass(element, className) {
const classes = element.properties?.className;
if (Array.isArray(classes)) {
return classes.includes(className);
}
if (typeof classes === 'string') {
return classes.split(' ').includes(className);
}
return false;
}
/**
* Checks if a HAST element is a span with class `line`.
*/
export function isLineSpan(node) {
if (node.type !== 'element' || node.tagName !== 'span') {
return false;
}
return hasClass(node, 'line');
}
/**
* Checks if a HAST element is a comment span (pl-c).
*/
export function isCommentSpan(element) {
return element.tagName === 'span' && hasClass(element, 'pl-c');
}
/**
* Checks if a HAST element is a property-name span (pl-v).
*/
export function isPropertyNameSpan(element) {
return element.tagName === 'span' && hasClass(element, 'pl-v');
}
/**
* Checks if a HAST element is an entity-name span (pl-en).
*/
export function isEntityNameSpan(element) {
return element.tagName === 'span' && hasClass(element, 'pl-en');
}
/**
* Checks if a HAST element is a keyword span (pl-k).
*/
export function isKeywordSpan(element) {
return element.tagName === 'span' && hasClass(element, 'pl-k');
}
/**
* Checks if a HAST element is a string literal span (pl-s).
*/
export function isStringLiteralSpan(element) {
return element.tagName === 'span' && hasClass(element, 'pl-s');
}
/**
* Checks if a HAST element is a constant/primitive span (pl-c1).
*/
export function isConstantSpan(element) {
return element.tagName === 'span' && hasClass(element, 'pl-c1');
}
/**
* Finds all elements with a specific class in a HAST tree.
*/
function findElementsWithClass(node, className) {
const results = [];
if (node.type === 'element') {
if (hasClass(node, className)) {
results.push(node);
}
}
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findElementsWithClass(child, className));
}
}
return results;
}
/**
* Checks if a HAST tree contains a top-level pipe operator (|) indicating a union type.
* Top-level means not nested inside parentheses or braces.
*
* Starry Night highlights `|` with the `pl-k` (keyword) class.
*/
export function isUnionHast(hast) {
// Track nesting depth
const fullText = getHastTextContent(hast);
let parenDepth = 0;
let braceDepth = 0;
let bracketDepth = 0;
let angleDepth = 0;
// Scan through text to find top-level pipes
for (const char of fullText) {
switch (char) {
case '(':
parenDepth += 1;
break;
case ')':
parenDepth -= 1;
break;
case '{':
braceDepth += 1;
break;
case '}':
braceDepth -= 1;
break;
case '[':
bracketDepth += 1;
break;
case ']':
bracketDepth -= 1;
break;
case '<':
angleDepth += 1;
break;
case '>':
angleDepth -= 1;
break;
case '|':
// Check if this is a top-level pipe (not nested)
if (parenDepth === 0 && braceDepth === 0 && bracketDepth === 0 && angleDepth === 0) {
return true;
}
break;
default:
// Other characters don't affect depth tracking
break;
}
}
return false;
}
/**
* Checks if a HAST tree represents a function type.
*
* Function types contain `=>` which Starry Night highlights with `pl-k` (keyword) class.
*/
export function isFunctionHast(hast) {
// Find all keyword elements (pl-k class)
const keywordElements = findElementsWithClass(hast, 'pl-k');
// Check if any keyword element contains '=>'
for (const element of keywordElements) {
const text = getHastTextContent(element);
if (text.includes('=>')) {
return true;
}
}
return false;
}
/**
* Checks if a HAST tree represents an object type.
*
* Object types contain `{` and `}` braces at the top level.
*/
export function isObjectHast(hast) {
const fullText = getHastTextContent(hast);
// Simple check: starts with { (after trimming)
const trimmed = fullText.trim();
return trimmed.startsWith('{') && trimmed.endsWith('}');
}
/**
* Checks if a HAST tree represents an array type.
*
* Array types end with `[]`.
*/
export function isArrayHast(hast) {
const fullText = getHastTextContent(hast);
return fullText.trim().endsWith('[]');
}
/**
* Checks if a HAST tree represents a tuple type.
*
* Tuple types start with `[` and end with `]` but are not arrays.
*/
export function isTupleHast(hast) {
const fullText = getHastTextContent(hast);
const trimmed = fullText.trim();
return trimmed.startsWith('[') && trimmed.endsWith(']') && !trimmed.endsWith('[]');
}
/**
* Derives the short type string from a highlighted HAST based on its structure.
*
* This function analyzes the HAST structure to determine what simplified label
* to show (e.g., "Union", "function") without needing the original plain text.
*
* @param name - The property name (used for special cases like className, render)
* @param hast - The syntax-highlighted HAST to analyze
* @returns A short type string, or undefined if no shortening is needed
*/
export function getShortTypeFromHast(name, hast) {
// Event handlers and getters show as "function"
if (/^(on|get)[A-Z].*/.test(name)) {
return 'function';
}
// className can be string or function
if (name === 'className') {
return 'string | function';
}
// style can be React.CSSProperties or function
if (name === 'style') {
return 'React.CSSProperties | function';
}
// render can be ReactElement or function
if (name === 'render') {
return 'ReactElement | function';
}
// These props never get shortened - always show actual type
if (name.endsWith('Ref') || name === 'children') {
return undefined;
}
// Check for union type first - complex unions should be shortened to "Union"
// even if they contain function types
if (isUnionHast(hast)) {
const fullText = getHastTextContent(hast);
const pipeCount = (fullText.match(/\|/g) || []).length;
// Simple unions (less than 3 members AND short text) don't need shortening
// This matches the original: (type.split('|').length < 3 && type.length < 30)
if (pipeCount < 2 && fullText.length < 30) {
return undefined;
}
return 'Union';
}
// Check for function type (only for non-union function types)
if (isFunctionHast(hast)) {
return 'function';
}
// Simple types don't need a short version
return undefined;
}
/**
* Determines whether a type should have a detailed expanded view based on its HAST structure.
*
* @param name - The property name (used for special cases)
* @param hast - The syntax-highlighted HAST to analyze
* @returns true if the type should have a detailed view
*/
export function shouldShowDetailedTypeFromHast(name, hast) {
// Event handlers and getters typically have complex function signatures
if (/^(on|get)[A-Z].*/.test(name)) {
return true;
}
// className can be string or function, show details
if (name === 'className') {
return true;
}
// render prop can be ReactElement or function, show details
if (name === 'render') {
return true;
}
// Simple types and short unions don't need expansion
if (name.endsWith('Ref') || name === 'children') {
return false;
}
const fullText = getHastTextContent(hast);
// Simple intrinsic types don't need expansion
if (fullText === 'boolean' || fullText === 'string' || fullText === 'number') {
return false;
}
// Check if it's a union
if (!isUnionHast(hast)) {
return false;
}
// Count union members (approximate by counting top-level pipes)
const pipeCount = (fullText.match(/\|/g) || []).length;
// Short unions (less than 3 members and short text) don't need expansion
if (pipeCount < 2 && fullText.length < 30) {
return false;
}
// Complex unions benefit from detailed expansion
return true;
}
/**
* Result of collecting type references from HAST.
*/
/**
* Collects all type references (pl-en spans) from a HAST tree.
*
* This function walks the HAST and identifies type references that could be
* replaced with their definitions. It handles both:
* - Single identifiers (e.g., `DirectionProvider`)
* - Dotted identifiers (e.g., `Slider.Root.State`)
*
* @param hast - The HAST root to analyze
* @returns Array of type references found
*/
export function collectTypeReferences(hast) {
const references = [];
function walkElement(element) {
const children = element.children || [];
let i = 0;
while (i < children.length) {
const child = children[i];
// Look for pl-en spans (entity names / type identifiers)
if (child.type === 'element' && child.tagName === 'span' && hasClass(child, 'pl-en')) {
// Start collecting a potential multi-part reference
const startIndex = i;
const nameParts = [getHastTextContent(child)];
// Look ahead for `.` followed by another pl-en span
let j = i + 1;
while (j < children.length - 1) {
const dotCandidate = children[j];
const nextCandidate = children[j + 1];
// Check if we have a dot text node followed by a pl-en span
const isDot = dotCandidate.type === 'text' && dotCandidate.value === '.';
const isNextPlEn = nextCandidate.type === 'element' && nextCandidate.tagName === 'span' && hasClass(nextCandidate, 'pl-en');
if (isDot && isNextPlEn) {
nameParts.push(getHastTextContent(nextCandidate));
j += 2; // Skip the dot and the pl-en span
} else {
break;
}
}
// Record the reference
references.push({
name: nameParts.join('.'),
startIndex,
endIndex: j,
parent: element
});
// Continue from where we left off
i = j;
} else {
// Recurse into child elements
if (child.type === 'element') {
walkElement(child);
}
i += 1;
}
}
}
// Start from root's children
for (const child of hast.children) {
if (child.type === 'element') {
walkElement(child);
}
}
return references;
}
/**
* Replaces type references in a HAST with their expanded definitions.
*
* @param hast - The HAST to modify (will be cloned)
* @param highlightedExports - Map of export names to their highlighted HAST definitions
* @returns A new HAST with references replaced, or the original if no replacements needed
*/
export function replaceTypeReferences(hast, highlightedExports) {
// Deep clone the HAST to avoid mutating the original
const cloned = JSON.parse(JSON.stringify(hast));
const references = collectTypeReferences(cloned);
// Process references in reverse order so indices remain valid
for (let i = references.length - 1; i >= 0; i -= 1) {
const ref = references[i];
// Check if this reference has a definition in our exports
const definition = highlightedExports[ref.name];
if (!definition) {
continue;
}
// Get the content to insert (children of the code element from the definition)
const defCodeElement = definition.children[0];
if (defCodeElement?.type !== 'element') {
continue;
}
const replacementChildren = defCodeElement.children || [];
// Check if replacement contains a function type (has '=>')
// Function types need parentheses in unions for correct parsing by Prettier
const replacementText = getHastTextContent(definition);
const needsParens = replacementText.includes('=>');
// Replace the reference span(s) with the definition content
const parent = ref.parent;
if (needsParens) {
const openParen = {
type: 'text',
value: '('
};
const closeParen = {
type: 'text',
value: ')'
};
parent.children.splice(ref.startIndex, ref.endIndex - ref.startIndex, openParen, ...JSON.parse(JSON.stringify(replacementChildren)), closeParen);
} else {
parent.children.splice(ref.startIndex, ref.endIndex - ref.startIndex, ...JSON.parse(JSON.stringify(replacementChildren)));
}
}
return cloned;
}
/**
* A JSON-serialized wrapper around a HastRoot. Defers tree allocation to
* render time: V8 stores only a string, and `JSON.parse` at render time
* provides both deserialization and a free deep clone.
*/
/**
* A DEFLATE-compressed (with shared dictionary), base64-encoded wrapper around a HastRoot.
* Smaller than JSON for transport; decompressed with the matching dictionary at render time.
*/
/** Controls the output format of HAST fields in type metadata. */
/** Converts a HastRoot to a JSON-serialized wrapper. */
export function serializeHastRoot(hast) {
return {
hastJson: JSON.stringify(hast)
};
}
/** Converts a HastRoot to a dictionary-compressed, base64-encoded wrapper. */
export function compressHastRoot(hast, textContent) {
return {
hastCompressed: compressHast(JSON.stringify(hast), textContent)
};
}
/** Returns the appropriate serializer function for the given output format. */
export function resolveSerializer(output, textContent) {
if (output === 'hastCompressed') {
return hast => compressHastRoot(hast, textContent);
}
if (output === 'hastJson') {
return serializeHastRoot;
}
return hastIdentity;
}
/** No-op passthrough — avoids allocating a fresh closure on every call. */
export function hastIdentity(hast) {
return hast;
}