mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
Feature: Feature and preset reference — E2E tests + implementation (#1248)
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
d2c43db550
commit
a4d8e2a255
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Feature reference extraction.
|
||||
*
|
||||
* Discovers features from packages/core/src/dom/store/features/ and extracts
|
||||
* state/action definitions from their state interfaces in media/state.ts.
|
||||
*
|
||||
* Uses the TypeScript checker API (not TAE) for interface extraction because
|
||||
* state interfaces use method signatures (play(): void) which TAE doesn't
|
||||
* handle — it only handles property-with-function-type syntax.
|
||||
*
|
||||
* Convention:
|
||||
* - Feature files: *.ts in the features directory (excluding index, presets, feature.parts)
|
||||
* - Feature exports: const matching *Feature (singular, not *Features)
|
||||
* - State type: explicit return type annotation on the state() arrow function
|
||||
* - State interfaces: exported from packages/core/src/core/media/state.ts
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as ts from 'typescript';
|
||||
import * as tae from 'typescript-api-extractor';
|
||||
import type { FeatureActionDef, FeatureReference, FeatureResult, FeatureStateDef } from './pipeline.js';
|
||||
|
||||
const SKIP_FILES = new Set(['index.ts', 'presets.ts', 'feature.parts.ts']);
|
||||
|
||||
interface FeatureSource {
|
||||
filePath: string;
|
||||
name: string;
|
||||
stateTypeName: string;
|
||||
}
|
||||
|
||||
// ─── Discovery ────────────────────────────────────────────────────
|
||||
|
||||
function discoverFeatureSources(featuresDir: string): FeatureSource[] {
|
||||
const sources: FeatureSource[] = [];
|
||||
const files = fs.readdirSync(featuresDir).filter((f) => f.endsWith('.ts') && !SKIP_FILES.has(f));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(featuresDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
||||
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (!ts.isVariableStatement(node)) return;
|
||||
if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return;
|
||||
|
||||
for (const decl of node.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(decl.name)) continue;
|
||||
const varName = decl.name.text;
|
||||
if (!varName.endsWith('Feature') || varName.endsWith('Features')) continue;
|
||||
|
||||
if (!decl.initializer || !ts.isCallExpression(decl.initializer)) continue;
|
||||
const arg = decl.initializer.arguments[0];
|
||||
if (!arg || !ts.isObjectLiteralExpression(arg)) continue;
|
||||
|
||||
let name: string | undefined;
|
||||
let stateTypeName: string | undefined;
|
||||
|
||||
for (const prop of arg.properties) {
|
||||
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
|
||||
|
||||
if (prop.name.text === 'name' && ts.isStringLiteral(prop.initializer)) {
|
||||
name = prop.initializer.text;
|
||||
}
|
||||
|
||||
if (prop.name.text === 'state') {
|
||||
const fn = prop.initializer;
|
||||
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && fn.type && ts.isTypeReferenceNode(fn.type)) {
|
||||
stateTypeName = fn.type.typeName.getText(sourceFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (name && stateTypeName) {
|
||||
sources.push({ filePath, name, stateTypeName });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
// ─── Type Formatting ──────────────────────────────────────────────
|
||||
|
||||
function formatCheckerType(type: ts.Type, checker: ts.TypeChecker): string {
|
||||
if (type.isUnion()) {
|
||||
// TypeScript internally represents `boolean` as `false | true`
|
||||
const isBooleanUnion =
|
||||
type.types.length === 2 && type.types.every((t) => !!(t.flags & ts.TypeFlags.BooleanLiteral));
|
||||
if (isBooleanUnion) return 'boolean';
|
||||
|
||||
return type.types.map((t) => formatCheckerType(t, checker)).join(' | ');
|
||||
}
|
||||
if (type.isStringLiteral()) {
|
||||
return `'${type.value}'`;
|
||||
}
|
||||
return checker.typeToString(type);
|
||||
}
|
||||
|
||||
// ─── JSDoc Extraction ─────────────────────────────────────────────
|
||||
|
||||
function getJSDocDescription(node: ts.Node): string | undefined {
|
||||
const jsDocNodes = (node as { jsDoc?: ts.JSDoc[] }).jsDoc;
|
||||
if (!jsDocNodes || jsDocNodes.length === 0) return undefined;
|
||||
|
||||
const doc = jsDocNodes[0]!;
|
||||
if (typeof doc.comment === 'string') return doc.comment;
|
||||
if (!doc.comment) return undefined;
|
||||
|
||||
// NodeArray<JSDocComment> — concatenate text parts
|
||||
const parts: string[] = [];
|
||||
for (const part of doc.comment) {
|
||||
if (typeof part === 'string') {
|
||||
parts.push(part);
|
||||
} else if ('text' in part) {
|
||||
parts.push(part.text);
|
||||
}
|
||||
}
|
||||
return parts.join('') || undefined;
|
||||
}
|
||||
|
||||
// ─── Interface Extraction ─────────────────────────────────────────
|
||||
|
||||
function extractInterfaceMembers(
|
||||
interfaceDecl: ts.InterfaceDeclaration,
|
||||
checker: ts.TypeChecker,
|
||||
sourceFile: ts.SourceFile
|
||||
): { state: Record<string, FeatureStateDef>; actions: Record<string, FeatureActionDef> } {
|
||||
const state: Record<string, FeatureStateDef> = {};
|
||||
const actions: Record<string, FeatureActionDef> = {};
|
||||
|
||||
for (const member of interfaceDecl.members) {
|
||||
const name = member.name?.getText(sourceFile);
|
||||
if (!name) continue;
|
||||
|
||||
const description = getJSDocDescription(member);
|
||||
|
||||
if (ts.isMethodSignature(member)) {
|
||||
const params = member.parameters
|
||||
.map((p) => {
|
||||
const pName = p.name.getText(sourceFile);
|
||||
const pType = p.type ? formatCheckerType(checker.getTypeFromTypeNode(p.type), checker) : 'unknown';
|
||||
return `${pName}: ${pType}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
let returnType = 'void';
|
||||
if (member.type) {
|
||||
returnType = formatCheckerType(checker.getTypeFromTypeNode(member.type), checker);
|
||||
}
|
||||
|
||||
const def: FeatureActionDef = { type: `(${params}) => ${returnType}` };
|
||||
if (description) def.description = description;
|
||||
actions[name] = def;
|
||||
} else if (ts.isPropertySignature(member) && member.type) {
|
||||
const memberType = checker.getTypeFromTypeNode(member.type);
|
||||
const typeStr = formatCheckerType(memberType, checker);
|
||||
const def: FeatureStateDef = { type: typeStr };
|
||||
if (description) def.description = description;
|
||||
state[name] = def;
|
||||
}
|
||||
}
|
||||
|
||||
return { state, actions };
|
||||
}
|
||||
|
||||
// ─── Pipeline ─────────────────────────────────────────────────────
|
||||
|
||||
export function generateFeatureReferences(monorepoRoot: string): FeatureResult[] {
|
||||
const featuresDir = path.join(monorepoRoot, 'packages/core/src/dom/store/features');
|
||||
const stateFilePath = path.join(monorepoRoot, 'packages/core/src/core/media/state.ts');
|
||||
|
||||
if (!fs.existsSync(featuresDir) || !fs.existsSync(stateFilePath)) return [];
|
||||
|
||||
const sources = discoverFeatureSources(featuresDir);
|
||||
if (sources.length === 0) return [];
|
||||
|
||||
// Create a TS program with the state file for the checker
|
||||
const tsconfigPath = path.join(monorepoRoot, 'tsconfig.base.json');
|
||||
const config = tae.loadConfig(tsconfigPath);
|
||||
config.options.rootDir = monorepoRoot;
|
||||
const program = ts.createProgram([stateFilePath], config.options);
|
||||
const checker = program.getTypeChecker();
|
||||
const stateSourceFile = program.getSourceFile(stateFilePath);
|
||||
if (!stateSourceFile) return [];
|
||||
|
||||
// Build a map of interface name → declaration
|
||||
const interfaces = new Map<string, ts.InterfaceDeclaration>();
|
||||
ts.forEachChild(stateSourceFile, (node) => {
|
||||
if (ts.isInterfaceDeclaration(node)) {
|
||||
interfaces.set(node.name.text, node);
|
||||
}
|
||||
});
|
||||
|
||||
const results: FeatureResult[] = [];
|
||||
for (const source of sources) {
|
||||
const interfaceDecl = interfaces.get(source.stateTypeName);
|
||||
if (!interfaceDecl) continue;
|
||||
|
||||
const description = getJSDocDescription(interfaceDecl);
|
||||
const { state, actions } = extractInterfaceMembers(interfaceDecl, checker, stateSourceFile);
|
||||
|
||||
const ref: FeatureReference = {
|
||||
name: source.name,
|
||||
slug: source.name,
|
||||
state,
|
||||
actions,
|
||||
};
|
||||
|
||||
if (description) ref.description = description;
|
||||
|
||||
results.push({ name: source.name, slug: source.name, reference: ref });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -509,3 +509,64 @@ export function generateComponentReferences(monorepoRoot: string): ComponentResu
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// FEATURE REFERENCE PIPELINE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface FeatureStateDef {
|
||||
type: string;
|
||||
detailedType?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FeatureActionDef {
|
||||
type: string;
|
||||
detailedType?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FeatureReference {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
state: Record<string, FeatureStateDef>;
|
||||
actions: Record<string, FeatureActionDef>;
|
||||
}
|
||||
|
||||
export interface FeatureResult {
|
||||
name: string;
|
||||
slug: string;
|
||||
reference: FeatureReference;
|
||||
}
|
||||
|
||||
export { generateFeatureReferences } from './feature-handler.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PRESET REFERENCE PIPELINE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export interface PresetSkinDef {
|
||||
name: string;
|
||||
tagName?: string;
|
||||
}
|
||||
|
||||
export interface PresetReference {
|
||||
name: string;
|
||||
featureBundle: string;
|
||||
features: string[];
|
||||
html: {
|
||||
skins: PresetSkinDef[];
|
||||
};
|
||||
react: {
|
||||
skins: PresetSkinDef[];
|
||||
mediaElement: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PresetResult {
|
||||
name: string;
|
||||
reference: PresetReference;
|
||||
}
|
||||
|
||||
export { generatePresetReferences } from './preset-handler.js';
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Preset reference extraction.
|
||||
*
|
||||
* Discovers presets from packages/{html,react}/src/presets/ and extracts
|
||||
* feature bundles, skins, and media elements from their index files.
|
||||
*
|
||||
* Uses raw TypeScript AST (no type checker needed) since classification
|
||||
* is naming-convention-based and tagName extraction is from static properties.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
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 {
|
||||
name: string;
|
||||
sourceSpecifier: string;
|
||||
}
|
||||
|
||||
// ─── Export Parsing ───────────────────────────────────────────────
|
||||
|
||||
function parseNamedExports(filePath: string): ExportInfo[] {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
||||
const exports: ExportInfo[] = [];
|
||||
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (!ts.isExportDeclaration(node) || !node.moduleSpecifier) return;
|
||||
const sourceSpecifier = (node.moduleSpecifier as ts.StringLiteral).text;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
// Note: `export * from` (namespace re-exports) are skipped — we only handle named exports
|
||||
});
|
||||
|
||||
return exports;
|
||||
}
|
||||
|
||||
// ─── Export Classification ────────────────────────────────────────
|
||||
|
||||
function isFeatureBundle(name: string): boolean {
|
||||
return name.endsWith('Features');
|
||||
}
|
||||
|
||||
function isTailwind(sourceSpecifier: string): boolean {
|
||||
return sourceSpecifier.includes('.tailwind');
|
||||
}
|
||||
|
||||
function isSkin(name: string): boolean {
|
||||
return /Skin(Element)?$/.test(name);
|
||||
}
|
||||
|
||||
// ─── Tag Name Extraction ─────────────────────────────────────────
|
||||
|
||||
function extractTagName(elementFilePath: string): string | undefined {
|
||||
if (!fs.existsSync(elementFilePath)) return undefined;
|
||||
|
||||
const content = fs.readFileSync(elementFilePath, 'utf-8');
|
||||
const sourceFile = ts.createSourceFile(elementFilePath, content, ts.ScriptTarget.Latest, true);
|
||||
|
||||
let tagName: string | undefined;
|
||||
|
||||
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;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return tagName;
|
||||
}
|
||||
|
||||
// ─── Feature Bundle Resolution ────────────────────────────────────
|
||||
|
||||
function parseFeatureBundles(presetsFilePath: string): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
if (!fs.existsSync(presetsFilePath)) return map;
|
||||
|
||||
const content = fs.readFileSync(presetsFilePath, 'utf-8');
|
||||
const sourceFile = ts.createSourceFile(presetsFilePath, content, ts.ScriptTarget.Latest, true);
|
||||
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (!ts.isVariableStatement(node)) return;
|
||||
if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return;
|
||||
|
||||
for (const decl of node.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(decl.name)) continue;
|
||||
const name = decl.name.text;
|
||||
if (!name.endsWith('Features')) continue;
|
||||
|
||||
if (decl.initializer && ts.isArrayLiteralExpression(decl.initializer)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
map.set(name, features);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─── Preset Discovery ─────────────────────────────────────────────
|
||||
|
||||
function discoverPresetNames(htmlPresetsDir: string, reactPresetsDir: string): string[] {
|
||||
const names = new Set<string>();
|
||||
|
||||
// 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$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
// ─── Preset Reference Building ────────────────────────────────────
|
||||
|
||||
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');
|
||||
|
||||
const htmlExports = parseNamedExports(htmlPresetFile);
|
||||
const reactExports = parseNamedExports(reactPresetFile);
|
||||
|
||||
// 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 features = featureBundleMap.get(bundleExport.name) ?? [];
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
const ref: PresetReference = {
|
||||
name: presetName,
|
||||
featureBundle: bundleExport.name,
|
||||
features,
|
||||
html: { skins: htmlSkins },
|
||||
react: { skins: reactSkins, mediaElement: reactMediaElement ?? '' },
|
||||
};
|
||||
|
||||
return { name: presetName, reference: ref };
|
||||
}
|
||||
|
||||
// ─── 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 results: PresetResult[] = [];
|
||||
for (const name of presetNames) {
|
||||
const result = buildPresetReference(name, htmlPresetsDir, reactPresetsDir, featureBundleMap, monorepoRoot);
|
||||
if (result) results.push(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -35,10 +35,40 @@
|
||||
* create* factory, mixin display name stripping, selector discovery,
|
||||
* @label overloads, slug collision (react vs html create-player),
|
||||
* framework assignment.
|
||||
*
|
||||
* Features (packages/core/src/dom/store/features/):
|
||||
* playback.ts — Simple feature. Exercises: boolean state properties,
|
||||
* void/Promise action methods, JSDoc description extraction.
|
||||
* volume.ts — Complex feature. Exercises: numeric state, type alias
|
||||
* (MediaFeatureAvailability), methods with params + returns,
|
||||
* interface-level JSDoc → feature description.
|
||||
* presets.ts — Feature bundles. Exercises: plural *Features naming
|
||||
* (filtered out of feature discovery), array resolution
|
||||
* for preset feature lists.
|
||||
* feature.parts.ts — Short aliases (playbackFeature as playback, etc.).
|
||||
* Exercises: namespace re-export filtering (export * as features).
|
||||
* index.ts — Re-export barrel. Exercises: feature discovery filtering
|
||||
* (singular *Feature only, not *Features or namespaces).
|
||||
*
|
||||
* Presets:
|
||||
* HTML (packages/html/src/presets/):
|
||||
* video.ts — Exercises: feature bundle export, multiple HTML skins
|
||||
* (SkinElement inheritance), tailwind skin exclusion.
|
||||
* audio.ts — Exercises: single skin, subset of features.
|
||||
* React (packages/react/src/presets/):
|
||||
* video/ — Exercises: feature bundle, React skins (*Skin naming),
|
||||
* media element export, tailwind skin exclusion.
|
||||
* audio/ — Exercises: single skin, different media element.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateComponentReferences } from '../pipeline';
|
||||
import {
|
||||
type FeatureResult,
|
||||
generateComponentReferences,
|
||||
generateFeatureReferences,
|
||||
generatePresetReferences,
|
||||
type PresetResult,
|
||||
} from '../pipeline';
|
||||
import { getUtilEntries, type UtilEntry } from '../util-handler';
|
||||
|
||||
const FIXTURE_ROOT = path.resolve(import.meta.dirname, 'fixtures/monorepo');
|
||||
@@ -629,3 +659,306 @@ describe('Util pipeline (end-to-end)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// FEATURE PIPELINE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Features are defined via `definePlayerFeature()` and discovered from
|
||||
// the features index. Each feature's state interface is split into two
|
||||
// records: `state` (non-method properties) and `actions` (methods).
|
||||
//
|
||||
// Key behaviors:
|
||||
// - Discovery: singular *Feature exports from the features index
|
||||
// - Filtering: plural *Features (feature bundles) are excluded
|
||||
// - State extraction: interface properties → state record
|
||||
// - Action extraction: interface methods → actions record
|
||||
// - JSDoc: member descriptions flow through, interface-level JSDoc
|
||||
// becomes the feature description
|
||||
// - Type aliases: expanded in the output (MediaFeatureAvailability →
|
||||
// 'available' | 'unavailable' | 'unsupported')
|
||||
// - Slug: derived from feature name, used for cross-linking from presets
|
||||
|
||||
describe('Feature pipeline (end-to-end)', () => {
|
||||
const results = generateFeatureReferences(FIXTURE_ROOT);
|
||||
|
||||
function findFeature(name: string): FeatureResult | undefined {
|
||||
return results.find((r) => r.name === name);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// DISCOVERY
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Discovery', () => {
|
||||
it('discovers features from the features index', () => {
|
||||
const names = results.map((r) => r.name);
|
||||
expect(names).toContain('playback');
|
||||
expect(names).toContain('volume');
|
||||
});
|
||||
|
||||
it('excludes feature bundles (plural *Features)', () => {
|
||||
const names = results.map((r) => r.name);
|
||||
expect(names).not.toContain('videoFeatures');
|
||||
expect(names).not.toContain('audioFeatures');
|
||||
});
|
||||
|
||||
it('excludes namespace re-exports (export * as features)', () => {
|
||||
const names = results.map((r) => r.name);
|
||||
expect(names).not.toContain('features');
|
||||
});
|
||||
|
||||
it('produces one result per feature', () => {
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// PLAYBACK FEATURE (simple: booleans + void methods)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// MediaPlaybackState has:
|
||||
// - paused: boolean (state)
|
||||
// - ended: boolean (state)
|
||||
// - play(): Promise<void> (action)
|
||||
// - pause(): void (action)
|
||||
// No interface-level JSDoc → no feature description.
|
||||
|
||||
describe('playback (simple feature)', () => {
|
||||
it('has name and slug', () => {
|
||||
const playback = findFeature('playback');
|
||||
expect(playback).toBeDefined();
|
||||
expect(playback!.slug).toBe('playback');
|
||||
expect(playback!.reference.name).toBe('playback');
|
||||
expect(playback!.reference.slug).toBe('playback');
|
||||
});
|
||||
|
||||
it('has no description (no interface-level JSDoc)', () => {
|
||||
const ref = findFeature('playback')!.reference;
|
||||
expect(ref.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts boolean properties as state', () => {
|
||||
const state = findFeature('playback')!.reference.state;
|
||||
expect(state.paused).toEqual({
|
||||
type: 'boolean',
|
||||
description: 'Whether playback is paused.',
|
||||
});
|
||||
expect(state.ended).toEqual({
|
||||
type: 'boolean',
|
||||
description: 'Whether playback has reached the end.',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts methods as actions', () => {
|
||||
const actions = findFeature('playback')!.reference.actions;
|
||||
|
||||
expect(actions.play).toBeDefined();
|
||||
expect(actions.play!.type).toContain('Promise');
|
||||
expect(actions.play!.description).toBe('Start playback.');
|
||||
|
||||
expect(actions.pause).toBeDefined();
|
||||
expect(actions.pause!.type).toContain('void');
|
||||
expect(actions.pause!.description).toBe('Pause playback.');
|
||||
});
|
||||
|
||||
it('does not mix state and actions', () => {
|
||||
const ref = findFeature('playback')!.reference;
|
||||
// Methods should not appear in state
|
||||
expect(ref.state['play' as keyof typeof ref.state]).toBeUndefined();
|
||||
expect(ref.state['pause' as keyof typeof ref.state]).toBeUndefined();
|
||||
// Properties should not appear in actions
|
||||
expect(ref.actions['paused' as keyof typeof ref.actions]).toBeUndefined();
|
||||
expect(ref.actions['ended' as keyof typeof ref.actions]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// VOLUME FEATURE (complex: types, params, returns, description)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// MediaVolumeState has interface-level JSDoc → feature description.
|
||||
// - volume: number (state)
|
||||
// - muted: boolean (state)
|
||||
// - volumeAvailability: MediaFeatureAvailability (state, type alias)
|
||||
// - setVolume(volume: number): number (action with param + return)
|
||||
// - toggleMuted(): boolean (action with return)
|
||||
|
||||
describe('volume (complex feature)', () => {
|
||||
it('has description from interface-level JSDoc', () => {
|
||||
const ref = findFeature('volume')!.reference;
|
||||
expect(ref.description).toBe('Controls audio volume and mute state.');
|
||||
});
|
||||
|
||||
it('extracts state with various types', () => {
|
||||
const state = findFeature('volume')!.reference.state;
|
||||
|
||||
expect(state.volume).toMatchObject({
|
||||
type: 'number',
|
||||
description: 'Volume level from 0 (silent) to 1 (max).',
|
||||
});
|
||||
|
||||
expect(state.muted).toMatchObject({
|
||||
type: 'boolean',
|
||||
description: 'Whether audio is muted.',
|
||||
});
|
||||
});
|
||||
|
||||
it('expands type aliases in state', () => {
|
||||
const state = findFeature('volume')!.reference.state;
|
||||
// MediaFeatureAvailability should be expanded to the union
|
||||
const avail = state.volumeAvailability!;
|
||||
expect(avail.type).toContain("'available'");
|
||||
expect(avail.type).toContain("'unavailable'");
|
||||
expect(avail.type).toContain("'unsupported'");
|
||||
});
|
||||
|
||||
it('extracts actions with parameters and return types', () => {
|
||||
const actions = findFeature('volume')!.reference.actions;
|
||||
|
||||
// setVolume has a parameter and returns a number
|
||||
expect(actions.setVolume).toBeDefined();
|
||||
expect(actions.setVolume!.type).toContain('number');
|
||||
expect(actions.setVolume!.description).toBe('Set volume (clamped 0-1). Returns the clamped value.');
|
||||
|
||||
// toggleMuted returns a boolean
|
||||
expect(actions.toggleMuted).toBeDefined();
|
||||
expect(actions.toggleMuted!.type).toContain('boolean');
|
||||
expect(actions.toggleMuted!.description).toBe('Toggle mute state. Returns the new muted value.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PRESET PIPELINE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Presets bundle features, skins, and media elements for a specific use
|
||||
// case. They are discovered from directories under packages/{html,react}/
|
||||
// src/presets/.
|
||||
//
|
||||
// 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
|
||||
// - 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>)
|
||||
|
||||
describe('Preset pipeline (end-to-end)', () => {
|
||||
const results = generatePresetReferences(FIXTURE_ROOT);
|
||||
|
||||
function findPreset(name: string): PresetResult | undefined {
|
||||
return results.find((r) => r.name === name);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// DISCOVERY
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Discovery', () => {
|
||||
it('discovers presets from preset directories', () => {
|
||||
const names = results.map((r) => r.name).sort();
|
||||
expect(names).toEqual(['audio', 'video']);
|
||||
});
|
||||
|
||||
it('produces one result per preset', () => {
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// VIDEO PRESET (full: multiple skins, tailwind exclusion)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('video preset', () => {
|
||||
it('identifies the feature bundle', () => {
|
||||
const ref = findPreset('video')!.reference;
|
||||
expect(ref.featureBundle).toBe('videoFeatures');
|
||||
});
|
||||
|
||||
it('resolves feature names from the bundle', () => {
|
||||
const ref = findPreset('video')!.reference;
|
||||
expect(ref.features).toEqual(expect.arrayContaining(['playback', 'volume']));
|
||||
expect(ref.features.length).toBe(2);
|
||||
});
|
||||
|
||||
it('detects HTML skins with tagNames', () => {
|
||||
const skins = findPreset('video')!.reference.html.skins;
|
||||
expect(skins).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: 'VideoSkinElement', tagName: 'video-skin' },
|
||||
{ name: 'MinimalVideoSkinElement', tagName: 'video-minimal-skin' },
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes HTML tailwind skins', () => {
|
||||
const skinNames = findPreset('video')!.reference.html.skins.map((s) => s.name);
|
||||
expect(skinNames).not.toContain('VideoSkinTailwindElement');
|
||||
});
|
||||
|
||||
it('detects React skins', () => {
|
||||
const skins = findPreset('video')!.reference.react.skins;
|
||||
expect(skins).toEqual(expect.arrayContaining([{ name: 'VideoSkin' }, { name: 'MinimalVideoSkin' }]));
|
||||
});
|
||||
|
||||
it('excludes React tailwind skins', () => {
|
||||
const skinNames = findPreset('video')!.reference.react.skins.map((s) => s.name);
|
||||
expect(skinNames).not.toContain('VideoSkinTailwind');
|
||||
});
|
||||
|
||||
it('detects React media element', () => {
|
||||
const ref = findPreset('video')!.reference;
|
||||
expect(ref.react.mediaElement).toBe('Video');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// AUDIO PRESET (minimal: single skin, subset of features)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('audio preset', () => {
|
||||
it('identifies the feature bundle', () => {
|
||||
const ref = findPreset('audio')!.reference;
|
||||
expect(ref.featureBundle).toBe('audioFeatures');
|
||||
});
|
||||
|
||||
it('resolves feature names (subset of video)', () => {
|
||||
const ref = findPreset('audio')!.reference;
|
||||
expect(ref.features).toEqual(['playback']);
|
||||
});
|
||||
|
||||
it('detects single HTML skin', () => {
|
||||
const skins = findPreset('audio')!.reference.html.skins;
|
||||
expect(skins).toEqual([{ name: 'AudioSkinElement', tagName: 'audio-skin' }]);
|
||||
});
|
||||
|
||||
it('detects single React skin', () => {
|
||||
const skins = findPreset('audio')!.reference.react.skins;
|
||||
expect(skins).toEqual([{ name: 'AudioSkin' }]);
|
||||
});
|
||||
|
||||
it('detects React media element', () => {
|
||||
const ref = findPreset('audio')!.reference;
|
||||
expect(ref.react.mediaElement).toBe('Audio');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// CROSS-CUTTING: feature links
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Cross-cutting', () => {
|
||||
it('feature names in presets match feature reference slugs', () => {
|
||||
const featureResults = generateFeatureReferences(FIXTURE_ROOT);
|
||||
const featureSlugs = featureResults.map((r) => r.slug);
|
||||
|
||||
const videoPreset = findPreset('video')!.reference;
|
||||
for (const featureName of videoPreset.features) {
|
||||
expect(featureSlugs).toContain(featureName);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Feature state interface fixtures.
|
||||
*
|
||||
* Exercises: property extraction (state), method extraction (actions),
|
||||
* JSDoc description flow-through, type alias resolution (MediaFeatureAvailability),
|
||||
* method parameter types, method return types, Promise return types.
|
||||
*/
|
||||
|
||||
export interface MediaPlaybackState {
|
||||
/**
|
||||
* Whether playback is paused.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/paused
|
||||
*/
|
||||
paused: boolean;
|
||||
/**
|
||||
* Whether playback has reached the end.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ended
|
||||
*/
|
||||
ended: boolean;
|
||||
/**
|
||||
* Start playback.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play
|
||||
*/
|
||||
play(): Promise<void>;
|
||||
/** Pause playback. */
|
||||
pause(): void;
|
||||
}
|
||||
|
||||
/** Indicates whether a feature can be programmatically controlled on this platform. */
|
||||
export type MediaFeatureAvailability = 'available' | 'unavailable' | 'unsupported';
|
||||
|
||||
/** Controls audio volume and mute state. */
|
||||
export interface MediaVolumeState {
|
||||
/**
|
||||
* Volume level from 0 (silent) to 1 (max).
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/volume
|
||||
*/
|
||||
volume: number;
|
||||
/** Whether audio is muted. */
|
||||
muted: boolean;
|
||||
/** Whether volume can be programmatically set on this platform. */
|
||||
volumeAvailability: MediaFeatureAvailability;
|
||||
/**
|
||||
* Set volume (clamped 0-1). Returns the clamped value.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/volume
|
||||
*/
|
||||
setVolume(volume: number): number;
|
||||
/** Toggle mute state. Returns the new muted value. */
|
||||
toggleMuted(): boolean;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Mock definePlayerFeature — identity function matching the real signature.
|
||||
* The builder only needs the TypeScript types to resolve; it never runs this.
|
||||
*/
|
||||
export const definePlayerFeature = <State>(config: {
|
||||
name?: string;
|
||||
state: (ctx: any) => State;
|
||||
attach?: (ctx: any) => void;
|
||||
}) => config;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Short alias re-exports for features.
|
||||
*
|
||||
* Exercises: namespace re-export filtering — `export * as features from './feature.parts'`
|
||||
* in the index should NOT produce a feature entry named "features".
|
||||
*/
|
||||
export { playbackFeature as playback } from './playback';
|
||||
export { volumeFeature as volume } from './volume';
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Features index fixture.
|
||||
*
|
||||
* Exercises: feature discovery filters singular *Feature exports, ignores
|
||||
* plural *Features (feature bundles) from presets, and ignores namespace
|
||||
* re-exports (export * as features).
|
||||
*/
|
||||
|
||||
export * as features from './feature.parts';
|
||||
export * from './playback';
|
||||
export * from './presets';
|
||||
export * from './volume';
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Mock playback feature.
|
||||
*
|
||||
* Exercises: simple boolean state properties, void and Promise<void> action methods,
|
||||
* JSDoc description extraction from the state interface.
|
||||
*/
|
||||
import type { MediaPlaybackState } from '../../../core/media/state';
|
||||
import { definePlayerFeature } from '../../feature';
|
||||
|
||||
export const playbackFeature = definePlayerFeature({
|
||||
name: 'playback',
|
||||
state: (): MediaPlaybackState => ({
|
||||
paused: true,
|
||||
ended: false,
|
||||
play() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
pause() {},
|
||||
}),
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Mock feature bundles.
|
||||
*
|
||||
* Exercises: feature bundle arrays (plural *Features naming), feature list
|
||||
* resolution from array elements. videoFeatures has both features,
|
||||
* audioFeatures has only playback.
|
||||
*/
|
||||
import { playbackFeature } from './playback';
|
||||
import { volumeFeature } from './volume';
|
||||
|
||||
export const videoFeatures = [playbackFeature, volumeFeature];
|
||||
|
||||
export const audioFeatures = [playbackFeature];
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Mock volume feature.
|
||||
*
|
||||
* Exercises: numeric state property, type alias (MediaFeatureAvailability),
|
||||
* methods with parameters and return values, boolean state property.
|
||||
*/
|
||||
import type { MediaVolumeState } from '../../../core/media/state';
|
||||
import { definePlayerFeature } from '../../feature';
|
||||
|
||||
export const volumeFeature = definePlayerFeature({
|
||||
name: 'volume',
|
||||
state: (): MediaVolumeState => ({
|
||||
volume: 1,
|
||||
muted: false,
|
||||
volumeAvailability: 'available',
|
||||
setVolume(_volume: number) {
|
||||
return 1;
|
||||
},
|
||||
toggleMuted() {
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Mock HTML audio skin element.
|
||||
*
|
||||
* Exercises: single skin per preset, skin detection via SkinElement inheritance.
|
||||
*/
|
||||
import { SkinElement } from '../skin-element';
|
||||
|
||||
export class AudioSkinElement extends SkinElement {
|
||||
static readonly tagName = 'audio-skin';
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Mock SkinElement base class.
|
||||
*
|
||||
* The builder detects HTML skins by checking if a class extends SkinElement.
|
||||
* This fixture provides the base class for that inheritance check.
|
||||
*/
|
||||
export class SkinElement {
|
||||
static shadowRootOptions: any;
|
||||
static styles?: any;
|
||||
static template?: any;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Mock HTML minimal video skin element.
|
||||
*
|
||||
* Exercises: multiple skins per preset, skin detection via SkinElement inheritance.
|
||||
*/
|
||||
import { SkinElement } from '../skin-element';
|
||||
|
||||
export class MinimalVideoSkinElement extends SkinElement {
|
||||
static readonly tagName = 'video-minimal-skin';
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Mock HTML video tailwind skin element.
|
||||
*
|
||||
* Exercises: tailwind skin exclusion — this should NOT appear in the output.
|
||||
*/
|
||||
import { SkinElement } from '../skin-element';
|
||||
|
||||
export class VideoSkinTailwindElement extends SkinElement {
|
||||
static readonly tagName = 'video-skin-tailwind';
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Mock HTML video skin element.
|
||||
*
|
||||
* Exercises: skin detection via SkinElement inheritance, tagName extraction.
|
||||
*/
|
||||
import { SkinElement } from '../skin-element';
|
||||
|
||||
export class VideoSkinElement extends SkinElement {
|
||||
static readonly tagName = 'video-skin';
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Mock HTML audio preset.
|
||||
*
|
||||
* Exercises: preset with fewer features and a single skin.
|
||||
*/
|
||||
export { audioFeatures } from '../../../core/src/dom/store/features/presets';
|
||||
export { AudioSkinElement } from '../define/audio/skin';
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Mock HTML video preset.
|
||||
*
|
||||
* Exercises: preset discovery, feature bundle export, skin exports,
|
||||
* tailwind skin exclusion. HTML presets do NOT export media elements
|
||||
* (the native <video> is implied by the preset name).
|
||||
*/
|
||||
export { videoFeatures } from '../../../core/src/dom/store/features/presets';
|
||||
export { MinimalVideoSkinElement } from '../define/video/minimal-skin';
|
||||
export { VideoSkinElement } from '../define/video/skin';
|
||||
export { VideoSkinTailwindElement } from '../define/video/skin.tailwind';
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Mock React Audio media element.
|
||||
*/
|
||||
export function Audio(): void {}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Mock React Video media element.
|
||||
*
|
||||
* Exercises: media element detection in React presets. Media elements are
|
||||
* exports that are not feature bundles (*Features) and not skins (*Skin).
|
||||
*/
|
||||
export function Video(): void {}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Mock React audio preset.
|
||||
*
|
||||
* Exercises: preset with fewer features, single skin, different media element.
|
||||
*/
|
||||
export { audioFeatures } from '../../../../core/src/dom/store/features/presets';
|
||||
export { Audio } from '../../media/audio';
|
||||
export { AudioSkin } from './skin';
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Mock React AudioSkin component.
|
||||
*/
|
||||
export function AudioSkin(): void {}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Mock React video preset.
|
||||
*
|
||||
* Exercises: preset discovery, feature bundle export, skin exports (named),
|
||||
* media element export, tailwind skin exclusion.
|
||||
*/
|
||||
export { videoFeatures } from '../../../../core/src/dom/store/features/presets';
|
||||
export { Video } from '../../media/video';
|
||||
export { MinimalVideoSkin } from './minimal-skin';
|
||||
export { VideoSkin } from './skin';
|
||||
export { VideoSkinTailwind } from './skin.tailwind';
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Mock React MinimalVideoSkin component.
|
||||
*
|
||||
* Exercises: multiple skins per preset.
|
||||
*/
|
||||
export function MinimalVideoSkin(): void {}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Mock React VideoSkinTailwind component.
|
||||
*
|
||||
* Exercises: tailwind skin exclusion — this should NOT appear in the output.
|
||||
*/
|
||||
export function VideoSkinTailwind(): void {}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Mock React VideoSkin component.
|
||||
*
|
||||
* Exercises: React skin detection via *Skin naming convention.
|
||||
*/
|
||||
export function VideoSkin(): void {}
|
||||
Reference in New Issue
Block a user