UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

814 lines (758 loc) 34.1 kB
import * as React from 'react'; import { decodeHastSource } from "../pipeline/loadIsomorphicCodeVariant/decodeHastSource.mjs"; import { useUrlHashState } from "../useUrlHashState/index.mjs"; import { countLines } from "../pipeline/parseSource/addLineGutters.mjs"; import { getLanguageFromExtension } from "../pipeline/loaderUtils/getLanguageFromExtension.mjs"; import { getVariantFileLineCounts } from "./sourceLineCounts.mjs"; import { Pre } from "./Pre.mjs"; import { useSourceEnhancing } from "./useSourceEnhancing.mjs"; import { toKebabCase } from "../pipeline/loaderUtils/toKebabCase.mjs"; import { generateFileSlug } from "../pipeline/loaderUtils/generateFileSlug.mjs"; /** * Gets the language from a filename by extracting its extension. * @param fileName - The filename (e.g., 'index.tsx', 'styles.css') * @returns The language name or undefined */ import { jsx as _jsx } from "react/jsx-runtime"; function getLanguageFromFileName(fileName) { if (!fileName) { return undefined; } const lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex === -1) { return undefined; } const extension = fileName.substring(lastDotIndex); return getLanguageFromExtension(extension); } /** * Checks if the URL hash is relevant to a specific demo * Hash format is: {mainSlug}:{variantName}:{fileName} or {mainSlug}:{fileName} * @param urlHash - The URL hash (without '#') * @param mainSlug - The main slug for the demo * @returns true if the hash starts with the demo's slug */ export function isHashRelevantToDemo(urlHash, mainSlug) { if (!urlHash || !mainSlug) { return false; } const kebabSlug = toKebabCase(mainSlug); return urlHash.startsWith(`${kebabSlug}:`); } function getPreRenderKey(slug, enhancementPhase = 'plain') { // Intentionally keyed only on the canonical file slug (and enhancement // phase). Transforms (e.g. JS↔TS) swap the *content* of the same file — // including `selectedTransform` here would unmount/remount `<Pre>` on // every transform toggle, dropping scroll position, focus, and any // IO/RO-driven UI state. Slugs are already built from the original // (pre-transform) file name, so the key stays stable across transforms. return `${slug ?? 'code'}:${enhancementPhase}`; } /** * Hook for managing file selection and navigation within a code variant */ export function useFileNavigation({ selectedVariant, transformedFiles, // Note: `selectedTransform` is accepted as a prop (callers spread the // result of `useTransformManagement`) but intentionally not destructured // here — the rendered <Pre> children come from `transformedFiles` / // `selectedFile`, which already reflect the active transform. Keying or // memo-deping on the transform name would only cause unnecessary // remounts on transform toggles. mainSlug = '', selectedVariantKey = '', variantKeys = [], shouldHighlight, preClassName, setSource, editActivation, onActivate, effectiveCode, selectVariant, fileHashMode = 'remove-hash', saveHashVariantToLocalStorage = 'on-interaction', saveVariantToLocalStorage, hashVariant, sourceEnhancers, fallbacks, expanded, collapseToEmpty, expand, transforming, onPreTransitionReady, variantBridgeLineMode, swapPartnerVariant, selectedFileName: selectedFileNameInternal, setSelectedFileName: setSelectedFileNameInternal }) { // Use the simplified URL hash hook const [hash, setHash] = useUrlHashState(); // Track if we're waiting for a variant switch to complete, and which file to select after const pendingFileSelection = React.useRef(null); const justCompletedPendingSelection = React.useRef(false); // Track the previous variant key to detect user-initiated changes const prevVariantKeyRef = React.useRef(selectedVariantKey); const [prevVariantKeyState, setPrevVariantKeyState] = React.useState(selectedVariantKey); const isInitialMount = React.useRef(true); // Detect if the current variant change was driven by a hash change // A variant change is hash-driven if the hash has a variant that matches where we're going // AND we weren't already on that variant (i.e., the hash is what triggered the change) const isHashDrivenVariantChange = hashVariant === selectedVariantKey && prevVariantKeyState !== selectedVariantKey; // Update prevVariantKeyState when variant changes React.useEffect(() => { if (selectedVariantKey !== prevVariantKeyState) { // eslint-disable-next-line react-hooks/set-state-in-effect -- previous-value tracker; render-time rewrite changes effect ordering for the hash-update effect that reads isHashDrivenVariantChange as a dep setPrevVariantKeyState(selectedVariantKey); } }, [selectedVariantKey, prevVariantKeyState]); // Helper function to check URL hash and switch to matching file const checkUrlHashAndSelectFile = React.useCallback(() => { if (!hash) { return; } // Try to find matching file - check current variant first let matchingFileName; let matchingVariantKey; // Step 1: Check current variant (if we have one) if (selectedVariant) { // Check main file if (selectedVariant.fileName) { const mainFileSlug = generateFileSlug(mainSlug, selectedVariant.fileName, selectedVariantKey); if (hash === mainFileSlug) { matchingFileName = selectedVariant.fileName; matchingVariantKey = selectedVariantKey; } } // Check extra files if (!matchingFileName && selectedVariant.extraFiles) { for (const fileName of Object.keys(selectedVariant.extraFiles)) { const fileSlug = generateFileSlug(mainSlug, fileName, selectedVariantKey); if (hash === fileSlug) { matchingFileName = fileName; matchingVariantKey = selectedVariantKey; break; } } } // Check transformed files if (!matchingFileName && transformedFiles) { for (const file of transformedFiles.files) { const fileSlug = generateFileSlug(mainSlug, file.originalName, selectedVariantKey); if (hash === fileSlug) { matchingFileName = file.originalName; matchingVariantKey = selectedVariantKey; break; } } } } // Step 2: If no match and we can switch variants, search other variants if (!matchingFileName && effectiveCode && selectVariant) { for (const [variantKey, variant] of Object.entries(effectiveCode)) { // Skip current variant (already checked) and invalid variants if (variantKey === selectedVariantKey || !variant || typeof variant === 'string') { continue; } // Check main file if (variant.fileName) { const mainFileSlug = generateFileSlug(mainSlug, variant.fileName, variantKey); if (hash === mainFileSlug) { matchingFileName = variant.fileName; matchingVariantKey = variantKey; break; } } // Check extra files if (!matchingFileName && variant.extraFiles) { for (const fileName of Object.keys(variant.extraFiles)) { const fileSlug = generateFileSlug(mainSlug, fileName, variantKey); if (hash === fileSlug) { matchingFileName = fileName; matchingVariantKey = variantKey; break; } } } if (matchingFileName) { break; } } } if (matchingFileName && matchingVariantKey) { // If the matching file is in a different variant, switch to that variant first if (matchingVariantKey !== selectedVariantKey && selectVariant) { // Remember which file to select after variant switch pendingFileSelection.current = matchingFileName; selectVariant(matchingVariantKey); // Don't set the file here - it will be set after variant changes return; } // Set the file if we're in the correct variant pendingFileSelection.current = null; setSelectedFileNameInternal(matchingFileName); } }, [hash, selectedVariant, selectedVariantKey, mainSlug, transformedFiles, effectiveCode, selectVariant, setSelectedFileNameInternal]); // Run hash check when URL hash changes to select the matching file React.useEffect(() => { checkUrlHashAndSelectFile(); }, [checkUrlHashAndSelectFile]); // When variant switches with a pending file selection, complete the file selection React.useEffect(() => { if (pendingFileSelection.current && selectedVariant) { const fileToSelect = pendingFileSelection.current; pendingFileSelection.current = null; justCompletedPendingSelection.current = true; setSelectedFileNameInternal(fileToSelect); } else { justCompletedPendingSelection.current = false; } }, [selectedVariantKey, selectedVariant, setSelectedFileNameInternal]); // Reset selectedFileName when variant changes React.useEffect(() => { // Skip reset if we have a pending file selection from hash navigation // OR if we just completed a pending file selection if (pendingFileSelection.current || justCompletedPendingSelection.current) { return; } if (selectedVariant && selectedFileNameInternal !== selectedVariant.fileName) { // Only reset if current selectedFileName doesn't exist in the new variant const hasFile = selectedVariant.fileName === selectedFileNameInternal || selectedFileNameInternal && selectedVariant.extraFiles && selectedVariant.extraFiles[selectedFileNameInternal]; if (!hasFile) { setSelectedFileNameInternal(selectedVariant.fileName); } } }, [selectedVariant, selectedFileNameInternal, setSelectedFileNameInternal]); // Update hash when variant changes (user-initiated variant switch) React.useEffect(() => { // Skip on initial mount - let hash-driven navigation handle it if (isInitialMount.current) { isInitialMount.current = false; prevVariantKeyRef.current = selectedVariantKey; return; } // Only update hash if there's already a relevant hash present if (typeof window === 'undefined' || !isHashRelevantToDemo(hash, mainSlug)) { prevVariantKeyRef.current = selectedVariantKey; return; } // Skip if variant hasn't actually changed if (prevVariantKeyRef.current === selectedVariantKey) { return; } // Skip if this is a hash-driven variant change (hash is driving the variant selection) if (pendingFileSelection.current || justCompletedPendingSelection.current || isHashDrivenVariantChange) { prevVariantKeyRef.current = selectedVariantKey; return; } // User switched variants, update hash based on fileHashMode // Note: localStorage is already saved by setSelectedVariantKeyAsUser if (fileHashMode === 'remove-filename') { // Keep variant in hash: mainSlug or mainSlug:variant (for non-Default variants) const kebabMainSlug = toKebabCase(mainSlug); if (selectedVariantKey === 'Default') { setHash(kebabMainSlug); } else { const kebabVariantName = toKebabCase(selectedVariantKey); setHash(`${kebabMainSlug}:${kebabVariantName}`); } } else { // Remove entire hash setHash(null); } prevVariantKeyRef.current = selectedVariantKey; }, [selectedVariantKey, hash, mainSlug, fileHashMode, setHash, isHashDrivenVariantChange]); // Compute the displayed filename (transformed if applicable) const selectedFileName = React.useMemo(() => { if (!selectedVariant) { return undefined; } // If selectedFileNameInternal is undefined, we're selecting the main file const effectiveFileName = selectedFileNameInternal || selectedVariant.fileName; if (!effectiveFileName) { return undefined; } // If we have transformed files, return the transformed name if (transformedFiles) { const file = transformedFiles.files.find(f => f.originalName === effectiveFileName); return file ? file.name : effectiveFileName; } // Otherwise, return the original filename return effectiveFileName; }, [selectedVariant, selectedFileNameInternal, transformedFiles]); // Derive the URL of the currently selected file by combining the variant URL // with the selected file's name and (optional) `relativeUrl`. When the // selected file is the variant entry, the variant URL is used directly. // // For an extra file: // - string entry: it is itself a fully-qualified URL. // - object entry with `relativeUrl`: resolve `relativeUrl` against the // variant URL. // - object entry without `relativeUrl`: by the `extraFiles` contract the // key itself resolves to the file URL against the variant URL, so we // resolve the key. Authors who provide a synthetic key for an inline // entry should also avoid setting `variant.url` (or should not consume // `selectedFileUrl`). const selectedFileUrl = React.useMemo(() => { if (!selectedVariant?.url) { return undefined; } const effectiveFileName = selectedFileNameInternal || selectedVariant.fileName; if (!effectiveFileName || effectiveFileName === selectedVariant.fileName) { return selectedVariant.url; } const extraFile = selectedVariant.extraFiles?.[effectiveFileName]; if (typeof extraFile === 'string') { // String form is already a fully-qualified URL. return extraFile; } const relativeUrl = extraFile && typeof extraFile === 'object' ? extraFile.relativeUrl : undefined; try { return new URL(relativeUrl ?? effectiveFileName, selectedVariant.url).href; } catch (error) { if (process.env.NODE_ENV !== 'production') { console.warn(`useFileNavigation: failed to derive selectedFileUrl for "${effectiveFileName}" against "${selectedVariant.url}": ${error instanceof Error ? error.message : String(error)}`); } return undefined; } }, [selectedVariant, selectedFileNameInternal]); // Slug for the currently selected file. Always derived from the canonical // (original) file name so that transforms remain a view preference and do // not produce a separate URL — clicking a permalink lands on the same // file regardless of which transform the visitor has selected. const selectedFileSlug = React.useMemo(() => { if (!selectedVariant) { return undefined; } const effectiveFileName = selectedFileNameInternal || selectedVariant.fileName; if (!effectiveFileName) { return undefined; } return generateFileSlug(mainSlug, effectiveFileName, selectedVariantKey); }, [selectedVariant, selectedFileNameInternal, mainSlug, selectedVariantKey]); const selectedFile = React.useMemo(() => { if (!selectedVariant) { return null; } // If we have transformed files, use them if (transformedFiles) { const effectiveFileName = selectedFileNameInternal || selectedVariant.fileName; const file = transformedFiles.files.find(f => f.originalName === effectiveFileName); return file ? file.source : null; } // Otherwise, use the original untransformed files if (selectedFileNameInternal === selectedVariant.fileName || !selectedFileNameInternal) { return selectedVariant.source ?? null; } // Look in extraFiles if (selectedFileNameInternal && selectedVariant.extraFiles && selectedVariant.extraFiles[selectedFileNameInternal]) { const extraFile = selectedVariant.extraFiles[selectedFileNameInternal]; if (typeof extraFile === 'string') { return extraFile; } if (extraFile && typeof extraFile === 'object' && 'source' in extraFile) { return extraFile.source ?? null; } } return null; }, [selectedVariant, selectedFileNameInternal, transformedFiles]); // Get comments for the selected file. When a transformed view is // active, prefer the transformed file's own `comments` map — it has // already been remapped onto the post-transform line numbers by // `applyCodeTransformWithComments`, so client-side enhancers see // markers that line up with the source they're being handed. Falling // back to `selectedVariant.comments` would feed the enhancer the // original (pre-transform) line numbers, silently mis-aligning every // `@focus` / `@padding-*` marker on transformed renders. const selectedFileComments = React.useMemo(() => { if (!selectedVariant) { return undefined; } const effectiveFileName = selectedFileNameInternal || selectedVariant.fileName; if (!effectiveFileName) { return undefined; } if (transformedFiles) { const transformedFile = transformedFiles.files.find(file => file.originalName === effectiveFileName); if (transformedFile) { return transformedFile.comments; } } // Check if it's the main file if (effectiveFileName === selectedVariant.fileName) { return selectedVariant.comments; } // Check extra files if (selectedVariant.extraFiles?.[effectiveFileName]) { const extraFile = selectedVariant.extraFiles[effectiveFileName]; if (typeof extraFile === 'object' && 'comments' in extraFile) { return extraFile.comments; } } return undefined; }, [selectedVariant, selectedFileNameInternal, transformedFiles]); // Resolve per-file DEFLATE dictionaries from both places a fallback can // arrive: `fallbacks` (hoisted from a `ContentLoading` component, for files // whose fallback was stripped off `Code`) and the variant's own per-file // `fallback` fields (kept on `Code` for files that weren't stripped — e.g. // extra files when `fallbackUsesExtraFiles` is off, the no-`ContentLoading` // path, or fallbacks scattered back from the residual blob). For most files // only one is populated. When both are — a `fallbackCollapsed` block hoists // the *visible* window but scatters the *full* fallback onto `Code` — the // `Code` copy must win, because the full text is the dictionary // `hastCompressed` needs. So the variant copy takes precedence. const resolvedFallbacks = React.useMemo(() => { const fromVariant = {}; if (selectedVariant) { if (selectedVariant.fileName && selectedVariant.fallback) { fromVariant[selectedVariant.fileName] = selectedVariant.fallback; } for (const [name, fileData] of Object.entries(selectedVariant.extraFiles || {})) { if (typeof fileData === 'object' && fileData?.fallback) { fromVariant[name] = fileData.fallback; } } } return { ...fallbacks, ...fromVariant }; }, [selectedVariant, fallbacks]); // The DEFLATE dictionary for the currently selected file's `hastCompressed` // source. Extra files key by name; the main file prefers its fileName-keyed // entry, falling back to the variant's own `fallback` field (the only way to // resolve a main file with no fileName, e.g. a bare markdown fence). const selectedFileFallback = React.useMemo(() => { if (selectedFileNameInternal && selectedFileNameInternal !== selectedVariant?.fileName) { return resolvedFallbacks[selectedFileNameInternal]; } return (selectedVariant?.fileName ? resolvedFallbacks[selectedVariant.fileName] : undefined) ?? selectedVariant?.fallback; }, [selectedFileNameInternal, selectedVariant, resolvedFallbacks]); const selectedFileLineCounts = React.useMemo(() => { if (!selectedVariant) { return null; } const fileName = selectedFileNameInternal || selectedVariant.fileName; return fileName ? getVariantFileLineCounts(selectedVariant, fileName) : null; }, [selectedFileNameInternal, selectedVariant]); // Apply source enhancers to the selected file const { enhancedSource, isEnhancing } = useSourceEnhancing({ source: selectedFile, fileName: selectedFileName, comments: selectedFileComments, sourceEnhancers, fallback: selectedFileFallback }); // Look up the partner variant's matching-file line counts so `<Pre>` // can append a bridge `.collapse` placeholder while a variant swap // is in flight. When the same-named file is absent from the partner // variant, fall back to the partner's main file — that's what the // file-navigation reset will commit to at the swap point (see the // "Only reset if current selectedFileName doesn't exist in the new // variant" effect above), so the bridge must measure against the // same target. Without the fallback the bridge returns `null`, the // animation is skipped, and the layout snaps when the swap commits. const resolveSwapTarget = React.useCallback(fileName => { if (!swapPartnerVariant || !fileName) { return null; } const counts = getVariantFileLineCounts(swapPartnerVariant, fileName); if (counts) { return { totalLines: counts.totalLines, focusedLines: counts.focusedLines }; } const partnerMainFileName = 'fileName' in swapPartnerVariant ? swapPartnerVariant.fileName : undefined; if (!partnerMainFileName || partnerMainFileName === fileName) { return null; } const mainCounts = getVariantFileLineCounts(swapPartnerVariant, partnerMainFileName); return mainCounts ? { totalLines: mainCounts.totalLines, focusedLines: mainCounts.focusedLines } : null; }, [swapPartnerVariant]); const selectedFileComponent = React.useMemo(() => { if (!selectedVariant) { return null; } // Determine the source to render: // - If enhancers are present, use enhanced source (falls back to selectedFile) // - Otherwise use selectedFile directly (which may be from transformed files) const sourceToRender = sourceEnhancers && sourceEnhancers.length > 0 ? enhancedSource ?? selectedFile : selectedFile; if (sourceToRender != null) { // Determine language: use variant's language for main file, or derive from filename for extra files const isMainFile = !selectedFileNameInternal || selectedFileNameInternal === selectedVariant.fileName; const language = isMainFile ? selectedVariant.language : getLanguageFromFileName(selectedFileNameInternal); const fileName = selectedFileNameInternal || selectedVariant.fileName; const fileSlug = generateFileSlug(mainSlug, selectedFileNameInternal ?? selectedVariant.fileName ?? 'code', selectedVariantKey); let enhancementPhase = 'plain'; if (sourceEnhancers && sourceEnhancers.length > 0) { enhancementPhase = isEnhancing ? 'base' : 'enhanced'; } return /*#__PURE__*/_jsx(Pre, { className: preClassName, fileName: fileName, bridgeLineMode: variantBridgeLineMode, language: language, setSource: setSource, editActivation: editActivation, onActivate: onActivate, shouldHighlight: shouldHighlight, fallback: selectedFileFallback, fallbackLineCounts: selectedFileLineCounts, expanded: expanded, collapseToEmpty: collapseToEmpty, expand: expand, transforming: transforming, onTransitionReady: onPreTransitionReady, swapTarget: resolveSwapTarget(fileName), children: sourceToRender }, getPreRenderKey(fileSlug, enhancementPhase)); } return null; }, [selectedVariant, shouldHighlight, preClassName, setSource, editActivation, onActivate, enhancedSource, isEnhancing, mainSlug, selectedFile, selectedVariantKey, sourceEnhancers, selectedFileNameInternal, selectedFileFallback, selectedFileLineCounts, expanded, collapseToEmpty, expand, transforming, onPreTransitionReady, variantBridgeLineMode, resolveSwapTarget]); const selectedFileLines = React.useMemo(() => { if (selectedFile == null) { return 0; } // If it's a string, split by newlines and count. if (typeof selectedFile === 'string') { return selectedFile.split('\n').length; } // A compressed payload (`hastCompressed` / `hastJson`) is never a transformed // tree — transforms produce live HAST — so the stored variant count is // authoritative for it. Use it instead of decompressing the source just to read // the count, even when OTHER files in the variant are transformed. Only a live // (transformed or in-hand) tree, or a legacy payload with no stored count, falls // through to read the hast below. const isCompressedSource = 'hastJson' in selectedFile || 'hastCompressed' in selectedFile; if (isCompressedSource && selectedFileLineCounts && selectedFileLineCounts.totalLines > 0) { return selectedFileLineCounts.totalLines; } // A live HAST tree (e.g. a transformed source) carries its own up-to-date counts // in `root.data`; `decodeHastSource` returns such a tree unchanged (no // decompression). A compressed source with no stored count (a legacy precompute) // is the only case that actually decompresses here — and it's the same tree // `<Pre>` decodes to render, so the shared decode cache amortizes it. The // selected file's `fallback` is forwarded as the matching DEFLATE dictionary. const hastSelectedFile = decodeHastSource(selectedFile, selectedFileFallback); if (hastSelectedFile) { if (hastSelectedFile.data && 'totalLines' in hastSelectedFile.data) { const totalLines = hastSelectedFile.data.totalLines; // Check if totalLines is a valid number (not null, undefined, or NaN) if (totalLines != null && !Number.isNaN(Number(totalLines))) { const numLines = Number(totalLines); if (numLines >= 0) { return numLines; } } // Fall through to children count if totalLines is invalid } if ('children' in hastSelectedFile) { // Use countLines for more accurate line counting of HAST trees return countLines(hastSelectedFile); } } return 0; }, [selectedFile, selectedFileFallback, selectedFileLineCounts]); // Convert files for the return interface const files = React.useMemo(() => { if (!selectedVariant) { return []; } // If we have transformed files, use them if (transformedFiles) { return transformedFiles.files.map(f => ({ name: f.name, slug: generateFileSlug(mainSlug, f.originalName, selectedVariantKey), component: /*#__PURE__*/_jsx(Pre, { className: preClassName, fileName: f.originalName, bridgeLineMode: variantBridgeLineMode, setSource: setSource, editActivation: editActivation, onActivate: onActivate, shouldHighlight: shouldHighlight, expanded: expanded, expand: expand, transforming: transforming, onTransitionReady: onPreTransitionReady, swapTarget: resolveSwapTarget(f.originalName), children: f.source }, getPreRenderKey(generateFileSlug(mainSlug, f.originalName, selectedVariantKey))) })); } // Otherwise, create files from original untransformed data const result = []; // Only add main file if it has a fileName if (selectedVariant.fileName && selectedVariant.source) { result.push({ name: selectedVariant.fileName, slug: generateFileSlug(mainSlug, selectedVariant.fileName, selectedVariantKey), component: /*#__PURE__*/_jsx(Pre, { className: preClassName, fileName: selectedVariant.fileName, language: selectedVariant.language, setSource: setSource, editActivation: editActivation, onActivate: onActivate, shouldHighlight: shouldHighlight, fallback: selectedVariant.fileName ? resolvedFallbacks[selectedVariant.fileName] : undefined, fallbackLineCounts: getVariantFileLineCounts(selectedVariant, selectedVariant.fileName), expanded: expanded, expand: expand, transforming: transforming, onTransitionReady: onPreTransitionReady, bridgeLineMode: variantBridgeLineMode, swapTarget: resolveSwapTarget(selectedVariant.fileName), children: selectedVariant.source }, getPreRenderKey(generateFileSlug(mainSlug, selectedVariant.fileName, selectedVariantKey))) }); } if (selectedVariant.extraFiles) { Object.entries(selectedVariant.extraFiles).forEach(([fileName, fileData]) => { let source; let language; if (typeof fileData === 'string') { source = fileData; } else if (fileData && typeof fileData === 'object' && 'source' in fileData) { source = fileData.source; language = fileData.language; } else { return; // Skip invalid entries } if (!source) { return; // Skip null/undefined sources } result.push({ name: fileName, slug: generateFileSlug(mainSlug, fileName, selectedVariantKey), component: /*#__PURE__*/_jsx(Pre, { className: preClassName, fileName: fileName, language: language ?? getLanguageFromFileName(fileName), setSource: setSource, editActivation: editActivation, onActivate: onActivate, shouldHighlight: shouldHighlight, fallback: resolvedFallbacks[fileName], fallbackLineCounts: getVariantFileLineCounts(selectedVariant, fileName), expanded: expanded, expand: expand, transforming: transforming, onTransitionReady: onPreTransitionReady, bridgeLineMode: variantBridgeLineMode, swapTarget: resolveSwapTarget(fileName), children: source }, getPreRenderKey(generateFileSlug(mainSlug, fileName, selectedVariantKey))) }); }); } return result; }, [selectedVariant, transformedFiles, mainSlug, selectedVariantKey, shouldHighlight, preClassName, resolvedFallbacks, setSource, editActivation, onActivate, expanded, expand, transforming, onPreTransitionReady, variantBridgeLineMode, resolveSwapTarget]); // Create a wrapper for selectFileName that handles transformed filenames and URL updates const selectFileName = React.useCallback(fileName => { if (!selectedVariant) { return; } let targetFileName = fileName; // If we have transformed files, we need to reverse-lookup the original filename if (transformedFiles) { // Check if the fileName is a transformed name - if so, find the original const fileByTransformedName = transformedFiles.files.find(f => f.name === fileName); if (fileByTransformedName) { targetFileName = fileByTransformedName.originalName; } else { // Check if the fileName is already an original name const fileByOriginalName = transformedFiles.files.find(f => f.originalName === fileName); if (fileByOriginalName) { targetFileName = fileName; } } } // Handle hash removal based on fileHashMode if (typeof window !== 'undefined' && isHashRelevantToDemo(hash, mainSlug)) { // Save variant to localStorage if on-interaction mode (clicking a tab counts as interaction) if (saveVariantToLocalStorage && saveHashVariantToLocalStorage === 'on-interaction') { saveVariantToLocalStorage(selectedVariantKey); } if (fileHashMode === 'remove-filename') { // Keep variant in hash: mainSlug or mainSlug:variant (for non-Default variants) const kebabMainSlug = toKebabCase(mainSlug); if (selectedVariantKey === 'Default') { setHash(kebabMainSlug); } else { const kebabVariantName = toKebabCase(selectedVariantKey); setHash(`${kebabMainSlug}:${kebabVariantName}`); } } else { // Remove entire hash setHash(null); } } setSelectedFileNameInternal(targetFileName); }, [selectedVariant, transformedFiles, mainSlug, selectedVariantKey, fileHashMode, hash, setHash, saveHashVariantToLocalStorage, saveVariantToLocalStorage, setSelectedFileNameInternal]); // Memoized array of all file slugs for all variants const allFilesSlugs = React.useMemo(() => { const result = []; if (!effectiveCode || !variantKeys.length) { return result; } // Iterate through all variants for (const variantKey of variantKeys) { const variant = effectiveCode[variantKey]; // Skip invalid variants if (!variant || typeof variant === 'string') { continue; } // Add variant-only slug (points to main file of the variant) // Skip for Default variant since it doesn't have variant name in hash if (variant.fileName && variantKey !== 'Default') { const kebabMainSlug = toKebabCase(mainSlug); const kebabVariantName = toKebabCase(variantKey); const variantOnlySlug = `${kebabMainSlug}:${kebabVariantName}`; result.push({ fileName: variant.fileName, slug: variantOnlySlug, variantName: variantKey }); } // Add main file if it exists if (variant.fileName) { result.push({ fileName: variant.fileName, slug: generateFileSlug(mainSlug, variant.fileName, variantKey), variantName: variantKey }); } // Add extra files if (variant.extraFiles) { Object.keys(variant.extraFiles).forEach(fileName => { result.push({ fileName, slug: generateFileSlug(mainSlug, fileName, variantKey), variantName: variantKey }); }); } } return result; }, [effectiveCode, variantKeys, mainSlug]); return { selectedFileName, selectedFileUrl, selectedFileSlug, selectedFile, selectedFileFallback, selectedFileComponent, selectedFileLines, files, allFilesSlugs, selectFileName }; }