UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

471 lines (443 loc) 18.8 kB
import { visit } from 'unist-util-visit'; import { getHastTextContent } from "../loadServerTypes/hastTypeUtils.mjs"; import { loadIsomorphicCodeVariant } from "../loadIsomorphicCodeVariant/loadIsomorphicCodeVariant.mjs"; import { createParseSource } from "../parseSource/index.mjs"; import { TypescriptToJavascriptTransformer } from "../transformTypescriptToJavascript/index.mjs"; import { IGNORE_COMMENT_PREFIXES, parseImportsAndComments } from "../loaderUtils/index.mjs"; import { createEnhanceCodeEmphasis, EMPHASIS_COMMENT_PREFIX, FOCUS_COMMENT_PREFIX } from "../enhanceCodeEmphasis/enhanceCodeEmphasis.mjs"; const DEFAULT_PADDING_FRAME_MAX_SIZE = 25; const DEFAULT_FOCUS_FRAMES_MAX_SIZE = 60; /** * Reserved data properties that are handled internally and should not be passed to userProps. * These are either processed by the transform pipeline or have special meaning. */ const RESERVED_DATA_PROPS = new Set(['dataFilename', // Used for fileName 'dataVariant', // Used for variant name 'dataTransform', // Used for skipTransforms 'dataPrecompute', // The precomputed output itself 'dataContentProps', // The serialized user props output 'dataName', // Used for demo name 'dataSlug', // Used for demo slug/URL 'dataDisplayComments' // Used to preserve @highlight comments in displayed code ]); /** * Extracts user-defined data properties from a code element. * Filters out reserved properties and returns remaining data-* attributes. * Converts from camelCase (dataTitle) to kebab-case keys (title). */ function parseDataBoolean(value) { if (value === true || value === 'true' || value === '') { return true; } if (value === false || value === 'false') { return false; } return undefined; } function extractUserProps(codeElement) { const props = codeElement.properties; if (!props) { return undefined; } const userProps = {}; for (const [key, value] of Object.entries(props)) { // Only process data-* attributes (in camelCase form: dataXxx) if (key.startsWith('data') && key.length > 4 && !RESERVED_DATA_PROPS.has(key)) { // Convert dataTitle -> title, dataHighlight -> highlight const propName = key.charAt(4).toLowerCase() + key.slice(5); if (propName === 'collapseToEmpty' || propName === 'initialExpanded') { const parsed = parseDataBoolean(value); if (parsed !== undefined) { userProps[propName] = parsed; } continue; } // Convert value to string userProps[propName] = String(value); } } return Object.keys(userProps).length > 0 ? userProps : undefined; } /** * Gets the filename from data-filename attribute only * Returns undefined if no explicit filename is provided */ function getFileName(codeElement) { // Check for explicit data-filename attribute const dataFilename = codeElement.properties?.dataFilename; if (dataFilename && typeof dataFilename === 'string') { return dataFilename; } return undefined; } /** * Extracts language from a className like "language-typescript" or "language-js" * Returns the language portion after "language-" prefix */ function extractLanguageFromClassName(className) { if (!className) { return undefined; } const classes = Array.isArray(className) ? className : [className]; for (const cls of classes) { if (typeof cls === 'string' && cls.startsWith('language-')) { return cls.slice('language-'.length); } } return undefined; } /** * Gets the language from class="language-*" attribute */ function getLanguage(codeElement) { const className = codeElement.properties?.className; return extractLanguageFromClassName(className); } /** * JSX languages where trailing semicolons on solo JSX expression lines * should be stripped. These are artifacts of how MDX/JSX is parsed. */ const JSX_LANGUAGES = new Set(['jsx', 'tsx']); /** * Strips a trailing semicolon from a JSX expression. * * In JSX/TSX code blocks, expressions like `<Component />;` are common artifacts * from MDX parsing. If the source ends with `>;`, the trailing `;` is removed. */ function stripJsxExpressionSemicolon(source) { if (source.endsWith('>;\n')) { return source.slice(0, -2); } if (source.endsWith('>;')) { return source.slice(0, -1); } return source; } /** * A single code element extracted from a dl pair (dt for the filename, dd for the code). * Multiple files may belong to the same variant. */ /** * A variant extracted from the semantic structure. A variant maps to one figure * inside a section (multi-variant) or to a single standalone dl/pre (single variant). * The first entry in `files` is treated as the variant's main source; any * subsequent entries become `extraFiles`. */ /** * Extracts variants and their files from semantic HTML structure. * Handles both `<section>` (with one or more `<figure>` children) and standalone `<dl>`. */ function extractCodeFromSemanticStructure(element) { const results = []; if (element.tagName === 'section') { // Handle section with multiple figures const figures = element.children.filter(child => child.type === 'element' && child.tagName === 'figure'); for (const figure of figures) { // Extract variant name from figcaption (the literal " variant" suffix is stripped) let variantName; const figcaption = figure.children.find(child => child.type === 'element' && child.tagName === 'figcaption'); if (figcaption && figcaption.children[0] && figcaption.children[0].type === 'text') { variantName = figcaption.children[0].value.replace(' variant', ''); } // Find dl element in figure const dl = figure.children.find(child => child.type === 'element' && child.tagName === 'dl'); if (dl) { const files = extractFromDl(dl); if (files.length > 0) { // figcaption takes precedence; data-variant on the first code element is a fallback. const firstDataVariant = files[0].codeElement.properties?.dataVariant; results.push({ variantName: variantName || firstDataVariant, files }); } } } } else if (element.tagName === 'dl') { // Handle standalone dl const files = extractFromDl(element); if (files.length > 0) { const firstDataVariant = files[0].codeElement.properties?.dataVariant; results.push({ variantName: firstDataVariant, files }); } } return results; } /** * Extracts every dt/dd pair from a `<dl>` element. * Pairs are matched positionally: each `<dt>` is bound to the first subsequent * `<dd>` containing a `<pre><code>` element. A `<dd>` without a preceding `<dt>` * is treated as a file with no explicit filename. */ function extractFromDl(dl) { const files = []; let pendingFilename; let hasPendingFilename = false; for (const child of dl.children) { if (child.type !== 'element') { continue; } if (child.tagName === 'dt') { // Extract filename from dt > code const codeInDt = child.children.find(dtChild => dtChild.type === 'element' && dtChild.tagName === 'code'); if (codeInDt && codeInDt.children[0] && codeInDt.children[0].type === 'text') { pendingFilename = codeInDt.children[0].value; } else { pendingFilename = undefined; } hasPendingFilename = true; } else if (child.tagName === 'dd') { // Extract code from dd > pre > code const pre = child.children.find(ddChild => ddChild.type === 'element' && ddChild.tagName === 'pre'); if (!pre) { continue; } const codeElement = pre.children.find(preChild => preChild.type === 'element' && preChild.tagName === 'code'); if (!codeElement) { continue; } files.push({ codeElement, filename: hasPendingFilename ? pendingFilename : undefined, language: getLanguage(codeElement) }); pendingFilename = undefined; hasPendingFilename = false; } } return files; } /** * Rehype plugin that transforms semantic HTML code structures to use loadIsomorphicCodeVariant * * This plugin: * 1. Finds section and dl elements in the HTML AST * 2. Extracts code elements from the semantic structure (figure/dl/dd/pre/code) * 3. Creates variants from multiple code elements or single Default variant * 4. Uses loadIsomorphicCodeVariant to process each variant * 5. Stores the combined precompute data on the root element * 6. Clears all code element contents and replaces with error message */ export const transformHtmlCodeBlock = (options = {}) => { return async tree => { const transformPromises = []; // Get the source parser, transformers, and enhancers const sourceParser = createParseSource(); const sourceTransformers = [TypescriptToJavascriptTransformer]; const sourceEnhancers = [createEnhanceCodeEmphasis({ paddingFrameMaxSize: options.paddingFrameMaxSize ?? DEFAULT_PADDING_FRAME_MAX_SIZE, focusFramesMaxSize: options.focusFramesMaxSize ?? DEFAULT_FOCUS_FRAMES_MAX_SIZE, oversizedFocus: options.oversizedFocus })]; visit(tree, 'element', node => { let extractedVariants = []; // Handle basic pre > code structure from standard markdown if (node.tagName === 'pre' && node.children && node.children.length > 0 && !node.properties?.dataPrecompute // Don't process if already processed ) { // Look for direct code element in pre const codeElement = node.children.find(child => child.type === 'element' && child.tagName === 'code'); if (codeElement) { // Extract filename from data-filename attribute (explicit only) const filename = getFileName(codeElement); // Extract language from className const language = getLanguage(codeElement); extractedVariants = [{ variantName: undefined, // Basic pre > code doesn't have variants files: [{ codeElement, filename, language }] }]; } } // Look for section elements (multi-variant) or dl elements (single variant) else if ((node.tagName === 'section' || node.tagName === 'dl') && node.children && node.children.length > 0) { // Extract variants (each with one or more files) from semantic structure extractedVariants = extractCodeFromSemanticStructure(node); } if (extractedVariants.length > 0) { const transformPromise = (async () => { try { // Process a single file (dt/dd pair or bare pre>code) into the fields // needed to populate a VariantCode `source` or `extraFiles` entry. const processFile = async file => { let sourceCode = getHastTextContent(file.codeElement); const derivedFilename = file.filename || getFileName(file.codeElement); // Strip trailing semicolon from JSX expressions if (file.language && JSX_LANGUAGES.has(file.language)) { sourceCode = stripJsxExpressionSemicolon(sourceCode); } // Check if displayComments is enabled - if so, don't strip comments const displayComments = file.codeElement.properties?.dataDisplayComments === 'true'; // Parse the source to extract @highlight comments // When displayComments is true, we only collect comments but don't strip them const parseResult = await parseImportsAndComments(sourceCode, derivedFilename || 'code.txt', { removeCommentsWithPrefix: displayComments ? undefined : [EMPHASIS_COMMENT_PREFIX, FOCUS_COMMENT_PREFIX, ...IGNORE_COMMENT_PREFIXES], notableCommentsPrefix: [EMPHASIS_COMMENT_PREFIX, FOCUS_COMMENT_PREFIX] }); return { fileName: derivedFilename, language: file.language, source: parseResult.code ?? sourceCode, comments: parseResult.comments, skipTransforms: !file.codeElement.properties?.dataTransform }; }; // Build a VariantCode for each extracted variant. The first file // populates `source`/`fileName`/`language`/`comments`; any additional // files become `extraFiles` entries on the same variant. const buildVariant = async (extracted, index) => { const processedFiles = await Promise.all(extracted.files.map(processFile)); const [mainFile, ...restFiles] = processedFiles; const variant = { source: mainFile.source, skipTransforms: mainFile.skipTransforms, comments: mainFile.comments }; if (mainFile.fileName) { variant.fileName = mainFile.fileName; } if (mainFile.language) { variant.language = mainFile.language; } if (restFiles.length > 0) { const extraFiles = {}; for (const extra of restFiles) { // Files without an explicit filename can't be addressed as extra files; skip. if (!extra.fileName) { continue; } const entry = { source: extra.source, skipTransforms: extra.skipTransforms }; if (extra.language) { entry.language = extra.language; } if (extra.comments) { entry.comments = extra.comments; } extraFiles[extra.fileName] = entry; } if (Object.keys(extraFiles).length > 0) { variant.extraFiles = extraFiles; } } const variantName = extracted.variantName || (index === 0 ? 'Default' : `Variant ${index + 1}`); return { variantName, variant }; }; const builtVariants = await Promise.all(extractedVariants.map(buildVariant)); const variants = {}; for (const { variantName, variant } of builtVariants) { variants[variantName] = variant; } // Process each variant with loadIsomorphicCodeVariant const processedCode = {}; const variantPromises = Object.entries(variants).map(async ([variantName, variantData]) => { if (variantData && typeof variantData === 'object') { const result = await loadIsomorphicCodeVariant(undefined, // url - not needed for inline code variantName, variantData, { sourceParser, loadSource: undefined, // loadSource - not needed since we have the data loadVariantMeta: undefined, // loadVariantMeta - not needed since we have the data sourceTransformers, sourceEnhancers, // For @highlight emphasis comments disableTransforms: variantData.skipTransforms || false, // TODO: output option output: 'hastCompressed' }); return { variantName, processedVariant: result.code }; } return null; }); const variantResults = await Promise.all(variantPromises); for (const result of variantResults) { if (result) { processedCode[result.variantName] = result.processedVariant; } } // The first code element of the first variant carries the // top-level metadata (user props, name, slug) for the demo. const firstCodeElement = extractedVariants[0].files[0].codeElement; // Extract user props from the first code element. Per-block render // flags (e.g. ` ```ts collapseToEmpty ` / ` ```ts initialExpanded `) // arrive as `data-*` attributes and flow through here as content // props. When the block sets no flag, fall back to the transform's // matching option so it can default every block. let userProps = extractUserProps(firstCodeElement); if (firstCodeElement.properties?.dataCollapseToEmpty === undefined && options.collapseToEmpty) { userProps = { ...(userProps ?? {}), collapseToEmpty: true }; } if (firstCodeElement.properties?.dataInitialExpanded === undefined && options.initialExpanded) { userProps = { ...(userProps ?? {}), initialExpanded: true }; } // Clear all code element contents (across every variant and every file) for (const extracted of extractedVariants) { for (const file of extracted.files) { file.codeElement.children = []; } } // Replace the semantic structure with a <pre> element node.tagName = 'pre'; node.children = [{ type: 'text', value: 'Error: expected pre tag with precomputed data to be handled by the CodeHighlighter component' }]; // Set the precompute data on the pre element directly on properties for immediate HTML serialization if (!node.properties) { node.properties = {}; } node.properties.dataPrecompute = JSON.stringify(processedCode); // Pass through name and slug if provided on the code element if (firstCodeElement.properties?.dataName) { node.properties.dataName = firstCodeElement.properties.dataName; } if (firstCodeElement.properties?.dataSlug) { node.properties.dataSlug = firstCodeElement.properties.dataSlug; } // Set user props if any exist if (userProps) { node.properties.dataContentProps = JSON.stringify(userProps); } } catch (error) { console.warn('Failed to transform code block:', error); } })(); transformPromises.push(transformPromise); } }); // Wait for all transformations to complete await Promise.all(transformPromises); }; };