@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
766 lines (721 loc) • 29 kB
JavaScript
import { visit } from 'unist-util-visit';
import { normalizeLanguage } from "../loaderUtils/getLanguageFromExtension.mjs";
/**
* Pattern to match inline code with language suffix like `code{:lang}`
* Captures:
* - Group 1: The code content (everything before `{:`)
* - Group 2: The language identifier
*
* Note: Using [\s\S] instead of . with /s flag for compatibility
*/
const INLINE_CODE_LANG_SUFFIX_PATTERN = /^([\s\S]+)\{:(\w+)\}$/;
/**
* Remark plugin that transforms code blocks with variants into semantic HTML structures.
*
* Transforms consecutive code blocks with variant metadata like:
*
* ```javascript variant=npm
* npm install @mui/internal-docs-infra
* ```
* ```javascript variant=pnpm
* pnpm install @mui/internal-docs-infra
* ```
* ```javascript variant=yarn
* yarn add @mui/internal-docs-infra
* ```
*
* OR with variant-type (with labels between):
*
* npm
* ```bash variant-type=install
* npm install @mui/internal-docs-infra
* ```
* pnpm
* ```bash variant-type=install
* pnpm install @mui/internal-docs-infra
* ```
* yarn
* ```bash variant-type=install
* yarn add @mui/internal-docs-infra
* ```
*
* OR individual code blocks with options:
*
* ```ts transform
* console.log('test' as const)
* ```
*
* Into semantic HTML that the existing rehype plugin can process:
* <section>
* <figure>
* <figcaption>npm variant</figcaption>
* <dl>
* <dt><code>index.js</code></dt>
* <dd>
* <pre><code class="language-bash">npm install @mui/internal-docs-infra</code></pre>
* </dd>
* </dl>
* </figure>
* <figure>
* <figcaption>pnpm variant</figcaption>
* <dl>
* <dt><code>index.js</code></dt>
* <dd>
* <pre><code class="language-shell">pnpm install @mui/internal-docs-infra</code></pre>
* </dd>
* </dl>
* </figure>
* </section>
*
* Or for individual blocks without filename (no dl wrapper needed):
* <pre><code class="language-typescript" data-transform="true">console.log('test' as const)</code></pre>
*
* Or with explicit filename (uses dl/dt/dd structure):
* <dl>
* <dt><code>example.ts</code></dt>
* <dd>
* <pre><code class="language-typescript">console.log('test' as const)</code></pre>
* </dd>
* </dl>
*
* Note: The `dl/dt/dd` structure is only used when an explicit `filename` prop is provided.
* Language information is passed via the `class="language-*"` attribute on the code element.
*
* Also handles inline code with language hints:
*
* ```md
* This is a `<Component />{:jsx}` example
* ```
*
* Becomes inline code with `class="language-jsx"` for syntax highlighting.
* The `{:lang}` suffix is removed from the displayed code.
*
* A default language can be set via `defaultInlineCodeLanguage` option to apply
* syntax highlighting to all inline code without explicit suffixes.
*/
/**
* Gets filename from explicit filename prop only.
* Does not derive filename from language - language is passed via className instead.
*/
function getFileName(props) {
// Only return explicit filename
if (props.filename) {
return props.filename;
}
return null;
}
/**
* Parse meta string to extract variant and other properties
*/
function parseMeta(meta) {
const result = {
props: {}
};
// Parse key=value pairs first, handling quoted values
const keyValueRegex = /([\w-]+)=("(?:[^"\\]|\\.)*"|[^\s]+)/g;
let match = keyValueRegex.exec(meta);
const processedPositions = [];
while (match !== null) {
const [fullMatch, key, rawValue] = match;
const startPos = match.index;
const endPos = match.index + fullMatch.length;
processedPositions.push([startPos, endPos]);
// Remove quotes if present
const value = rawValue.startsWith('"') && rawValue.endsWith('"') ? rawValue.slice(1, -1) : rawValue;
if (key === 'variant') {
result.variant = value;
} else if (key === 'variant-type') {
result.variantType = value;
} else {
result.props[key] = value;
}
match = keyValueRegex.exec(meta);
}
// Extract remaining parts as standalone flags
let remainingMeta = meta;
// Remove processed key=value pairs from the string (in reverse order to maintain positions)
processedPositions.sort((a, b) => b[0] - a[0]).forEach(([start, end]) => {
remainingMeta = remainingMeta.slice(0, start) + remainingMeta.slice(end);
});
// Process remaining standalone flags
const remainingParts = remainingMeta.trim().split(/\s+/).filter(Boolean);
for (const part of remainingParts) {
if (part === 'variant') {
// This shouldn't happen, but just in case
result.variant = 'true';
} else if (part === 'variant-type') {
// This shouldn't happen, but just in case
result.variantType = 'true';
} else {
// Handle standalone flags (e.g., "transform" becomes "transform": "true")
result.props[part] = 'true';
}
}
return result;
}
/**
* Processes inline code with language suffix like `code{:lang}`.
* Returns the code content and language if a suffix is found.
*/
function parseInlineCodeLanguage(value, defaultLanguage) {
const match = value.match(INLINE_CODE_LANG_SUFFIX_PATTERN);
if (match) {
return {
code: match[1],
language: match[2]
};
}
return {
code: value,
language: defaultLanguage
};
}
export const transformMarkdownCode = (options = {}) => {
const {
defaultInlineCodeLanguage = 'tsx'
} = options;
return tree => {
const processedIndices = new Set();
// First pass: handle inline code with language suffixes
visit(tree, 'inlineCode', node => {
const effectiveDefault = defaultInlineCodeLanguage === false ? undefined : defaultInlineCodeLanguage;
const {
code,
language
} = parseInlineCodeLanguage(node.value, effectiveDefault);
if (language) {
// Update the node value to remove the suffix
node.value = code;
// Add language class to be picked up by rehype
// This follows the standard convention: class="language-{lang}"
node.data = node.data || {};
node.data.hProperties = node.data.hProperties || {};
const hProperties = node.data.hProperties;
const existingClassName = hProperties.className;
if (Array.isArray(existingClassName)) {
existingClassName.push(`language-${language}`);
} else if (typeof existingClassName === 'string') {
hProperties.className = [existingClassName, `language-${language}`];
} else {
hProperties.className = [`language-${language}`];
}
}
});
// Second pass: handle code blocks with variants and options
visit(tree, (node, index, parent) => {
if (!parent || !Array.isArray(parent.children) || typeof index !== 'number') {
return;
}
// Skip if already processed
if (processedIndices.has(index)) {
return;
}
const parentNode = parent;
// Look for code blocks with variant metadata or options
if (node.type === 'code') {
const codeNode = node;
// Check if variant metadata is in meta field or lang field (when no language is specified)
let metaString = codeNode.meta;
let langFromMeta = codeNode.lang || null;
// If meta is empty but lang contains '=', it means variant info is in lang
if (!metaString && codeNode.lang && codeNode.lang.includes('=')) {
metaString = codeNode.lang;
langFromMeta = null;
}
// Check if we have variants/variant-types or individual options
let metaData = {
props: {}
};
if (metaString) {
metaData = parseMeta(metaString);
}
// Use props from meta as the options for individual blocks
const allProps = metaData.props;
// Handle individual code blocks with options (but no variants)
if (!metaData.variant && !metaData.variantType && Object.keys(allProps).length > 0) {
const codeHProperties = {};
// Add normalized language as class
if (langFromMeta) {
codeHProperties.className = `language-${normalizeLanguage(langFromMeta)}`;
}
// Add all props as data attributes (in camelCase)
Object.entries(allProps).forEach(([key, value]) => {
// Convert kebab-case to camelCase for data attributes
const camelKey = key.includes('-') ? `data${key.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('')}` : `data${key.charAt(0).toUpperCase() + key.slice(1)}`;
codeHProperties[camelKey] = value;
});
const fileName = getFileName(allProps);
// If there's a filename, wrap in dl/dt/dd structure
if (fileName) {
// Create pre > code element (custom structure for filename display)
const preElement = {
type: 'element',
tagName: 'pre',
data: {
hName: 'pre',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'code',
data: {
hName: 'code',
hProperties: codeHProperties
},
children: [{
type: 'text',
value: codeNode.value
}]
}]
};
const outputElement = {
type: 'element',
tagName: 'dl',
data: {
hName: 'dl',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'dt',
data: {
hName: 'dt',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'code',
data: {
hName: 'code',
hProperties: {}
},
children: [{
type: 'text',
value: fileName
}]
}]
}, {
type: 'element',
tagName: 'dd',
data: {
hName: 'dd',
hProperties: {}
},
children: [preElement]
}]
};
// Replace this individual code block with the wrapped structure
parentNode.children[index] = outputElement;
} else {
// No filename - just add properties to the existing MDAST code node
// This preserves the original code structure and lets remarkRehype handle it properly
codeNode.data = codeNode.data || {};
codeNode.data.hProperties = {
...codeNode.data.hProperties,
...codeHProperties
};
}
processedIndices.add(index);
return;
}
// Handle variant/variant-type logic (existing code)
if (!metaString) {
return;
}
if (metaData.variant || metaData.variantType) {
// Collect consecutive code blocks that belong together
const codeBlocks = [];
let currentIndex = index;
// For variant-type, look for pattern: [label] -> code block
// For variant, look for adjacent code blocks only
if (metaData.variantType) {
// Add the current code block as the first one for variant-type
let currentLabelFromPrevious;
if (index > 0) {
const prevNode = parentNode.children[index - 1];
if (prevNode.type === 'paragraph' && prevNode.children.length === 1 && prevNode.children[0].type === 'text') {
currentLabelFromPrevious = prevNode.children[0].value.trim();
}
}
codeBlocks.push({
node: codeNode,
index,
variant: currentLabelFromPrevious || metaData.variantType || 'default',
props: allProps,
actualLang: langFromMeta,
labelFromPrevious: currentLabelFromPrevious
});
processedIndices.add(index);
// Start looking from the next element
currentIndex = index + 1;
// Collect all blocks with the same variant-type
while (currentIndex < parentNode.children.length) {
const currentNode = parentNode.children[currentIndex];
// Check if this is a potential label paragraph
if (currentNode.type === 'paragraph' && currentNode.children.length === 1 && currentNode.children[0].type === 'text') {
// Look for a code block after this paragraph
if (currentIndex + 1 < parentNode.children.length) {
const nextNode = parentNode.children[currentIndex + 1];
if (nextNode.type === 'code') {
// Check if this code block has the same variant-type
let nextMetaString = nextNode.meta;
let nextActualLang = nextNode.lang;
if (!nextMetaString && nextActualLang && nextActualLang.includes('=')) {
nextMetaString = nextActualLang;
nextActualLang = null;
}
if (nextMetaString) {
const nextMetaData = parseMeta(nextMetaString);
if (nextMetaData.variantType === metaData.variantType) {
const labelFromPrevious = currentNode.children[0].value.trim();
codeBlocks.push({
node: nextNode,
index: currentIndex + 1,
variant: labelFromPrevious || nextMetaData.variantType || 'default',
props: nextMetaData.props,
actualLang: nextActualLang,
labelFromPrevious
});
processedIndices.add(currentIndex + 1);
// Skip the code block and move to next potential label
currentIndex += 2;
continue;
}
}
}
}
// If we didn't find a matching code block, break
break;
}
if (currentNode.type === 'code') {
// Parse language and meta for current node
let currentMetaString = currentNode.meta;
let currentActualLang = currentNode.lang;
if (!currentMetaString && currentActualLang && currentActualLang.includes('=')) {
currentMetaString = currentActualLang;
currentActualLang = null;
}
if (currentMetaString) {
const currentMetaData = parseMeta(currentMetaString);
if (currentMetaData.variantType === metaData.variantType) {
// Look for label before this code block
let labelFromPrevious;
if (currentIndex > 0) {
const prevNode = parentNode.children[currentIndex - 1];
if (prevNode.type === 'paragraph' && prevNode.children.length === 1 && prevNode.children[0].type === 'text') {
labelFromPrevious = prevNode.children[0].value.trim();
}
}
codeBlocks.push({
node: currentNode,
index: currentIndex,
variant: labelFromPrevious || currentMetaData.variantType || 'default',
props: currentMetaData.props,
actualLang: currentActualLang,
labelFromPrevious
});
processedIndices.add(currentIndex);
// Skip the label and move to next potential code block
currentIndex += 1;
// Skip ahead past any paragraph that could be a label
if (currentIndex < parentNode.children.length) {
const nextNode = parentNode.children[currentIndex];
if (nextNode.type === 'paragraph' && nextNode.children.length === 1 && nextNode.children[0].type === 'text') {
currentIndex += 1; // Skip the potential label
}
}
} else {
break; // Different group, stop collecting
}
} else {
break; // No meta, stop collecting
}
} else {
break; // Not a code block, stop collecting
}
}
} else if (metaData.variant) {
// Add the current code block as the first one for variant
codeBlocks.push({
node: codeNode,
index,
variant: metaData.variant,
props: allProps,
actualLang: langFromMeta
});
processedIndices.add(index);
// Start looking from the next element
currentIndex = index + 1;
// Collect adjacent code blocks with variants
while (currentIndex < parentNode.children.length) {
const currentNode = parentNode.children[currentIndex];
if (currentNode.type === 'code') {
// Parse language and meta for current node
let currentMetaString = currentNode.meta;
let currentActualLang = currentNode.lang;
if (!currentMetaString && currentActualLang && currentActualLang.includes('=')) {
currentMetaString = currentActualLang;
currentActualLang = null;
}
if (currentMetaString) {
const currentMetaData = parseMeta(currentMetaString);
if (currentMetaData.variant) {
codeBlocks.push({
node: currentNode,
index: currentIndex,
variant: currentMetaData.variant,
props: currentMetaData.props,
actualLang: currentActualLang
});
processedIndices.add(currentIndex);
currentIndex += 1;
} else {
break; // No variant, stop collecting
}
} else {
break; // No meta, stop collecting
}
} else {
break; // Not a code block, stop collecting
}
}
}
// Only process if we have multiple blocks
if (codeBlocks.length > 1) {
// Create section with figure elements for each variant
const sectionElement = {
type: 'element',
tagName: 'section',
data: {
hName: 'section',
hProperties: {}
},
children: codeBlocks.map(block => {
// Build hProperties for HTML attributes
const codeHProperties = {};
// Add normalized language as class
if (block.actualLang) {
codeHProperties.className = `language-${normalizeLanguage(block.actualLang)}`;
}
// Add additional props as data attributes (in camelCase)
Object.entries(block.props).forEach(([key, value]) => {
// Convert kebab-case to camelCase for data attributes
const camelKey = key.includes('-') ? `data${key.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('')}` : `data${key.charAt(0).toUpperCase() + key.slice(1)}`;
codeHProperties[camelKey] = value;
});
// Add data-variant to track the variant
codeHProperties.dataVariant = block.variant;
const fileName = getFileName(block.props);
return {
type: 'element',
tagName: 'figure',
data: {
hName: 'figure',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'figcaption',
data: {
hName: 'figcaption',
hProperties: {}
},
children: [{
type: 'text',
value: `${block.variant} variant`
}]
}, {
type: 'element',
tagName: 'dl',
data: {
hName: 'dl',
hProperties: {}
},
children: [...(fileName ? [{
type: 'element',
tagName: 'dt',
data: {
hName: 'dt',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'code',
data: {
hName: 'code',
hProperties: {}
},
children: [{
type: 'text',
value: fileName
}]
}]
}] : []), {
type: 'element',
tagName: 'dd',
data: {
hName: 'dd',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'pre',
data: {
hName: 'pre',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'code',
data: {
hName: 'code',
hProperties: codeHProperties,
meta: `variant=${block.variant}${Object.entries(block.props).map(([key, value]) => ` ${key}=${value}`).join('')}`
},
children: [{
type: 'text',
value: block.node.value
}]
}]
}]
}]
}]
};
})
};
// Replace the first block with the group and mark others for removal
parentNode.children[codeBlocks[0].index] = sectionElement;
// Remove all other code blocks and their labels (in reverse order to maintain indices)
const indicesToRemove = codeBlocks.slice(1).map(block => {
const indices = [block.index];
// Also include label paragraph if it exists
if (block.labelFromPrevious && block.index > 0) {
const prevNode = parentNode.children[block.index - 1];
if (prevNode.type === 'paragraph') {
indices.push(block.index - 1);
}
}
return indices;
}).flat().sort((a, b) => b - a); // Sort in descending order
// Remove the marked indices
for (const removeIdx of indicesToRemove) {
if (removeIdx < parentNode.children.length) {
parentNode.children.splice(removeIdx, 1);
// Update processed indices to account for removed elements
const updatedProcessedIndices = new Set();
processedIndices.forEach(processedIdx => {
if (processedIdx > removeIdx) {
updatedProcessedIndices.add(processedIdx - 1);
} else if (processedIdx < removeIdx) {
updatedProcessedIndices.add(processedIdx);
}
// Don't add the removed index
});
processedIndices.clear();
updatedProcessedIndices.forEach(processedIdx => {
processedIndices.add(processedIdx);
});
}
}
// Also remove the label of the first block if it exists
if (codeBlocks[0].labelFromPrevious && codeBlocks[0].index > 0) {
const labelIndex = codeBlocks[0].index - 1;
const prevNode = parentNode.children[labelIndex];
if (prevNode && prevNode.type === 'paragraph') {
parentNode.children.splice(labelIndex, 1);
// Update processed indices
const updatedProcessedIndices = new Set();
processedIndices.forEach(processedIdx => {
if (processedIdx > labelIndex) {
updatedProcessedIndices.add(processedIdx - 1);
} else if (processedIdx < labelIndex) {
updatedProcessedIndices.add(processedIdx);
}
});
processedIndices.clear();
updatedProcessedIndices.forEach(processedIdx => {
processedIndices.add(processedIdx);
});
}
}
} else if (codeBlocks.length === 1) {
// Single code block with variant - create a simple dl without figure wrapper
const block = codeBlocks[0];
const codeHProperties = {};
// Add normalized language as class
if (block.actualLang) {
codeHProperties.className = `language-${normalizeLanguage(block.actualLang)}`;
}
// Add additional props as data attributes (in camelCase)
Object.entries(block.props).forEach(([key, value]) => {
// Convert kebab-case to camelCase for data attributes
const camelKey = key.includes('-') ? `data${key.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('')}` : `data${key.charAt(0).toUpperCase() + key.slice(1)}`;
codeHProperties[camelKey] = value;
});
// Add data-variant to track the variant
codeHProperties.dataVariant = block.variant;
const fileName = getFileName(block.props);
const dlElement = {
type: 'element',
tagName: 'dl',
data: {
hName: 'dl',
hProperties: {}
},
children: [...(fileName ? [{
type: 'element',
tagName: 'dt',
data: {
hName: 'dt',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'code',
data: {
hName: 'code',
hProperties: {}
},
children: [{
type: 'text',
value: fileName
}]
}]
}] : []), {
type: 'element',
tagName: 'dd',
data: {
hName: 'dd',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'pre',
data: {
hName: 'pre',
hProperties: {}
},
children: [{
type: 'element',
tagName: 'code',
data: {
hName: 'code',
hProperties: codeHProperties,
meta: `variant=${block.variant}${Object.entries(block.props).map(([key, value]) => ` ${key}=${value}`).join('')}`
},
children: [{
type: 'text',
value: block.node.value
}]
}]
}]
}]
};
// Replace this single code block
parentNode.children[codeBlocks[0].index] = dlElement;
}
}
}
});
};
};