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:
Darius Cepulis
2026-04-14 12:28:16 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d8cd59e0fd
commit 7daec38b88
13 changed files with 536 additions and 143 deletions
@@ -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
// ─────────────────────────────────────────────────────────────────
@@ -11,3 +11,5 @@ import { volumeFeature } from './volume';
export const videoFeatures = [playbackFeature, volumeFeature];
export const audioFeatures = [playbackFeature];
export const backgroundFeatures = [];
@@ -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"
}
}
}
@@ -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';
}
@@ -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';
}
@@ -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';
@@ -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';
@@ -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"
}
}
}
@@ -0,0 +1,4 @@
/**
* Mock React BackgroundVideo media component.
*/
export function BackgroundVideo(): void {}
@@ -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';
@@ -0,0 +1,4 @@
/**
* Mock React BackgroundVideoSkin component.
*/
export function BackgroundVideoSkin(): void {}