@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
1,497 lines (1,416 loc) • 52.1 kB
JavaScript
import * as path from 'path-module';
import { fileUrlToPortablePath, portablePathToFileUrl } from "./fileUrlToPortablePath.mjs";
/**
* Resolves a relative import path against the URL/path of the importing file.
*
* - For `http://` and `https://` files, uses WHATWG `URL` resolution so that
* demos can be parsed straight out of remote sources (e.g. GitHub) without
* first being mapped onto a placeholder `file://` URL.
* - For everything else, falls back to POSIX `path.resolve` against the
* portable path form, which preserves the existing cross-platform behavior
* for local files.
*/
function resolveRelativeImport(baseFilePath, modulePath) {
if (baseFilePath.startsWith('http://') || baseFilePath.startsWith('https://')) {
return new URL(modulePath, baseFilePath).href;
}
return portablePathToFileUrl(path.resolve(path.dirname(baseFilePath), modulePath));
}
/**
* Comment prefixes for tool-specific ignore directives that should be stripped
* from documentation code blocks by default. These comments are noise in docs
* and don't provide value to the reader.
*/
export const IGNORE_COMMENT_PREFIXES = ['prettier-ignore', 'eslint-disable', '@ts-ignore', '@ts-expect-error', '@ts-nocheck'];
/**
* Represents a single import name with its properties.
*/
/**
* Represents the position of an import path in the source code.
*/
/**
* Represents an import from a relative path (starts with ./ or ../).
*/
/**
* Represents an import from an external package (node_modules).
*/
/**
* The result of parsing import statements from source code.
*/
/**
* Checks if a character starts a string literal.
* @param ch - The character to check
* @param withinMdx - Whether we're parsing within an MDX file (affects quote handling)
* @returns True if the character starts a string literal
*/
function isStringStart(ch, withinMdx) {
if (withinMdx) {
// quotes in MDX don't create strings
return ch === '`';
}
return ch === '"' || ch === "'" || ch === '`';
}
/**
* Checks if a comment matches any of the specified prefixes for removal.
* @param commentText - The full comment text including comment markers
* @param removeCommentsWithPrefix - Array of prefixes to match against
* @returns True if the comment starts with any of the specified prefixes
*/
function matchesCommentPrefix(commentText, removeCommentsWithPrefix) {
return removeCommentsWithPrefix.some(prefix => {
// For single-line comments, check after the //
if (commentText.startsWith('//')) {
const content = commentText.slice(2).trim();
return content.startsWith(prefix);
}
// For multi-line comments, check after the /*
if (commentText.startsWith('/*')) {
const content = commentText.slice(2, -2).trim();
return content.startsWith(prefix);
}
return false;
});
}
/**
* Removes comment markers from comment text and returns the content as an array of lines.
* @param commentText - The full comment text including markers
* @returns Array of comment content lines with markers removed and whitespace trimmed
*/
function stripCommentMarkers(commentText) {
// For single-line comments, remove // and trim, return as single-item array
if (commentText.startsWith('//')) {
return [commentText.slice(2).trim()];
}
// For multi-line comments, remove /* and */, split by lines, and trim each line
if (commentText.startsWith('/*') && commentText.endsWith('*/')) {
const content = commentText.slice(2, -2);
return content.split('\n').map(line => line.trim()).filter(line => line !== '');
}
// Fallback: return as single-item array if format is unexpected
return [commentText];
}
/**
* Counts consecutive backticks starting at a given position (used for MDX code blocks).
* @param sourceText - The source text to scan
* @param startPos - The position to start counting from
* @returns The number of consecutive backticks found
*/
function countBackticks(sourceText, startPos) {
let count = 0;
let pos = startPos;
while (pos < sourceText.length && sourceText[pos] === '`') {
count += 1;
pos += 1;
}
return count;
}
/**
* Generic function to scan source code character-by-character, finding import statements
* while correctly handling strings, comments, and template literals. Optionally processes
* comments for removal and collection.
*
* @param sourceCode - The source code to scan
* @param importDetector - Function that detects import statements at a given position
* @param isMdxFile - Whether this is an MDX file (affects string and code block handling)
* @param removeCommentsWithPrefix - Optional array of prefixes for comments to remove
* @param notableCommentsPrefix - Optional array of prefixes for comments to collect
* @returns Object containing found import statements and optionally processed code/comments
*/
function scanForImports(sourceCode, importDetector, isMdxFile, removeCommentsWithPrefix, notableCommentsPrefix) {
const statements = [];
const comments = {};
const shouldProcessComments = !!(removeCommentsWithPrefix || notableCommentsPrefix);
// Only map positions when actually stripping comments (code will differ from source)
const shouldMapPositions = !!removeCommentsWithPrefix;
let result = shouldProcessComments ? '' : sourceCode;
// Track whether any comment was actually stripped (not just that the option was provided)
let anyCommentStripped = false;
// Position mapping from original source to processed source (after comment removal)
const positionMapping = new Map();
let processedPos = 0;
// Helper to check if a comment matches notable prefix
const matchesNotablePrefix = commentText => {
if (!notableCommentsPrefix || notableCommentsPrefix.length === 0) {
return false; // If no notable prefix specified, don't match any comments as notable
}
return notableCommentsPrefix.some(prefix => {
if (commentText.startsWith('//')) {
const content = commentText.slice(2).trim();
return content.startsWith(prefix);
}
if (commentText.startsWith('/*')) {
const content = commentText.slice(2, -2).trim();
return content.startsWith(prefix);
}
return false;
});
};
let i = 0;
let outputLine = 0; // Line number in output code after comment removal
const len = sourceCode.length;
let state = 'code';
let stringQuote = null;
let codeblockBacktickCount = 0; // Track how many backticks opened the current code block
// Comment stripping variables
let commentStart = 0;
let commentStartOutputLine = 0;
let lineStartPos = 0;
let preCommentContent = '';
while (i < len) {
const ch = sourceCode[i];
const next = sourceCode[i + 1];
if (state === 'code') {
// Track line numbers for newlines in code
if (ch === '\n') {
if (shouldProcessComments) {
result += ch;
processedPos += 1;
}
outputLine += 1;
lineStartPos = i + 1;
i += 1;
continue;
}
// Check for backtick sequences (3 or more backticks start code blocks in MDX)
if (isMdxFile && ch === '`') {
// Count consecutive backticks
const backtickCount = countBackticks(sourceCode, i);
if (backtickCount >= 3) {
state = 'codeblock';
codeblockBacktickCount = backtickCount;
if (shouldProcessComments) {
result += sourceCode.slice(i, i + backtickCount);
processedPos += backtickCount;
}
i += backtickCount;
continue;
}
}
// Start of single-line comment
if (ch === '/' && next === '/') {
if (shouldProcessComments) {
commentStart = i;
commentStartOutputLine = outputLine;
// Remove content that was already added to result for this line
const contentSinceLineStart = sourceCode.slice(lineStartPos, commentStart);
result = result.slice(0, result.length - contentSinceLineStart.length);
processedPos -= contentSinceLineStart.length;
preCommentContent = contentSinceLineStart;
}
state = 'singleline-comment';
i += 2;
continue;
}
// Start of multi-line comment
if (ch === '/' && next === '*') {
if (shouldProcessComments) {
commentStart = i;
commentStartOutputLine = outputLine;
// Remove content that was already added to result for this line
const contentSinceLineStart = sourceCode.slice(lineStartPos, commentStart);
result = result.slice(0, result.length - contentSinceLineStart.length);
processedPos -= contentSinceLineStart.length;
preCommentContent = contentSinceLineStart;
}
state = 'multiline-comment';
i += 2;
continue;
}
// Start of string
if (isStringStart(ch, isMdxFile)) {
state = ch === '`' ? 'template' : 'string';
stringQuote = ch;
if (shouldProcessComments) {
result += ch;
processedPos += 1;
}
i += 1;
continue;
}
// Update position mapping for current position
if (shouldProcessComments) {
positionMapping.set(i, processedPos);
}
// Create position mapper function
const positionMapper = originalPos => {
if (!shouldMapPositions) {
return originalPos; // No comment stripping, positions are unchanged
}
// Find the closest mapped position
let closest = 0;
positionMapping.forEach((procPos, origPos) => {
if (origPos <= originalPos && origPos > closest) {
closest = origPos;
}
});
const offset = originalPos - closest;
return (positionMapping.get(closest) || 0) + offset;
};
// Use the provided import detector on the original source code
const detection = importDetector(sourceCode, i, positionMapper);
if (detection.found) {
if (detection.statement) {
statements.push(detection.statement);
}
// Copy the detected import to result if we're building one
if (shouldProcessComments) {
const importText = sourceCode.slice(i, detection.nextPos);
result += importText;
processedPos += importText.length;
// Count newlines in multi-line imports to keep outputLine accurate
for (let j = 0; j < importText.length; j += 1) {
if (importText[j] === '\n') {
outputLine += 1;
lineStartPos = i + j + 1;
}
}
}
i = detection.nextPos;
continue;
}
if (shouldProcessComments) {
result += ch;
processedPos += 1;
}
i += 1;
continue;
}
if (state === 'singleline-comment') {
if (ch === '\n') {
if (shouldProcessComments) {
// End of single-line comment
const commentText = sourceCode.slice(commentStart, i);
const shouldStrip = removeCommentsWithPrefix && matchesCommentPrefix(commentText, removeCommentsWithPrefix);
const isNotable = matchesNotablePrefix(commentText);
// Collect comments if they're notable (all stripped comments when no prefix specified, or notable comments when prefix specified)
const shouldCollect = shouldStrip && !notableCommentsPrefix || isNotable;
if (shouldCollect) {
// Record the comment 1-indexed: `Code` comments are always 1-indexed (the
// convention the enhancers match against the `dataLn` line gutter). `outputLine`
// is tracked 0-indexed internally, so add one at the point of storage.
const commentLine = commentStartOutputLine + 1;
if (!comments[commentLine]) {
comments[commentLine] = [];
}
comments[commentLine].push(...stripCommentMarkers(commentText));
}
if (shouldStrip) {
anyCommentStripped = true;
// Check if comment is the only thing on its line (ignoring whitespace)
const isCommentOnlyLine = preCommentContent.trim() === '';
if (isCommentOnlyLine) {
// Don't add the pre-comment content or newline for comment-only lines
// Skip the newline entirely
} else {
// Comment is inline, keep the pre-comment content (with trailing whitespace trimmed) and newline
result += preCommentContent.trimEnd();
result += '\n';
processedPos += preCommentContent.trimEnd().length + 1;
outputLine += 1;
}
} else {
// Keep the comment and newline
result += preCommentContent;
result += commentText;
result += '\n';
processedPos += preCommentContent.length + commentText.length + 1;
outputLine += 1;
}
preCommentContent = '';
lineStartPos = i + 1;
}
state = 'code';
}
i += 1;
continue;
}
if (state === 'multiline-comment') {
if (ch === '*' && next === '/') {
if (shouldProcessComments) {
// End of multi-line comment
const commentText = sourceCode.slice(commentStart, i + 2);
const shouldStrip = removeCommentsWithPrefix && matchesCommentPrefix(commentText, removeCommentsWithPrefix);
const isNotable = matchesNotablePrefix(commentText);
// Collect comments if they're notable (all stripped comments when no prefix specified, or notable comments when prefix specified)
const shouldCollect = shouldStrip && !notableCommentsPrefix || isNotable;
if (shouldCollect) {
// Record the comment 1-indexed: `Code` comments are always 1-indexed (the
// convention the enhancers match against the `dataLn` line gutter). `outputLine`
// is tracked 0-indexed internally, so add one at the point of storage.
const commentLine = commentStartOutputLine + 1;
if (!comments[commentLine]) {
comments[commentLine] = [];
}
comments[commentLine].push(...stripCommentMarkers(commentText));
}
if (shouldStrip) {
anyCommentStripped = true;
// Find the end of the comment and check what's after
const afterCommentPos = i + 2;
let afterCommentContent = '';
let nextNewlinePos = sourceCode.indexOf('\n', afterCommentPos);
if (nextNewlinePos === -1) {
nextNewlinePos = sourceCode.length;
}
afterCommentContent = sourceCode.slice(afterCommentPos, nextNewlinePos);
// Check for JSX comment syntax: {/* comment */}
// preCommentContent ends with '{' (ignoring whitespace) and afterCommentContent starts with '}' (ignoring whitespace)
const trimmedPreComment = preCommentContent.trimEnd();
const trimmedAfterComment = afterCommentContent.trimStart();
const isJsxComment = trimmedPreComment.endsWith('{') && trimmedAfterComment.startsWith('}');
// For JSX comments, check if removing the braces leaves only whitespace
const preCommentWithoutBrace = isJsxComment ? trimmedPreComment.slice(0, -1) : preCommentContent;
const afterCommentWithoutBrace = isJsxComment ? trimmedAfterComment.slice(1) : afterCommentContent;
const isCommentOnlyLines = preCommentWithoutBrace.trim() === '' && afterCommentWithoutBrace.trim() === '';
if (isCommentOnlyLines) {
// Skip the entire comment and everything up to and including the next newline
// For JSX comments, this also skips the surrounding braces
i = nextNewlinePos;
if (i < len && sourceCode[i] === '\n') {
// Skip the newline entirely - advance to the character after it
i += 1;
lineStartPos = i;
} else {
lineStartPos = i;
}
state = 'code';
preCommentContent = '';
continue;
} else if (isJsxComment) {
// JSX comment is inline with other code - strip the braces too
// e.g., `<Footer /> {/* @highlight */}` -> `<Footer />`
result += preCommentWithoutBrace.trimEnd();
processedPos += preCommentWithoutBrace.trimEnd().length;
// Skip past the closing brace after the comment
i = afterCommentPos;
while (i < nextNewlinePos && /\s/.test(sourceCode[i])) {
i += 1;
}
if (i < nextNewlinePos && sourceCode[i] === '}') {
i += 1; // Skip the closing brace
}
// Don't advance past here - let the main loop continue from i
} else {
// Comment is inline or mixed with code, add pre-comment content (with trailing whitespace trimmed)
result += preCommentContent.trimEnd();
processedPos += preCommentContent.trimEnd().length;
i += 2;
}
} else {
// Keep the comment - add pre-comment content and comment
result += preCommentContent;
result += commentText;
processedPos += preCommentContent.length + commentText.length;
// Count newlines in the kept comment to update output line
const newlineCount = (commentText.match(/\n/g) || []).length;
outputLine += newlineCount;
i += 2;
}
preCommentContent = '';
} else {
i += 2;
}
state = 'code';
continue;
}
i += 1;
continue;
}
if (state === 'string') {
if (ch === '\n') {
outputLine += 1;
lineStartPos = i + 1;
}
if (ch === '\\\\') {
if (shouldProcessComments) {
result += sourceCode.slice(i, i + 2);
processedPos += 2;
}
i += 2;
continue;
}
if (ch === stringQuote) {
state = 'code';
stringQuote = null;
}
if (shouldProcessComments) {
result += ch;
processedPos += 1;
}
i += 1;
continue;
}
if (state === 'template') {
if (ch === '\n') {
outputLine += 1;
lineStartPos = i + 1;
}
if (ch === '`') {
state = 'code';
stringQuote = null;
if (shouldProcessComments) {
result += ch;
processedPos += 1;
}
i += 1;
continue;
}
if (ch === '\\\\') {
if (shouldProcessComments) {
result += sourceCode.slice(i, i + 2);
processedPos += 2;
}
i += 2;
continue;
}
if (shouldProcessComments) {
result += ch;
processedPos += 1;
}
i += 1;
continue;
}
if (state === 'codeblock') {
if (ch === '\n') {
outputLine += 1;
lineStartPos = i + 1;
}
// Look for closing backticks that match or exceed the opening count
if (ch === '`') {
const closingBacktickCount = countBackticks(sourceCode, i);
if (closingBacktickCount >= codeblockBacktickCount) {
state = 'code';
codeblockBacktickCount = 0;
if (shouldProcessComments) {
result += sourceCode.slice(i, i + closingBacktickCount);
processedPos += closingBacktickCount;
}
i += closingBacktickCount;
continue;
}
}
if (shouldProcessComments) {
result += ch;
processedPos += 1;
}
i += 1;
continue;
}
if (shouldProcessComments) {
result += ch;
processedPos += 1;
}
i += 1;
}
// Handle case where file ends with a comment
if (shouldProcessComments && (state === 'singleline-comment' || state === 'multiline-comment')) {
const commentText = sourceCode.slice(commentStart);
const shouldStrip = removeCommentsWithPrefix && matchesCommentPrefix(commentText, removeCommentsWithPrefix);
const isNotable = matchesNotablePrefix(commentText);
// Collect comments if they're notable (all stripped comments when no prefix specified, or notable comments when prefix specified)
const shouldCollect = shouldStrip && !notableCommentsPrefix || isNotable;
if (shouldCollect) {
// Record 1-indexed (the `Code` convention); see the other recording sites above.
const commentLine = commentStartOutputLine + 1;
if (!comments[commentLine]) {
comments[commentLine] = [];
}
comments[commentLine].push(...stripCommentMarkers(commentText));
}
if (shouldStrip) {
anyCommentStripped = true;
} else {
result += commentText;
processedPos += commentText.length;
}
}
// Create the final position mapper for return
const finalPositionMapper = originalPos => {
if (!shouldMapPositions) {
return originalPos; // No comment stripping, positions are unchanged
}
// Find the closest mapped position
let closest = 0;
positionMapping.forEach((procPos, origPos) => {
if (origPos <= originalPos && origPos > closest) {
closest = origPos;
}
});
const offset = originalPos - closest;
return (positionMapping.get(closest) || 0) + offset;
};
// Only return code/comments/positionMapper when comments were actually stripped
// If only notableCommentsPrefix is provided (without removeCommentsWithPrefix),
// we collect comments but don't modify the code, so don't return it
return {
statements,
...(anyCommentStripped && {
code: result,
...(Object.keys(comments).length > 0 && {
comments
}),
positionMapper: finalPositionMapper
}),
// If only collecting notable comments (no stripping), just return the comments
...(!anyCommentStripped && notableCommentsPrefix && Object.keys(comments).length > 0 && {
comments
})
};
}
/**
* Adds an import name to the target array if it doesn't already exist.
* @param target - The array of import names to add to
* @param name - The name of the import
* @param type - The type of import (default, named, or namespace)
* @param alias - Optional alias for the import
* @param isType - Whether this is a TypeScript type-only import
*/
function addImportName(target, name, type, alias, isType) {
const existing = target.find(n => n.name === name && n.type === type && n.alias === alias);
if (!existing) {
target.push({
name,
...(alias && {
alias
}),
type,
...(isType && {
isType: true
})
});
}
}
/**
* Checks if a character is a valid JavaScript identifier character.
* @param ch - The character to check
* @returns True if the character can be part of a JavaScript identifier
*/
function isIdentifierChar(ch) {
return /[a-zA-Z0-9_$]/.test(ch);
}
/**
* Checks if a character is whitespace.
* @param ch - The character to check
* @returns True if the character is whitespace
*/
function isWhitespace(ch) {
return /\s/.test(ch);
}
/**
* Skips whitespace characters and returns the next non-whitespace position.
* @param text - The text to scan
* @param start - The starting position
* @returns The position of the next non-whitespace character
*/
function skipWhitespace(text, start) {
let pos = start;
while (pos < text.length && isWhitespace(text[pos])) {
pos += 1;
}
return pos;
}
/**
* Reads a JavaScript identifier starting at the given position.
* @param text - The text to read from
* @param start - The starting position
* @returns Object containing the identifier name and the next position
*/
function readIdentifier(text, start) {
let pos = start;
let name = '';
// First character must be letter, underscore, or dollar sign
if (pos < text.length && /[a-zA-Z_$]/.test(text[pos])) {
name += text[pos];
pos += 1;
// Subsequent characters can be letters, digits, underscore, or dollar sign
while (pos < text.length && isIdentifierChar(text[pos])) {
name += text[pos];
pos += 1;
}
}
return {
name,
nextPos: pos
};
}
// Helper function to read a quoted string starting at position
function readQuotedString(text, start) {
const quote = text[start];
let pos = start + 1;
let value = '';
const pathStart = start; // Start at the opening quote
while (pos < text.length) {
const ch = text[pos];
if (ch === '\\' && pos + 1 < text.length) {
// Skip escaped character
pos += 2;
continue;
}
if (ch === quote) {
const pathEnd = pos + 1; // End after the closing quote
pos += 1;
return {
value,
nextPos: pos,
pathStart,
pathEnd
};
}
value += ch;
pos += 1;
}
// If we reach here, no closing quote was found - fallback
return {
value,
nextPos: pos,
pathStart,
pathEnd: pos
};
}
// Helper function to parse named imports from a brace-enclosed section
function parseNamedImports(text, start, end) {
const imports = [];
let pos = start;
while (pos < end) {
pos = skipWhitespace(text, pos);
if (pos >= end) {
break;
}
// Handle comments within named imports
if (pos + 1 < end && text[pos] === '/' && text[pos + 1] === '/') {
// Skip single-line comment
while (pos < end && text[pos] !== '\n') {
pos += 1;
}
continue;
}
if (pos + 1 < end && text[pos] === '/' && text[pos + 1] === '*') {
// Skip multi-line comment
pos += 2;
while (pos + 1 < end) {
if (text[pos] === '*' && text[pos + 1] === '/') {
pos += 2;
break;
}
pos += 1;
}
continue;
}
// Skip comma if we encounter it
if (text[pos] === ',') {
pos += 1;
continue;
}
// Check for 'type' keyword
let isTypeImport = false;
if (text.slice(pos, pos + 4) === 'type' && !isIdentifierChar(text[pos + 4] || '')) {
isTypeImport = true;
pos += 4;
pos = skipWhitespace(text, pos);
}
// Read the import name
const {
name,
nextPos
} = readIdentifier(text, pos);
if (!name) {
pos += 1;
continue;
}
pos = nextPos;
pos = skipWhitespace(text, pos);
// Check for 'as' keyword (alias)
let alias;
if (text.slice(pos, pos + 2) === 'as' && !isIdentifierChar(text[pos + 2] || '')) {
pos += 2;
pos = skipWhitespace(text, pos);
const aliasResult = readIdentifier(text, pos);
alias = aliasResult.name;
pos = aliasResult.nextPos;
pos = skipWhitespace(text, pos);
}
imports.push({
name,
...(alias && {
alias
}),
...(isTypeImport && {
isType: true
})
});
// Skip comma if present
if (text[pos] === ',') {
pos += 1;
}
}
return imports;
}
// Function to parse a single CSS @import statement
function parseCssImportStatement(cssCode, start) {
let pos = start + 7; // Skip '@import'
const len = cssCode.length;
// Skip whitespace
while (pos < len && /\s/.test(cssCode[pos])) {
pos += 1;
}
let modulePath = null;
let pathStart;
let pathEnd;
// Check for url() syntax
if (cssCode.slice(pos, pos + 4) === 'url(') {
pos += 4;
// Skip whitespace
while (pos < len && /\s/.test(cssCode[pos])) {
pos += 1;
}
// Read the URL (quoted or unquoted)
if (pos < len && (cssCode[pos] === '"' || cssCode[pos] === "'")) {
const quote = cssCode[pos];
pathStart = pos; // Start at the opening quote
pos += 1;
let url = '';
while (pos < len && cssCode[pos] !== quote) {
// Only stop at newlines - parentheses and semicolons are valid in URLs
if (cssCode[pos] === '\n') {
break;
}
if (cssCode[pos] === '\\') {
pos += 2;
continue;
}
url += cssCode[pos];
pos += 1;
}
if (pos < len && cssCode[pos] === quote) {
pathEnd = pos + 1; // End after the closing quote
pos += 1;
modulePath = url;
}
// If we didn't find the closing quote, don't set modulePath (malformed)
} else {
// Unquoted URL
pathStart = pos;
let url = '';
while (pos < len && cssCode[pos] !== ')' && !/\s/.test(cssCode[pos])) {
url += cssCode[pos];
pos += 1;
}
pathEnd = pos;
modulePath = url;
}
// Skip to closing parenthesis - if we don't find it, the url() is malformed
while (pos < len && cssCode[pos] !== ')' && cssCode[pos] !== ';' && cssCode[pos] !== '\n') {
pos += 1;
}
if (pos < len && cssCode[pos] === ')') {
pos += 1;
// Only consider this a valid URL if we found the closing parenthesis
} else {
// Malformed url() - don't set modulePath
modulePath = null;
pathStart = undefined;
pathEnd = undefined;
}
} else if (pos < len && (cssCode[pos] === '"' || cssCode[pos] === "'")) {
// Direct quoted import
const quote = cssCode[pos];
pathStart = pos; // Start at the opening quote
pos += 1;
let url = '';
while (pos < len && cssCode[pos] !== quote) {
// Stop if we hit a newline (likely malformed), but semicolons are valid in URLs
if (cssCode[pos] === '\n') {
break;
}
if (cssCode[pos] === '\\') {
pos += 2;
continue;
}
url += cssCode[pos];
pos += 1;
}
if (pos < len && cssCode[pos] === quote) {
pathEnd = pos + 1; // End after the closing quote
pos += 1;
modulePath = url;
}
// If we didn't find the closing quote, don't set modulePath (malformed import)
}
// Skip to semicolon or end of statement
while (pos < len && cssCode[pos] !== ';' && cssCode[pos] !== '\n') {
pos += 1;
}
if (pos < len && cssCode[pos] === ';') {
pos += 1;
}
return {
modulePath,
nextPos: pos,
pathStart,
pathEnd
};
}
// CSS import detector function
function detectCssImport(sourceText, pos, cssResult, cssExternals, cssFilePath, positionMapper) {
const ch = sourceText[pos];
// Look for '@import' keyword
if (ch === '@' && sourceText.slice(pos, pos + 7) === '@import' && /\s/.test(sourceText[pos + 7] || '')) {
// Parse the @import statement
const importResult = parseCssImportStatement(sourceText, pos);
if (importResult.modulePath && importResult.pathStart !== undefined && importResult.pathEnd !== undefined) {
// In CSS, imports are relative unless they have a protocol, hostname,
// or are scoped npm packages (start with @scope/)
const hasProtocol = /^https?:\/\//.test(importResult.modulePath);
const hasHostname = /^\/\//.test(importResult.modulePath);
const isScopedPackage = /^@[^/]+\//.test(importResult.modulePath);
const isRelative = !hasProtocol && !hasHostname && !isScopedPackage;
const position = {
start: positionMapper(importResult.pathStart),
end: positionMapper(importResult.pathEnd)
};
if (isRelative) {
// Normalize bare filenames (e.g. "reset.css") to relative paths
let normalizedPath = importResult.modulePath;
if (!normalizedPath.startsWith('./') && !normalizedPath.startsWith('../')) {
normalizedPath = `./${normalizedPath}`;
}
const resolvedUrl = resolveRelativeImport(cssFilePath, normalizedPath);
if (!cssResult[importResult.modulePath]) {
cssResult[importResult.modulePath] = {
url: resolvedUrl,
names: [],
positions: []
};
}
cssResult[importResult.modulePath].positions.push(position);
} else {
if (!cssExternals[importResult.modulePath]) {
cssExternals[importResult.modulePath] = {
names: [],
positions: []
};
}
cssExternals[importResult.modulePath].positions.push(position);
}
}
return {
found: true,
nextPos: importResult.nextPos
};
}
return {
found: false,
nextPos: pos
};
}
/**
* Parses CSS @import statements from CSS source code.
* @param cssCode - The CSS source code to parse
* @param cssFilePath - The CSS file path for resolving relative imports
* @param cssResult - Object to store relative CSS import results
* @param cssExternals - Object to store external CSS import results
* @param removeCommentsWithPrefix - Optional prefixes for comments to remove
* @param notableCommentsPrefix - Optional prefixes for comments to collect
* @returns The parsed CSS import results with optional processed code and comments
*/
function parseCssImports(cssCode, cssFilePath, cssResult, cssExternals, removeCommentsWithPrefix, notableCommentsPrefix) {
// Use the generic scanner with a bound detector function
const scanResult = scanForImports(cssCode, (sourceText, pos, positionMapper) => detectCssImport(sourceText, pos, cssResult, cssExternals, cssFilePath, positionMapper), false, removeCommentsWithPrefix, notableCommentsPrefix);
return {
relative: cssResult,
externals: cssExternals,
...(scanResult.code && {
code: scanResult.code
}),
...(scanResult.comments && {
comments: scanResult.comments
})
};
}
/**
* Parses JavaScript/TypeScript import and export-from statements from source code.
* @param code - The source code to parse
* @param filePath - The file path for resolving relative imports
* @param result - Object to store relative import results
* @param externals - Object to store external import results
* @param isMdxFile - Whether this is an MDX file
* @param removeCommentsWithPrefix - Optional prefixes for comments to remove
* @param notableCommentsPrefix - Optional prefixes for comments to collect
* @returns The parsed import results with optional processed code and comments
*/
function parseJSImports(code, filePath, result, externals, isMdxFile, removeCommentsWithPrefix, notableCommentsPrefix) {
// Scan code for JavaScript import statements
const scanResult = scanForImports(code, detectJavaScriptImport, isMdxFile, removeCommentsWithPrefix, notableCommentsPrefix);
// Now, parse each import/export statement using character-by-character parsing
for (const {
start,
text
} of scanResult.statements) {
let pos = 0;
const textLen = text.length;
// Check if this is an export statement
const isExport = text.startsWith('export');
// Skip 'import' or 'export'
pos = isExport ? 6 : 6; // Both are 6 characters
pos = skipWhitespace(text, pos);
// Check for 'type' keyword
let isTypeImport = false;
if (text.slice(pos, pos + 4) === 'type' && !isIdentifierChar(text[pos + 4] || '')) {
isTypeImport = true;
pos += 4;
pos = skipWhitespace(text, pos);
}
// Check if this is a side-effect import (starts with quote)
if (pos < textLen && (text[pos] === '"' || text[pos] === "'")) {
const {
value: modulePath,
pathStart,
pathEnd
} = readQuotedString(text, pos);
if (modulePath) {
// Calculate the position in the original source code
const originalPathStart = start + pathStart;
const originalPathEnd = start + pathEnd;
// Apply position mapping if available (for comment-stripped positions)
let mappedStart = originalPathStart;
let mappedEnd = originalPathEnd;
if (scanResult.positionMapper) {
mappedStart = scanResult.positionMapper(originalPathStart);
mappedEnd = scanResult.positionMapper(originalPathEnd);
}
const position = {
start: mappedStart,
end: mappedEnd
};
const isRelative = modulePath.startsWith('./') || modulePath.startsWith('../');
if (isRelative) {
if (!result[modulePath]) {
result[modulePath] = {
url: resolveRelativeImport(filePath, modulePath),
names: [],
positions: []
};
}
result[modulePath].positions.push(position);
} else {
if (!externals[modulePath]) {
externals[modulePath] = {
names: [],
positions: []
};
}
externals[modulePath].positions.push(position);
}
}
continue;
}
// Parse import specifiers
let defaultImport;
let namespaceImport;
let namedImports = [];
// Check for default import (identifier not followed by 'from')
if (pos < textLen && /[a-zA-Z_$]/.test(text[pos])) {
const {
name,
nextPos
} = readIdentifier(text, pos);
const afterName = skipWhitespace(text, nextPos);
// If next non-whitespace is comma or 'from', this is a default import
if (afterName >= textLen || text[afterName] === ',' || text.slice(afterName, afterName + 4) === 'from') {
defaultImport = name;
pos = afterName;
// Skip comma if present
if (pos < textLen && text[pos] === ',') {
pos += 1;
pos = skipWhitespace(text, pos);
}
}
}
// Check for namespace import (* as Name)
if (pos < textLen && text[pos] === '*') {
pos += 1;
pos = skipWhitespace(text, pos);
// Expect 'as'
if (text.slice(pos, pos + 2) === 'as') {
pos += 2;
pos = skipWhitespace(text, pos);
const {
name
} = readIdentifier(text, pos);
if (name) {
namespaceImport = name;
pos = readIdentifier(text, pos).nextPos;
pos = skipWhitespace(text, pos);
}
}
}
// Check for named imports ({ ... })
if (pos < textLen && text[pos] === '{') {
pos += 1;
const braceStart = pos;
// Find the closing brace
let braceDepth = 1;
while (pos < textLen && braceDepth > 0) {
if (text[pos] === '{') {
braceDepth += 1;
} else if (text[pos] === '}') {
braceDepth -= 1;
}
pos += 1;
}
if (braceDepth === 0) {
const braceEnd = pos - 1;
namedImports = parseNamedImports(text, braceStart, braceEnd);
}
}
// Skip to 'from' keyword
pos = skipWhitespace(text, pos);
while (pos < textLen && text.slice(pos, pos + 4) !== 'from') {
pos += 1;
}
if (pos >= textLen || text.slice(pos, pos + 4) !== 'from') {
continue; // No 'from' found, skip this import
}
pos += 4;
pos = skipWhitespace(text, pos);
// Read module path
if (pos >= textLen || !(text[pos] === '"' || text[pos] === "'")) {
continue; // No quoted module path found
}
const {
value: modulePath,
pathStart,
pathEnd
} = readQuotedString(text, pos);
if (!modulePath) {
continue;
}
// Calculate the position in the original source code
const originalPathStart = start + pathStart;
const originalPathEnd = start + pathEnd;
const isRelative = modulePath.startsWith('./') || modulePath.startsWith('../');
// Apply position mapping if available (for comment-stripped positions)
let mappedStart = originalPathStart;
let mappedEnd = originalPathEnd;
if (scanResult.positionMapper) {
mappedStart = scanResult.positionMapper(originalPathStart);
mappedEnd = scanResult.positionMapper(originalPathEnd);
}
const position = {
start: mappedStart,
end: mappedEnd
};
if (isRelative) {
if (!result[modulePath]) {
result[modulePath] = {
url: resolveRelativeImport(filePath, modulePath),
names: [],
positions: [],
...(isTypeImport && {
includeTypeDefs: true
})
};
} else if (isTypeImport && !result[modulePath].includeTypeDefs) {
result[modulePath].includeTypeDefs = true;
}
// Add position information
result[modulePath].positions.push(position);
if (defaultImport) {
addImportName(result[modulePath].names, defaultImport, 'default', undefined, isTypeImport);
}
if (namespaceImport) {
addImportName(result[modulePath].names, namespaceImport, 'namespace', undefined, isTypeImport);
}
namedImports.forEach(({
name,
alias,
isType
}) => {
addImportName(result[modulePath].names, name, 'named', alias, isTypeImport || isType);
});
} else {
if (!externals[modulePath]) {
externals[modulePath] = {
names: [],
positions: []
};
}
// Add position information
externals[modulePath].positions.push(position);
if (defaultImport) {
addImportName(externals[modulePath].names, defaultImport, 'default', undefined, isTypeImport);
}
if (namespaceImport) {
addImportName(externals[modulePath].names, namespaceImport, 'namespace', undefined, isTypeImport);
}
namedImports.forEach(({
name,
alias,
isType
}) => {
addImportName(externals[modulePath].names, name, 'named', alias, isTypeImport || isType);
});
}
}
return {
relative: result,
externals,
...(scanResult.code && {
code: scanResult.code
}),
...(scanResult.comments && {
comments: scanResult.comments
})
};
}
/**
* Detects JavaScript import and export-from statements at a given position in source code.
* @param sourceText - The source text to scan
* @param pos - The current position in the text
* @param positionMapper - Function to map original positions to processed positions
* @returns Object indicating if an import/export was found, the next position, and statement details
*/
function detectJavaScriptImport(sourceText, pos, _positionMapper) {
const ch = sourceText[pos];
// Look for 'export' keyword followed by 'from' (export ... from '...')
if (ch === 'e' && sourceText.slice(pos, pos + 6) === 'export' && (pos === 0 || /[^a-zA-Z0-9_$]/.test(sourceText[pos - 1])) && /[^a-zA-Z0-9_$]/.test(sourceText[pos + 6] || '')) {
// Check if this export statement has a 'from' clause
const exportStart = pos;
const len = sourceText.length;
let j = pos + 6;
// Skip whitespace and look ahead for 'from' keyword
let hasFrom = false;
let tempPos = j;
let tempBraceDepth = 0;
while (tempPos < len) {
const tempCh = sourceText[tempPos];
if (tempCh === '{') {
tempBraceDepth += 1;
} else if (tempCh === '}') {
tempBraceDepth -= 1;
} else if (sourceText.slice(tempPos, tempPos + 4) === 'from' && /\s/.test(sourceText[tempPos + 4] || '')) {
hasFrom = true;
break;
} else if (tempCh === ';' || tempCh === '\n' && tempBraceDepth === 0) {
break;
}
tempPos += 1;
}
if (!hasFrom) {
// This is not an export-from statement, skip it
return {
found: false,
nextPos: pos
};
}
// Now scan to find the end of the export-from statement
let exportState = 'code';
let exportQuote = null;
let braceDepth = 0;
let foundFrom = false;
let foundModulePath = false;
while (j < len) {
const cj = sourceText[j];
if (exportState === 'code') {
if (cj === ';') {
j += 1;
break;
}
if (isStringStart(cj)) {
exportState = cj === '`' ? 'template' : 'string';
exportQuote = cj;
if (foundFrom) {
foundModulePath = true;
}
j += 1;
continue;
}
if (cj === '{') {
braceDepth += 1;
}
if (cj === '}') {
braceDepth -= 1;
}
if (sourceText.slice(j, j + 4) === 'from' && /\s/.test(sourceText[j + 4] || '')) {
foundFrom = true;
}
if (foundModulePath && braceDepth === 0 && /\s/.test(cj)) {
let k = j;
while (k < len && /\s/.test(sourceText[k])) {
k += 1;
}
if (k >= len || sourceText[k] === ';' || sourceText[k] === '\n') {
if (sourceText[k] === ';') {
j = k + 1;
} else {
j = k;
}
break;
}
}
} else if (exportState === 'string') {
if (cj === '\\') {
j += 2;
continue;
}
if (cj === exportQuote) {
exportState = 'code';
exportQuote = null;
}
j += 1;
continue;
} else if (exportState === 'template') {
if (cj === '`') {
exportState = 'code';
exportQuote = null;
} else if (cj === '\\') {
j += 2;
continue;
}
j += 1;
continue;
}
j += 1;
}
const exportText = sourceText.slice(exportStart, j);
return {
found: true,
nextPos: j,
statement: {
start: exportStart,
end: j,
text: exportText
}
};
}
// Look for 'import' keyword (not part of an identifier, and not preceded by @)
if (ch === 'i' && sourceText.slice(pos, pos + 6) === 'import' && (pos === 0 || /[^a-zA-Z0-9_$@]/.test(sourceText[pos - 1])) && /[^a-zA-Z0-9_$]/.test(sourceText[pos + 6] || '')) {
// Mark start of import statement
const importStart = pos;
const len = sourceText.length;
// Now, scan forward to find the end of the statement (semicolon or proper end for side-effect imports)
let j = pos + 6;
let importState = 'code';
let importQuote = null;
let braceDepth = 0;
let foundFrom = false;
let foundModulePath = false;
while (j < len) {
const cj = sourceText[j];
if (importState === 'code') {
if (cj === ';') {
j += 1;
break;
}
// Check if we're at a bare import statement (no 'from')
if (cj === '\n' && !foundFrom && !foundModulePath && braceDepth === 0) {
// This might be a side-effect import or end of statement
// Look ahead to see if there's content that could be part of the import
let k = j + 1;
while (k < len && /\s/.test(sourceText[k])) {
k += 1;
}
if (k >= len || sourceText.slice(k, k + 4) === 'from' || isStringStart(sourceText[k])) {
// Continue, this newline is within the import
} else {
// This looks like the end of a side-effect import
j += 1;
break;
}
}
if (isStringStart(cj)) {
importState = cj === '`' ? 'template' : 'string';
importQuote = cj;
if (foundFrom) {
foundModulePath = true;
}
j += 1;
continue;
}
if (cj === '{') {
braceDepth += 1;
}
if (cj === '}') {
braceDepth -= 1;
}
if (sourceText.slice(j, j + 4) === 'from' && /\s/.test(sourceText[j + 4] || '')) {
foundFrom = true;
}
// If we found a module path and we're back to normal code, we might be done
if (foundModulePath && braceDepth === 0 && /\s/.test(cj)) {
// Look ahead for semicolon or end of statement
let k = j;
while (k < len && /\s/.test(sourceText[k])) {
k += 1;
}
if (k >= len || sourceText[k] === ';' || sourceText[k] === '\n') {
if (sourceText[k] === ';') {
j = k + 1;
} else {
j = k;
}
break;
}
}
} else if (importState === 'string') {
if (cj === '\\') {
j += 2;
continue;
}
if (cj === importQuote) {
importState = 'code';
importQuote = null;
}
j += 1;
continue;
} else if (importState === 'template') {
if (cj === '`') {
importState = 'code';
importQuote = null;
} else if (cj === '\\') {
j += 2;
continue;
}
j += 1;
continue;
}
j += 1;
}
const importText = sourceText.slice(importStart, j);
return {
found: true,
nextPos: j,
statement: {
start: importStart,
end: j,
text: importText
}
};
}
return {
found: false,
nextPos: pos
};
}
/**
* Parse import and export-from statements from JavaScript/TypeScript/CSS code.
*
* This function analyzes source code to extract all import and export-from statements,
* categorizing them as either relative imports (local files) or external imports (packages).
* It supports JavaScript, TypeScript, CSS, and MDX files.
*
* Comment processing (stripping/collecting) is performed during import parsing
* for efficiency. Since we must already parse the entire file character-by-character
* to correctly identify imports while avoiding false positives in strings, comments,
* and template literals, it's most efficient to handle comment processing in this
* same pass rather than requiring separate parsing steps.
*
* The function accepts file:// URLs, http(s):// URLs, or file paths. File URLs
* and OS paths are normalized to a portable POSIX-style path internally and
* resolved via `path.resolve`. http(s):// URLs are preserved verbatim and
* relative imports are resolved via WHATWG `URL`, which means demos can be
* parsed straight out of remote sources without first being mapped onto a
* placeholder `file://` URL.
*
* @param code - The source code to parse
* @param fileUrl