diff --git a/site/scripts/api-docs-builder/src/pipeline.ts b/site/scripts/api-docs-builder/src/pipeline.ts index 0f3d942a..3c9d6a6c 100644 --- a/site/scripts/api-docs-builder/src/pipeline.ts +++ b/site/scripts/api-docs-builder/src/pipeline.ts @@ -553,10 +553,12 @@ export interface PresetSkinDef { export interface PresetReference { name: string; + description?: string; featureBundle: string; features: string[]; html: { skins: PresetSkinDef[]; + mediaElement?: string; }; react: { skins: PresetSkinDef[]; diff --git a/site/scripts/api-docs-builder/src/preset-handler.ts b/site/scripts/api-docs-builder/src/preset-handler.ts index f6644c3e..5d5e0ece 100644 --- a/site/scripts/api-docs-builder/src/preset-handler.ts +++ b/site/scripts/api-docs-builder/src/preset-handler.ts @@ -1,101 +1,311 @@ /** * Preset reference extraction. * - * Discovers presets from packages/{html,react}/src/presets/ and extracts - * feature bundles, skins, and media elements from their index files. + * Discovers presets from package.json exports in packages/{html,react}/ and + * extracts feature bundles, skins, and media elements. * - * Uses raw TypeScript AST (no type checker needed) since classification - * is naming-convention-based and tagName extraction is from static properties. + * Discovery: + * - Reads package.json exports to find preset names and their source paths + * - Barrel file (./X export) → feature bundle name + file-level description + * - Source directory (./X/* export) → skins + media elements via directory scan * - * Convention: - * - HTML presets: packages/html/src/presets/{name}.ts - * - React presets: packages/react/src/presets/{name}/index.ts - * - Feature bundles: exports matching *Features (plural) - * - Skins: exports matching *Skin or *SkinElement (not *Tailwind*) - * - Tailwind: source specifier contains '.tailwind' → excluded - * - Media elements: remaining value exports (React only) - * - Feature resolution: packages/core/src/dom/store/features/presets.ts + * Classification (positive detection only): + * - HTML: classes with `static readonly tagName` + * - *Skin*Element → skin + * - *Player* → skip + * - remaining → media element + * - React: exported functions/classes/consts + * - *Skin → skin + * - remaining → media element + * - .tailwind in filename → excluded (both frameworks) + * + * Feature resolution: packages/core/src/dom/store/features/presets.ts */ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as ts from 'typescript'; import type { PresetReference, PresetResult, PresetSkinDef } from './pipeline.js'; -interface ExportInfo { +// ─── Types ────────────────────────────────────────────────────────── + +interface PresetInfo { name: string; - sourceSpecifier: string; + html?: { + barrelPath: string; + scanDir: string; + }; + react?: { + barrelPath: string; + scanDir: string; + }; } -// ─── Export Parsing ─────────────────────────────────────────────── +// ─── Package.json Discovery ───────────────────────────────────────── -function parseNamedExports(filePath: string): ExportInfo[] { - if (!fs.existsSync(filePath)) return []; +/** + * Resolve a dist output path back to its source path. + * Handles both real packages (dist/dev/... → src/...) and test fixtures + * (src/... → src/..., already source paths). + */ +function distToSrc(distPath: string): string { + // Real packages: dist/(dev|default)/foo/bar.js → src/foo/bar.ts + const distMatch = distPath.match(/^\.\/dist\/(?:dev|default)\/(.+?)(?:\.d\.ts|\.js)$/); + if (distMatch) return `./src/${distMatch[1]}.ts`; - const content = fs.readFileSync(filePath, 'utf-8'); - const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); - const exports: ExportInfo[] = []; + // Already a source path (test fixtures) + return distPath; +} - ts.forEachChild(sourceFile, (node) => { - if (!ts.isExportDeclaration(node) || !node.moduleSpecifier) return; - const sourceSpecifier = (node.moduleSpecifier as ts.StringLiteral).text; +/** + * Extract the source file path from a package.json export value. + * Handles both conditional exports ({ types, default }) and string exports. + */ +function resolveExportPath(exportValue: unknown): string | undefined { + if (typeof exportValue === 'string') return exportValue; + if (typeof exportValue === 'object' && exportValue !== null) { + const obj = exportValue as Record; + // Prefer types (points to source in some configs), fall back to default + const raw = (obj.types ?? obj.default) as string | undefined; + return raw; + } + return undefined; +} - if (node.exportClause && ts.isNamedExports(node.exportClause)) { - for (const element of node.exportClause.elements) { - // Skip type-only exports - if (element.isTypeOnly) continue; - exports.push({ name: element.name.text, sourceSpecifier }); - } +/** + * Discover presets from package.json exports for a single package. + * Returns a map of preset name → { barrelPath, scanDir }. + */ +function discoverPresetsFromPackage(packageDir: string): Map { + const pkgJsonPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) return new Map(); + + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + const exports: Record = pkgJson.exports ?? {}; + + const result = new Map(); + + for (const key of Object.keys(exports)) { + // Match ./name (not ./, not ./name/*, not ./name/*.css) + const match = key.match(/^\.\/([a-z][a-z0-9-]*)$/); + if (!match) continue; + + const name = match[1]!; + + // Must have a corresponding wildcard export + const wildcardKey = `./${name}/*`; + if (!(wildcardKey in exports)) continue; + + const barrelRaw = resolveExportPath(exports[key]); + const wildcardRaw = resolveExportPath(exports[wildcardKey]); + if (!barrelRaw || !wildcardRaw) continue; + + const barrelSrc = distToSrc(barrelRaw); + const wildcardSrc = distToSrc(wildcardRaw); + + // Resolve barrel to absolute path + const barrelPath = path.resolve(packageDir, barrelSrc); + + // Wildcard path ends with /*.ts — strip the wildcard to get the directory + const scanDir = path.resolve(packageDir, wildcardSrc.replace(/\/\*\.ts$/, '').replace(/\/\*$/, '')); + + if (fs.existsSync(barrelPath) && fs.existsSync(scanDir)) { + result.set(name, { barrelPath, scanDir }); } - // Note: `export * from` (namespace re-exports) are skipped — we only handle named exports - }); + } - return exports; + return result; } -// ─── Export Classification ──────────────────────────────────────── +/** + * Discover all presets from both HTML and React packages. + */ +function discoverPresets(monorepoRoot: string): PresetInfo[] { + const htmlPkgDir = path.join(monorepoRoot, 'packages/html'); + const reactPkgDir = path.join(monorepoRoot, 'packages/react'); + + const htmlPresets = discoverPresetsFromPackage(htmlPkgDir); + const reactPresets = discoverPresetsFromPackage(reactPkgDir); + + const allNames = new Set([...htmlPresets.keys(), ...reactPresets.keys()]); + + return [...allNames].sort().map((name) => { + const info: PresetInfo = { name }; + const html = htmlPresets.get(name); + const react = reactPresets.get(name); + if (html) info.html = html; + if (react) info.react = react; + return info; + }); +} + +// ─── Classification Helpers ───────────────────────────────────────── function isFeatureBundle(name: string): boolean { return name.endsWith('Features'); } -function isTailwind(sourceSpecifier: string): boolean { - return sourceSpecifier.includes('.tailwind'); +function isTailwindFile(filePath: string): boolean { + return path.basename(filePath).includes('.tailwind'); } -function isSkin(name: string): boolean { - return /Skin(Element)?$/.test(name); +function isSkinClass(name: string): boolean { + return /Skin.*Element/.test(name) || /Skin(Element)?$/.test(name); } -// ─── Tag Name Extraction ───────────────────────────────────────── +function isPlayerClass(name: string): boolean { + return /Player/.test(name); +} -function extractTagName(elementFilePath: string): string | undefined { - if (!fs.existsSync(elementFilePath)) return undefined; +function isReactSkin(name: string): boolean { + return /Skin$/.test(name); +} - const content = fs.readFileSync(elementFilePath, 'utf-8'); - const sourceFile = ts.createSourceFile(elementFilePath, content, ts.ScriptTarget.Latest, true); +// ─── Tag Name Extraction ──────────────────────────────────────────── - let tagName: string | undefined; +interface ClassWithTagName { + className: string; + tagName: string; +} - function visit(node: ts.Node) { - if ( - ts.isPropertyDeclaration(node) && - node.name && - ts.isIdentifier(node.name) && - node.name.text === 'tagName' && - node.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) && - node.initializer && - ts.isStringLiteral(node.initializer) - ) { - tagName = node.initializer.text; +function extractClassesWithTagName(filePath: string): ClassWithTagName[] { + if (!fs.existsSync(filePath)) return []; + + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const results: ClassWithTagName[] = []; + + ts.forEachChild(sourceFile, (node) => { + // Follow `export * from './foo'` re-exports + if (ts.isExportDeclaration(node) && !node.exportClause && node.moduleSpecifier) { + const specifier = (node.moduleSpecifier as ts.StringLiteral).text; + const resolved = resolveModulePath(path.dirname(filePath), specifier); + if (resolved) { + results.push(...extractClassesWithTagName(resolved)); + } + return; } - ts.forEachChild(node, visit); - } - visit(sourceFile); - return tagName; + if (!ts.isClassDeclaration(node) || !node.name) return; + if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return; + + for (const member of node.members) { + if ( + ts.isPropertyDeclaration(member) && + member.name && + ts.isIdentifier(member.name) && + member.name.text === 'tagName' && + member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) && + member.initializer && + ts.isStringLiteral(member.initializer) + ) { + results.push({ + className: node.name.text, + tagName: member.initializer.text, + }); + } + } + }); + + return results; } -// ─── Feature Bundle Resolution ──────────────────────────────────── +function resolveModulePath(dir: string, specifier: string): string | undefined { + for (const ext of ['.ts', '.tsx']) { + const candidate = path.join(dir, `${specifier}${ext}`); + if (fs.existsSync(candidate)) return candidate; + } + // Try index file in directory + for (const ext of ['.ts', '.tsx']) { + const candidate = path.join(dir, specifier, `index${ext}`); + if (fs.existsSync(candidate)) return candidate; + } + return undefined; +} + +// ─── React Export Extraction ──────────────────────────────────────── + +function extractValueExports(filePath: string): string[] { + if (!fs.existsSync(filePath)) return []; + + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const names: string[] = []; + + ts.forEachChild(sourceFile, (node) => { + if ( + ts.isFunctionDeclaration(node) && + node.name && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) { + names.push(node.name.text); + } + if (ts.isVariableStatement(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + for (const decl of node.declarationList.declarations) { + if (ts.isIdentifier(decl.name)) { + names.push(decl.name.text); + } + } + } + if ( + ts.isClassDeclaration(node) && + node.name && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) + ) { + names.push(node.name.text); + } + }); + + return names; +} + +// ─── Barrel Parsing (feature bundle only) ─────────────────────────── + +/** + * Parse named value export names from a barrel file. + * Only reads `export { X } from '...'` syntax — skips `export *` since + * skins are discovered via directory scanning. + */ +function parseBarrelExportNames(filePath: string): string[] { + if (!fs.existsSync(filePath)) return []; + + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const names: string[] = []; + + ts.forEachChild(sourceFile, (node) => { + if (!ts.isExportDeclaration(node) || !node.moduleSpecifier) return; + + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const element of node.exportClause.elements) { + if (element.isTypeOnly) continue; + names.push(element.name.text); + } + } + }); + + return names; +} + +function findFeatureBundleExport(filePath: string): string | undefined { + return parseBarrelExportNames(filePath).find(isFeatureBundle); +} + +/** + * Find the media element from a React barrel's named exports. + * The media element is a named re-export that isn't a feature bundle or skin. + */ +function findReactMediaElement(filePath: string): string | undefined { + const names = parseBarrelExportNames(filePath); + for (const name of names) { + if (isFeatureBundle(name)) continue; + if (isReactSkin(name)) continue; + if (/Tailwind$/.test(name)) continue; + return name; + } + return undefined; +} + +// ─── Feature Bundle Resolution ────────────────────────────────────── function parseFeatureBundles(presetsFilePath: string): Map { const map = new Map(); @@ -117,7 +327,6 @@ function parseFeatureBundles(presetsFilePath: string): Map { const features: string[] = []; for (const element of decl.initializer.elements) { if (ts.isIdentifier(element)) { - // Strip 'Feature' suffix: playbackFeature → playback const featureName = element.text.replace(/Feature$/, ''); features.push(featureName); } @@ -130,109 +339,128 @@ function parseFeatureBundles(presetsFilePath: string): Map { return map; } -// ─── Preset Discovery ───────────────────────────────────────────── +// ─── Description Extraction ───────────────────────────────────────── -function discoverPresetNames(htmlPresetsDir: string, reactPresetsDir: string): string[] { - const names = new Set(); +function extractFileDescription(filePath: string): string | undefined { + if (!fs.existsSync(filePath)) return undefined; - // HTML presets: {name}.ts files - if (fs.existsSync(htmlPresetsDir)) { - for (const file of fs.readdirSync(htmlPresetsDir)) { - if (file.endsWith('.ts')) { - names.add(file.replace(/\.ts$/, '')); - } - } - } + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); - // React presets: {name}/ directories with index.ts - if (fs.existsSync(reactPresetsDir)) { - for (const dir of fs.readdirSync(reactPresetsDir, { withFileTypes: true })) { - if (dir.isDirectory() && fs.existsSync(path.join(reactPresetsDir, dir.name, 'index.ts'))) { - names.add(dir.name); - } - } - } + const firstStatement = sourceFile.statements[0]; + if (!firstStatement) return undefined; - return [...names].sort(); + const jsDocNodes = (firstStatement as { jsDoc?: ts.JSDoc[] }).jsDoc; + if (!jsDocNodes || jsDocNodes.length === 0) return undefined; + + const doc = jsDocNodes[0]!; + if (typeof doc.comment === 'string') return doc.comment; + return undefined; } -// ─── Preset Reference Building ──────────────────────────────────── +// ─── Directory Scanning ───────────────────────────────────────────── -function buildPresetReference( - presetName: string, - htmlPresetsDir: string, - reactPresetsDir: string, - featureBundleMap: Map, - monorepoRoot: string -): PresetResult | null { - const htmlPresetFile = path.join(htmlPresetsDir, `${presetName}.ts`); - const reactPresetFile = path.join(reactPresetsDir, presetName, 'index.ts'); +function scanHtmlDirectory(scanDir: string): { skins: PresetSkinDef[]; mediaElement?: string } { + const skins: PresetSkinDef[] = []; + let mediaElement: string | undefined; - const htmlExports = parseNamedExports(htmlPresetFile); - const reactExports = parseNamedExports(reactPresetFile); + if (!fs.existsSync(scanDir)) return { skins }; - // Find feature bundle (from either HTML or React exports) - const allExports = [...htmlExports, ...reactExports]; - const bundleExport = allExports.find((e) => isFeatureBundle(e.name)); - if (!bundleExport) return null; + const files = fs.readdirSync(scanDir).filter((f) => f.endsWith('.ts') && !isTailwindFile(f)); - const features = featureBundleMap.get(bundleExport.name) ?? []; + for (const file of files) { + const filePath = path.join(scanDir, file); + const classes = extractClassesWithTagName(filePath); - // Classify HTML exports - const htmlSkins: PresetSkinDef[] = []; - for (const exp of htmlExports) { - if (isFeatureBundle(exp.name)) continue; - if (isTailwind(exp.sourceSpecifier)) continue; - if (isSkin(exp.name)) { - // Resolve the source file to extract tagName - const resolvedPath = path.resolve(path.dirname(htmlPresetFile), `${exp.sourceSpecifier}.ts`); - const tagName = extractTagName(resolvedPath); - if (tagName) { - htmlSkins.push({ name: exp.name, tagName }); + for (const cls of classes) { + if (isSkinClass(cls.className)) { + skins.push({ name: cls.className, tagName: cls.tagName }); + } else if (!isPlayerClass(cls.className)) { + mediaElement = cls.tagName; } } } - // Classify React exports - const reactSkins: PresetSkinDef[] = []; - let reactMediaElement: string | undefined; - for (const exp of reactExports) { - if (isFeatureBundle(exp.name)) continue; - if (isTailwind(exp.sourceSpecifier)) continue; - if (isSkin(exp.name)) { - reactSkins.push({ name: exp.name }); - } else { - // Remaining value exports → media element - reactMediaElement = exp.name; + return { skins, mediaElement }; +} + +function scanReactDirectory(scanDir: string, barrelPath: string): PresetSkinDef[] { + const skins: PresetSkinDef[] = []; + + if (!fs.existsSync(scanDir)) return skins; + + const barrelBasename = path.basename(barrelPath); + const files = fs + .readdirSync(scanDir) + .filter((f) => (f.endsWith('.ts') || f.endsWith('.tsx')) && !isTailwindFile(f) && f !== barrelBasename); + + for (const file of files) { + const filePath = path.join(scanDir, file); + const exports = extractValueExports(filePath); + + for (const name of exports) { + if (isFeatureBundle(name)) continue; + if (isReactSkin(name)) { + skins.push({ name }); + } } } + return skins; +} + +// ─── Preset Reference Building ────────────────────────────────────── + +function buildPresetReference(preset: PresetInfo, featureBundleMap: Map): PresetResult | null { + // Find feature bundle name from barrel files (try both frameworks) + const bundleName = + (preset.html && findFeatureBundleExport(preset.html.barrelPath)) ?? + (preset.react && findFeatureBundleExport(preset.react.barrelPath)); + + if (!bundleName) return null; + + const features = featureBundleMap.get(bundleName) ?? []; + + // Scan HTML directory + const htmlResult = preset.html ? scanHtmlDirectory(preset.html.scanDir) : { skins: [] as PresetSkinDef[] }; + + // Scan React directory for skins, read barrel for media element + const reactSkins = preset.react + ? scanReactDirectory(preset.react.scanDir, preset.react.barrelPath) + : ([] as PresetSkinDef[]); + const reactMediaElement = preset.react ? findReactMediaElement(preset.react.barrelPath) : undefined; + + // Extract description from barrel JSDoc (try React first, fall back to HTML) + const description = + (preset.react && extractFileDescription(preset.react.barrelPath)) ?? + (preset.html && extractFileDescription(preset.html.barrelPath)); + const ref: PresetReference = { - name: presetName, - featureBundle: bundleExport.name, + name: preset.name, + featureBundle: bundleName, features, - html: { skins: htmlSkins }, + html: { skins: htmlResult.skins }, react: { skins: reactSkins, mediaElement: reactMediaElement ?? '' }, }; - return { name: presetName, reference: ref }; + if (htmlResult.mediaElement) ref.html.mediaElement = htmlResult.mediaElement; + if (description) ref.description = description; + + return { name: preset.name, reference: ref }; } -// ─── Pipeline ───────────────────────────────────────────────────── +// ─── Pipeline ─────────────────────────────────────────────────────── export function generatePresetReferences(monorepoRoot: string): PresetResult[] { - const htmlPresetsDir = path.join(monorepoRoot, 'packages/html/src/presets'); - const reactPresetsDir = path.join(monorepoRoot, 'packages/react/src/presets'); const presetsFilePath = path.join(monorepoRoot, 'packages/core/src/dom/store/features/presets.ts'); - - const presetNames = discoverPresetNames(htmlPresetsDir, reactPresetsDir); - if (presetNames.length === 0) return []; - const featureBundleMap = parseFeatureBundles(presetsFilePath); + const presets = discoverPresets(monorepoRoot); + if (presets.length === 0) return []; + const results: PresetResult[] = []; - for (const name of presetNames) { - const result = buildPresetReference(name, htmlPresetsDir, reactPresetsDir, featureBundleMap, monorepoRoot); + for (const preset of presets) { + const result = buildPresetReference(preset, featureBundleMap); if (result) results.push(result); } diff --git a/site/scripts/api-docs-builder/src/tests/e2e.test.ts b/site/scripts/api-docs-builder/src/tests/e2e.test.ts index f114cd01..997f8191 100644 --- a/site/scripts/api-docs-builder/src/tests/e2e.test.ts +++ b/site/scripts/api-docs-builder/src/tests/e2e.test.ts @@ -854,17 +854,18 @@ describe('Feature pipeline (end-to-end)', () => { // ═══════════════════════════════════════════════════════════════════════ // // Presets bundle features, skins, and media elements for a specific use -// case. They are discovered from directories under packages/{html,react}/ -// src/presets/. +// case. They are discovered from package.json exports in +// packages/{html,react}/. // // Key behaviors: -// - Discovery: directories under both HTML and React preset paths -// - Feature bundle: *Features export → resolved to list of feature names -// - HTML skins: classes extending SkinElement, with tagName +// - Discovery: reads package.json exports for ./X + ./X/* pairs +// - Feature bundle: *Features export from barrel → resolved to feature names +// - HTML skins: classes with static tagName whose name matches *Skin*Element +// - HTML media element: classes with static tagName that aren't skins or players // - React skins: exports matching *Skin naming -// - Media element: React exports that aren't bundles or skins -// - Tailwind exclusion: .tailwind files/exports are filtered out -// - HTML media element: implied by preset name (video →