import fs from 'fs'; import path from 'path'; import { getDisplayName } from './fileUtils'; import { migrateLegacyEntries, toCompatMaps } from './entriesManifest'; export type SidebarTreeTab = 'prototypes' | 'components' | 'docs' | 'canvas'; type ScannableGroup = 'components' | 'prototypes' | 'themes'; export interface ScannedEntryItem { name: string; displayName: string; demoUrl: string; specUrl: string; jsUrl: string; filePath: string; isReference?: boolean; hasSubPages?: boolean; } export interface EntriesFileData extends Record { schemaVersion?: number; generatedAt?: string; items?: Record; js?: Record; html?: Record; } export interface EntryScanResult { entries: { js: Record; html: Record; }; items: Record; } const MANIFEST_SCANNED_GROUPS: ScannableGroup[] = ['components', 'prototypes', 'themes']; function encodeUrlPathSegments(value: string): string { return value .split('/') .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/'); } function isScannedGroupKey(key: string): boolean { return MANIFEST_SCANNED_GROUPS.some((group) => key.startsWith(`${group}/`)); } function scanGroup(projectRoot: string, group: ScannableGroup): { entries: { js: Record; html: Record }; items: ScannedEntryItem[]; } { const groupDir = path.resolve(projectRoot, 'src', group); const entries = { js: {} as Record, html: {} as Record }; const items: ScannedEntryItem[] = []; if (!fs.existsSync(groupDir)) { return { entries, items }; } const names = fs .readdirSync(groupDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort((a, b) => a.localeCompare(b)); for (const name of names) { const folderPath = path.join(groupDir, name); const jsEntry = path.join(folderPath, 'index.tsx'); if (!fs.existsSync(jsEntry)) { continue; } const key = `${group}/${name}`; entries.js[key] = jsEntry; entries.html[key] = path.join(folderPath, 'index.html'); let displayName = getDisplayName(jsEntry) || name; const encodedKey = encodeUrlPathSegments(key); items.push({ name, displayName, demoUrl: `/${encodedKey}`, specUrl: `/${encodedKey}/spec`, jsUrl: `/build/${encodedKey}.js`, filePath: jsEntry, isReference: name.startsWith('ref-'), hasSubPages: group === 'prototypes' && fs.existsSync(path.join(folderPath, 'pages.json')), }); } items.sort((a, b) => a.name.localeCompare(b.name)); return { entries, items, }; } export function scanEntries(projectRoot: string): EntryScanResult { const result: EntryScanResult = { entries: { js: {}, html: {} }, items: { components: [], prototypes: [], docs: [], canvas: [], }, }; for (const group of MANIFEST_SCANNED_GROUPS) { const scanned = scanGroup(projectRoot, group); Object.assign(result.entries.js, scanned.entries.js); Object.assign(result.entries.html, scanned.entries.html); if (group === 'components' || group === 'prototypes') { result.items[group] = scanned.items; } } return result; } export function allowedItemKeysByTab( scannedEntries: Record, tab: SidebarTreeTab, ): Set { return new Set( Object.keys(scannedEntries) .filter((key) => key.startsWith(`${tab}/`)) .sort((a, b) => a.localeCompare(b)), ); } export function mergeScannedEntries(existing: EntriesFileData, scanned: EntryScanResult['entries']): EntriesFileData { const migrated = migrateLegacyEntries(existing, process.cwd()); const nextItems = { ...(migrated.items || {}) }; for (const key of Object.keys(nextItems)) { if (isScannedGroupKey(key) && !Object.prototype.hasOwnProperty.call(scanned.js, key)) { delete nextItems[key]; } } for (const [key, jsPath] of Object.entries(scanned.js || {})) { const [group, ...nameParts] = key.split('/'); const name = nameParts.join('/'); if (!group || !name) continue; nextItems[key] = { group, name, js: jsPath, html: scanned.html?.[key] || `src/${group}/${name}/index.html`, }; } const sortedItems = Object.keys(nextItems) .sort((a, b) => a.localeCompare(b)) .reduce>((acc, key) => { acc[key] = nextItems[key]; return acc; }, {}); const compat = toCompatMaps(sortedItems); const nextEntries: EntriesFileData = { ...existing, schemaVersion: 2, generatedAt: new Date().toISOString(), items: sortedItems, js: compat.js, html: compat.html, }; delete (nextEntries as { sidebarTree?: unknown }).sidebarTree; return nextEntries; }