mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 09:59:44 +00:00
chore(site): preset pipeline — scan source directories instead of barrel files (#1333)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d8cd59e0fd
commit
7daec38b88
@@ -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[];
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
// 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<string, { barrelPath: string; scanDir: string }> {
|
||||
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<string, unknown> = pkgJson.exports ?? {};
|
||||
|
||||
const result = new Map<string, { barrelPath: string; scanDir: string }>();
|
||||
|
||||
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<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
@@ -117,7 +327,6 @@ function parseFeatureBundles(presetsFilePath: string): Map<string, string[]> {
|
||||
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<string, string[]> {
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─── Preset Discovery ─────────────────────────────────────────────
|
||||
// ─── Description Extraction ─────────────────────────────────────────
|
||||
|
||||
function discoverPresetNames(htmlPresetsDir: string, reactPresetsDir: string): string[] {
|
||||
const names = new Set<string>();
|
||||
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<string, string[]>,
|
||||
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<string, string[]>): 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 → <video>)
|
||||
// - React media element: remaining exports that aren't bundles or skins
|
||||
// - Tailwind exclusion: .tailwind files are filtered out
|
||||
// - Player exclusion: *Player* classes are filtered out
|
||||
|
||||
describe('Preset pipeline (end-to-end)', () => {
|
||||
const results = generatePresetReferences(FIXTURE_ROOT);
|
||||
@@ -878,13 +879,13 @@ describe('Preset pipeline (end-to-end)', () => {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Discovery', () => {
|
||||
it('discovers presets from preset directories', () => {
|
||||
it('discovers presets from package.json exports', () => {
|
||||
const names = results.map((r) => r.name).sort();
|
||||
expect(names).toEqual(['audio', 'video']);
|
||||
expect(names).toEqual(['audio', 'background', 'video']);
|
||||
});
|
||||
|
||||
it('produces one result per preset', () => {
|
||||
expect(results.length).toBe(2);
|
||||
expect(results.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -919,6 +920,11 @@ describe('Preset pipeline (end-to-end)', () => {
|
||||
expect(skinNames).not.toContain('VideoSkinTailwindElement');
|
||||
});
|
||||
|
||||
it('does not produce an HTML media element for native video', () => {
|
||||
const ref = findPreset('video')!.reference;
|
||||
expect(ref.html.mediaElement).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects React skins', () => {
|
||||
const skins = findPreset('video')!.reference.react.skins;
|
||||
expect(skins).toEqual(expect.arrayContaining([{ name: 'VideoSkin' }, { name: 'MinimalVideoSkin' }]));
|
||||
@@ -955,6 +961,11 @@ describe('Preset pipeline (end-to-end)', () => {
|
||||
expect(skins).toEqual([{ name: 'AudioSkinElement', tagName: 'audio-skin' }]);
|
||||
});
|
||||
|
||||
it('does not produce an HTML media element for native audio', () => {
|
||||
const ref = findPreset('audio')!.reference;
|
||||
expect(ref.html.mediaElement).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects single React skin', () => {
|
||||
const skins = findPreset('audio')!.reference.react.skins;
|
||||
expect(skins).toEqual([{ name: 'AudioSkin' }]);
|
||||
@@ -966,6 +977,47 @@ describe('Preset pipeline (end-to-end)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// BACKGROUND PRESET (incomplete barrel, custom media element)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('background preset', () => {
|
||||
it('identifies the feature bundle', () => {
|
||||
const ref = findPreset('background')!.reference;
|
||||
expect(ref.featureBundle).toBe('backgroundFeatures');
|
||||
});
|
||||
|
||||
it('resolves empty features array', () => {
|
||||
const ref = findPreset('background')!.reference;
|
||||
expect(ref.features).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects HTML skin from directory scan (not in barrel)', () => {
|
||||
const skins = findPreset('background')!.reference.html.skins;
|
||||
expect(skins).toEqual([{ name: 'BackgroundVideoSkinElement', tagName: 'background-video-skin' }]);
|
||||
});
|
||||
|
||||
it('detects HTML media element via export * chain', () => {
|
||||
const ref = findPreset('background')!.reference;
|
||||
expect(ref.html.mediaElement).toBe('background-video');
|
||||
});
|
||||
|
||||
it('excludes player elements', () => {
|
||||
const skinNames = findPreset('background')!.reference.html.skins.map((s) => s.name);
|
||||
expect(skinNames).not.toContain('BackgroundVideoPlayerElement');
|
||||
});
|
||||
|
||||
it('detects React skin', () => {
|
||||
const skins = findPreset('background')!.reference.react.skins;
|
||||
expect(skins).toEqual([{ name: 'BackgroundVideoSkin' }]);
|
||||
});
|
||||
|
||||
it('detects React media element', () => {
|
||||
const ref = findPreset('background')!.reference;
|
||||
expect(ref.react.mediaElement).toBe('BackgroundVideo');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// CROSS-CUTTING: feature links
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
+2
@@ -11,3 +11,5 @@ import { volumeFeature } from './volume';
|
||||
export const videoFeatures = [playbackFeature, volumeFeature];
|
||||
|
||||
export const audioFeatures = [playbackFeature];
|
||||
|
||||
export const backgroundFeatures = [];
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@videojs/html",
|
||||
"exports": {
|
||||
"./video": {
|
||||
"types": "./src/presets/video.ts",
|
||||
"default": "./src/presets/video.ts"
|
||||
},
|
||||
"./video/*": {
|
||||
"types": "./src/define/video/*.ts",
|
||||
"default": "./src/define/video/*.ts"
|
||||
},
|
||||
"./audio": {
|
||||
"types": "./src/presets/audio.ts",
|
||||
"default": "./src/presets/audio.ts"
|
||||
},
|
||||
"./audio/*": {
|
||||
"types": "./src/define/audio/*.ts",
|
||||
"default": "./src/define/audio/*.ts"
|
||||
},
|
||||
"./background": {
|
||||
"types": "./src/presets/background.ts",
|
||||
"default": "./src/presets/background.ts"
|
||||
},
|
||||
"./background/*": {
|
||||
"types": "./src/define/background/*.ts",
|
||||
"default": "./src/define/background/*.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Mock HTML background player element.
|
||||
*
|
||||
* Exercises: player exclusion — has static tagName but class name
|
||||
* contains "Player", so it should NOT appear in skins or media elements.
|
||||
*/
|
||||
export class BackgroundVideoPlayerElement extends HTMLElement {
|
||||
static readonly tagName = 'background-video-player';
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Mock HTML background video skin element.
|
||||
*
|
||||
* Exercises: skin detection via *Skin*Element naming + static tagName.
|
||||
*/
|
||||
import { SkinElement } from '../skin-element';
|
||||
|
||||
export class BackgroundVideoSkinElement extends SkinElement {
|
||||
static readonly tagName = 'background-video-skin';
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Mock HTML background video re-export.
|
||||
*
|
||||
* Exercises: `export *` re-export that resolves to a file with static tagName.
|
||||
* Mirrors real define/background/video.ts which re-exports from
|
||||
* define/media/background-video.ts (the file that registers the custom element).
|
||||
*/
|
||||
export * from '../media/background-video';
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Mock HTML background preset.
|
||||
*
|
||||
* Exercises: intentionally incomplete barrel — only exports the feature bundle.
|
||||
* The skin and media element are NOT re-exported here, proving the pipeline
|
||||
* must scan the directory (define/background/) to find them.
|
||||
*/
|
||||
export { backgroundFeatures } from '../../../core/src/dom/store/features/presets';
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@videojs/react",
|
||||
"exports": {
|
||||
"./video": {
|
||||
"types": "./src/presets/video/index.ts",
|
||||
"default": "./src/presets/video/index.ts"
|
||||
},
|
||||
"./video/*": {
|
||||
"types": "./src/presets/video/*.ts",
|
||||
"default": "./src/presets/video/*.ts"
|
||||
},
|
||||
"./audio": {
|
||||
"types": "./src/presets/audio/index.ts",
|
||||
"default": "./src/presets/audio/index.ts"
|
||||
},
|
||||
"./audio/*": {
|
||||
"types": "./src/presets/audio/*.ts",
|
||||
"default": "./src/presets/audio/*.ts"
|
||||
},
|
||||
"./background": {
|
||||
"types": "./src/presets/background/index.ts",
|
||||
"default": "./src/presets/background/index.ts"
|
||||
},
|
||||
"./background/*": {
|
||||
"types": "./src/presets/background/*.ts",
|
||||
"default": "./src/presets/background/*.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Mock React BackgroundVideo media component.
|
||||
*/
|
||||
export function BackgroundVideo(): void {}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Mock React background preset.
|
||||
*
|
||||
* Exercises: preset with no features, single skin, custom media element.
|
||||
*/
|
||||
export { backgroundFeatures } from '../../../../core/src/dom/store/features/presets';
|
||||
export { BackgroundVideo } from '../../media/background-video';
|
||||
export * from './skin';
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Mock React BackgroundVideoSkin component.
|
||||
*/
|
||||
export function BackgroundVideoSkin(): void {}
|
||||
Reference in New Issue
Block a user