UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

1,156 lines (1,076 loc) 49.9 kB
import * as path from 'path-module'; import { compressHastAsync } from "../hastUtils/index.mjs"; import { buildRootFallback, buildCriticalFallback, fallbackToText } from "../../CodeHighlighter/fallbackFormat.mjs"; import { getInitialVisibleFrames } from "../parseSource/frameVisibility.mjs"; import { transformSource } from "./transformSource.mjs"; import { diffHast } from "./diffHast.mjs"; import { isFrameSpan } from "../parseSource/isFrameSpan.mjs"; import { getFileNameFromUrl, getLanguageFromExtension, normalizeLanguage } from "../loaderUtils/index.mjs"; import { mergeExternals } from "../loaderUtils/mergeExternals.mjs"; import { applyUrlPrefixToVariant } from "../loaderUtils/applyUrlPrefix.mjs"; import { performanceMeasure } from "../loadPrecomputedCodeHighlighter/performanceLogger.mjs"; import { starryNightGutter } from "../parseSource/addLineGutters.mjs"; import { applyEnhancers } from "./runSourceEnhancers.mjs"; import { embedTransformsInRoot, splitTransformsForEmbed } from "./embedTransforms.mjs"; /** * Check if a path is absolute (either filesystem absolute or URL) */ function isAbsolutePath(filePath) { return path.isAbsolute(filePath) || filePath.includes('://'); } /** * Removes the per-frame `data.fallback` text from each `span.frame` before the * hast is serialized. The variant-level root fallback already carries this text * (and `redistributeRootFallback` puts it back on decode), so keeping it on the * stored tree would duplicate it in every payload. */ function stripFrameFallbacks(root) { for (const child of root.children) { if (child.type !== 'element' || child.tagName !== 'span' || !child.data) { continue; } if (isFrameSpan(child) && 'fallback' in child.data) { delete child.data.fallback; } } } /** * Generate a conflict-free filename for globalsCode files. * Strategy: * 1. Try original filename * 2. If conflict, try "global_" prefix * 3. If still conflict, add numbers: "global_filename_1.ext", "global_filename_2.ext", etc. */ function generateConflictFreeFilename(originalFilename, existingFiles) { // First try the original filename if (!existingFiles.has(originalFilename)) { return originalFilename; } // Try with global_ prefix const globalFilename = `global_${originalFilename}`; if (!existingFiles.has(globalFilename)) { return globalFilename; } // Use path.parse to cleanly split filename into name and extension const parsed = path.parse(originalFilename); const nameWithoutExt = parsed.name; const extension = parsed.ext; // Add numbers until we find a free name, preserving extension let counter = 1; let candidateName; do { candidateName = `global_${nameWithoutExt}_${counter}${extension}`; counter += 1; } while (existingFiles.has(candidateName)); return candidateName; } // Helper function to check if we're in production function isProduction() { return typeof process !== 'undefined' && process.env.NODE_ENV === 'production'; } /** * Computes a URL-style relative path from `sourceFileUrl` (the URL of a file) * to `targetFileUrl` such that `new URL(result, sourceFileUrl).href === targetFileUrl`. * * Returns `undefined` if the URLs differ in scheme or origin, in which case * a relative reference cannot be produced. */ function computeRelativeUrl(sourceFileUrl, targetFileUrl) { let source; let target; try { source = new URL(sourceFileUrl); target = new URL(targetFileUrl); } catch { return undefined; } if (source.protocol !== target.protocol || source.host !== target.host) { return undefined; } const sourceSegments = source.pathname.split('/'); const targetSegments = target.pathname.split('/'); // Drop the source file segment so we walk from its containing directory. sourceSegments.pop(); let commonLength = 0; while (commonLength < sourceSegments.length && commonLength < targetSegments.length - 1 && sourceSegments[commonLength] === targetSegments[commonLength]) { commonLength += 1; } const upSegments = sourceSegments.length - commonLength; const downSegments = targetSegments.slice(commonLength); const prefix = upSegments === 0 ? './' : '../'.repeat(upSegments); return `${prefix}${downSegments.join('/')}`; } // Helper function to convert a nested key based on the directory of the source file key. // // Note: this operates on POSIX-style paths via `path-module`, not URLs. It does // not decode percent-encoded segments, so callers should pass simple relative // paths (e.g. `'../styles.css'`). Percent-encoded segments would be treated as // opaque path components. function convertKeyBasedOnDirectory(nestedKey, sourceFileKey) { // If it's an absolute path (starts with / or contains ://), keep as-is if (isAbsolutePath(nestedKey)) { return nestedKey; } // Treat bare filenames as relative to current directory (same as ./filename) let processedNestedKey = nestedKey; if (!nestedKey.startsWith('.')) { processedNestedKey = `./${nestedKey}`; } // Get the directory of the source file const sourceDir = path.dirname(sourceFileKey); // If sourceDir is '.' (current directory), just return the processed nested key // This avoids path.resolve which can produce absolute paths on Windows if (sourceDir === '.') { // Remove leading './' if present for consistency return processedNestedKey.startsWith('./') ? processedNestedKey.slice(2) : processedNestedKey; } // Use path.join instead of path.resolve to avoid producing absolute paths // path.join keeps paths relative, while path.resolve can make them absolute const joinedPath = path.join(sourceDir, processedNestedKey); // Normalize the path to clean up any ../ or ./ segments const normalizedPath = path.normalize(joinedPath); // Ensure we return a clean relative path (remove leading './' if present after normalization) if (normalizedPath.startsWith('./')) { return normalizedPath.slice(2); } return normalizedPath === '.' ? '' : normalizedPath; } /** * Normalize a relative path key by removing unnecessary ./ prefix and cleaning up the path */ function normalizePathKey(key) { // Handle edge cases if (key === '.' || key === '') { return ''; } // Use path.normalize to clean up the path, then remove leading './' if present const normalized = path.normalize(key); // Convert './filename' to 'filename' using path.relative if (normalized.startsWith('./')) { return path.relative('.', normalized); } return normalized === '.' ? '' : normalized; } /** * Loads and processes extra files recursively with support for relative paths * and circular dependency detection. Uses Promise.all for parallel loading. */ async function loadSingleFile(variantName, fileName, source, url, loadSource, sourceParser, sourceTransformers, sourceEnhancers, loadSourceCache, transforms, options = {}, allFilesListed = false, knownExtraFiles = new Set(), language, variantComments) { const { disableTransforms = false, disableParsing = false, framePlainFallback = false } = options; let finalSource = source; let finalFallback; let finalFallbackCritical; let finalTotalLines; let finalFocusedLines; let finalCollapsible; let extraFilesFromSource; let extraDependenciesFromSource; let externalsFromSource; let commentsFromSource = variantComments; const functionName = 'Load Variant File'; let currentMark = performanceMeasure(undefined, { mark: 'Start', measure: 'Start' }, [functionName, url || fileName], true); // Load source if not provided if (!finalSource) { if (!loadSource) { throw new Error('"loadSource" function is required when source is not provided'); } if (!url) { throw new Error('URL is required when loading source'); } try { // Check cache first to avoid duplicate loadSource calls let loadPromise = loadSourceCache.get(url); if (!loadPromise) { loadPromise = loadSource(url); loadSourceCache.set(url, loadPromise); } const loadResult = await loadPromise; finalSource = loadResult.source; extraFilesFromSource = loadResult.extraFiles; extraDependenciesFromSource = loadResult.extraDependencies; externalsFromSource = loadResult.externals; commentsFromSource = loadResult.comments; currentMark = performanceMeasure(currentMark, { mark: 'Loaded File', measure: 'File Loading' }, [functionName, url]); // Validate that extraFiles from loadSource contain only absolute URLs as values if (extraFilesFromSource) { for (const [extraFileName, fileData] of Object.entries(extraFilesFromSource)) { // Validate that keys are relative paths (not absolute) if (isAbsolutePath(extraFileName)) { throw new Error(`Invalid extraFiles from loadSource: key "${extraFileName}" appears to be an absolute path. ` + `extraFiles keys should be relative paths from the current file.`); } // Validate that values are absolute URLs (not relative paths) if (typeof fileData === 'string' && fileData.startsWith('.')) { throw new Error(`Invalid extraFiles from loadSource: "${extraFileName}" has relative path "${fileData}". ` + `All extraFiles values must be absolute URLs.`); } } } // Validate that extraDependencies from loadSource contain only absolute URLs if (extraDependenciesFromSource) { for (const dependency of extraDependenciesFromSource) { if (dependency.startsWith('.')) { throw new Error(`Invalid extraDependencies from loadSource: "${dependency}" is a relative path. ` + `All extraDependencies must be absolute URLs.`); } if (dependency === url) { throw new Error(`Invalid extraDependencies from loadSource: "${dependency}" is the same as the input URL. ` + `extraDependencies should not include the file being loaded.`); } } } // Check for new files when allFilesListed is enabled if (allFilesListed && (extraFilesFromSource || extraDependenciesFromSource)) { const newFiles = []; if (extraFilesFromSource) { // Check if any extraFiles keys are not in the known set for (const extraFileKey of Object.keys(extraFilesFromSource)) { if (!knownExtraFiles.has(extraFileKey)) { newFiles.push(extraFileKey); } } } if (newFiles.length > 0) { const message = `Unexpected files discovered via loadSource when allFilesListed=true (variant: ${variantName}, file: ${fileName}). ` + `New files: ${newFiles.join(', ')}. ` + `Please update the loadVariantMeta function to provide the complete list of files upfront.`; if (isProduction()) { console.warn(message); } else { throw new Error(message); } } } } catch (error) { // Re-throw validation errors without wrapping them if (error instanceof Error && (error.message.startsWith('Invalid extraFiles from loadSource:') || error.message.startsWith('Invalid extraDependencies from loadSource:') || error.message.startsWith('Unexpected files discovered via loadSource when allFilesListed=true'))) { throw error; } throw new Error(`Failed to load source code (variant: ${variantName}, file: ${fileName}, url: ${url}): ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } // Apply source transformers if no transforms exist and transforms are not disabled let finalTransforms = transforms; if (sourceTransformers && !finalTransforms && !disableTransforms && finalSource) { finalTransforms = await transformSource(finalSource, normalizePathKey(fileName), sourceTransformers, commentsFromSource); currentMark = performanceMeasure(currentMark, { mark: 'Transformed File', measure: 'File Transforming' }, [functionName, url || fileName]); } // Parse source if it's a string and parsing is not disabled if (typeof finalSource === 'string' && !disableParsing) { if (!sourceParser) { throw new Error('"sourceParser" function is required when source is a string and parsing is not disabled'); } try { const sourceString = finalSource; const parseSource = await sourceParser; let parsedSource = parseSource(finalSource, fileName, language); currentMark = performanceMeasure(currentMark, { mark: 'Parsed File', measure: 'File Parsing' }, [functionName, url || fileName]); // `commentsFromSource` is already 1-indexed (both `Code` comments and // `parseImportsAndComments`/`loadSource` output use the 1-indexed convention). // Aliased so the diff path (below) can reuse it when wrapping `parseSource` for // transformed sources — the comments live in the code itself and don't shift for // transforms that only blank lines. const oneIndexedComments = commentsFromSource; // Apply source enhancers if provided (run sequentially as a pipeline). // Enhancers with a stable `enhancerName` are recorded on the HAST root // and skipped if they have already been applied (e.g. by a previous // server-side pass). if (sourceEnhancers && sourceEnhancers.length > 0) { parsedSource = await applyEnhancers(parsedSource, oneIndexedComments, fileName, sourceEnhancers); currentMark = performanceMeasure(currentMark, { mark: 'Enhanced File', measure: 'File Enhancing' }, [functionName, url || fileName]); } finalSource = parsedSource; if (finalTransforms && !disableTransforms) { // Wrap parseSource so transformed sources receive the same source // enhancers as the original. The frame structure produced by // enhanceCodeEmphasis depends on `@focus`/`@padding-*` comments; // running enhancers on both sides keeps the per-frame children // layout aligned for a positional diff. Without this the diff // balloons at the frame level (source has N frames, transform // has 1) and jsondiffpatch deletes the extras. const parseSourceForDiff = sourceEnhancers && sourceEnhancers.length > 0 ? async (transformedSourceString, transformedFileName, _language, transformedComments) => { const transformedTree = await parseSource(transformedSourceString, transformedFileName); // Prefer the transform-provided comment map (already // 1-indexed against the transformed source) so enhancers // emit the same frame structure on both sides. Falling // back to the source's `oneIndexedComments` is safe for // transforms that only blank lines in place, where the // comment positions don't shift. return applyEnhancers(transformedTree, transformedComments ?? oneIndexedComments, transformedFileName, sourceEnhancers); } : parseSource; finalTransforms = await diffHast(sourceString, finalSource, normalizePathKey(fileName), finalTransforms, parseSourceForDiff); currentMark = performanceMeasure(currentMark, { mark: 'Transform Parsed File', measure: 'Parsed File Transforming' }, [functionName, url || fileName]); } // When the source is about to be serialized (compressed or stringified to // JSON), embed the transform deltas inside the hast root's `data` field // so they ride along inside the compressed payload — DEFLATE then // shares the dictionary across the tree and the deltas, and the deltas // never appear as plain JSON in the rendered HTML / module graph. // The variant-level `finalTransforms` becomes a manifest (no `delta`). if (finalTransforms && (options.output === 'hastCompressed' || options.output === 'hastJson') && finalSource && typeof finalSource === 'object' && !('hastJson' in finalSource) && !('hastCompressed' in finalSource)) { const root = finalSource; const split = splitTransformsForEmbed(finalTransforms); if (split) { embedTransformsInRoot(root, split.embedded); finalTransforms = split.manifest; } else { // Every entry was empty; drop transforms entirely so we don't emit // an empty manifest. finalTransforms = undefined; } } // Derive a variant-level root fallback from the per-frame `data.fallback` // text before any serialization. This fallback is rendered by a // `ContentLoading` component before the hast is decoded, and its text // doubles as the DEFLATE dictionary so the compressed payload can be // decompressed on the client once the fallback travels over via context. if (finalSource && typeof finalSource === 'object' && !('hastJson' in finalSource) && !('hastCompressed' in finalSource)) { finalFallback = buildRootFallback(finalSource); // Sparse highlighted-visible companion (see `VariantCode.fallbackCritical`), // computed here while the source is still a live `HastRoot` (no // decompression) and BEFORE `stripFrameFallbacks` removes the per-frame // text it reuses. `false` builds the `collapseToEmpty: false` form (the only // one carrying highlighting); the boundary skips promoting it under // `collapseToEmpty`. Empty (no visible frames) → omit it entirely. const critical = buildCriticalFallback(finalSource, getInitialVisibleFrames(finalSource, false)); finalFallbackCritical = Object.keys(critical).length > 0 ? critical : undefined; // Hoist the window counts off `root.data` while the source is still a live // `HastRoot`. They then ride on the variant (see the return below) so every // downstream reader (`prepareInitialSource`, `getVariantFileLineCounts`, // layout-shift classification) gets `totalLines`/`focusedLines`/`collapsible` // WITHOUT decompressing the payload — the compact fallback and the compressed // source both drop `root.data`, so without this the only way to recover the // counts is to decode the hast (the first-render decompression we want to avoid). const rootData = finalSource.data; if (rootData?.totalLines !== undefined) { const total = Number(rootData.totalLines); if (Number.isFinite(total) && total >= 0) { finalTotalLines = total; const focused = Number(rootData.focusedLines); finalFocusedLines = Number.isFinite(focused) && focused >= 0 ? focused : total; finalCollapsible = rootData.collapsible === true; } } } if (options.output === 'hastCompressed' && process.env.NODE_ENV === 'production') { if (finalFallback) { stripFrameFallbacks(finalSource); } const json = JSON.stringify(finalSource); // Use the fallback text as a DEFLATE dictionary for better compression. // The same dictionary is rebuilt on decode from the variant `fallback`. const dictionary = finalFallback ? fallbackToText(finalFallback) : undefined; finalSource = { hastCompressed: await compressHastAsync(json, dictionary) }; currentMark = performanceMeasure(currentMark, { mark: 'Compressed File', measure: 'File Compression' }, [functionName, url || fileName]); } else if (options.output === 'hastJson' || options.output === 'hastCompressed') { // in development, we skip compression but still convert to JSON if (finalFallback) { stripFrameFallbacks(finalSource); } finalSource = { hastJson: JSON.stringify(finalSource) }; performanceMeasure(currentMark, { mark: 'JSON Stringified File', measure: 'File Stringification' }, [functionName, url || fileName]); } } catch (error) { throw new Error(`Failed to parse source code (variant: ${variantName}, file: ${fileName}, url: ${url}): ${error instanceof Error ? error.message : ''}`); } } // Parsing disabled (deferred highlight) but a framed fallback was requested: keep // `finalSource` a plain string so the client still knows it needs syntax // highlighting — a HAST source reads as "already loaded" (see `isSourceLoaded`) and // would never re-highlight. But the loading fallback MUST be framed — rendered code // needs frames — so build a line-guttered plain-text HAST (no syntax engine: the // light `starryNightGutter`), run the same enhancers (focus window / truncation), // and derive the root fallback from it. The string source is returned unchanged; // only `fallback` is produced. Gated by `framePlainFallback` so lazy variant loads // (which never paint a fallback) keep skipping the work. if (framePlainFallback && typeof finalSource === 'string' && !finalFallback) { const plainRoot = { type: 'root', children: [{ type: 'text', value: finalSource }] }; starryNightGutter(plainRoot, finalSource.split(/\r?\n|\r/)); let framedRoot = plainRoot; if (sourceEnhancers && sourceEnhancers.length > 0) { framedRoot = await applyEnhancers(framedRoot, commentsFromSource, fileName, sourceEnhancers); } finalFallback = buildRootFallback(framedRoot); // Surface the enhancer's window counts (the compact fallback drops `root.data`, // and a plain-string source can't recompute `focusedLines`/`collapsible` downstream). finalTotalLines = framedRoot.data?.totalLines ?? 0; finalFocusedLines = framedRoot.data?.focusedLines ?? finalTotalLines; finalCollapsible = framedRoot.data?.collapsible === true; } return { source: finalSource, fallback: finalFallback, fallbackCritical: finalFallbackCritical, totalLines: finalTotalLines, focusedLines: finalFocusedLines, collapsible: finalCollapsible, transforms: finalTransforms, extraFiles: extraFilesFromSource, extraDependencies: extraDependenciesFromSource, externals: externalsFromSource, // `commentsFromSource` is already 1-indexed (the stored `Code` convention). comments: commentsFromSource }; } /** * Loads and processes extra files recursively with support for relative paths * and circular dependency detection. Uses Promise.all for parallel loading. */ async function loadExtraFiles(variantName, extraFiles, baseUrl, entryUrl, // Track the original entry file URL loadSource, sourceParser, sourceTransformers, sourceEnhancers, loadSourceCache, options = {}, allFilesListed = false, knownExtraFiles = new Set(), globalsFileKeys = new Set() // Track which files came from globals ) { const { maxDepth = 10, loadedFiles = new Set() } = options; if (maxDepth <= 0) { throw new Error('Maximum recursion depth reached while loading extra files'); } const processedExtraFiles = {}; const allFilesUsed = []; const allExternals = {}; // Start loading all extra files in parallel const extraFilePromises = Object.entries(extraFiles).map(async ([fileName, fileData]) => { try { let fileUrl; let sourceData; let inlineComments; let transforms; let nextLoadedFiles; // True when the entry references an external file to load (string form // or object form carrying only a `relativeUrl`). Used both to pick the // load branch and to record the resolved URL in `filesUsedFromFile`. let treatAsExternalUrl = false; if (typeof fileData === 'string') { // fileData is a URL/path - use it directly, don't modify it fileUrl = fileData; treatAsExternalUrl = true; // Check for circular dependencies if (loadedFiles.has(fileUrl)) { throw new Error(`Circular dependency detected: ${fileUrl}`); } // Create a new set with the current file added for the recursive call // Don't mutate the parent's loadedFiles set nextLoadedFiles = new Set(loadedFiles); nextLoadedFiles.add(fileUrl); } else if (fileData.source === undefined && fileData.relativeUrl) { // Object form carrying only a `relativeUrl` (e.g., from loadServerCodeSource // when the extraFiles key was rewritten). Derive the file URL by // resolving the relative URL against the parent file's URL. fileUrl = new URL(fileData.relativeUrl, baseUrl).href; transforms = fileData.transforms; treatAsExternalUrl = true; if (loadedFiles.has(fileUrl)) { throw new Error(`Circular dependency detected: ${fileUrl}`); } nextLoadedFiles = new Set(loadedFiles); nextLoadedFiles.add(fileUrl); } else { // fileData is an object with source and/or transforms sourceData = fileData.source; // Inline extra files carry their own 1-indexed comments (their marker lines were // stripped from the source upstream); forward them so the enhancers apply the // `@focus`/`@highlight` frames instead of silently dropping them. inlineComments = fileData.comments; transforms = fileData.transforms; fileUrl = baseUrl; // Use base URL as fallback // For inline source, just pass a copy of loadedFiles without adding current file nextLoadedFiles = new Set(loadedFiles); } // Derive language from fileName for extra files const extraFileExtension = fileName.slice(fileName.lastIndexOf('.')); const extraFileLanguage = getLanguageFromExtension(extraFileExtension); // Load the file (this will handle recursive extra files) const fileResult = await loadSingleFile(variantName, fileName, sourceData, fileUrl, loadSource, sourceParser, sourceTransformers, sourceEnhancers, loadSourceCache, transforms, { ...options, maxDepth: maxDepth - 1, loadedFiles: nextLoadedFiles }, allFilesListed, knownExtraFiles, extraFileLanguage, inlineComments); // Collect files used from this file load const filesUsedFromFile = []; if (treatAsExternalUrl) { filesUsedFromFile.push(fileUrl); } if (fileResult.extraDependencies) { filesUsedFromFile.push(...fileResult.extraDependencies); } // Collect externals from this file load const externalsFromFile = {}; if (fileResult.externals) { Object.assign(externalsFromFile, fileResult.externals); } return { fileName, fileUrl, result: fileResult, filesUsed: filesUsedFromFile, externals: externalsFromFile }; } catch (error) { throw new Error(`Failed to load extra file (variant: ${variantName}, file: ${fileName}, url: ${baseUrl}): ${error instanceof Error ? error.message : ''}`); } }); // Wait for all extra files to load const extraFileResults = await Promise.all(extraFilePromises); // Process results and handle nested extra files const nestedExtraFilesPromises = []; for (const { fileName, fileUrl, result, filesUsed, externals } of extraFileResults) { const normalizedFileName = normalizePathKey(fileName); const originalFileData = extraFiles[fileName]; // Preserve metadata flag if it exists in the original data, or if this file came from globals let metadata; if (typeof originalFileData !== 'string') { metadata = originalFileData.metadata; } else if (globalsFileKeys.has(fileName)) { metadata = true; } // Anchor `relativeUrl` to the entry variant URL so the consumer can always // recover the actual file URL via `new URL(relativeUrl, variant.url)`. We // emit it whenever the entry-anchored key (`./normalizedFileName`) doesn't // already resolve to the file URL — this also handles cases where // `loadServerCodeSource` stored the file as a plain string URL because its // local key happened to match, since once the file bubbles up through // multiple parents the local key no longer reflects its true location. let entryRelativeUrl; if (entryUrl && fileUrl) { try { const expectedFromEntry = new URL(`./${normalizedFileName}`, entryUrl).href; if (expectedFromEntry !== fileUrl) { entryRelativeUrl = computeRelativeUrl(entryUrl, fileUrl); } } catch { // entryUrl wasn't a valid URL base; leave relativeUrl unset. } } // Derive language from fileName extension for extra files const extraFileExtension = normalizedFileName.slice(normalizedFileName.lastIndexOf('.')); const extraFileLanguage = getLanguageFromExtension(extraFileExtension); processedExtraFiles[normalizedFileName] = { source: result.source, ...(result.fallback && { fallback: result.fallback }), ...(result.totalLines !== undefined && { totalLines: result.totalLines }), ...(result.focusedLines !== undefined && { focusedLines: result.focusedLines }), ...(result.collapsible !== undefined && { collapsible: result.collapsible }), ...(extraFileLanguage && { language: extraFileLanguage }), ...(result.transforms && { transforms: result.transforms }), ...(metadata !== undefined && { metadata }), ...(entryRelativeUrl !== undefined && { relativeUrl: entryRelativeUrl }), ...(result.comments && { comments: result.comments }) }; // Add files used from this file load allFilesUsed.push(...filesUsed); // Add externals from this file load using proper merging const mergedExternals = mergeExternals([allExternals, externals]); Object.assign(allExternals, mergedExternals); // Collect promises for nested extra files with their source key if (result.extraFiles) { nestedExtraFilesPromises.push(loadExtraFiles(variantName, result.extraFiles, fileUrl, // Use the resolved file URL as base for its extra files entryUrl, // Keep the entry URL for final conversion loadSource, sourceParser, sourceTransformers, sourceEnhancers, loadSourceCache, { ...options, maxDepth: maxDepth - 1, loadedFiles: new Set(loadedFiles) }, allFilesListed, knownExtraFiles, globalsFileKeys // Pass through globals file tracking ).then(nestedResult => ({ files: nestedResult.extraFiles, allFilesUsed: nestedResult.allFilesUsed, allExternals: nestedResult.allExternals, sourceFileKey: normalizedFileName // Pass the normalized key }))); } } // Wait for all nested extra files and merge them, converting paths based on key structure if (nestedExtraFilesPromises.length > 0) { const nestedExtraFilesResults = await Promise.all(nestedExtraFilesPromises); for (const { files: nestedExtraFiles, allFilesUsed: nestedFilesUsed, allExternals: nestedExternals, sourceFileKey } of nestedExtraFilesResults) { // Add nested files used allFilesUsed.push(...nestedFilesUsed); // Add nested externals using proper merging const mergedNestedExternals = mergeExternals([allExternals, nestedExternals]); Object.assign(allExternals, mergedNestedExternals); for (const [nestedKey, nestedValue] of Object.entries(nestedExtraFiles)) { // Convert the storage key based on the directory structure of the source key. // The nested file's `relativeUrl` is already entry-anchored at this point, // so it carries the authoritative location and no further rewriting is needed. const convertedKey = convertKeyBasedOnDirectory(nestedKey, sourceFileKey); const normalizedConvertedKey = normalizePathKey(convertedKey); processedExtraFiles[normalizedConvertedKey] = nestedValue; } } } return { extraFiles: processedExtraFiles, allFilesUsed, allExternals }; } /** * Loads a variant with support for recursive extra file loading. * The loadSource function can now return extraFiles that will be loaded recursively. * Supports both relative and absolute paths for extra files. * Uses Promise.all for efficient parallel loading of extra files. * * @param url - File URL for the variant * @param variantName - Name of the variant (used for error messages) * @param variant - Variant data object or URL string * @param options - Loading and processing options (source parser, transformers, enhancers, etc.) */ export async function loadIsomorphicCodeVariant(url, variantName, variant, options = {}) { if (!variant) { throw new Error(`Variant is missing from code: ${variantName}`); } const { sourceParser, loadSource, loadVariantMeta, sourceTransformers, sourceEnhancers, globalsCode, disableParsing } = options; // Create a cache for loadSource calls scoped to this loadIsomorphicCodeVariant call const loadSourceCache = new Map(); const functionName = 'Load Variant'; let currentMark = performanceMeasure(undefined, { mark: 'Start', measure: 'Start' }, [functionName, url || variantName], true); if (typeof variant === 'string') { if (!loadVariantMeta) { // Create a basic loadVariantMeta function as fallback const { fileName } = getFileNameFromUrl(variant); if (!fileName) { throw new Error(`Cannot determine fileName from URL "${variant}" for variant "${variantName}". ` + `Please provide a loadVariantMeta function or ensure the URL has a valid file extension.`); } variant = { url: variant, fileName }; } else { try { variant = await loadVariantMeta(variantName, variant); } catch (error) { throw new Error(`Failed to load variant code (variant: ${variantName}, url: ${variant}): ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } currentMark = performanceMeasure(currentMark, { mark: 'Loaded Variant Meta', measure: 'Variant Meta Loading' }, [functionName, url || variantName]); } } const loadedFiles = new Set(); if (url) { loadedFiles.add(url); } const allFilesUsed = url ? [url] : []; // Start with the main file URL if available let allExternals = {}; // Collect externals from all sources // Build set of known extra files from variant definition const knownExtraFiles = new Set(); if (variant.extraFiles) { for (const extraFileName of Object.keys(variant.extraFiles)) { knownExtraFiles.add(extraFileName); } } // Load main file const fileName = variant.fileName || (url ? getFileNameFromUrl(url).fileName : undefined); // Derive language from variant.language or from fileName extension // Normalize the language to its canonical form (e.g., 'js' -> 'javascript') let language = variant.language ? normalizeLanguage(variant.language) : undefined; if (!language && fileName) { const extension = fileName.slice(fileName.lastIndexOf('.')); language = getLanguageFromExtension(extension); } // If we don't have a fileName and no URL, we can still parse if we have language if (!fileName && !url) { let finalSource = variant.source; let finalFallback; let finalFallbackCritical; // Parse the source if we have language and sourceParser if (typeof finalSource === 'string' && language && sourceParser && !disableParsing) { const parseSource = await sourceParser; finalSource = parseSource(finalSource, '', language); } else if (typeof finalSource === 'string') { // No language or parser - build plain-text HAST root with line gutters const root = { type: 'root', children: [{ type: 'text', value: finalSource || '' }] }; const sourceLines = (finalSource || '').split(/\r?\n|\r/); starryNightGutter(root, sourceLines); finalSource = root; } // Apply source enhancers if provided and parsing is not disabled if (!disableParsing && sourceEnhancers && sourceEnhancers.length > 0) { // `variant.comments` is already 1-indexed (the stored `Code` convention). const oneIndexedComments = variant.comments; finalSource = await applyEnhancers(finalSource, oneIndexedComments, '', sourceEnhancers); } // Apply output format compression in production. Other format conversions // happen lazily via the loader so tests can inspect the parsed HAST directly. if (finalSource && typeof finalSource === 'object' && 'type' in finalSource) { // Always derive a variant-level root fallback from the per-frame text so a // `ContentLoading` component can render before the hast is decoded. finalFallback = buildRootFallback(finalSource); // Sparse highlighted-visible companion (see `VariantCode.fallbackCritical`), // built from the live `HastRoot` before `stripFrameFallbacks` runs. const critical = buildCriticalFallback(finalSource, getInitialVisibleFrames(finalSource, false)); finalFallbackCritical = Object.keys(critical).length > 0 ? critical : undefined; if (options.output === 'hastCompressed' && process.env.NODE_ENV === 'production') { if (finalFallback) { stripFrameFallbacks(finalSource); } const json = JSON.stringify(finalSource); // Use the fallback text as a DEFLATE dictionary; rebuilt on decode. const dictionary = finalFallback ? fallbackToText(finalFallback) : undefined; finalSource = { hastCompressed: await compressHastAsync(json, dictionary) }; } } const finalVariant = { ...variant, language, source: finalSource, ...(finalFallback ? { fallback: finalFallback } : {}), ...(finalFallbackCritical ? { fallbackCritical: finalFallbackCritical } : {}) }; return { code: finalVariant, dependencies: [], // No dependencies without URL externals: {} // No externals without URL }; } if (!fileName) { throw new Error(`No fileName available for variant "${variantName}". ` + `Please provide a fileName in the variant definition or ensure the URL has a valid file extension.`); } const mainFileResult = await loadSingleFile(variantName, fileName, variant.source, url, loadSource, sourceParser, sourceTransformers, sourceEnhancers, loadSourceCache, variant.transforms, { ...options, loadedFiles }, variant.allFilesListed || false, knownExtraFiles, language, variant.comments); // Add files used from main file loading if (mainFileResult.extraDependencies) { allFilesUsed.push(...mainFileResult.extraDependencies); } currentMark = performanceMeasure(currentMark, { mark: 'Loaded Main File', measure: 'Main File Loading' }, [functionName, url || fileName], true); // Validate extraFiles keys from variant definition if (variant.extraFiles) { for (const extraFileName of Object.keys(variant.extraFiles)) { // Check if key is an absolute URL (should be relative) if (isAbsolutePath(extraFileName)) { throw new Error(`Invalid extraFiles key in variant: "${extraFileName}" appears to be an absolute path. ` + `extraFiles keys in variant definition should be relative paths from the main file.`); } } } // Collect extra files from variant definition and from loaded source const extraFilesToLoad = { ...(variant.extraFiles || {}), ...(mainFileResult.extraFiles || {}) }; // Add externals from main file loading if (mainFileResult.externals) { allExternals = mergeExternals([allExternals, mainFileResult.externals]); } const externalsMergedMark = performanceMeasure(currentMark, { mark: 'Externals Merged', measure: 'Merging Externals' }, [functionName, url || fileName]); currentMark = externalsMergedMark; // Track which files come from globals for metadata marking const globalsFileKeys = new Set(); // Track globals file keys for loadExtraFiles // Process globalsCode array and add to extraFiles if provided if (globalsCode && globalsCode.length > 0) { // Collect existing filenames to avoid conflicts const existingFiles = new Set(); // Add main variant filename if it exists if (variant.fileName) { existingFiles.add(variant.fileName); } // Add already loaded extra files for (const key of Object.keys(extraFilesToLoad)) { existingFiles.add(key); } // Process all globals items in parallel const globalsPromises = globalsCode.map(async globalsItem => { let globalsVariant; if (typeof globalsItem === 'string') { // Handle string case - load the variant metadata if (!loadVariantMeta) { // Create a basic variant as fallback const { fileName: globalsFileName } = getFileNameFromUrl(globalsItem); if (!globalsFileName) { throw new Error(`Cannot determine fileName from globalsCode URL "${globalsItem}". ` + `Please provide a loadVariantMeta function or ensure the URL has a valid file extension.`); } globalsVariant = { url: globalsItem, fileName: globalsFileName }; } else { try { globalsVariant = await loadVariantMeta(variantName, globalsItem); currentMark = performanceMeasure(currentMark, { mark: 'Globals Variant Meta Loaded', measure: 'Globals Variant Meta Loading' }, [functionName, globalsItem, url || fileName]); } catch (error) { throw new Error(`Failed to load globalsCode variant metadata (variant: ${variantName}, url: ${globalsItem}): ${JSON.stringify(error)}`); } } } else { globalsVariant = globalsItem; } // Load the globals code separately without affecting allFilesListed try { const globalsResult = await loadIsomorphicCodeVariant(globalsVariant.url, variantName, globalsVariant, { ...options, globalsCode: undefined } // Prevent infinite recursion ); currentMark = performanceMeasure(currentMark, { mark: 'Globals Variant Loaded', measure: 'Globals Variant Loading' }, [functionName, globalsVariant.url || variantName, url || fileName]); return globalsResult; } catch (error) { throw new Error(`Failed to load globalsCode (variant: ${variantName}): ${error instanceof Error ? error.message : JSON.stringify(error)}`); } }); // Wait for all globals to load const globalsResults = await Promise.all(globalsPromises); // Merge results from all globals for (const globalsResult of globalsResults) { // Add globals extraFiles (but NOT the main file) if (globalsResult.code.extraFiles) { // Add globals extra files with conflict-free naming and metadata flag for (const [key, value] of Object.entries(globalsResult.code.extraFiles)) { const conflictFreeKey = generateConflictFreeFilename(key, existingFiles); // Always add metadata: true flag for globals files if (typeof value === 'string') { // For string URLs, we can't easily wrap them but need to track for later metadata addition extraFilesToLoad[conflictFreeKey] = value; globalsFileKeys.add(conflictFreeKey); // Track for loadExtraFiles } else { // For object values, add metadata directly extraFilesToLoad[conflictFreeKey] = { ...value, metadata: true }; } existingFiles.add(conflictFreeKey); // Track the added file for subsequent iterations } } // Add globals dependencies allFilesUsed.push(...globalsResult.dependencies); // Add globals externals allExternals = mergeExternals([allExternals, globalsResult.externals]); } } currentMark = performanceMeasure(externalsMergedMark, { mark: 'Globals Loaded', measure: 'Globals Loading' }, [functionName, url || fileName], true); let allExtraFiles = {}; // Load all extra files if any exist and we have a URL if (Object.keys(extraFilesToLoad).length > 0) { if (!url) { // If there's no URL, we can only load extra files that have inline source or absolute URLs const loadableFiles = {}; for (const [key, value] of Object.entries(extraFilesToLoad)) { if (typeof value !== 'string' && value.source !== undefined) { // Inline source - can always load loadableFiles[key] = value; } else if (typeof value === 'string' && isAbsolutePath(value)) { // Absolute URL - can load without base URL loadableFiles[key] = value; } else { console.warn(`Skipping extra file "${key}" - no URL provided and file requires loading from external source`); } } if (Object.keys(loadableFiles).length > 0) { // Process loadable files: inline sources without URL-based loading, absolute URLs with loading for (const [key, value] of Object.entries(loadableFiles)) { if (typeof value !== 'string') { // Inline source - preserve metadata if it was marked as globals const metadata = value.metadata || globalsFileKeys.has(key) ? true : undefined; // Derive language from filename extension const extension = key.slice(key.lastIndexOf('.')); const extraFileLanguage = getLanguageFromExtension(extension); allExtraFiles[normalizePathKey(key)] = { source: value.source, ...(extraFileLanguage && { language: extraFileLanguage }), ...(value.transforms && { transforms: value.transforms }), ...(metadata !== undefined && { metadata }) }; } } // For absolute URLs, we need to load them const urlFilesToLoad = {}; for (const [key, value] of Object.entries(loadableFiles)) { if (typeof value === 'string') { urlFilesToLoad[key] = value; } } if (Object.keys(urlFilesToLoad).length > 0) { // Load absolute URL files even without base URL const extraFilesResult = await loadExtraFiles(variantName, urlFilesToLoad, '', // No base URL needed for absolute URLs '', // No entry URL loadSource, sourceParser, sourceTransformers, sourceEnhancers, loadSourceCache, { ...options, loadedFiles }, variant.allFilesListed || false, knownExtraFiles, globalsFileKeys // Pass globals file tracking ); allExtraFiles = { ...allExtraFiles, ...extraFilesResult.extraFiles }; allFilesUsed.push(...extraFilesResult.allFilesUsed); allExternals = mergeExternals([allExternals, extraFilesResult.allExternals]); } } } else { const extraFilesResult = await loadExtraFiles(variantName, extraFilesToLoad, url, url, // Entry URL is the same as the main file URL loadSource, sourceParser, sourceTransformers, sourceEnhancers, loadSourceCache, { ...options, loadedFiles }, variant.allFilesListed || false, knownExtraFiles, globalsFileKeys // Pass globals file tracking ); allExtraFiles = extraFilesResult.extraFiles; allFilesUsed.push(...extraFilesResult.allFilesUsed); allExternals = mergeExternals([allExternals, extraFilesResult.allExternals]); } currentMark = performanceMeasure(currentMark, { mark: 'Extra Files Loaded', measure: 'Extra Files Loading' }, [functionName, url || fileName], true); } // Note: metadata marking is now handled during loadExtraFiles processing const finalVariant = { ...variant, language, source: mainFileResult.source, ...(mainFileResult.fallback && { fallback: mainFileResult.fallback }), ...(mainFileResult.fallbackCritical && { fallbackCritical: mainFileResult.fallbackCritical }), ...(mainFileResult.totalLines !== undefined && { totalLines: mainFileResult.totalLines }), ...(mainFileResult.focusedLines !== undefined && { focusedLines: mainFileResult.focusedLines }), ...(mainFileResult.collapsible !== undefined && { collapsible: mainFileResult.collapsible }), transforms: mainFileResult.transforms, extraFiles: Object.keys(allExtraFiles).length > 0 ? allExtraFiles : undefined, externals: Object.keys(allExternals).length > 0 ? Object.keys(allExternals) : undefined, // Include comments so they can be used by enhancers on server or client ...(mainFileResult.comments && { comments: mainFileResult.comments }) }; // Apply `urlPrefix` (if any) at the boundary so the file:// URLs the loader // received from disk don't leak to the client. We do this here — rather than // inside `loadSource` — so the rewrite is shared across every loader // implementation and applied consistently to extraFiles too. const variantWithPrefix = options.urlPrefix ? applyUrlPrefixToVariant(finalVariant, options.urlPrefix) : finalVariant; return { code: variantWithPrefix, dependencies: Array.from(new Set(allFilesUsed)), // Remove duplicates externals: allExternals }; }