diff --git a/commitlint.config.js b/commitlint.config.js index bdf9fdcee..8a522b72c 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -30,6 +30,7 @@ export default { 'skins', 'test', 'utils', + 'compiler', ]], }, }; diff --git a/eslint.config.mjs b/eslint.config.mjs index b1b3ef602..df2bf87a6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,6 +23,7 @@ export default antfu( // https://github.com/antfu/eslint-config/blob/main/src/globs.ts#L56 ignores: [ '**/CLAUDE.md', + '**/ARCHITECTURE.md', '**/.astro/', '**/.vercel/', '**/dist/', diff --git a/packages/compiler/README.md b/packages/compiler/README.md new file mode 100644 index 000000000..24bb4cfd5 --- /dev/null +++ b/packages/compiler/README.md @@ -0,0 +1,107 @@ +# @videojs/compiler + +Compiles React skin components to HTML web component modules. + +## Installation + +```bash +pnpm add @videojs/compiler +``` + +## Usage + +### Basic Compilation + +```typescript +import { readFileSync } from 'node:fs'; +import { compile } from '@videojs/compiler'; + +// Read the React skin source file +const source = readFileSync('./MediaSkinMinimal.tsx', 'utf-8'); + +// Compile returns a complete HTML module as a string +const htmlModule = compile({ input: { source } }); + +// Output includes: +// - Import statements (MediaSkinElement, defineCustomElement, component definitions, styles) +// - Template function with HTML structure +// - Class declaration extending MediaSkinElement +// - Custom element registration +``` + +Where `MediaSkinMinimal.tsx` contains: + +```tsx +import type { PropsWithChildren } from 'react'; +import { MediaContainer, PlayButton } from '@videojs/react'; +import { PlayIcon, PauseIcon } from '@videojs/react/icons'; +import styles from './styles'; + +export default function MediaSkinMinimal({ children }: PropsWithChildren) { + return ( + + {children} + + + + + + ); +} +``` + +### Module Output Structure + +The compiler generates a complete TypeScript/JavaScript module: + +```typescript +import { MediaSkinElement } from '@/media/media-skin'; +import { defineCustomElement } from '@/utils/custom-element'; +import styles from './styles.css'; +import '@/define/media-container'; +import '@/define/media-play-button'; +import '@/icons'; + +export function getTemplateHTML(): string { + return /* @__PURE__ */ ` + ${MediaSkinElement.getTemplateHTML()} + + + + + + + + + `; +} + +export class MediaSkinMinimalElement extends MediaSkinElement { + static getTemplateHTML: () => string = getTemplateHTML; +} + +defineCustomElement('media-skin-minimal', MediaSkinMinimalElement); +``` + +**Note:** The compiler currently handles JSX/import transformation and generates a placeholder CSS reference (`${styles}`). Compilation of Tailwind utilities to vanilla CSS is not yet implemented. + +## Documentation + +For detailed information on architecture and design, see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md). + +## Development + +```bash +# Run tests +pnpm test + +# Type check +pnpm typecheck + +# Build +pnpm build +``` + +## License + +Apache-2.0 diff --git a/packages/compiler/docs/ARCHITECTURE.md b/packages/compiler/docs/ARCHITECTURE.md new file mode 100644 index 000000000..71a80c3e7 --- /dev/null +++ b/packages/compiler/docs/ARCHITECTURE.md @@ -0,0 +1,384 @@ +# @videojs/compiler Architecture + +## Overview + +The compiler transforms React skin components into HTML web component modules through a **config-driven 3-phase pipeline**. Each phase is completely generic and knows nothing about React, HTML, or Video.js—all domain knowledge lives in the configuration. + +``` +Source Code (string) + ↓ +[Phase 1: Analysis] + Extract facts from AST using config-driven visitors + ↓ +AnalyzedContext { input, imports, classNames, jsx, defaultExport } + ↓ +[Phase 2: Categorization] + Classify entities using config-driven predicates + ↓ +CategorizedContext { ..., imports[].category, classNames[].category, ... } + ↓ +[Phase 3: Projection] + Transform using config-driven projectors + compose module + ↓ +HTML Module (string) +``` + +## Core Architecture Principles + +### 1. Config-Driven Everything + +The compiler has **zero hardcoded logic** about what to analyze, categorize, or project. All behavior is defined in `videoJSReactSkinConfig`: + +```typescript +{ + phases: { + imports: { visitor, categories }, + classNames: { visitor, categories }, + jsx: { visitor, categories }, + defaultExport: { visitor, categories } + }, + classNameProjectors: { ... }, + projectionState: { ... }, + composeModule: (state) => string +} +``` + +### 2. Single Context Object + +A single `context` object flows through all three phases, **accumulating data**: + +- **Phase 1** adds analyzed fields (`imports`, `classNames`, etc.) +- **Phase 2** adds `category` property to each entity +- **Phase 3** adds `projectionState` with transformed output + +The context is never replaced—each phase extends it. + +### 3. Concerns (Not Hardcoded Fields) + +The pipeline doesn't know about "imports" or "jsx"—it iterates over whatever concerns are defined in `config.phases`. Each concern gets: +- A visitor (Phase 1) that extracts entities +- Categories with predicates (Phase 2) that classify entities +- Projectors (Phase 3) that transform entities + +## Phase 1: Analysis + +**Question:** What exists in the source code? + +**Responsibility:** Parse AST and extract usage facts without any interpretation. + +### How It Works + +1. Babel parses source into AST +2. For each concern in `config.phases`, run its visitor +3. Visitors use **reducer pattern**: `(prevValue, path) => newValue` +4. Accumulated values stored in context under concern key + +### Example: Imports Visitor + +```typescript +importsVisitor: { + ImportDeclaration: (prevImports = [], path) => { + const importUsage = { + source: path.node.source.value, + node: path, + specifiers: { named: [...], default: '...', namespace: '...' } + }; + return [...prevImports, importUsage]; + } +} +``` + +**Output:** `AnalyzedContext` with raw facts: +```typescript +{ + input: { source: "..." }, + imports: ImportUsage[], + classNames: ClassNameUsage[], + jsx: JSXUsage, // tree structure + defaultExport: DefaultExportUsage +} +``` + +### Key Point: No Interpretation + +Phase 1 extracts **what exists**, not **what things are**. It doesn't know if an import is from React or Video.js—that's Phase 2's job. + +## Phase 2: Categorization + +**Question:** What ARE these things? + +**Responsibility:** Classify entities using predicates, without transforming them. + +### How It Works + +1. For each concern in `config.phases`, get its `categories` config +2. For each entity, test predicates in order (first match wins) +3. Predicates return `true`/`false` based on entity properties +4. Add `category` property to entity +5. Handle recursive structures (e.g., JSX children) + +### Category Matching Rules + +- **AND logic**: All predicates in array must pass +- **First match wins**: Insertion order matters +- **Fallback**: Empty predicate array `[]` is catch-all + +### Example: Import Categories + +```typescript +categories: { + style: [isImportUsedAsStyle], // Check first + framework: [isImportFromFrameworkPackage], // Then this + 'vjs-component': [isImportUsedAsComponent, isImportFromVJSPackage], // Both must pass + external: [] // Catch-all fallback +} +``` + +A predicate is just a function: +```typescript +function isImportUsedAsStyle(entity: ImportUsage, context: AnalyzedContext): boolean { + return entity.source.endsWith('.css') || + entity.specifiers.default?.toLowerCase().includes('style'); +} +``` + +**Output:** `CategorizedContext` with classifications: +```typescript +{ + input: { source: "..." }, + imports: Array, + classNames: Array, + jsx: JSXUsage & { category: '...' }, // + children recursively categorized + defaultExport: DefaultExportUsage & { category: 'react-functional-component' } +} +``` + +### Key Point: Classification Only + +Phase 2 adds **category labels** but doesn't transform data. The original AST nodes, source strings, etc. are preserved. + +## Phase 3: Projection + +**Question:** What should these things become? + +**Responsibility:** Transform categorized entities to output format and compose final module. + +### Two-Part Structure + +Phase 3 has two distinct parts: + +#### Part A: State Projectors (`projectionState`) + +State-based projectors that build up `ProjectionState` object: + +```typescript +projectionState: { + styleVariableName: 'styles', // Static value + imports: projectImports, // Function: (context, prevState, config) => ProjectedImport[] + elementClassName: projectElementClassName, // Function: (context, prevState, config) => string + elementName: projectElementName, // Function: (context, prevState, config) => string + css: projectCSS, // Function: (context, prevState, config) => string + html: projectHTML // Function: (context, prevState, config) => ProjectedHTML[] +} +``` + +**Projectors are accumulative**: Each projector receives `prevState` with previously computed fields. This enables dependencies (e.g., `html` projector can use `styleVariableName`). + +#### Part B: Module Composition (`composeModule`) + +Final template function that takes complete `ProjectionState` and generates output: + +```typescript +composeModule(projection: ProjectionState): string { + return `${formatImports(projection.imports)} + +export function getTemplateHTML() { + return /* html */\` + \${MediaSkinElement.getTemplateHTML()} + + ${formatHTML(projection.html)} + \`; +} + +export class ${projection.elementClassName} extends MediaSkinElement { + static getTemplateHTML: () => string = getTemplateHTML; +} + +defineCustomElement('${projection.elementName}', ${projection.elementClassName}); +`; +} +``` + +### Special: className Projectors + +There's also `classNameProjectors` which are **element-level** projectors invoked inline during HTML generation: + +```typescript +classNameProjectors: { + 'literal-classes': projectLiteralClasses, // "foo bar" → ["foo", "bar"] + 'component-match': projectComponentMatch, // styles.PlayIcon on → [] (omit) + 'generic-style': projectGenericStyle // styles.Button → ["button"] (kebab-case) +} +``` + +These are called during the `html` projector to resolve `class` attributes for each element. + +### Current Limitation: CSS Compilation + +**What's implemented:** The compiler transforms JSX structure, imports, and className references. The `css` projector currently outputs a **placeholder reference** (`${styles}`) that expects pre-compiled CSS. + +**What's NOT implemented:** Compilation of Tailwind utility classes to vanilla CSS. React skins use `styles.ts` with Tailwind utilities (e.g., `vjs:relative vjs:isolate vjs:@container/root`), while HTML skins need vanilla CSS (e.g., `position: relative; isolation: isolate; container: root / inline-size;`). + +**Example comparison** (Frosted skin): +- **React**: `styles.MediaContainer` → `"vjs:relative vjs:isolate vjs:@container/root vjs:bg-black ..."` +- **HTML**: Needs vanilla CSS with proper selectors, media queries, pseudo-elements, etc. + +This transformation involves: +- Processing Tailwind utilities through PostCSS/Tailwind compiler +- Resolving CSS variables and theme values +- Generating proper CSS with element/class selectors +- Handling advanced features (container queries, `:has()`, `@media` queries, pseudo-elements) + +Pre-existing incomplete prototype implementations exist that can serve as reference points for potential approaches, but the final implementation is still being determined. + +### Example: Import Projection + +```typescript +const projectImports: StateProjector = (context, prevState, config) => { + const imports = context.imports ?? []; + + return [ + // Base framework imports + { type: 'import', source: '@/media/media-skin', specifiers: [{ type: 'named', name: 'MediaSkinElement' }] }, + { type: 'import', source: '@/utils/custom-element', specifiers: [{ type: 'named', name: 'defineCustomElement' }] }, + + // Style imports (category: 'style') + ...imports + .filter(i => i.category === 'style') + .map(i => ({ type: 'import', source: `${i.source}.css`, specifiers: [{ type: 'default', name: 'styles' }] })), + + // Component imports (category: 'vjs-component') + ...imports + .filter(i => i.category === 'vjs-component') + .flatMap(i => i.specifiers.named.map(name => + ({ type: 'import', source: `@/define/${componentNameToElementName(name)}` }) + )), + + // Icon imports (category: 'vjs-icon') - deduplicated to single import + ...(imports.some(i => i.category === 'vjs-icon') ? [{ type: 'import', source: '@/icons' }] : []) + ]; +}; +``` + +**Output:** Complete HTML module as string + +## Data Flow Example + +Let's trace `import { PlayButton } from '@videojs/react'`: + +### Phase 1: Analysis +```typescript +{ + source: '@videojs/react', + node: NodePath, + specifiers: { named: ['PlayButton'] } +} +``` + +### Phase 2: Categorization + +Predicates checked: +1. `isImportUsedAsStyle` → `false` +2. `isImportFromFrameworkPackage` → `false` +3. `isImportUsedAsComponent` → `true` AND `isImportFromVJSPackage` → `true` ✓ + +Result: +```typescript +{ + source: '@videojs/react', + node: NodePath, + specifiers: { named: ['PlayButton'] }, + category: 'vjs-component' // ← Added +} +``` + +### Phase 3: Projection + +`projectImports` transforms it: +```typescript +{ + type: 'import', + source: '@/define/media-play-button' + // Side-effect import, no specifiers +} +``` + +`composeModule` formats it: +```typescript +"import '@/define/media-play-button';" +``` + +## Configuration Structure + +The complete config structure (simplified): + +```typescript +interface CompilerConfig { + // Phase 1 + 2: Analysis and Categorization + phases: { + [concernName: string]: { + visitor: AnalysisVisitors, // Phase 1: Extract entities + categories: Record // Phase 2: Classify entities + } + }, + + // Phase 3: Projection + classNameProjectors: Record, // Element-level className resolution + projectionState: Record, // Module-level transformations + composeModule: (projectionState) => string // Final output generation +} +``` + +## Why Config-Driven? + +1. **Zero Hardcoding**: Core pipeline knows nothing about React, HTML, or Video.js +2. **Easy Testing**: Test visitors, predicates, and projectors in isolation +3. **Extensibility**: Add new concerns without modifying pipeline code +4. **Clear Boundaries**: Each phase has single responsibility +5. **Discoverable**: All behavior is explicit in config + +## Testing Strategy + +The architecture enables comprehensive testing: + +1. **Visitor Tests**: Test fact extraction in isolation +2. **Predicate Tests**: Test classification logic with fixtures +3. **Projector Tests**: Test transformations with categorized data +4. **Integration Tests**: Test complete pipeline +5. **Component Tests**: Test real-world React components + +All core functions are pure (string in, string out), making them easy to test without filesystem access. + +## Future Considerations + +### Priority: CSS Compilation + +The most critical missing piece is **Tailwind-to-vanilla-CSS compilation**. Currently, the compiler outputs a placeholder CSS reference, but production skins need actual compiled CSS. + +This requires: +- Tailwind/PostCSS integration for processing utility classes +- Selector generation (element selectors, class selectors, combinators) +- Advanced CSS feature support (container queries, `:has()`, pseudo-elements) +- CSS variable resolution and theme configuration + +### Other Enhancements + +The config-driven architecture makes other enhancements straightforward: + +- **New Frameworks**: Add Vue visitor/predicates/projectors in new config +- **New Output Formats**: Add Lit projectors, keep same analysis/categorization +- **New Features**: Add new concerns (e.g., `typeImports`, `hooks`) to phases +- **Optimizations**: Add new projectors without changing pipeline +- **CLI/File I/O**: Add boundary layer around pure pipeline + +The pipeline doesn't need to change—just the configuration. diff --git a/packages/compiler/package.json b/packages/compiler/package.json new file mode 100644 index 000000000..a69488a25 --- /dev/null +++ b/packages/compiler/package.json @@ -0,0 +1,36 @@ +{ + "name": "@videojs/compiler", + "type": "module", + "version": "0.1.0", + "description": "Compile React components to web components", + "license": "Apache-2.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "test": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@babel/core": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/traverse": "^7.23.0", + "@babel/types": "^7.23.0" + }, + "devDependencies": { + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.5", + "@types/node": "^20.0.0", + "@types/react": "^18.0.0", + "@videojs/react": "workspace:*", + "jsdom": "^25.0.0", + "react": "^18.0.0", + "tsdown": "^0.15.9", + "typescript": "^5.9.2", + "vitest": "^1.0.0" + } +} diff --git a/packages/compiler/src/compile.ts b/packages/compiler/src/compile.ts new file mode 100644 index 000000000..63f6307c5 --- /dev/null +++ b/packages/compiler/src/compile.ts @@ -0,0 +1,45 @@ +/** + * End-to-End Compilation + * + * Main entry point for compiling React skins to HTML skins + * Runs complete 3-phase pipeline: Analysis → Categorization → Projection + */ + +import type { CompilerConfig, SourceContext } from './phases/types'; +import type { DeepPartial } from './utils/deep-merge'; +import { defaultCompilerConfig } from './configs/videojs-react-skin'; +import { analyze } from './phases/analyze'; +import { categorize } from './phases/categorize'; +import { project } from './phases/project'; +import { deepMerge } from './utils/deep-merge'; + +/** + * Compile React skin source to HTML skin module + * End-to-end pipeline through all three phases + * Context flows through: initial → analyzed → categorized → projected + * + * @param sourceContext - Source context with code and initial state values + * @param config - Optional compiler configuration (uses defaults if not provided) + * @returns Complete HTML skin module source code + * + * @example + * const htmlSkin = compile({ input: { source: reactSkinSource } }); + * // Returns complete module with imports, class, template, registration + */ +export function compile( + sourceContext: SourceContext, + config: DeepPartial = {}, +): string { + // Merge user config with defaults + const fullConfig = deepMerge(defaultCompilerConfig, config); + + // Phase 1: Analysis - identify what exists (config-driven visitors) + // SourceContext flows through all phases unchanged + const analyzedContext = analyze(sourceContext, fullConfig); + + // Phase 2: Categorization - determine what things are + const categorizedContext = categorize(analyzedContext, fullConfig); + + // Phase 3: Projection - transform to output and compose final module + return project(categorizedContext, fullConfig); +} diff --git a/packages/compiler/src/configs/types.ts b/packages/compiler/src/configs/types.ts new file mode 100644 index 000000000..0292f4449 --- /dev/null +++ b/packages/compiler/src/configs/types.ts @@ -0,0 +1,605 @@ +/** + * VideoJS React Skin Compiler Types + * + * Config-specific type definitions for compiling React skins to HTML skins + * Organized by phase: Analysis → Categorization → Projection → Configuration + */ + +import type { NodePath } from '@babel/traverse'; +import type * as BabelTypes from '@babel/types'; +import type { + AnalysisVisitors, + AnalyzedContext as GenericAnalyzedContext, + CompilerConfig as GenericCompilerConfig, + Predicate, + ProjectionPhaseConfig, + SingleAnalyzedEntry, + TreeAnalyzedEntry, +} from '../phases/types'; +import type { + ProjectedHTML, + ProjectedImportEntry, +} from '../types'; + +// ============================================================================ +// Phase 1: Analysis Types +// ============================================================================ +// Extract facts about module usage - what exists, what's used where +// All references are AST NodePaths for later categorization and transformation + +// ---------------------------------------------------------------------------- +// Analysis Output Types +// ---------------------------------------------------------------------------- + +/** + * Import usage - captured from import statements + * Extends AnalyzedEntry base type with required node + */ +export interface ImportUsage extends SingleAnalyzedEntry> { + /** AST reference to import statement (override to make required) */ + node: NodePath; + /** Import source path */ + source: string; + /** What's being imported */ + specifiers: { + /** Variable name for default import */ + default?: string; + /** Variable names for named imports */ + named: string[]; + /** Variable name for namespace import (import * as foo) */ + namespace?: string; + }; +} + +/** + * Symbol indicating className attribute is tracked separately in ClassNameUsage + * Used as a marker in JSXUsage.attributes to indicate presence without storing value + */ +export const CLASSNAME_TRACKED_SEPARATELY: unique symbol = Symbol('className-tracked-separately'); + +/** + * JSX attribute value types + * Simple primitive values extracted during analysis + * className is special-cased with a symbol (tracked in ClassNameUsage) + */ +export type JSXAttributeValue = string | number | boolean | typeof CLASSNAME_TRACKED_SEPARATELY; + +/** + * JSX text node - represents text content within JSX + * Extracted from JSXText nodes during analysis + * Does not extend AnalyzedEntry with node (text nodes have no AST reference) + */ +export interface JSXTextNode extends SingleAnalyzedEntry { + type: 'text'; + /** Text content (trimmed) */ + value: string; +} + +/** + * JSX expression node - represents expression containers within JSX + * Extracted from JSXExpressionContainer nodes during analysis + * Used for patterns like {children}, {someVar}, etc. + * Does not extend AnalyzedEntry with node (expression containers have no categorizable AST reference) + */ +export interface JSXExpressionNode extends SingleAnalyzedEntry { + type: 'expression'; + /** Type of expression */ + expressionType: 'identifier' | 'member' | 'other'; + /** Identifier name (for expressionType === 'identifier') */ + identifierName?: string; +} + +/** + * JSX element node - tree-based structure with recursive children + * Extends AnalyzedEntry base type with required node and recursive children + * + * Forms a recursive tree where each element has children + * Root element is stored directly in context.jsx (not in an array) + */ +export interface JSXElementNode extends TreeAnalyzedEntry, JSXUsage> { + type: 'element'; + /** Identifier used as JSX element name (e.g., 'PlayButton', 'TimeSlider') */ + identifier: string; + /** Member property for compound components (e.g., 'Root', 'Track') */ + member?: string; + /** Extracted attributes (basic types during analysis) */ + attributes: Record; +} + +/** + * JSX usage - discriminated union of all JSX node types + * Includes JSX elements, text nodes, and expression containers + */ +export type JSXUsage = JSXElementNode | JSXTextNode | JSXExpressionNode; + +/** + * className attribute usage - base fields + * + * Note on order preservation: + * When a className has mixed member expressions and string literals + * (e.g., `${styles.Button} active ${styles.Play}`), the relative order + * between member expressions and literals is not preserved. + * Order is maintained within each type (member expressions array order, + * string literal classes array order), but not between types. + * + * This is acceptable because: + * - CSS specificity is determined by stylesheet order, not class attribute order + * - Member expressions (CSS modules) are scoped and don't conflict with literals + * - String literals are typically utility classes that don't conflict with modules + * + * If order preservation becomes necessary, the data structure could be revisited + * to more closely reflect the original code structure. + */ +interface BaseClassNameUsage extends SingleAnalyzedEntry> { + /** Which component has this className */ + component: { + /** Component identifier (e.g., 'PlayButton', 'TimeSlider', 'div') */ + identifier: string; + /** Member property for compound components (e.g., 'Root', 'Track') */ + member?: string; + /** AST reference to the JSX element node (used for identity matching) */ + node: NodePath; + }; +} + +/** + * Member expression className - styles.Button + * Extends AnalyzedEntry base type with required node + */ +export interface MemberExpressionClassName extends BaseClassNameUsage { + /** Type discriminator */ + type: 'member-expression'; + /** Style object variable name (e.g., 'styles') */ + identifier: string; + /** Style key accessed (e.g., 'Button', 'PlayButton') */ + key: string; +} + +/** + * String literal className - "button primary" + * Extends AnalyzedEntry base type with required node + */ +export interface StringLiteralClassName extends BaseClassNameUsage { + /** Type discriminator */ + type: 'string-literal'; + /** Extracted class names (split by whitespace) */ + classes: string[]; + /** Original literal value */ + literalValue: string; +} + +/** + * className attribute usage - discriminated union + */ +export type ClassNameUsage = MemberExpressionClassName | StringLiteralClassName; + +/** + * Default export - the main component being compiled + * Extends AnalyzedEntry base type with required node + */ +export interface DefaultExportUsage extends SingleAnalyzedEntry> { + /** AST reference to export default statement (override to make required) */ + node: NodePath; + /** Component function name */ + componentName: string; + /** + * AST reference to root JSX element + * Used internally in Phase 1 to mark the root element with isRoot flag + * After Phase 1, access root via jsxUsage.find(el => el.isRoot) instead + */ + jsxElement: NodePath; +} + +/** + * Analyzed fields for videojs-react-skin config + * Defines the specific fields produced by this config's analysis phase + */ +export interface VideoJSAnalyzedFields { + /** All imports */ + imports?: ImportUsage[]; + /** JSX tree (single root element with recursive children) */ + jsx?: JSXUsage; + /** className values */ + classNames?: ClassNameUsage[]; + /** Default export component */ + defaultExport?: DefaultExportUsage; +} + +/** + * Analyzed context - full execution context after analysis + * Concrete type for videojs-react-skin config + * Extends SourceContext with analyzed fields + * Fields may be undefined if visitor returns nothing + */ +export type AnalyzedContext = GenericAnalyzedContext; + +// ============================================================================ +// Phase 2: Categorization Types +// ============================================================================ +// Category enums and configuration for analyzing usage patterns + +/** + * Import categories based on usage and package context + */ +export type ImportCategory + = | 'vjs-component' // VJS component (from @videojs/* or relative) + | 'vjs-icon' // VJS icon (from @videojs/*-icons or icon naming pattern) + | 'vjs-core' // VJS platform-agnostic (core, utils) + | 'framework' // Framework-specific (react, react-dom) + | 'style' // Style import (used in className) + | 'external'; // External package (non-VJS) + +/** + * className categories based on transformation strategy + */ +export type ClassNameCategory + = | 'component-match' // Key matches component identifier (omit from class) + | 'generic-style' // CSS module key that doesn't match (add to class) + | 'literal-classes'; // String literal classes (handle specially) + +/** + * JSX element categories based on component type and usage patterns + */ +export type JSXElementCategory + = | 'native-element' // Native HTML element (div, button, span) + | 'generic-component' // Generic component (not special-cased) + | 'compound-root' // Compound component root (TimeSlider when TimeSlider.Root exists) + | 'media-container' // MediaContainer component (special handling) + | 'tooltip-root' // Tooltip.Root + | 'tooltip-trigger' // Tooltip.Trigger + | 'tooltip-positioner' // Tooltip.Positioner + | 'tooltip-popup' // Tooltip.Popup + | 'tooltip-portal' // Tooltip.Portal + | 'popover-root' // Popover.Root + | 'popover-trigger' // Popover.Trigger + | 'popover-positioner' // Popover.Positioner + | 'popover-popup' // Popover.Popup + | 'popover-portal'; // Popover.Portal + +/** + * Default export categories based on component type + */ +export type DefaultExportCategory = 'react-functional-component'; // React functional component (arrow function or function declaration) + +// Note: Concrete categorization config interfaces removed in favor of generic types +// See CategorizationConfig and CategorizationPhaseConfig in ./phases/types.ts +// CompilerConfig below provides the concrete implementation for videojs-react-skin + +/** + * Categorized import - extends ImportUsage with category + * Pattern: CategorizedEntryArray + * Flattened structure - all usage fields directly accessible + */ +export interface CategorizedImport extends Omit { + category: ImportCategory; +} + +/** + * Categorized className - extends ClassNameUsage with category + * Pattern: CategorizedEntryArray + * Flattened structure - all usage fields directly accessible + */ +export interface CategorizedClassName extends Omit { + category: ClassNameCategory; +} + +/** + * Categorized JSX child types - discriminated union + * Only JSX elements get categorized; text and expressions pass through unchanged + */ +export type CategorizedJSXChild = CategorizedJSXElement | JSXTextNode | JSXExpressionNode; + +/** + * Categorized JSX element - extends JSXElementNode with category + * Pattern: TreeCategorizedEntry + * Recursive tree structure with categorized children + */ +export interface CategorizedJSXElement extends Omit { + category: JSXElementCategory; + /** Categorized children (elements are categorized, text/expressions unchanged) */ + children: CategorizedJSXChild[]; +} + +/** + * Categorized default export - extends DefaultExportUsage with category + * Pattern: SingleCategorizedEntry + * Flattened structure - all usage fields directly accessible + */ +export interface CategorizedDefaultExport extends Omit { + category: DefaultExportCategory; +} + +/** + * Categorized context - full execution context after categorization + * Preserves all fields from the input context (AnalyzedContext) + * while transforming the usage analysis fields to categorized results + */ +export type CategorizedContext = Omit & { + /** Categorized imports */ + imports: CategorizedImport[]; + /** Categorized className values */ + classNames: CategorizedClassName[]; + /** Categorized JSX tree (single root with recursive children) */ + jsx: CategorizedJSXElement; + /** Categorized default export */ + defaultExport: CategorizedDefaultExport; +}; + +// ============================================================================ +// Phase 3: Projection Types +// ============================================================================ +// Types for transforming categorized usage to output strings +// +// Projection uses different patterns depending on the concern: +// - Imports: Module-level reducer with Projector, accumulates into ProjectionState +// - DefaultExport: Direct transform returning specific fields +// - JSX: Recursive tree traversal, returns HTML strings +// - ClassNames: Element-level reducer, accumulates class strings per element +// - CSS: Direct implementation (no separate projector type) +// +// All concerns share a common wrapper: (context, config) → updated context + +/** + * Projection state - accumulator state during projection + * Passed through projectors as they reduce over categorized entities + * Each projection function takes context + input and returns updated context + * + * Conventions (styleVariableName) are now provided via config.projectionState + * Accumulators (imports, elementClassName, etc.) are OPTIONAL initially + * and populated during projection by projectors + * After projectModule() completes, all fields are guaranteed to be populated + */ +export interface ProjectionState { + // Conventions (applied from config, can be overridden in context) + /** Style variable name convention (applied from config.projectionState if not in context) */ + styleVariableName?: string; + + // Optional accumulators (populated during projection) + /** Accumulated import entries (populated by projectImports) */ + imports?: ProjectedImportEntry[]; + + /** Derived custom element class name (populated by projectDefaultExport) */ + elementClassName?: string; + + /** Derived custom element tag name (populated by projectElementName) */ + elementName?: string; + + /** Projected HTML content (populated by projectHTML) */ + html?: ProjectedHTML[]; + + /** Projected CSS content (populated by projectCSS) */ + css?: string; + + // Future state: + // importedComponents: Set; + // generatedUtilityClasses: Map; +} + +/** + * Module-level Projector function type: (state, entity, context) → updated state + * Reducer pattern - takes current state, returns updated state with projections applied + * Pure function - should not mutate input state + * Used for import projectors (module-scoped entities) + * + * Note: Other projection concerns use different patterns: + * - DefaultExport: Direct transform returning specific fields + * - JSX: Recursive tree traversal returning HTML strings + * - ClassNames: Element-level reducer with custom accumulator + * - CSS: Direct implementation (no separate projector type) + */ +export type Projector = ( + projectionState: ProjectionState, + entity: T, + context: CategorizedContext, +) => ProjectionState; + +/** + * JSX Projector function type: (categorized, projectors, context, config) → HTML string + * Recursive tree traversal - projector processes element and recursively projects children + * Context parameter provides access to classNames for className resolution + * Config parameter provides access to className projectors and other configuration + * Pure function - should not mutate inputs + */ +export type JSXProjector = ( + categorized: CategorizedJSXElement, + projectors: Record, + context: CategorizedContext, + config: CompilerConfig, +) => string; + +/** + * className Projector function type: (classes, className, context) → updated classes + * Element-level reducer pattern - accumulates class strings for a single element + * + * Pattern difference: Unlike module-level Projector which accumulates into ProjectionState, + * this uses a simple string[] accumulator because: + * 1. ClassNames are element-scoped (not module-scoped) + * 2. Multiple classNames reduce to a single class attribute per element (many:1) + * 3. Invoked inline during JSX tree traversal, not in a separate projection phase + * + * Projectors implement category-specific transformations: + * - component-match: Return unchanged (omit from output) + * - generic-style: Extract key, transform to kebab-case, append + * - literal-classes: Extract classes array, spread into accumulator + * + * Pure function - should not mutate input array + * Used during JSX attribute resolution via resolveClassName() + */ +export type ClassNameProjector = ( + classes: string[], + className: CategorizedClassName, + context: CategorizedContext, +) => string[]; + +// Note: Structured projection types (ProjectedImport, ProjectedHTML, etc.) +// are now defined in ../types.ts as they are generic/reusable + +/** + * Compiler context - full execution context at Phase 3 (input to projection) + * Contains categorized data + projection state + * This is the context threaded through projection functions + * projectionState is required (not optional) for projection phase + */ +export type CompilerContext = Omit & { + projectionState: ProjectionState; +}; + +// ============================================================================ +// Configuration Types +// ============================================================================ +// Unified configuration spanning all phases +// Grouped by concern - each concern contains full pipeline configuration + +/** + * Default export projector function type + * Projects categorized default export to element names + * Returns both elementClassName and elementName for the custom element + */ +export type DefaultExportProjector = ( + categorized: CategorizedDefaultExport, +) => { + elementClassName: string; + elementName: string; +}; + +/** + * Import concern configuration + * Pipeline: visitor (Phase 1) → categories (Phase 2) + * Projection handled by projectionState.imports + * + * @param V - The value type produced by the visitor (defaults to ImportUsage[]) + */ +export interface ImportConfig { + /** Analysis visitor (Phase 1) */ + visitor: AnalysisVisitors; + + /** Categorization predicates (Phase 2) */ + categories: Record[]>; +} + +/** + * className concern configuration + * Pipeline: visitor (Phase 1) → categories (Phase 2) + * Projection handled by top-level classNameProjectors + * + * Note: className projectors use a different pattern than state projectors: + * - Element-scoped (not module-scoped) + * - Accumulate into string[] (not ProjectionState) + * - Invoked inline during HTML projection (not in separate phase) + * + * @param V - The value type produced by the visitor (defaults to ClassNameUsage[]) + */ +export interface ClassNameConfig { + /** Analysis visitor (Phase 1) */ + visitor: AnalysisVisitors; + + /** Categorization predicates (Phase 2) */ + categories: Record[]>; +} + +/** + * JSX concern configuration + * Pipeline: visitor (Phase 1) → categories (Phase 2) + * Projection handled by projectionState.html + * + * @param V - The value type produced by the visitor (defaults to JSXUsage) + */ +export interface JSXConfig { + /** Analysis visitor (Phase 1) */ + visitor: AnalysisVisitors; + + /** Categorization predicates (Phase 2) */ + categories: Record[]>; +} + +/** + * Default export concern configuration + * Handles default export extraction, categorization, and name derivation + * Pipeline: visitor (Phase 1) → categories (Phase 2) + * Projection handled by projectionState.elementClassName and elementName + * + * @param V - The value type produced by the visitor (defaults to DefaultExportUsage) + */ +export interface DefaultExportConfig { + /** Analysis visitor (Phase 1) */ + visitor: AnalysisVisitors; + + /** Categorization predicates (Phase 2) */ + categories: Record[]>; +} + +/** + * State-based projector configuration + * Application-specific alias for ProjectionPhaseConfig + * Maps ProjectionState keys to either: + * - Projector functions that compute values dynamically + * - Static values that are applied directly + * Type-safe: ensures values match ProjectionState field types + */ +type ProjectionStateConfig = ProjectionPhaseConfig; + +// ============================================================================ +// VideoJS React Skin Compiler Configuration +// ============================================================================ +// Concrete implementation of CompilerConfig for videojs-react-skin + +/** + * VideoJS React Skin compiler configuration + * Concrete implementation for compiling @videojs/react skins to HTML skins + * Extends generic CompilerConfig with videojs-specific fields + * + * Defines HOW to compile (static processing rules) + * Config contains only processing rules (visitors, categorizers, projectors) + * Runtime values (source code, initial state) live in SourceContext + */ +export interface CompilerConfig extends GenericCompilerConfig< + { + phases: { + imports: ImportConfig; + classNames: ClassNameConfig; + jsx: JSXConfig; + defaultExport: DefaultExportConfig; + }; + }, + ProjectionState +> { + /** Phase configurations grouped by concern (analysis & categorization) */ + phases: { + /** Import configuration */ + imports: ImportConfig; + + /** className configuration */ + classNames: ClassNameConfig; + + /** JSX element configuration */ + jsx: JSXConfig; + + /** Default export configuration */ + defaultExport: DefaultExportConfig; + }; + + /** + * className projectors (element-level reducers) + * Invoked inline during HTML projection to resolve class attributes + * Separate from state projectors because they accumulate per-element, not module-level + */ + classNameProjectors: Record; + + /** + * State-based projectors + * Type-safe: projector return types must match ProjectionState field types + * These handle module-level projections (imports, html, css, names) + * + * Supports both: + * - Functions: Computed dynamically based on context (e.g., projectImports) + * - Static values: Applied directly (e.g., css: 'default-styles') + */ + projectionState?: ProjectionStateConfig; + + /** + * Module composition function + * Takes complete projection state and composes it into final output + * Overrides generic signature to require all projection state fields + */ + composeModule?: (projectionState: ProjectionState) => string; +} diff --git a/packages/compiler/src/configs/videojs-react-skin/config.ts b/packages/compiler/src/configs/videojs-react-skin/config.ts new file mode 100644 index 000000000..e14683707 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/config.ts @@ -0,0 +1,159 @@ +/** + * Video.js React Skin Compiler Configuration + * + * Complete compiler configuration for transforming React skins to HTML skins + * Defines processing rules (visitors, categorizers, projectors) for each concern + * Grouped by concern (imports, classNames, jsx, defaultExport) + */ + +import type { CompilerConfig } from '../types'; +import { composeModule } from './outputs'; +import { + isClassNameComponentMatch, + isClassNameGenericStyle, + isClassNameLiteral, + isImportFromFrameworkPackage, + isImportFromVJSCore, + isImportFromVJSPackage, + isImportUsedAsComponent, + isImportUsedAsIcon, + isImportUsedAsStyle, + isJSXElementCompoundRoot, + isJSXElementMediaContainer, + isJSXElementNative, + isJSXElementPopoverPopup, + isJSXElementPopoverPortal, + isJSXElementPopoverPositioner, + isJSXElementPopoverRoot, + isJSXElementPopoverTrigger, + isJSXElementTooltipPopup, + isJSXElementTooltipPortal, + isJSXElementTooltipPositioner, + isJSXElementTooltipRoot, + isJSXElementTooltipTrigger, + isReactFunctionalComponent, +} from './predicates'; +import { + projectComponentMatch, + projectCSS, + projectElementClassName, + projectElementName, + projectGenericStyle, + projectHTML, + projectImports, + projectLiteralClasses, +} from './projectors'; +import { + classNamesVisitor, + defaultExportVisitor, + importsVisitor, + jsxVisitor, +} from './visitors'; + +/** + * Video.js React Skin compiler configuration + * Defines HOW to process each concern through the 3-phase pipeline + * + * Structure: + * - phases: Analysis (Phase 1) and categorization (Phase 2) for each concern + * - classNameProjectors: Element-level class resolution (Phase 3, invoked inline) + * - projectionState: Module-level projection (Phase 3, state-based) + */ +export const videoJSReactSkinConfig: CompilerConfig = { + phases: { + imports: { + // Phase 1: Analysis visitor + visitor: importsVisitor, + + // Phase 2: Categorization predicates (order matters, first match wins) + categories: { + style: [isImportUsedAsStyle], + framework: [isImportFromFrameworkPackage], + 'vjs-core': [isImportFromVJSCore], + 'vjs-icon': [isImportUsedAsIcon, isImportFromVJSPackage], + 'vjs-component': [isImportUsedAsComponent, isImportFromVJSPackage], + external: [], // Fallback + }, + }, + + classNames: { + // Phase 1: Analysis visitor + visitor: classNamesVisitor, + + // Phase 2: Categorization predicates (order matters, first match wins) + categories: { + 'literal-classes': [isClassNameLiteral], + 'component-match': [isClassNameComponentMatch], + 'generic-style': [isClassNameGenericStyle], + }, + }, + + jsx: { + // Phase 1: Analysis visitor + visitor: jsxVisitor, + + // Phase 2: Categorization predicates (order matters, first match wins) + categories: { + 'native-element': [isJSXElementNative], + 'media-container': [isJSXElementMediaContainer], + 'tooltip-root': [isJSXElementTooltipRoot], + 'tooltip-trigger': [isJSXElementTooltipTrigger], + 'tooltip-positioner': [isJSXElementTooltipPositioner], + 'tooltip-popup': [isJSXElementTooltipPopup], + 'tooltip-portal': [isJSXElementTooltipPortal], + 'popover-root': [isJSXElementPopoverRoot], + 'popover-trigger': [isJSXElementPopoverTrigger], + 'popover-positioner': [isJSXElementPopoverPositioner], + 'popover-popup': [isJSXElementPopoverPopup], + 'popover-portal': [isJSXElementPopoverPortal], + 'compound-root': [isJSXElementCompoundRoot], + 'generic-component': [], + }, + }, + + defaultExport: { + // Phase 1: Analysis visitor + visitor: defaultExportVisitor, + + // Phase 2: Categorization predicates + categories: { + 'react-functional-component': [isReactFunctionalComponent], + }, + }, + }, + + /** + * className projectors (element-level, Phase 3) + * Invoked inline during HTML projection to resolve class attributes + */ + classNameProjectors: { + 'literal-classes': projectLiteralClasses, + 'component-match': projectComponentMatch, + 'generic-style': projectGenericStyle, + }, + + /** + * State-based projectors (module-level, Phase 3) + * Type-safe projectors that populate ProjectionState fields + */ + projectionState: { + /** Style variable name convention (static value) */ + styleVariableName: 'styles', + /** Structured imports projector - returns ProjectedImportEntry[] */ + imports: projectImports, + /** Element class name projector - returns string */ + elementClassName: projectElementClassName, + /** Element tag name projector - returns string */ + elementName: projectElementName, + /** CSS template reference projector - returns string */ + css: projectCSS, + /** HTML structure projector - returns ProjectedHTML[] */ + html: projectHTML, + }, + + /** + * Module composition (final output generation, Phase 3) + * Composes complete projection state into final module source code + */ + composeModule, +}; diff --git a/packages/compiler/src/configs/videojs-react-skin/index.ts b/packages/compiler/src/configs/videojs-react-skin/index.ts new file mode 100644 index 000000000..08d38564b --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/index.ts @@ -0,0 +1,24 @@ +/** + * Videojs React Skin Configuration + * + * Complete compiler configuration for compiling @videojs/react skins + * to vanilla web components. + * + * This is a use-case-specific configuration, not generic infrastructure. + * For other component libraries or output formats, create a new config. + * + * TYPES: No types are re-exported from this module. Import types directly from: + * - Config-specific types: '../types' + * - Phase types: '../../phases/types' + * - Generic types: '../../types' + */ + +// Complete compiler configuration for @videojs/react skins +export { videoJSReactSkinConfig } from './config'; +// Alias for backward compatibility (this is currently the only/default config) +export { videoJSReactSkinConfig as defaultCompilerConfig } from './config'; + +export * from './outputs'; +export * from './predicates'; +export * from './projectors'; +export * from './visitors'; diff --git a/packages/compiler/src/configs/videojs-react-skin/outputs/compose-module.ts b/packages/compiler/src/configs/videojs-react-skin/outputs/compose-module.ts new file mode 100644 index 000000000..068d3bcf0 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/outputs/compose-module.ts @@ -0,0 +1,59 @@ +/** + * Module Template Composer + * + * Phase 3: Projection + * Composes projection context into complete HTML skin module + * Uses template substitution with accumulated projection data + */ + +import type { ProjectionState } from '../../types'; +import { formatHTML } from '../../../utils/formatters/html'; +import { formatImports } from '../../../utils/formatters/imports'; + +/** + * Compose complete HTML skin module from projection state + * Uses fixed template structure that matches HTML package skins + * Handles final formatting (e.g., joining import lines) + * + * Expects complete projection state - all accumulator fields must be populated + * This should be guaranteed by projectModule(), but runtime validation provides safety + * + * @param projection - Projection state (expected to be complete with all fields populated) + * @returns Complete module source code + */ +export function composeModule(projection: ProjectionState): string { + // Runtime validation - ensure all required fields are present + if (!projection.imports || projection.imports.length === 0) { + throw new Error('Projection incomplete: imports array is empty or undefined'); + } + if (!projection.elementClassName) { + throw new Error('Projection incomplete: elementClassName is missing'); + } + if (!projection.elementName) { + throw new Error('Projection incomplete: elementName is missing'); + } + if (projection.html === undefined) { + throw new Error('Projection incomplete: html is missing'); + } + if (projection.css === undefined) { + throw new Error('Projection incomplete: css is missing'); + } + + return `${formatImports(projection.imports)} + +export function getTemplateHTML() { + return /* html */\` + \${MediaSkinElement.getTemplateHTML()} + + + ${formatHTML(projection.html, { depth: 2, indentStyle: ' ' })} + \`; +} + +export class ${projection.elementClassName} extends MediaSkinElement { + static getTemplateHTML: () => string = getTemplateHTML; +} + +defineCustomElement('${projection.elementName}', ${projection.elementClassName}); +`; +} diff --git a/packages/compiler/src/configs/videojs-react-skin/outputs/index.ts b/packages/compiler/src/configs/videojs-react-skin/outputs/index.ts new file mode 100644 index 000000000..09579d2cf --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/outputs/index.ts @@ -0,0 +1,8 @@ +/** + * Videojs React Skin - Module Composition + * + * HTML skin module template generation + * Composes final output from projected state + */ + +export { composeModule } from './compose-module'; diff --git a/packages/compiler/src/configs/videojs-react-skin/predicates/class-names.ts b/packages/compiler/src/configs/videojs-react-skin/predicates/class-names.ts new file mode 100644 index 000000000..0c10c9731 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/predicates/class-names.ts @@ -0,0 +1,45 @@ +/** + * className Categorization Predicates + * + * Phase 2: Categorization + * Predicates for determining className categories + * All predicates follow normalized signature: (entity, context) => boolean + */ + +import type { AnalyzedContext, ClassNameUsage } from '../../types'; + +/** + * Check if className key matches component identifier + * For member expressions only - exact match indicates redundancy with element selector + */ +export function isClassNameComponentMatch( + classNameUsage: ClassNameUsage, + _context: AnalyzedContext, +): boolean { + // Member expression: check exact match + return 'key' in classNameUsage && classNameUsage.key === classNameUsage.component.identifier; +} + +/** + * Check if className usage is a string literal + * String literals are utility classes that need special handling + */ +export function isClassNameLiteral( + classNameUsage: ClassNameUsage, + _context: AnalyzedContext, +): boolean { + return classNameUsage.type === 'string-literal'; +} + +/** + * Check if className is a generic style (member expression) + * This is the base case for CSS module references + * Matches any member expression, including those that match the component identifier + */ +export function isClassNameGenericStyle( + classNameUsage: ClassNameUsage, + _context: AnalyzedContext, +): boolean { + // Must be a member expression (has identifier) + return classNameUsage.type === 'member-expression'; +} diff --git a/packages/compiler/src/configs/videojs-react-skin/predicates/default-export.ts b/packages/compiler/src/configs/videojs-react-skin/predicates/default-export.ts new file mode 100644 index 000000000..576bba19a --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/predicates/default-export.ts @@ -0,0 +1,28 @@ +/** + * Default Export Predicates + * + * Phase 2: Categorization + * Predicates for categorizing default export component types + */ + +import type { AnalyzedContext, DefaultExportUsage } from '../../types'; + +/** + * Check if default export is a React functional component + * Identifies arrow functions, function declarations, and identifiers + * + * We support functional components as the standard modern React pattern. + * The analysis successfully extracts component name for all supported patterns. + * + * @param _entity - Default export usage (unused - we accept all default exports) + * @param _context - Full usage context (unused) + * @returns Always true (all default exports are treated as functional components) + */ +export function isReactFunctionalComponent( + _entity: DefaultExportUsage, + _context: AnalyzedContext, +): boolean { + // All default exports are categorized as functional components + // This predicate exists for consistency with the config-driven architecture + return true; +} diff --git a/packages/compiler/src/configs/videojs-react-skin/predicates/imports.ts b/packages/compiler/src/configs/videojs-react-skin/predicates/imports.ts new file mode 100644 index 000000000..3a706acb4 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/predicates/imports.ts @@ -0,0 +1,155 @@ +/** + * Categorization Predicates + * + * Phase 2: Categorization + * Default predicate functions for analyzing imports and style keys + * All predicates are configurable via CategorizationConfig + */ + +import type { AnalyzedContext, ImportUsage, JSXElementNode, JSXUsage } from '../../types'; + +/** + * Type guard to check if JSXUsage is a JSXElementNode + */ +function isJSXElement(node: JSXUsage): node is JSXElementNode { + return node.type === 'element'; +} + +/** + * Check if identifier exists anywhere in JSX tree + * Recursive search through children + */ +function hasIdentifierInTree(tree: JSXUsage, identifier: string): boolean { + // Only check element nodes (text and expression nodes don't have identifiers) + if (!isJSXElement(tree)) { + return false; + } + + // Check current element + if (tree.identifier === identifier) { + return true; + } + + // Recursively check children + if (tree.children) { + return tree.children.some(child => hasIdentifierInTree(child, identifier)); + } + + return false; +} + +/** + * Check if import source is a VJS package + * Matches: @videojs/*, @/* (path aliases) + * + * Note: Takes importUsage and context for normalized signature + * (context unused but keeps interface consistent) + */ +export function isImportFromVJSPackage( + importUsage: ImportUsage, + _context: AnalyzedContext, +): boolean { + const { source } = importUsage; + return ( + source.startsWith('@videojs/') + || source.startsWith('@/') // Path alias (e.g., @/icons, @/components) + ); +} + +/** + * Check if import source is a framework package + * Matches: react, react-dom, react/* + */ +export function isImportFromFrameworkPackage( + importUsage: ImportUsage, + _context: AnalyzedContext, +): boolean { + const { source } = importUsage; + return source === 'react' || source === 'react-dom' || source.startsWith('react/'); +} + +/** + * Check if import source is VJS core (platform-agnostic) + * Matches: @videojs/core, @videojs/utils + */ +export function isImportFromVJSCore( + importUsage: ImportUsage, + _context: AnalyzedContext, +): boolean { + const { source } = importUsage; + return ( + source === '@videojs/core' + || source === '@videojs/utils' + ); +} + +/** + * Check if import is used as a component (appears in JSX) + */ +export function isImportUsedAsComponent( + importUsage: ImportUsage, + context: AnalyzedContext, +): boolean { + // Collect all specifier names from this import + const allSpecifiers = [ + importUsage.specifiers.default, + ...importUsage.specifiers.named, + importUsage.specifiers.namespace, + ].filter((s): s is string => s !== undefined); + + // Check if any specifier is used as JSX element identifier in the tree + return allSpecifiers.some(spec => context.jsx && hasIdentifierInTree(context.jsx, spec)); +} + +/** + * Check if import is used as an icon (appears in JSX from icon package) + * Icons are a subcategory of components - this is more specific than isImportUsedAsComponent + */ +export function isImportUsedAsIcon( + importUsage: ImportUsage, + context: AnalyzedContext, +): boolean { + // Must be used as a component first + if (!isImportUsedAsComponent(importUsage, context)) { + return false; + } + + const { source } = importUsage; + + // Check if from icon package (package name ends with -icons or contains /icons) + if (source.endsWith('-icons') || source.includes('/icons')) { + return true; + } + + // Check if all specifiers follow icon naming convention (end with 'Icon') + const allSpecifiers = [ + importUsage.specifiers.default, + ...importUsage.specifiers.named, + importUsage.specifiers.namespace, + ].filter((s): s is string => s !== undefined); + + // All specifiers must end with 'Icon' + return allSpecifiers.length > 0 && allSpecifiers.every(spec => spec.endsWith('Icon')); +} + +/** + * Check if import is used for styles (used in className) + */ +export function isImportUsedAsStyle( + importUsage: ImportUsage, + context: AnalyzedContext, +): boolean { + // Collect all specifier names from this import + const allSpecifiers = [ + importUsage.specifiers.default, + ...importUsage.specifiers.named, + importUsage.specifiers.namespace, + ].filter((s): s is string => s !== undefined); + + // Check if any specifier is used as className identifier (member expressions only) + return allSpecifiers.some(spec => + context.classNames?.some(cn => + cn.type === 'member-expression' && cn.identifier === spec, + ), + ); +} diff --git a/packages/compiler/src/configs/videojs-react-skin/predicates/index.ts b/packages/compiler/src/configs/videojs-react-skin/predicates/index.ts new file mode 100644 index 000000000..7e31b60c3 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/predicates/index.ts @@ -0,0 +1,41 @@ +/** + * Videojs React Skin - Categorization Predicates + * + * Phase 2 predicates for @videojs/react skin compilation + * Categorizes usage patterns specific to @videojs/react components + */ + +export { + isClassNameComponentMatch, + isClassNameGenericStyle, + isClassNameLiteral, +} from './class-names'; + +export { + isReactFunctionalComponent, +} from './default-export'; + +export { + isImportFromFrameworkPackage, + isImportFromVJSCore, + isImportFromVJSPackage, + isImportUsedAsComponent, + isImportUsedAsIcon, + isImportUsedAsStyle, +} from './imports'; + +export { + isJSXElementCompoundRoot, + isJSXElementMediaContainer, + isJSXElementNative, + isJSXElementPopoverPopup, + isJSXElementPopoverPortal, + isJSXElementPopoverPositioner, + isJSXElementPopoverRoot, + isJSXElementPopoverTrigger, + isJSXElementTooltipPopup, + isJSXElementTooltipPortal, + isJSXElementTooltipPositioner, + isJSXElementTooltipRoot, + isJSXElementTooltipTrigger, +} from './jsx'; diff --git a/packages/compiler/src/configs/videojs-react-skin/predicates/jsx.ts b/packages/compiler/src/configs/videojs-react-skin/predicates/jsx.ts new file mode 100644 index 000000000..770a5949b --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/predicates/jsx.ts @@ -0,0 +1,221 @@ +/** + * JSX Element Categorization Predicates + * + * Phase 2: Categorization + * Predicates for determining JSX element categories + * All predicates follow normalized signature: (entity, context) => boolean + */ + +import type { AnalyzedContext, JSXElementNode, JSXUsage } from '../../types'; + +/** + * Type guard to check if JSXUsage is a JSXElementNode + */ +function isJSXElement(node: JSXUsage): node is JSXElementNode { + return node.type === 'element'; +} + +/** + * Check if identifier with member exists anywhere in JSX tree + * Recursive search through children + */ +function hasIdentifierWithMember(tree: JSXUsage, identifier: string): boolean { + // Only check element nodes (text and expression nodes don't have identifiers) + if (!isJSXElement(tree)) { + return false; + } + + // Check current element + if (tree.identifier === identifier && tree.member !== undefined) { + return true; + } + + // Recursively check children + if (tree.children) { + return tree.children.some(child => + hasIdentifierWithMember(child, identifier), + ); + } + + return false; +} + +/** + * Check if JSX element is a native HTML element + * Native elements are not imported - they're built-in HTML elements (div, button, span) + * Optionally checks for hyphenated names (custom elements) for extra safety + */ +export function isJSXElementNative( + jsx: JSXUsage, + context: AnalyzedContext, +): boolean { + // Must be an element node + if (!isJSXElement(jsx)) { + return false; + } + + // Must not have member (not TimeSlider.Root) + if (jsx.member !== undefined) { + return false; + } + + // Check if this identifier is imported + const isImported = context.imports?.some(imp => + imp.specifiers.default === jsx.identifier + || imp.specifiers.named.includes(jsx.identifier) + || imp.specifiers.namespace === jsx.identifier, + ) ?? false; + + // If it's imported, it's not a native element + if (isImported) { + return false; + } + + // Extra safety: exclude custom elements (hyphenated names like my-element) + // We don't anticipate supporting custom elements in skins yet + if (jsx.identifier.includes('-')) { + return false; + } + + // Not imported and not hyphenated = native HTML element + return true; +} + +/** + * Check if JSX element is a MediaContainer + * MediaContainer is special - wraps media element with slot="media" + */ +export function isJSXElementMediaContainer( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'MediaContainer' && jsx.member === undefined; +} + +/** + * Check if JSX element is a compound component root + * Compound root: Has matching children with member syntax (TimeSlider + TimeSlider.Root) + */ +export function isJSXElementCompoundRoot( + jsx: JSXUsage, + context: AnalyzedContext, +): boolean { + // Must be an element node + if (!isJSXElement(jsx)) { + return false; + } + + // Must not have member itself + if (jsx.member !== undefined) { + return false; + } + + // Must be a component (uppercase) + const firstChar = jsx.identifier[0]; + if (jsx.identifier.length === 0 || firstChar === undefined || firstChar !== firstChar.toUpperCase()) { + return false; + } + + // Check if there are any JSX usages with this identifier + member in the tree + return context.jsx ? hasIdentifierWithMember(context.jsx, jsx.identifier) : false; +} + +/** + * Check if JSX element is Tooltip.Root + */ +export function isJSXElementTooltipRoot( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Tooltip' && jsx.member === 'Root'; +} + +/** + * Check if JSX element is Tooltip.Trigger + */ +export function isJSXElementTooltipTrigger( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Tooltip' && jsx.member === 'Trigger'; +} + +/** + * Check if JSX element is Tooltip.Positioner + */ +export function isJSXElementTooltipPositioner( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Tooltip' && jsx.member === 'Positioner'; +} + +/** + * Check if JSX element is Tooltip.Popup + */ +export function isJSXElementTooltipPopup( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Tooltip' && jsx.member === 'Popup'; +} + +/** + * Check if JSX element is Tooltip.Portal + */ +export function isJSXElementTooltipPortal( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Tooltip' && jsx.member === 'Portal'; +} + +/** + * Check if JSX element is Popover.Root + */ +export function isJSXElementPopoverRoot( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Popover' && jsx.member === 'Root'; +} + +/** + * Check if JSX element is Popover.Trigger + */ +export function isJSXElementPopoverTrigger( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Popover' && jsx.member === 'Trigger'; +} + +/** + * Check if JSX element is Popover.Positioner + */ +export function isJSXElementPopoverPositioner( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Popover' && jsx.member === 'Positioner'; +} + +/** + * Check if JSX element is Popover.Popup + */ +export function isJSXElementPopoverPopup( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Popover' && jsx.member === 'Popup'; +} + +/** + * Check if JSX element is Popover.Portal + */ +export function isJSXElementPopoverPortal( + jsx: JSXUsage, + _context: AnalyzedContext, +): boolean { + return isJSXElement(jsx) && jsx.identifier === 'Popover' && jsx.member === 'Portal'; +} diff --git a/packages/compiler/src/configs/videojs-react-skin/projectors/classnames.ts b/packages/compiler/src/configs/videojs-react-skin/projectors/classnames.ts new file mode 100644 index 000000000..e4838ab12 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/projectors/classnames.ts @@ -0,0 +1,70 @@ +/** + * className Projectors + * + * Phase 3: Projection + * Element-level projectors for className values based on category + * Each projector receives accumulated class strings and returns updated array + * Invoked during JSX attribute projection via resolveClassName() + */ + +import type { CategorizedClassName, CategorizedContext, ClassNameProjector, MemberExpressionClassName, StringLiteralClassName } from '../../types'; +import { toKebabCase } from '../../../utils/string-transforms'; + +/** + * Project component-match className + * These classes match the component identifier and should be omitted + * (e.g., - omit "PlayButton" class) + * + * The class is redundant because the custom element name already identifies the component + * + * @returns Unchanged classes array (omits this className) + */ +export const projectComponentMatch: ClassNameProjector = ( + classes: string[], + _className: CategorizedClassName, + _context: CategorizedContext, +): string[] => { + // Return classes unchanged - omit component-match from output + return classes; +}; + +/** + * Project generic-style className + * These are CSS module classes that don't match the component name + * Transforms PascalCase/camelCase keys to kebab-case for CSS consistency + * (e.g., - output class="button") + * + * @returns Classes array with kebab-case transformed key appended + */ +export const projectGenericStyle: ClassNameProjector = ( + classes: string[], + className: CategorizedClassName, + _context: CategorizedContext, +): string[] => { + // Type narrow to member-expression (generic-style is always member-expression) + if (className.type === 'member-expression') { + // Transform key to kebab-case: Button → button, PlayButton → play-button + return [...classes, toKebabCase((className as MemberExpressionClassName).key)]; + } + return classes; +}; + +/** + * Project literal-classes className + * String literal classes passed through as-is + * (e.g.,
- output class="button primary") + * + * @returns Classes array with literal classes appended + */ +export const projectLiteralClasses: ClassNameProjector = ( + classes: string[], + className: CategorizedClassName, + _context: CategorizedContext, +): string[] => { + // Type narrow to string-literal (literal-classes is always string-literal) + if (className.type === 'string-literal') { + // Spread literal classes into array + return [...classes, ...(className as StringLiteralClassName).classes]; + } + return classes; +}; diff --git a/packages/compiler/src/configs/videojs-react-skin/projectors/css.ts b/packages/compiler/src/configs/videojs-react-skin/projectors/css.ts new file mode 100644 index 000000000..d5b43c350 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/projectors/css.ts @@ -0,0 +1,31 @@ +/** + * CSS State Projection + * + * Phase 3: Projection (New Architecture) + * Projects CSS template reference from conventions + * Returns string: "${styles}" + */ + +import type { StateProjector } from '../../../phases/types'; + +/** + * Project CSS template reference + * State-based projector - reads styleVariableName from prevState + * Generates template reference matching HTML package pattern + * + * @param _context - Full categorized context (unused) + * @param prevState - Previous projection state containing styleVariableName + * @returns CSS template reference string + * + * @example + * const css = projectCSS(context, { styleVariableName: 'styles' }); + * // Returns: "${styles}" + */ +export const projectCSS: StateProjector = (_context, prevState, _config) => { + // Read styleVariableName from accumulated projected state + const styleVariableName = prevState.styleVariableName ?? 'styles'; + + // Generate CSS template reference + // This creates: in the final output + return `\${${styleVariableName}}`; +}; diff --git a/packages/compiler/src/configs/videojs-react-skin/projectors/html.ts b/packages/compiler/src/configs/videojs-react-skin/projectors/html.ts new file mode 100644 index 000000000..e84ae3bce --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/projectors/html.ts @@ -0,0 +1,460 @@ +/** + * HTML State Projection + * + * Phase 3: Projection (New Architecture) + * Projects JSX tree to structured HTML data + * Returns ProjectedHTML[] instead of strings + * Formatting happens in separate module (format-html.ts) + */ + +import type { StateProjector } from '../../../phases/types'; +import type { + HTMLAttributeValue, + ProjectedHTML, +} from '../../../types'; +import type { + CategorizedContext, + CategorizedJSXChild, + CategorizedJSXElement, + CompilerConfig, +} from '../../types'; +import { componentNameToElementName } from '../../../utils/component-names'; +import { toKebabCase } from '../../../utils/string-transforms'; +import { CLASSNAME_TRACKED_SEPARATELY } from '../../types'; +import { resolveClassName } from './resolve-classnames'; +import { + extractTriggerChild, + generateTooltipPopoverId, +} from './tooltip-popover-helpers'; + +/** + * Type guard to check if CategorizedJSXChild is a CategorizedJSXElement + */ +function isCategorizedElement(child: CategorizedJSXChild): child is CategorizedJSXElement { + return child.type === 'element'; +} + +// Module-level ID tracking for tooltips/popovers +// TODO: Move to ProjectionState when JSX projectors are fully migrated +const generatedTooltipIds = new Set(); +const generatedPopoverIds = new Set(); + +/** + * Reset tooltip/popover ID tracking + * Called before each module projection + */ +export function resetHTMLIds(): void { + generatedTooltipIds.clear(); + generatedPopoverIds.clear(); +} + +/** + * Project JSX tree to structured HTML + * State-based projector - accesses context.jsx directly + * + * @param context - Full categorized context + * @param _prevState - Previous projection state (accumulated data) + * @param config - Compiler configuration (for classNameProjectors) + * @returns Structured HTML array + */ +export const projectHTML: StateProjector = (context, _prevState, config) => { + // Reset ID tracking for this module + resetHTMLIds(); + + // Project the root JSX element + return projectElement(context.jsx, context, config); +}; + +/** + * Project element name from categorized JSX element + */ +function projectElementName(categorized: CategorizedJSXElement): string { + // Native elements: lowercase identifier + if (categorized.category === 'native-element') { + return categorized.identifier.toLowerCase(); + } + + // Component elements: convert to kebab-case custom element name + const componentName = categorized.member + ? `${categorized.identifier}.${categorized.member}` + : categorized.identifier; + + return componentNameToElementName(componentName); +} + +/** + * Project attributes from categorized JSX element + * Returns structured attributes object + */ +function projectAttributes( + categorized: CategorizedJSXElement, + context: CategorizedContext, + config: CompilerConfig, +): Record { + const attrs: Record = {}; + + for (const [name, value] of Object.entries(categorized.attributes)) { + // Skip React-specific props + if (name === 'children' || name === 'key' || name === 'ref') { + continue; + } + + // Special handling for className + if (value === CLASSNAME_TRACKED_SEPARATELY) { + const resolvedClass = resolveClassName( + categorized, + context.classNames, + config.classNameProjectors, + context, + ); + if (resolvedClass) { + // Split into array for token list representation + attrs.class = resolvedClass.split(' '); + } + continue; + } + + // Transform attribute name to kebab-case + const htmlName = toKebabCase(name); + + // Store attribute with proper type + attrs[htmlName] = value; + } + + return attrs; +} + +/** + * Project children from categorized JSX element + * Works with extracted children data (no AST access needed) + * Returns array of ProjectedHTML | ProjectedHTML[] + */ +function projectChildren( + categorized: CategorizedJSXElement, + context: CategorizedContext, + config: CompilerConfig, +): (ProjectedHTML | ProjectedHTML[])[] { + const children: (ProjectedHTML | ProjectedHTML[])[] = []; + + // Process all extracted children in order + for (const child of categorized.children) { + if (isCategorizedElement(child)) { + // JSX element child - project recursively + const projected = projectElement(child, context, config); + // Flatten or preserve array based on projection result + if (projected.length === 1 && projected[0]) { + children.push(projected[0]); + } else if (projected.length > 1) { + children.push(projected); + } + } else if (child.type === 'text') { + // Text child - create text node + children.push({ + type: 'html', + name: '#text', + attributes: { value: child.value }, + children: [], + }); + } else if (child.type === 'expression') { + // Expression child - handle {children} → slot transformation + if (child.expressionType === 'identifier' && child.identifierName === 'children') { + children.push({ + type: 'html', + name: 'slot', + attributes: { name: 'media', slot: 'media' }, + children: [], + }); + } + // Other expressions ignored for now + } + } + + return children; +} + +/** + * Project single JSX element to ProjectedHTML + * Dispatches to category-specific logic + * Returns array to support multi-element projections (tooltips/popovers) + */ +function projectElement( + categorized: CategorizedJSXElement, + context: CategorizedContext, + config: CompilerConfig, +): ProjectedHTML[] { + // Dispatch based on category + switch (categorized.category) { + case 'native-element': + return projectNativeElement(categorized, context, config); + + case 'media-container': + return projectMediaContainer(categorized, context, config); + + case 'generic-component': + return projectGenericComponent(categorized, context, config); + + case 'compound-root': + return projectCompoundRoot(categorized, context, config); + + case 'tooltip-root': + return projectTooltipRoot(categorized, context, config); + + case 'popover-root': + return projectPopoverRoot(categorized, context, config); + + // Tooltip/popover sub-elements are handled by their root projectors + case 'tooltip-trigger': + case 'tooltip-positioner': + case 'tooltip-popup': + case 'tooltip-portal': + case 'popover-trigger': + case 'popover-positioner': + case 'popover-popup': + case 'popover-portal': + // These should not be called directly - they're processed by root projector + return []; + + default: + // Unknown category - return comment node + return [ + { + type: 'html', + name: '#comment', + attributes: { value: `${categorized.category} projector not implemented` }, + children: [], + }, + ]; + } +} + +/** + * Project native HTML element + */ +function projectNativeElement( + categorized: CategorizedJSXElement, + context: CategorizedContext, + config: CompilerConfig, +): ProjectedHTML[] { + const name = projectElementName(categorized); + const attributes = projectAttributes(categorized, context, config); + const children = projectChildren(categorized, context, config); + + return [ + { + type: 'html', + name, + attributes, + children, + }, + ]; +} + +/** + * Project MediaContainer element + */ +function projectMediaContainer( + categorized: CategorizedJSXElement, + context: CategorizedContext, + config: CompilerConfig, +): ProjectedHTML[] { + const attributes = projectAttributes(categorized, context, config); + const children = projectChildren(categorized, context, config); + + return [ + { + type: 'html', + name: 'media-container', + attributes, + children, + }, + ]; +} + +/** + * Project generic component element + */ +function projectGenericComponent( + categorized: CategorizedJSXElement, + context: CategorizedContext, + config: CompilerConfig, +): ProjectedHTML[] { + const name = projectElementName(categorized); + const attributes = projectAttributes(categorized, context, config); + const children = projectChildren(categorized, context, config); + + return [ + { + type: 'html', + name, + attributes, + children, + }, + ]; +} + +/** + * Project compound root element + * Unwraps compound root and projects its children directly + */ +function projectCompoundRoot( + categorized: CategorizedJSXElement, + context: CategorizedContext, + config: CompilerConfig, +): ProjectedHTML[] { + // Compound roots are unwrapped - project children directly + const children = projectChildren(categorized, context, config); + + // Flatten children into array of ProjectedHTML + return children.flatMap((child) => { + if (Array.isArray(child)) { + return child; + } + return [child]; + }); +} + +/** + * Project Tooltip.Root to trigger + tooltip siblings + * Transforms 5-level React structure into 2 sibling HTML elements + */ +function projectTooltipRoot( + categorized: CategorizedJSXElement, + context: CategorizedContext, + config: CompilerConfig, +): ProjectedHTML[] { + // 1. Extract trigger child element (Root → Trigger → child) + const triggerChild = extractTriggerChild(categorized); + + // 2. Generate unique ID + const id = generateTooltipPopoverId(triggerChild, 'tooltip', generatedTooltipIds); + generatedTooltipIds.add(id); + + // 3. Project trigger child element normally + const [triggerElement] = projectElement(triggerChild, context, config); + if (!triggerElement) throw new Error('projectElement must return at least one element'); + + // 4. Add commandfor attribute to trigger + triggerElement.attributes.commandfor = id; + triggerElement.attributes.command = 'show-tooltip'; + + // 5. Collect attributes from Root, Positioner, Popup + const tooltipAttrs: Record = { + id, + popover: 'manual', + }; + + // Extract attributes from Root element + for (const [key, value] of Object.entries(categorized.attributes)) { + if (key === 'delay' || key === 'trackCursorAxis') { + const htmlName = toKebabCase(key); + tooltipAttrs[htmlName] = value as HTMLAttributeValue; + } + } + + // Find Positioner → Popup to extract their attributes + const positioner = categorized.children.find(child => child.category === 'tooltip-positioner'); + let popupChildren: (ProjectedHTML | ProjectedHTML[])[] = []; + + if (positioner && isCategorizedElement(positioner)) { + // Extract Positioner attributes + for (const [key, value] of Object.entries(positioner.attributes)) { + if (key === 'side' || key === 'sideOffset' || key === 'collisionPadding') { + const htmlName = toKebabCase(key); + tooltipAttrs[htmlName] = value as HTMLAttributeValue; + } + } + + // Find Popup element + const popup = positioner.children.find(child => child.category === 'tooltip-popup'); + if (popup && isCategorizedElement(popup)) { + // Project popup children + popupChildren = projectChildren(popup, context, config); + } + } + + // 6. Create tooltip element + const tooltipElement: ProjectedHTML = { + type: 'html', + name: 'media-tooltip', + attributes: tooltipAttrs, + children: popupChildren, + }; + + // Return trigger + tooltip as siblings + return [triggerElement, tooltipElement]; +} + +/** + * Project Popover.Root to trigger + popover siblings + * Transforms 5-level React structure into 2 sibling HTML elements + */ +function projectPopoverRoot( + categorized: CategorizedJSXElement, + context: CategorizedContext, + config: CompilerConfig, +): ProjectedHTML[] { + // 1. Extract trigger child element (Root → Trigger → child) + const triggerChild = extractTriggerChild(categorized); + + // 2. Generate unique ID + const id = generateTooltipPopoverId(triggerChild, 'popover', generatedPopoverIds); + generatedPopoverIds.add(id); + + // 3. Project trigger child element normally + const [triggerElement] = projectElement(triggerChild, context, config); + if (!triggerElement) throw new Error('projectElement must return at least one element'); + + // 4. Add commandfor/command attributes to trigger (consistent with tooltips) + triggerElement.attributes.commandfor = id; + triggerElement.attributes.command = 'toggle-popover'; + + // 5. Collect attributes from Root, Positioner, Popup + // Check for openOnHover to determine popover type + const hasOpenOnHover = 'openOnHover' in categorized.attributes; + + const popoverAttrs: Record = { + id, + // Use "manual" when openOnHover is true (like tooltips) + popover: hasOpenOnHover ? 'manual' : 'auto', + }; + + // Extract attributes from Root element + for (const [key, value] of Object.entries(categorized.attributes)) { + if (key === 'delay' || key === 'closeDelay' || key === 'openOnHover') { + const htmlName = toKebabCase(key); + popoverAttrs[htmlName] = value as HTMLAttributeValue; + } + } + + // Find Positioner → Popup to extract their attributes + const positioner = categorized.children.find(child => child.category === 'popover-positioner'); + let popupChildren: (ProjectedHTML | ProjectedHTML[])[] = []; + + if (positioner && isCategorizedElement(positioner)) { + // Extract Positioner attributes + for (const [key, value] of Object.entries(positioner.attributes)) { + if (key === 'side' || key === 'sideOffset' || key === 'collisionPadding') { + const htmlName = toKebabCase(key); + popoverAttrs[htmlName] = value as HTMLAttributeValue; + } + } + + // Find Popup element + const popup = positioner.children.find(child => child.category === 'popover-popup'); + if (popup && isCategorizedElement(popup)) { + // Project popup children + popupChildren = projectChildren(popup, context, config); + } + } + + // 6. Create popover element + const popoverElement: ProjectedHTML = { + type: 'html', + name: 'media-popover', + attributes: popoverAttrs, + children: popupChildren, + }; + + // Return trigger + popover as siblings + return [triggerElement, popoverElement]; +} diff --git a/packages/compiler/src/configs/videojs-react-skin/projectors/imports.ts b/packages/compiler/src/configs/videojs-react-skin/projectors/imports.ts new file mode 100644 index 000000000..f315ab9ef --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/projectors/imports.ts @@ -0,0 +1,147 @@ +/** + * Structured Imports Projection (New Architecture) + * + * Phase 3: Projection + * Projects categorized imports to structured output (ProjectedImportEntry[]) + * Returns pseudo-AST that preserves formatting information + * + * Key differences from project-imports.ts: + * - Returns structured data (ProjectedImportEntry[]) instead of strings + * - Single function instead of per-category projectors + * - Accesses context.imports directly (decoupled from usage entities) + * - Uses groupBy/filter for cleaner logic + * - Inline comment/import additions + */ + +import type { StateProjector } from '../../../phases/types'; +import type { ProjectedImportEntry } from '../../../types'; +import type { CategorizedImport } from '../../types'; +import { componentNameToElementName } from '../../../utils/component-names'; + +type StyleImport = CategorizedImport & { category: 'style' }; +type VJSComponentImport = CategorizedImport & { category: 'vjs-component' }; +type VJSIconImport = CategorizedImport & { category: 'vjs-icon' }; + +/** + * Project imports to structured output + * + * Transformation rules by category: + * - vjs-core: Removed (base imports added separately) + * - framework: Removed (React/PropsWithChildren not needed) + * - style: Transform to CSS import with 'styles' default + * - vjs-component: Each component → separate @/define/element-name side-effect import + * - vjs-icon: All icons → single @/icons side-effect import (Many:1 deduplication) + * - external: Preserved as-is + * + * Output structure: + * 1. Base imports (MediaSkinElement, defineCustomElement) + * 2. Style imports + * 3. video-provider comment + import + * 4. Component define imports (sorted) + * 5. Icons import (if icons used) + */ +export const projectImports: StateProjector = ( + context, + prevProjectionState, + _config, +) => { + const imports = context.imports ?? []; + + // Pre-filter imports by category for type safety + const styleImports: StyleImport[] = imports + .filter((i: CategorizedImport): i is StyleImport => i.category === 'style'); + const vjsComponentImports: VJSComponentImport[] = imports + .filter((i: CategorizedImport): i is VJSComponentImport => i.category === 'vjs-component'); + const hasIconImports = imports + .some((i: CategorizedImport): i is VJSIconImport => i.category === 'vjs-icon'); + + return [ + // 1. Base framework imports (always first) + // NOTE: If preferred, this could also simply be an import line string, i.e.: + // import { MediaSkinElement } from '@/media/media-skin'; + { + type: 'import', + source: '@/media/media-skin', + specifiers: [{ type: 'named', name: 'MediaSkinElement' }], + }, + { + type: 'import' as const, + source: '@/utils/custom-element', + specifiers: [{ type: 'named', name: 'defineCustomElement' }], + }, + // 2. Style imports (CSS) + ...(styleImports + .map((styleImport: StyleImport, i: number) => { + // NOTE: We may want to abstract "validation" in a way that is less ad hoc. This would be an example of validation + // that would be expected after phase 2 but before projectedImports is invoked in phase 3. + if (i > 0) { + console.warn(`Multiple imports identified as category ${styleImport.category}. Currently unsupported. Ignoring.`); + return undefined; + } + + const source = styleImport.source; + const cssSource = source.endsWith('.ts') || source.endsWith('.tsx') + ? source.replace(/\.tsx?$/, '.css') + : `${source}.css`; + + return { + type: 'import', + source: cssSource, + // NOTE: Here is an example of shared projection state. This is also currently used in `projectCSS()` + specifiers: [{ type: 'default', name: prevProjectionState.styleVariableName }], + }; + }) + .filter(Boolean)), + // 3. video-provider block (should be imported before components or icons used) + { + type: 'comment', + style: 'line', + value: 'be sure to import video-provider first for proper context initialization', + }, + { + type: 'import', + source: '@/define/video-provider', + specifiers: [], // Side-effect import + }, + // 4. Component define imports (sorted by element name) + ...vjsComponentImports + .flatMap((vjsComponentImport: VJSComponentImport): string[] => { + // NOTE: We could refactor this to split the import path of the source instead of relying on the name (including variable name for defaults) + // of the imported value here. + if (vjsComponentImport.specifiers.default) { + return [vjsComponentImport.specifiers.default]; + } else if (vjsComponentImport.specifiers.named.length) { + return vjsComponentImport.specifiers.named; + } + + // NOTE: We may want to abstract "validation" in a way that is less ad hoc. This would be an example of validation + // that would be expected after phase 2 but before projectedImports is invoked in phase 3. + console.warn(`VJS Component imports only support mappings for default or named imports but encountered something else. Ignoring.`); + return []; + }) + .sort() // sort by element name + .map((componentName: string) => { + const elementName = componentNameToElementName(componentName); + return { + type: 'import', + source: `@/define/${elementName}`, + specifiers: [], // Side-effect import + }; + }), + // 5. Icons import (if any icon usage) + // Many:1 - all icon imports collapse to single side-effect import + // NOTE: When we have disparate icon sets, this will likely need to be updated + ...(hasIconImports + ? [{ + type: 'import', + source: '@/icons', + specifiers: [], // Side-effect import + }] + : [] + ), + // NOTE: There are currently defined categories that are not projected based on current skin needs: + // - framework (would likely always be excluded, though there may be some mappings from react -> another framework, e.g. svelte) + // - vjs-core (may need in the future. use above examples for reference) + // - external (currently not used by react skins, though we may need to support this in the future) + ] as ProjectedImportEntry[]; +}; diff --git a/packages/compiler/src/configs/videojs-react-skin/projectors/index.ts b/packages/compiler/src/configs/videojs-react-skin/projectors/index.ts new file mode 100644 index 000000000..3e24658c0 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/projectors/index.ts @@ -0,0 +1,34 @@ +/** + * Videojs React Skin - Phase 3 Projectors + * + * HTML-specific transformation logic for @videojs/react skin compilation + * Transforms categorized usage into HTML output structures + */ + +// className projectors (element-level) +export { + projectComponentMatch, + projectGenericStyle, + projectLiteralClasses, +} from './classnames'; + +// CSS template reference projector +export { projectCSS } from './css'; + +// HTML structure projector +export { projectHTML, resetHTMLIds } from './html'; + +// Import projector +export { projectImports } from './imports'; + +// Element naming projectors +export { projectElementClassName, projectElementName } from './names'; + +// Transformation helpers (shared utilities) +export { + collectTooltipPopoverAttributes, + extractTriggerChild, + generateTooltipPopoverId, + injectCommandForAttribute, + transformPropToAttribute, +} from './tooltip-popover-helpers'; diff --git a/packages/compiler/src/configs/videojs-react-skin/projectors/names.ts b/packages/compiler/src/configs/videojs-react-skin/projectors/names.ts new file mode 100644 index 000000000..ea5ea9737 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/projectors/names.ts @@ -0,0 +1,48 @@ +/** + * Names State Projection + * + * Phase 3: Projection (New Architecture) + * Projects element names from default export + * Two simple projectors: elementClassName and elementName + */ + +import type { StateProjector } from '../../../phases/types'; +import { componentNameToClassName, componentNameToSkinElementName } from '../../../utils/component-names'; + +/** + * Project element class name from default export + * Converts React component name to HTML skin class name + * Removes "Skin" suffix and wraps in MediaSkin...Element pattern + * + * @param context - Full categorized context + * @param _prevState - Previous projection state (unused) + * @returns Element class name + * + * @example + * projectElementClassName(context, {}) + * // FrostedSkin → "MediaSkinFrostedElement" + * // MinimalSkin → "MediaSkinMinimalElement" + * // MySkin → "MediaSkinMyElement" + */ +export const projectElementClassName: StateProjector = (context, _prevState, _config) => { + return componentNameToClassName(context.defaultExport.componentName); +}; + +/** + * Project element tag name from default export + * Converts React component name to skin custom element tag name + * Removes "Skin" suffix and converts to kebab-case with media-skin- prefix + * + * @param context - Full categorized context + * @param _prevState - Previous projection state (unused) + * @returns Element tag name + * + * @example + * projectElementName(context, {}) + * // FrostedSkin → "media-skin-frosted" + * // MinimalSkin → "media-skin-minimal" + * // MySkin → "media-skin-my" + */ +export const projectElementName: StateProjector = (context, _prevState, _config) => { + return componentNameToSkinElementName(context.defaultExport.componentName); +}; diff --git a/packages/compiler/src/configs/videojs-react-skin/projectors/resolve-classnames.ts b/packages/compiler/src/configs/videojs-react-skin/projectors/resolve-classnames.ts new file mode 100644 index 000000000..b64b24301 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/projectors/resolve-classnames.ts @@ -0,0 +1,56 @@ +/** + * className Resolution + * + * Resolves className attribute for JSX elements + * Filters and reduces classNames through category-specific projectors + */ + +import type { CategorizedClassName, CategorizedContext, CategorizedJSXElement, ClassNameCategory, ClassNameProjector } from '../../types'; + +/** + * Resolve className attribute for a JSX element + * Filters classNames for this element and reduces through category-specific projectors + * Uses reducer pattern to accumulate class strings via projectors + * + * @param element - Categorized JSX element + * @param classNames - All classNames from context + * @param projectorConfig - Category → projector mapping + * @param context - Full categorized context (for projectors) + * @returns Space-separated class string or empty string + * + * @example + * // + * // className categorized as 'generic-style' + * // projectGenericStyle invoked → "button" + * resolveClassName(element, classNames, projectors, context) // → "button" + * + * @example + * // + * // className categorized as 'component-match' + * // projectComponentMatch invoked → omitted + * resolveClassName(element, classNames, projectors, context) // → "" + */ +export function resolveClassName( + element: CategorizedJSXElement, + classNames: CategorizedClassName[], + projectorConfig: Record, + context: CategorizedContext, +): string { + // Filter classNames for this element (by node reference identity) + // Match component.node (JSXOpeningElement) with element.node (JSXElement) + const elementClassNames = classNames.filter(cn => + cn.component.node.node === element.node?.node.openingElement, + ); + + // Reduce classNames through category-specific projectors + const classes = elementClassNames.reduce((acc, cn) => { + const projector = projectorConfig[cn.category]; + if (!projector) { + console.warn(`No projector for className category: ${cn.category}`); + return acc; + } + return projector(acc, cn, context); + }, []); + + return classes.join(' '); +} diff --git a/packages/compiler/src/configs/videojs-react-skin/projectors/tooltip-popover-helpers.ts b/packages/compiler/src/configs/videojs-react-skin/projectors/tooltip-popover-helpers.ts new file mode 100644 index 000000000..7ad8d49b0 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/projectors/tooltip-popover-helpers.ts @@ -0,0 +1,277 @@ +/** + * Tooltip & Popover Projection Helpers + * + * Shared utilities for transforming React tooltip/popover structures + * into flat HTML with commandfor attributes and unique IDs + */ + +import type { CategorizedJSXChild, CategorizedJSXElement } from '../../types'; +import { toKebabCase } from '../../../utils/string-transforms'; + +/** Type guard to check if a JSX child is a categorized element (not text/expression) */ +function isCategorizedElement(child: CategorizedJSXChild): child is CategorizedJSXElement { + return 'identifier' in child && 'attributes' in child; +} + +/** + * Extract trigger child element from tooltip/popover Root + * Navigates: Root → Trigger → child element + * + * @param rootElement - Categorized Root element + * @returns The child element wrapped by Trigger + * @throws Error if Trigger not found or has no children + */ +export function extractTriggerChild(rootElement: CategorizedJSXElement): CategorizedJSXElement { + // Find Trigger child in Root's children + const trigger = rootElement.children.find(child => + child.category === 'tooltip-trigger' || child.category === 'popover-trigger', + ); + + if (!trigger) { + throw new Error( + `Tooltip/Popover Root must have a Trigger child. Found categories: ${rootElement.children.map(c => c.category).join(', ')}`, + ); + } + + if (!isCategorizedElement(trigger)) { + throw new Error('Tooltip/Popover Trigger must be an element, not text or expression'); + } + + // Trigger must have exactly one child + if (trigger.children.length === 0) { + throw new Error('Tooltip/Popover Trigger must wrap a child element'); + } + + if (trigger.children.length > 1) { + console.warn( + `Tooltip/Popover Trigger has ${trigger.children.length} children, using first child only`, + ); + } + + const triggerChild = trigger.children[0]; + if (!triggerChild || !isCategorizedElement(triggerChild)) { + throw new Error('Tooltip/Popover Trigger child must be an element, not text or expression'); + } + + return triggerChild; +} + +/** + * Generate unique ID for tooltip/popover element + * Pattern: {element-name}-{tooltip|popover} + * If duplicate exists, append counter: -2, -3, etc. + * + * @param triggerElement - The trigger child element + * @param type - 'tooltip' or 'popover' + * @param existingIds - Set of already-generated IDs for uniqueness check + * @returns Unique ID string + * + * @example + * generateTooltipPopoverId(playButton, 'tooltip', new Set()) + * // → 'play-button-tooltip' + * + * @example + * generateTooltipPopoverId(playButton, 'tooltip', new Set(['play-button-tooltip'])) + * // → 'play-button-tooltip-2' + */ +export function generateTooltipPopoverId( + triggerElement: CategorizedJSXElement, + type: 'tooltip' | 'popover', + existingIds: Set, +): string { + // Extract element name from identifier + const elementName = toKebabCase( + triggerElement.identifier || 'element', + ); + const baseId = `${elementName}-${type}`; + + // If base ID is unique, use it + if (!existingIds.has(baseId)) { + return baseId; + } + + // Otherwise, find the next available number + let counter = 2; + while (existingIds.has(`${baseId}-${counter}`)) { + counter++; + } + return `${baseId}-${counter}`; +} + +/** + * Collected attributes from tooltip/popover tree + */ +export interface TooltipPopoverAttributes { + /** Attributes for the tooltip/popover element */ + tooltipPopoverAttrs: Record; + /** HTML content for popup body (from Popup children) */ + popupContent: string; +} + +/** + * Collect attributes from tooltip/popover tree + * Traverses: Root → Portal → Positioner → Popup + * Extracts attributes from Root, Positioner, and Popup + * + * @param rootElement - Categorized Root element + * @param type - 'tooltip' or 'popover' + * @returns Object with collected attributes and popup content + */ +export function collectTooltipPopoverAttributes( + rootElement: CategorizedJSXElement, + type: 'tooltip' | 'popover', +): TooltipPopoverAttributes { + const tooltipPopoverAttrs: Record = {}; + + // Extract attributes from Root + // Common: delay, trackCursorAxis + // Popover-only: closeDelay, openOnHover + if (rootElement.attributes) { + for (const [key, value] of Object.entries(rootElement.attributes)) { + if (key === 'delay' + || key === 'trackCursorAxis' + || (type === 'popover' && (key === 'closeDelay' || key === 'openOnHover'))) { + // Cast to string | boolean (these attributes should never be numbers or symbols) + if (typeof value === 'string' || typeof value === 'boolean') { + tooltipPopoverAttrs[key] = value; + } + } + } + } + + // Find Portal → Positioner → Popup + const portal = rootElement.children.find(child => + child.category === 'tooltip-portal' || child.category === 'popover-portal', + ); + + let positioner: CategorizedJSXElement | undefined; + let popup: CategorizedJSXElement | undefined; + let popupContent = ''; + + if (portal && isCategorizedElement(portal)) { + const positionerChild = portal.children.find(child => + child.category === 'tooltip-positioner' || child.category === 'popover-positioner', + ); + positioner = positionerChild && isCategorizedElement(positionerChild) ? positionerChild : undefined; + + if (positioner) { + // Extract Positioner attributes: side, sideOffset, collisionPadding + if (positioner.attributes) { + for (const [key, value] of Object.entries(positioner.attributes)) { + if (key === 'side' || key === 'sideOffset' || key === 'collisionPadding') { + // Cast to string | boolean (these attributes should never be numbers or symbols) + if (typeof value === 'string' || typeof value === 'boolean') { + tooltipPopoverAttrs[key] = value; + } + } + } + } + + // Find Popup + const popupChild = positioner.children.find(child => + child.category === 'tooltip-popup' || child.category === 'popover-popup', + ); + popup = popupChild && isCategorizedElement(popupChild) ? popupChild : undefined; + + if (popup) { + // Extract className from Popup (will be projected normally) + if (popup.attributes && popup.attributes.className) { + // Store for later projection - we'll handle this in the projector + const className = popup.attributes.className; + if (typeof className === 'string' || typeof className === 'boolean') { + tooltipPopoverAttrs.className = className; + } + } + + // Popup content will be projected separately by the projector + // Store reference for now + popupContent = ''; + } + } + } + + return { + tooltipPopoverAttrs, + popupContent, + }; +} + +/** + * Inject commandfor attribute into HTML element string + * Optionally inject command="toggle-popover" for popovers + * + * @param elementHTML - HTML string with opening tag + * @param id - ID value for commandfor attribute + * @param addCommand - If true, also add command="toggle-popover" + * @returns Modified HTML string with injected attributes + * + * @example + * injectCommandForAttribute('', 'play-tooltip') + * // → '' + * + * @example + * injectCommandForAttribute('', 'settings-popover', true) + * // → '' + */ +export function injectCommandForAttribute( + elementHTML: string, + id: string, + addCommand = false, +): string { + // Find the opening tag - match from < to first > that's not in a string + const openTagMatch = elementHTML.match(/^<([a-z][\w-]*)((?:\s+[\w-]+(?:="[^"]*")?)*)(\/?)>/i); + + if (!openTagMatch) { + throw new Error(`Cannot parse HTML opening tag: ${elementHTML.substring(0, 50)}`); + } + + const [fullMatch, tagName, existingAttrs, selfClosing] = openTagMatch; + + // Build new attributes + let newAttrs = ` commandfor="${id}"`; + if (addCommand) { + newAttrs += ` command="toggle-popover"`; + } + + // Reconstruct opening tag with new attributes + const newOpenTag = `<${tagName}${newAttrs}${existingAttrs}${selfClosing}>`; + + // Replace opening tag in original HTML + return elementHTML.replace(fullMatch, newOpenTag); +} + +/** + * Transform React prop to HTML attribute + * Converts camelCase to kebab-case + * Handles boolean, number, and string values + * + * @param key - Prop name (camelCase) + * @param value - Prop value + * @returns Formatted attribute string or null if omitted + * + * @example + * transformPropToAttribute('sideOffset', 12) + * // → 'side-offset="12"' + * + * @example + * transformPropToAttribute('openOnHover', true) + * // → 'open-on-hover' + * + * @example + * transformPropToAttribute('disabled', false) + * // → null + */ +export function transformPropToAttribute( + key: string, + value: string | number | boolean, +): string | null { + const attrName = toKebabCase(key); + + // Boolean handling + if (typeof value === 'boolean') { + return value ? attrName : null; + } + + // Number/String - quote the value + return `${attrName}="${value}"`; +} diff --git a/packages/compiler/src/configs/videojs-react-skin/visitors/class-names.ts b/packages/compiler/src/configs/videojs-react-skin/visitors/class-names.ts new file mode 100644 index 000000000..5187f28f2 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/visitors/class-names.ts @@ -0,0 +1,131 @@ +/** + * ExtractClassNameUsage Visitor + * + * Phase 1: Identification + * Extracts style object member access in className attributes + * Captures: style identifier, key, and className attribute node + */ + +import type { NodePath } from '@babel/traverse'; +import type * as BabelTypes from '@babel/types'; +import type { AnalysisVisitors } from '../../../phases/types'; +import type { ClassNameUsage } from '../../types'; +import * as t from '@babel/types'; + +const classNamesVisitor: AnalysisVisitors = { + JSXAttribute: ( + prevClassNames: ClassNameUsage[] = [], + path: NodePath, + ): ClassNameUsage[] => { + // Only process className attributes + if (!t.isJSXIdentifier(path.node.name) || path.node.name.name !== 'className') { + return prevClassNames; + } + + const value = path.node.value; + if (!value) return prevClassNames; + + // Find parent JSX element to get component info + let jsxElement: NodePath | null = path.parentPath; + while (jsxElement && !jsxElement.isJSXOpeningElement()) { + jsxElement = jsxElement.parentPath; + } + + if (!jsxElement || !jsxElement.isJSXOpeningElement()) { + return prevClassNames; // Can't find parent JSX element + } + + const elementName = jsxElement.node.name; + let componentIdentifier: string; + let componentMember: string | undefined; + + if (elementName.type === 'JSXIdentifier') { + componentIdentifier = elementName.name; + } else if (elementName.type === 'JSXMemberExpression') { + // Compound component + let obj = elementName.object; + while (obj.type === 'JSXMemberExpression') { + obj = obj.object; + } + componentIdentifier = obj.type === 'JSXIdentifier' ? obj.name : ''; + componentMember = elementName.property.type === 'JSXIdentifier' ? elementName.property.name : undefined; + } else { + return prevClassNames; + } + + // Extract className usages from value + // Note: Order between member expressions and string literals is not preserved. + // For mixed values like `${styles.A} foo ${styles.B} bar`, we'll produce: + // - Two member expression entries (A, B) in source order + // - One string literal entry with classes: ['foo', 'bar'] in source order + // The relative ordering BETWEEN these types is lost, but this is acceptable + // since CSS specificity is determined by stylesheet order, not class attribute order. + const usages: ClassNameUsage[] = []; + const literalClasses: string[] = []; + + // Build base component reference + const componentRef = componentMember !== undefined + ? { + identifier: componentIdentifier, + member: componentMember, + node: jsxElement as any, + } + : { + identifier: componentIdentifier, + node: jsxElement as any, + }; + + // Helper to extract member expressions and string literals + const extractClassNames = (node: BabelTypes.Node) => { + if (t.isMemberExpression(node)) { + // Member expression: styles.Button + if (t.isIdentifier(node.object) && t.isIdentifier(node.property)) { + usages.push({ + type: 'member-expression', + identifier: node.object.name, + key: node.property.name, + node: path as any, + component: componentRef, + }); + } + } else if (t.isStringLiteral(node)) { + // String literal: "button primary" + const classes = node.value.split(/\s+/).filter(Boolean); + literalClasses.push(...classes); + } else if (t.isTemplateLiteral(node)) { + // Template literal: `${styles.Button} active ${styles.Play}` + // Extract from expressions (member expressions) + for (const expr of node.expressions) { + extractClassNames(expr); + } + // Extract from string segments (quasis) + for (const quasi of node.quasis) { + const classes = quasi.value.raw.split(/\s+/).filter(Boolean); + literalClasses.push(...classes); + } + } else if (t.isJSXExpressionContainer(node)) { + // Expression container: className={...} + extractClassNames(node.expression); + } + }; + + extractClassNames(value); + + // Add clustered string literal entry if any literal classes found + if (literalClasses.length > 0) { + usages.push({ + type: 'string-literal', + classes: literalClasses, + literalValue: literalClasses.join(' '), + node: path as any, + component: componentRef, + }); + } + + if (usages.length === 0) return prevClassNames; + + return [...prevClassNames, ...usages]; + }, +}; + +export default classNamesVisitor; diff --git a/packages/compiler/src/configs/videojs-react-skin/visitors/default-export.ts b/packages/compiler/src/configs/videojs-react-skin/visitors/default-export.ts new file mode 100644 index 000000000..f324098c8 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/visitors/default-export.ts @@ -0,0 +1,90 @@ +/** + * ExtractDefaultExport Visitor + * + * Phase 1: Identification + * Extracts component name and root JSX element from default export + * No categorization - just identification + */ + +import type { NodePath } from '@babel/traverse'; +import type * as BabelTypes from '@babel/types'; +import type { AnalysisVisitors } from '../../../phases/types'; +import type { DefaultExportUsage } from '../../types'; +import * as t from '@babel/types'; + +const defaultExportVisitor: AnalysisVisitors = { + ExportDefaultDeclaration: ( + prevExport: DefaultExportUsage | undefined, + path: NodePath, + ): DefaultExportUsage | undefined => { + const declaration = path.node.declaration; + + let componentName = 'UnknownComponent'; + let jsxElement: NodePath | null = null; + + // Extract component name and JSX based on declaration type + if (t.isFunctionDeclaration(declaration) && declaration.id) { + // export default function Foo() { } + componentName = declaration.id.name; + jsxElement = findJSXReturnPath(path.get('declaration.body') as NodePath, t); + } else if (t.isArrowFunctionExpression(declaration) || t.isFunctionExpression(declaration)) { + // export default () => { } or export default function() { } + + // Try to find name from variable declarator + const parentPath = path.parentPath; + if (parentPath && parentPath.isVariableDeclarator()) { + const id = parentPath.node.id; + if (t.isIdentifier(id)) { + componentName = id.name; + } + } + + // Extract JSX from body + if (t.isBlockStatement(declaration.body)) { + const bodyPath = path.get('declaration.body') as NodePath; + jsxElement = findJSXReturnPath(bodyPath, t); + } else if (t.isJSXElement(declaration.body)) { + // Direct JSX return: () =>
+ jsxElement = path.get('declaration.body') as NodePath; + } + } + + if (!jsxElement) { + return prevExport; + } + + const defaultExport: DefaultExportUsage = { + componentName, + node: path as any, + jsxElement: jsxElement as any, + }; + + return defaultExport; + }, +}; + +export default defaultExportVisitor; + +/** + * Find JSX element in return statement + * Returns NodePath to JSX element, not the element itself + */ +function findJSXReturnPath( + bodyPath: NodePath, + _t: typeof BabelTypes, +): NodePath | null { + const statements = bodyPath.get('body'); + + for (const statement of statements) { + if (statement.isReturnStatement()) { + const argument = statement.get('argument'); + if (Array.isArray(argument)) continue; + + if (argument.isJSXElement()) { + return argument; + } + } + } + + return null; +} diff --git a/packages/compiler/src/configs/videojs-react-skin/visitors/imports.ts b/packages/compiler/src/configs/videojs-react-skin/visitors/imports.ts new file mode 100644 index 000000000..5688c5606 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/visitors/imports.ts @@ -0,0 +1,45 @@ +/** + * ExtractImports Visitor + * + * Phase 1: Identification + * Extracts all import statements with source, AST reference, and specifiers + * No categorization or transformation - just facts + */ + +import type { NodePath } from '@babel/traverse'; +import type * as BabelTypes from '@babel/types'; +import type { AnalysisVisitors } from '../../../phases/types'; +import type { ImportUsage } from '../../types'; + +const importsVisitor: AnalysisVisitors = { + ImportDeclaration: ( + prevImports: ImportUsage[] = [], + path: NodePath, + ): ImportUsage[] => { + const source = path.node.source.value; + const specifiers = path.node.specifiers; + + const importUsage: ImportUsage = { + source, + node: path as any, // NodePath type compatible + specifiers: { + named: [], + }, + }; + + // Extract specifiers + for (const spec of specifiers) { + if (spec.type === 'ImportDefaultSpecifier') { + importUsage.specifiers.default = spec.local.name; + } else if (spec.type === 'ImportSpecifier') { + importUsage.specifiers.named.push(spec.local.name); + } else if (spec.type === 'ImportNamespaceSpecifier') { + importUsage.specifiers.namespace = spec.local.name; + } + } + + return [...prevImports, importUsage]; + }, +}; + +export default importsVisitor; diff --git a/packages/compiler/src/configs/videojs-react-skin/visitors/index.ts b/packages/compiler/src/configs/videojs-react-skin/visitors/index.ts new file mode 100644 index 000000000..fc2d4ef4e --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/visitors/index.ts @@ -0,0 +1,11 @@ +/** + * Videojs React Skin - Analysis Visitors + * + * Phase 1 visitors for @videojs/react skin compilation + * Extracts usage facts from React skin source code + */ + +export { default as classNamesVisitor } from './class-names'; +export { default as defaultExportVisitor } from './default-export'; +export { default as importsVisitor } from './imports'; +export { default as jsxVisitor } from './jsx'; diff --git a/packages/compiler/src/configs/videojs-react-skin/visitors/jsx.ts b/packages/compiler/src/configs/videojs-react-skin/visitors/jsx.ts new file mode 100644 index 000000000..5165370e8 --- /dev/null +++ b/packages/compiler/src/configs/videojs-react-skin/visitors/jsx.ts @@ -0,0 +1,250 @@ +/** + * ExtractJSXUsage Visitor + * + * Phase 1: Identification + * Builds tree structure of JSX elements with recursive children + * Extracts basic attributes (string literals, booleans, member expressions) + */ + +import type { NodePath } from '@babel/traverse'; +import type * as BabelTypes from '@babel/types'; +import type { AnalysisVisitors } from '../../../phases/types'; +import type { JSXAttributeValue, JSXUsage } from '../../types'; +import { CLASSNAME_TRACKED_SEPARATELY } from '../../types'; + +/** + * Find parent JSX element in AST + */ +function findParentJSXElement( + path: NodePath, +): NodePath | undefined { + let current: NodePath | null = path.parentPath; + + while (current) { + if (current.isJSXElement()) { + return current as NodePath; + } + current = current.parentPath; + } + + return undefined; +} + +/** + * Extract identifier and member from JSX element + */ +function extractJSXName(name: BabelTypes.JSXElement['openingElement']['name']): { + identifier: string; + member?: string; +} | undefined { + if (name.type === 'JSXIdentifier') { + return { identifier: name.name }; + } + + if (name.type === 'JSXMemberExpression') { + // Extract base identifier + let obj = name.object; + while (obj.type === 'JSXMemberExpression') { + obj = obj.object; + } + const identifier = obj.type === 'JSXIdentifier' ? obj.name : ''; + + // Extract member + const member = name.property.type === 'JSXIdentifier' ? name.property.name : undefined; + + if (!identifier) return undefined; + + // Handle exactOptionalPropertyTypes - don't include member if undefined + return member !== undefined ? { identifier, member } : { identifier }; + } + + return undefined; +} + +/** + * Extract single JSX attribute value + * Extracts simple primitive values from JSX attributes + * + * Supported: + * - String literals: prop="hello" → 'hello' + * - Booleans: disabled → true + * - Simple expressions: prop={true}, prop={42}, prop={"string"} + * + * Unsupported (returns null to skip): + * - Member expressions (except className which uses symbol) + * - Complex expressions + * - Arrays + */ +function extractAttributeValue( + value: BabelTypes.JSXAttribute['value'], +): JSXAttributeValue | null { + // Boolean attribute (e.g., disabled) + if (!value) { + return true; + } + + // String literal: prop="hello" + if (value.type === 'StringLiteral') { + return value.value; + } + + // JSX expression: prop={...} + if (value.type === 'JSXExpressionContainer') { + const expr = value.expression; + + // Simple primitives + if (expr.type === 'StringLiteral') { + return expr.value; + } + if (expr.type === 'NumericLiteral') { + return expr.value; + } + if (expr.type === 'BooleanLiteral') { + return expr.value; + } + + // Unsupported: member expressions, complex expressions, etc. + // These should be handled by specific concerns or categorization + return null; + } + + // Other types unsupported + return null; +} + +/** + * Extract all attributes from JSX element + * className is marked with CLASSNAME_TRACKED_SEPARATELY symbol + * Other attributes extracted as simple primitives + */ +function extractAttributes( + node: BabelTypes.JSXElement, +): Record { + const attributes: Record = {}; + + for (const attr of node.openingElement.attributes) { + if (attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier') { + const name = attr.name.name; + + // Special case: className tracked separately in ClassNameUsage + if (name === 'className') { + attributes[name] = CLASSNAME_TRACKED_SEPARATELY; + continue; + } + + // Extract other attributes + const value = extractAttributeValue(attr.value); + + // Skip unsupported attributes (member expressions, complex expressions) + if (value !== null) { + attributes[name] = value; + } + } + // Skip JSXSpreadAttribute for now + } + + return attributes; +} + +/** + * Recursively extract JSX element and all its children + * Builds complete subtree with elements, text, and expressions + */ +function extractJSXElement( + path: NodePath, +): JSXUsage | undefined { + const openingElement = path.node.openingElement; + const elementName = extractJSXName(openingElement.name); + + if (!elementName) return undefined; + + const { identifier, member } = elementName; + + // Extract attributes + const attributes = extractAttributes(path.node); + + // Extract all children (text, expressions, elements) + const children: JSXUsage[] = []; + + for (const child of path.node.children) { + if (child.type === 'JSXText') { + // Extract text content + const text = child.value.trim(); + if (text) { + children.push({ + type: 'text', + value: text, + }); + } + } else if (child.type === 'JSXExpressionContainer') { + // Extract expression container + const expr = child.expression; + if (expr.type === 'JSXEmptyExpression') { + // Skip empty expressions + continue; + } + + if (expr.type === 'Identifier') { + children.push({ + type: 'expression', + expressionType: 'identifier', + identifierName: expr.name, + }); + } else if (expr.type === 'MemberExpression') { + children.push({ + type: 'expression', + expressionType: 'member', + }); + } else { + children.push({ + type: 'expression', + expressionType: 'other', + }); + } + } else if (child.type === 'JSXElement') { + // Recursively extract child element + const childPath = path.get(`children.${path.node.children.indexOf(child)}`) as NodePath; + const childElement = extractJSXElement(childPath); + if (childElement) { + children.push(childElement); + } + } + // Skip JSXFragment and JSXSpreadChild for now + } + + // Create JSX element with all children + const newElement: JSXUsage = { + type: 'element', + identifier, + ...(member !== undefined && { member }), + node: path as any, + children: children as any, // Mixed array of text/expression/element nodes + attributes, + }; + + return newElement; +} + +const jsxVisitor: AnalysisVisitors = { + JSXElement: ( + prevJsx: JSXUsage | undefined, + path: NodePath, + ): JSXUsage | undefined => { + // Only process root elements (elements without a JSX parent) + const parentPath = findParentJSXElement(path); + + if (parentPath) { + // This is a child element, skip (will be processed by parent) + return prevJsx; + } + + // Root element: recursively extract tree + const jsxTree = extractJSXElement(path); + + if (!jsxTree) return prevJsx; + + return jsxTree; + }, +}; + +export default jsxVisitor; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts new file mode 100644 index 000000000..55812f973 --- /dev/null +++ b/packages/compiler/src/index.ts @@ -0,0 +1,29 @@ +/** + * @videojs/compiler + * + * Compile React components to web components + * 3-phase pipeline: Analysis → Categorization → Projection + * + * TYPES: No types are re-exported from this module. Import types directly from: + * - Generic types: './types' + * - Phase types: './phases/types' + * - Config types: './configs/types' + */ + +// Main compiler entry point +export { compile } from './compile'; + +// Configuration (videojs-react-skin) +export { defaultCompilerConfig, videoJSReactSkinConfig } from './configs/videojs-react-skin'; + +// Phase 3: Module Composition (config-specific) +export { composeModule } from './configs/videojs-react-skin/outputs'; + +// Phase 1: Analysis +export { analyze } from './phases/analyze'; + +// Phase 2: Categorization +export { categorize } from './phases/categorize'; + +// Phase 3: Projection +export { project, projectModule } from './phases/project'; diff --git a/packages/compiler/src/phases/analyze/analyze.ts b/packages/compiler/src/phases/analyze/analyze.ts new file mode 100644 index 000000000..7fe1fe47c --- /dev/null +++ b/packages/compiler/src/phases/analyze/analyze.ts @@ -0,0 +1,53 @@ +/** + * Usage Analysis + * + * Phase 1: Identification + * Analyzes module to extract usage facts + * Returns AST node references for later categorization + * Config-driven - visitors loaded from config + */ + +import type { AnalysisConfig, AnalysisPhaseConfig, ConfigToAnalyzedContext, SourceContext } from '../types'; +import { parseForAnalysis } from './parser'; + +/** + * Analyze module usage patterns + * Config-driven - uses visitors from compiler config + * Completely generic - no hardcoded concern names + * + * Phase 1 only: extracts usage facts from AST + * No dependency on projectionState (that's for Phase 3) + * + * The return type is automatically inferred from the config's phases: + * each phase key becomes a field with the visitor's value type + * + * @param context - Source context with code + * @param config - Analysis configuration with phases + * @returns Input context extended with analyzed fields from config + */ +export function analyze< + TInput extends SourceContext, + TConfig extends AnalysisConfig, +>( + context: TInput, + config: TConfig, +): TInput & ConfigToAnalyzedContext { + // Validate required input + if (!context.input?.source || context.input.source.trim() === '') { + throw new Error('Analysis requires non-empty source code in context.input.source'); + } + + // CONFIG-DRIVEN: Build visitor entries with context keys + // Config is source of truth for which concerns exist and in what order + const visitorEntries = Object.entries(config.phases) + .map(([contextKey, phase]) => ({ + contextKey, + visitor: (phase as AnalysisPhaseConfig).visitor, + })); + + // Single-pass AST traversal with all identification visitors + // Context is threaded through and enriched with analyzed fields + // Type inference: parseForAnalysis returns TInput & VisitorEntriesToContext + // which matches our promise of TInput & ConfigToAnalyzedContext + return parseForAnalysis(context, visitorEntries) as TInput & ConfigToAnalyzedContext; +} diff --git a/packages/compiler/src/phases/analyze/index.ts b/packages/compiler/src/phases/analyze/index.ts new file mode 100644 index 000000000..b34b23265 --- /dev/null +++ b/packages/compiler/src/phases/analyze/index.ts @@ -0,0 +1,9 @@ +/** + * Usage Analysis Module + * + * Phase 1: Identification + * Generic infrastructure for extracting usage facts from source code + * Use-case-specific types (AnalyzedContext, *Usage) are exported from config modules + */ + +export { analyze } from './analyze'; diff --git a/packages/compiler/src/phases/analyze/parser.ts b/packages/compiler/src/phases/analyze/parser.ts new file mode 100644 index 000000000..64be7dacb --- /dev/null +++ b/packages/compiler/src/phases/analyze/parser.ts @@ -0,0 +1,92 @@ +/** + * Analysis Parser + * + * Phase 1: Identification + * Parse source with analysis visitors - separate from transformation pipeline + */ + +import type { Visitor } from '@babel/traverse'; +import type { + AnyAnalysisVisitorHandler, + SourceContext, + VisitorEntriesToContext, + VisitorEntry, +} from '../types'; +import { parse } from '@babel/parser'; +import traverseModule from '@babel/traverse'; + +const traverse = (traverseModule as any).default || traverseModule; + +/** + * Parse source with analysis visitors + * Threads input context through and enriches with analyzed fields + * Parsing infrastructure (source, ast, t) are local variables only + * + * The return type is inferred from the visitor entries: + * each entry's contextKey becomes a field with the visitor's value type + */ +export function parseForAnalysis< + TInput extends SourceContext, + TEntries extends ReadonlyArray>, +>( + context: TInput, + visitorEntries: TEntries, +): TInput & VisitorEntriesToContext { + // Extract source and parse (local variables only) + const source = context.input.source; + const ast = parse(source, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + }); + + // Start with input context as accumulator + let analysisContext = context; + + // Build Babel visitor that updates context + const babelVisitor = buildAnalysisVisitor( + visitorEntries, + () => analysisContext, + (updates) => { + analysisContext = { ...analysisContext, ...updates }; + }, + ); + + // Traverse AST with visitor + traverse(ast, babelVisitor); + + return analysisContext as TInput & VisitorEntriesToContext; +} + +/** + * Build Babel visitor from analysis visitors + * Each visitor receives previous value and path, returns updated value + */ +function buildAnalysisVisitor>( + visitorEntries: ReadonlyArray>, + getContext: () => TContext, + updateContext: (updates: Partial) => void, +): Visitor { + const babelVisitor: Record = {}; + + for (const { contextKey, visitor: visitorMap } of visitorEntries) { + for (const [key, handler] of Object.entries(visitorMap as Record)) { + if (!handler) continue; + + // Handle array of handlers + const handlers = Array.isArray(handler) ? handler : [handler]; + + for (const h of handlers) { + const existing = babelVisitor[key]; + babelVisitor[key] = (path: any) => { + if (existing) existing(path); + const context = getContext(); + const previousValue = (context as any)[contextKey]; + const newValue = h(previousValue, path); + updateContext({ [contextKey]: newValue } as Partial); + }; + } + } + } + + return babelVisitor as Visitor; +} diff --git a/packages/compiler/src/phases/categorize/categorize.ts b/packages/compiler/src/phases/categorize/categorize.ts new file mode 100644 index 000000000..78c801991 --- /dev/null +++ b/packages/compiler/src/phases/categorize/categorize.ts @@ -0,0 +1,153 @@ +/** + * Categorization + * + * Phase 2: Categorization + * Main categorization function that analyzes usage context + * Pure function - no AST traversal, uses predicates and configuration + */ + +import type { + AnalyzedContext, + CategorizationConfig, + ConfigToCategorizedContext, + Predicate, +} from '../types'; + +/** + * Generic categorization function for any concern - array overload + * Handles arrays of entities + * Finds first category where all predicates pass + * Falls back to category with empty predicates (catch-all category) + */ +function categorizeConcern< + TEntity, + TCategory extends string, + TContext, +>( + entities: TEntity[], + categories: Record[]>, + context: TContext, +): (TEntity & { category: TCategory })[]; + +/** + * Generic categorization function for any concern - single entity overload + * Handles single entity + */ +function categorizeConcern< + TEntity, + TCategory extends string, + TContext, +>( + entities: TEntity, + categories: Record[]>, + context: TContext, +): TEntity & { category: TCategory }; + +/** + * Generic categorization function implementation + * Config-driven categorization using predicate matching + * Handles flat arrays, single entities, and recursive tree structures + */ +function categorizeConcern< + TEntity, + TCategory extends string, + TContext, +>( + entities: TEntity | TEntity[], + categories: Record[]>, + context: TContext, +): (TEntity & { category: TCategory }) | (TEntity & { category: TCategory })[] { + // Find fallback category (category with empty predicates array) + const fallbackEntry = Object.entries(categories).find(([_, predicates]) => predicates.length === 0); + + // Helper to categorize single entity + const categorizeSingle = (entity: TEntity): TEntity & { category: TCategory } => { + // Find first category where all predicates pass + const entry = Object.entries(categories).find(([_categoryName, predicates]) => + predicates.length > 0 && predicates.every(pred => pred(entity, context)), + ); + + // Use matched category, or fallback category, or throw error + const category = entry?.[0] ?? fallbackEntry?.[0]; + + if (!category) { + throw new Error('No matching category found and no fallback category defined (category with empty predicates)'); + } + + // Check if entity has children array (tree structure) + const hasChildren = entity + && typeof entity === 'object' + && 'children' in entity + && Array.isArray((entity as any).children); + + if (hasChildren) { + // Recursively categorize children + // Only categorize children that themselves need categorization (have 'node' property) + // Pass through other children unchanged (e.g., text, expressions) + const entityWithChildren = entity as any; + const categorizedChildren = entityWithChildren.children.map((child: any) => { + if (child && typeof child === 'object' && 'node' in child) { + return categorizeSingle(child); + } + return child; + }); + + return { + ...entity, + category: category as TCategory, + children: categorizedChildren, + }; + } + + return { + ...entity, + category: category as TCategory, + }; + }; + + // Handle arrays vs single entities + return Array.isArray(entities) + ? entities.map(categorizeSingle) + : categorizeSingle(entities); +} + +/** + * Categorize all imports, className values, JSX elements, and default export from analyzed context + * + * Generic function that preserves all fields from the input context (like projection) + * while transforming the usage analysis fields to categorized results + * + * The return type is automatically inferred from the config's phases: + * each phase with categories transforms analyzed fields by adding 'category' property + * + * @param context - Context from Phase 1 (analyzed usage, may include additional fields like projection) + * @param config - Categorization configuration with phases + * @returns Categorized context with all input fields preserved and analyzed fields categorized + */ +export function categorize< + TInput extends AnalyzedContext, + TConfig extends CategorizationConfig, +>( + context: TInput, + config: TConfig, +): TInput & ConfigToCategorizedContext { + return { + ...context as any, + ...Object.fromEntries( + (Object.keys(config.phases) as Array) + // Filter out undefined values and concerns without categories + .filter((k) => { + const value = (context as any)[k]; + const hasCategories = !!config.phases[k].categories; + return value !== undefined && hasCategories; + }) + .map((k) => { + const value = (context as any)[k]; + const categories = config.phases[k].categories; + + // Generic categorization handles arrays, single entities, and recursive trees + return [k, categorizeConcern(value, categories, context)]; + }), + ), + } as TInput & ConfigToCategorizedContext; +} diff --git a/packages/compiler/src/phases/categorize/index.ts b/packages/compiler/src/phases/categorize/index.ts new file mode 100644 index 000000000..9938e1838 --- /dev/null +++ b/packages/compiler/src/phases/categorize/index.ts @@ -0,0 +1,8 @@ +/** + * Categorization Module + * + * Phase 2: Categorization + * Generic infrastructure for categorizing usage patterns + * Use-case-specific types (CategorizedContext, *Category) are exported from config modules + */ +export { categorize } from './categorize'; diff --git a/packages/compiler/src/phases/project/index.ts b/packages/compiler/src/phases/project/index.ts new file mode 100644 index 000000000..87dc531ab --- /dev/null +++ b/packages/compiler/src/phases/project/index.ts @@ -0,0 +1,11 @@ +/** + * Projection Module + * + * Phase 3: Projection/Transformation + * Exports projection functions and types + */ + +export type { + ProjectionConfig, +} from '../../types'; +export { project, projectModule } from './project'; diff --git a/packages/compiler/src/phases/project/project.ts b/packages/compiler/src/phases/project/project.ts new file mode 100644 index 000000000..55e5aba35 --- /dev/null +++ b/packages/compiler/src/phases/project/project.ts @@ -0,0 +1,119 @@ +/** + * Module Projection + * + * Phase 3: Projection/Transformation + * Generic projection function that applies all configured projectors + * Config-driven - iterates through config.projectionState entries + */ + +import type { ProjectionPhaseConfig, StateProjector } from '../types'; + +/** + * Helper to apply a projection state config value + * Handles both projector functions and static values + * Generic over context, projected state, and config types + */ +function applyProjectionValue< + T, + TContext, + TProjected, + TConfig, +>( + value: StateProjector | T, + context: TContext, + prevState: Partial, + config: TConfig, +): T { + // Check if it's a function (projector) + if (typeof value === 'function') { + return (value as StateProjector)(context, prevState, config); + } + // Otherwise it's a static value + return value as T; +} + +/** + * Project categorized context through all projection concerns + * Generic implementation - iterates through config.projectionState entries + * Uses reducer pattern: each projector receives accumulated state via prevState + * + * @template TInput - Input context type (any object, projectionState will be added) + * @template TConfig - Config type (must have projectionState field) + * @template TProjected - Projected output shape type + * + * @param context - Categorized context from Phase 2 (projectionState optional, defaults to empty) + * @param config - Projection configuration with projectionState field + * @returns Context with completed projection state + */ +export function projectModule< + TInput extends Record, + TConfig extends { projectionState?: ProjectionPhaseConfig }, + TProjected = any, +>( + context: TInput, + config: TConfig, +): TInput & { projectionState: TProjected } { + if (!config.projectionState) { + throw new Error('Projection requires config.projectionState to be defined'); + } + + // Initialize projection state if not present (context may not have projectionState at all) + const initialState = (context.projectionState as Partial | undefined) ?? ({} as Partial); + + const projectionState = (Object.keys(config.projectionState) as Array) + .reduce((projectionState, k) => { + const value = config.projectionState![k]; + return { + ...projectionState, + [k]: applyProjectionValue(value as any, context, projectionState, config), + }; + }, initialState) as TProjected; + + // Return context with completed projection state + return { + ...context, + projectionState, + }; +} + +/** + * Main projection phase function + * Orchestrates Phase 3: applies projectors and composes final output + * + * This is the high-level phase entry point that: + * 1. Calls projectModule to populate projection state + * 2. Calls config.composeModule to generate final output + * + * @template TInput - Input context type (typically categorized context from Phase 2) + * @template TProjected - Projected output shape type + * @template TConfig - Config type (must have projectionState and composeModule) + * + * @param context - Categorized context from Phase 2 + * @param config - Projection configuration with projectionState and composeModule + * @returns Final composed output string + */ +export function project< + TInput extends Record, + TProjected = any, + TConfig extends { + projectionState?: ProjectionPhaseConfig; + composeModule?: (projectionState: TProjected) => string; + } = { + projectionState?: ProjectionPhaseConfig; + composeModule?: (projectionState: TProjected) => string; + }, +>( + context: TInput, + config: TConfig, +): string { + // Phase 3a: Projection - transform to output structure + // projectModule initializes projectionState if not present + const projectedContext = projectModule(context, config); + + // Phase 3b: Composition - compose final module from projection state + // projectModule guarantees all accumulator fields are populated + if (!config.composeModule) { + throw new Error('composeModule is required in compiler configuration'); + } + return config.composeModule(projectedContext.projectionState); +} diff --git a/packages/compiler/src/phases/types.ts b/packages/compiler/src/phases/types.ts new file mode 100644 index 000000000..293897b59 --- /dev/null +++ b/packages/compiler/src/phases/types.ts @@ -0,0 +1,651 @@ +import type { NodePath, Visitor } from '@babel/traverse'; + +// ============================================================================ +// Source Context Types +// ============================================================================ +// Input context type used throughout the compilation pipeline + +/** + * Compiler input context + * Minimal user-provided input to compile() + * Just the source code - all conventions and settings come from config + */ +export interface SourceContext { + /** Input source metadata */ + input: { + /** Required: the source code to compile */ + source: string; + /** Optional: path to source file (for future module resolution) */ + path?: string; + }; +} + +// ============================================================================ +// Analyzed Entry Base Type +// ============================================================================ +// Generic base type for all Phase 1 analysis/usage entries + +/** + * Base type for all Phase 1 analysis entries + * + * @template TNode - The NodePath type (defaults to never for entries without nodes) + * @template TSelf - The recursive type for children (defaults to never for non-recursive types) + * NOTE: When TSelf is not never, it should be a union that includes this type. + * This constraint is not yet enforced by TypeScript but should be maintained + * by convention. Future enhancement could add stricter validation. + * + * Enforces three rules: + * 1. category is reserved (cannot be set in Phase 1) + * 2. If node parameter is specified, node property is present + * 3. If TSelf is specified, children must be TSelf[] + */ +export interface AnalyzedEntry< + TNode extends NodePath | never = never, + TSelf extends AnalyzedEntry | never = never, +> { + /** Reserved for Phase 2 categorization */ + category?: never; + + /** Optional AST node reference - present only if TNode is specified */ + node?: TNode extends never ? never : TNode; + + /** Optional recursive children - present only if TSelf is specified */ + children?: TSelf extends never ? never : TSelf[]; +} + +// ============================================================================ +// Analyzed Context Value Patterns +// ============================================================================ +// Helper types for the three exhaustive patterns of analyzed context values + +/** + * Pattern 1: Single non-recursive entry + * Used for context values that are single entries without children + * + * @example + * defaultExport?: SingleAnalyzedEntry> + */ +export type SingleAnalyzedEntry | never> + = AnalyzedEntry; + +/** + * Pattern 2: Single recursive/tree entry + * Used for context values that are single entries with children (tree structures) + * + * @template TNode - The NodePath type for the root entry + * @template TSelf - The union type that includes this entry and its possible children + * + * @example + * jsx?: TreeAnalyzedEntry, JSXUsage> + * where JSXUsage = JSXElementNode | JSXTextNode | JSXExpressionNode + */ +export type TreeAnalyzedEntry< + TNode extends NodePath | never, + TSelf extends AnalyzedEntry, +> = AnalyzedEntry; + +/** + * Pattern 3: Array of entries (supports both recursive and non-recursive items) + * Used for context values that are arrays of entries + * + * @template TNode - The NodePath type for array items + * @template TSelf - Optional: the recursive type if array items can have children (defaults to never) + * + * @example + * // Non-recursive array items: + * imports?: AnalyzedEntryArray> + * classNames?: AnalyzedEntryArray> + * + * // Could support recursive array items in future: + * trees?: AnalyzedEntryArray, TreeUnion> + */ +export type AnalyzedEntryArray< + TNode extends NodePath | never = never, + TSelf extends AnalyzedEntry | never = never, +> = Array>; + +// ============================================================================ +// Analyzed Context Type +// ============================================================================ +// Generic context type for Phase 1 output + +/** + * Valid value types for analyzed context fields + * All analyzed fields must be one of these three patterns + */ +export type AnalyzedContextValue + = | SingleAnalyzedEntry + | TreeAnalyzedEntry + | AnalyzedEntryArray; + +/** + * Generic analyzed context + * Extends SourceContext with analyzed fields derived from config phases + * + * @template TAnalyzed - Analyzed fields object (must be explicitly provided) + * + * All analyzed fields must match one of three patterns: + * - SingleAnalyzedEntry (non-recursive single entry) + * - TreeAnalyzedEntry (recursive single entry with children) + * - AnalyzedEntryArray (array of entries) + * + * Note: Uses mapped type constraint to validate each property value + * without requiring index signatures + * + * @example + * // Generic usage: + * type MyAnalyzedContext = AnalyzedContext<{ + * imports?: AnalyzedEntryArray>; + * jsx?: TreeAnalyzedEntry, JSXUsage>; + * }>; + * + * // Result extends SourceContext with specified fields + */ +export type AnalyzedContext< + TAnalyzed extends { + [K in keyof TAnalyzed]: AnalyzedContextValue | undefined; + }, +> = SourceContext & TAnalyzed; + +// ============================================================================ +// Analysis Visitor Infrastructure +// ============================================================================ +// Generic visitor types for Phase 1 analysis +// These define HOW visitors work (not WHAT fields they populate) + +/** + * Extract the path parameter type from a Babel visitor function + */ +type ExtractPathType + = T extends (...args: infer Args) => any + ? Args[0] + : T extends { enter?: infer E } + ? E extends (...args: infer Args) => any + ? Args[0] + : never + : never; + +/** + * Get the properly typed path for a given Babel visitor key + */ +type PathForVisitorKey + = NonNullable extends infer V + ? ExtractPathType + : never; + +/** + * Type-safe reducer-like visitor handler for analysis + * Receives previous value and path, returns updated value + * + * @param K - Babel visitor key (e.g., 'ImportDeclaration', 'JSXAttribute') + * @param V - Value type being accumulated (e.g., ImportUsage[], ClassNameUsage[]) + */ +export type AnalysisVisitorHandler< + K extends keyof Visitor, + V, +> = ( + previousValue: V, + path: PathForVisitorKey, +) => V; + +/** + * Non-generic visitor handler type for runtime iteration + * This is the erased version of AnalysisVisitorHandler + * Used when we lose generic context (e.g., Object.entries iteration) + */ +export type AnyAnalysisVisitorHandler = ( + previousValue: any, + path: any, +) => any; + +/** + * Utility type to ensure at least one property is defined + * Prevents empty objects from being valid + */ +type AtLeastOne = { + [K in keyof T]: Pick & Partial>; +}[keyof T]; + +/** + * Type-safe visitor definitions for analysis + * Maps Babel visitor keys to reducer-like handlers + * Must have at least one visitor handler defined (cannot be empty object) + * + * @param V - The value type being accumulated (e.g., ImportUsage[], JSXUsage) + * Defaults to any for flexibility, but should be specified for type safety + */ +export type AnalysisVisitors = AtLeastOne<{ + [K in keyof Visitor]: AnalysisVisitorHandler; +}>; + +/** + * Visitor entry pairing a context key with its typed visitor + * Used to build visitor entries array with preserved type information + * + * @param K - The context key (e.g., 'imports', 'jsx', 'classNames') + * @param V - The value type being accumulated by this visitor + */ +export interface VisitorEntry { + /** The key where this visitor's result will be stored in the context */ + contextKey: K; + /** The visitor that produces values of type V */ + visitor: AnalysisVisitors; +} + +/** + * Extract the analyzed context shape from a tuple of visitor entries + * Maps each entry's contextKey to its visitor's value type + * + * @example + * type Entries = [ + * VisitorEntry<'imports', ImportUsage[]>, + * VisitorEntry<'jsx', JSXUsage> + * ]; + * type Result = VisitorEntriesToContext; + * // Result is: { imports?: ImportUsage[]; jsx?: JSXUsage } + */ +export type VisitorEntriesToContext< + TEntries extends ReadonlyArray>, +> = { + [Entry in TEntries[number] as Entry['contextKey']]?: Entry extends VisitorEntry + ? V + : never; +}; + +// ============================================================================ +// Generic Config Types for Analysis Phase +// ============================================================================ + +/** + * Generic analysis phase configuration + * Defines the minimal shape needed for a phase: it must have a visitor + * Other properties (categories, etc.) can be added by extending this interface + * + * @param V - The value type produced by this phase's visitor + */ +export interface AnalysisPhaseConfig { + /** The analysis visitor that produces values of type V */ + visitor: AnalysisVisitors; +} + +/** + * Generic analysis configuration + * Minimal interface for configs that support the analyze() function + * Other config properties can be added by extending this interface + * + * @template TPhases - The phases object mapping concern names to phase configs + */ +export interface AnalysisConfig> = Record>> { + /** Phase configurations keyed by concern name */ + phases: TPhases; +} + +/** + * Extract analyzed context type from config phases + * Maps each phase key to its visitor's value type + * + * @example + * type Config = AnalysisConfig<{ + * imports: AnalysisPhaseConfig; + * jsx: AnalysisPhaseConfig; + * }>; + * type Result = ConfigToAnalyzedContext; + * // Result is: { imports?: ImportUsage[]; jsx?: JSXUsage } + */ +export type ConfigToAnalyzedContext> = { + [K in keyof TConfig['phases']]?: TConfig['phases'][K]['visitor'] extends AnalysisVisitors + ? V + : never; +}; + +// ============================================================================ +// Generic Config Types for Categorization Phase +// ============================================================================ + +/** + * Extract entity type from visitor return type + * If visitor returns an array, unwraps to get the entry type + * Otherwise returns the type as-is + * + * @example + * ExtractEntityType = ImportUsage + * ExtractEntityType = DefaultExportUsage + */ +type ExtractEntityType = V extends Array ? Item : V; + +/** + * Extract entity type from a phase config's visitor + * Used to infer the correct predicate type for categorization + * + * @example + * ExtractPhaseEntityType> = ImportUsage + */ +type ExtractPhaseEntityType> + = TPhase extends AnalysisPhaseConfig ? ExtractEntityType : never; + +/** + * Generic predicate function type + * Tests if an entity matches a category based on entity properties and context + * + * @template T - The entity type being tested (e.g., ImportUsage, JSXUsage) + * @template TContext - The context type (defaults to any for flexibility) + * + * @returns true if entity matches the category criteria + */ +export type Predicate = ( + entity: T, + context: TContext, +) => boolean; + +/** + * Generic categorization phase configuration + * Defines categories with predicates for a single phase/concern + * + * Categorization logic: + * - All predicates in array must pass (AND logic) + * - First matching category wins (insertion order matters) + * - Empty predicate array [] means catch-all fallback category + * + * @template TEntity - The entity type being categorized (inferred from visitor) + * @template C - Union of category string literals for this phase + * + * @example + * interface ImportCategorizationConfig + * extends CategorizationPhaseConfig { + * categories: { + * 'vjs-component': [isFromVJS, isUsedAsComponent]; + * 'external': []; // catch-all + * }; + * } + */ +export interface CategorizationPhaseConfig< + TEntity = any, + C extends string = string, +> { + /** + * Category definitions mapping category names to predicate arrays + * Predicates are typed for the specific entity type + * Runtime guarantees: + * - Categories checked in insertion order + * - All predicates must pass (AND) + * - First match wins + */ + categories: Record[]>; +} + +/** + * Generic categorization configuration + * Builds upon an AnalysisConfig by adding category definitions to each phase + * Each phase must have categories defined for the entity type produced by its visitor + * + * @template TAnalysisConfig - The analysis config this categorization builds upon + * + * Note: This interface is structurally compatible with AnalysisConfig (all phases have visitor) + * but adds required categories field to each phase + * + * @example + * type MyAnalysisConfig = AnalysisConfig<{ + * imports: AnalysisPhaseConfig; + * defaultExport: AnalysisPhaseConfig; + * }>; + * + * type MyCategorizationConfig = CategorizationConfig; + * // phases.imports requires: AnalysisPhaseConfig & CategorizationPhaseConfig + * // phases.defaultExport requires: AnalysisPhaseConfig & CategorizationPhaseConfig + */ +export interface CategorizationConfig< + TAnalysisConfig extends AnalysisConfig, +> { + /** Phase configurations - must match analysis config keys with added categories */ + phases: { + [K in keyof TAnalysisConfig['phases']]: TAnalysisConfig['phases'][K] & CategorizationPhaseConfig< + ExtractPhaseEntityType, + string + >; + }; +} + +// ============================================================================ +// Categorized Entry Patterns +// ============================================================================ +// Matching patterns for categorized versions of analyzed entries + +/** + * Base categorized entry type + * Adds category field to an analyzed entry + * + * @template TEntry - The analyzed entry type + * @template TCategory - Union of possible category strings + */ +export type CategorizedEntry< + TEntry extends AnalyzedEntry, + TCategory extends string, +> = Omit & { + category: TCategory; +}; + +/** + * Pattern 1: Single categorized entry (non-recursive) + * Used for context values that are single entries without children + * Adds category field to a SingleAnalyzedEntry + * + * @template TEntry - The analyzed entry type (must be SingleAnalyzedEntry) + * @template TCategory - Union of possible category strings + * + * @example + * type CategorizedDefaultExport = SingleCategorizedEntry< + * DefaultExportUsage, + * 'react-functional-component' + * >; + */ +export type SingleCategorizedEntry< + TEntry extends SingleAnalyzedEntry, + TCategory extends string, +> = CategorizedEntry; + +/** + * Pattern 2: Tree categorized entry (recursive) + * Used for context values that are tree structures with children + * Adds category to root and recursively categorizes children + * + * @template TEntry - The analyzed entry type (must be TreeAnalyzedEntry) + * @template TCategorizedSelf - The categorized child type (union of categorized entries) + * @template TCategory - Union of possible category strings for this entry + * + * Note: children are recursively categorized using the TCategorizedSelf union + * + * @example + * type CategorizedJSXElement = TreeCategorizedEntry< + * JSXElementNode, + * CategorizedJSXChild, + * JSXElementCategory + * >; + * where CategorizedJSXChild = CategorizedJSXElement | JSXTextNode | JSXExpressionNode + */ +export type TreeCategorizedEntry< + TEntry extends TreeAnalyzedEntry, + TCategorizedSelf, + TCategory extends string, +> = Omit & { + category: TCategory; + children: TCategorizedSelf[]; +}; + +/** + * Pattern 3: Array of categorized entries + * Used for context values that are arrays of entries + * Adds category to each array item + * + * @template TEntry - The analyzed entry type for array items + * @template TCategory - Union of possible category strings + * + * @example + * type CategorizedImports = CategorizedEntryArray< + * ImportUsage, + * 'vjs-component' | 'external' + * >; + * // Result: Array + */ +export type CategorizedEntryArray< + TEntry extends AnalyzedEntry, + TCategory extends string, +> = Array>; + +/** + * Extract categorized context type from config and analyzed context + * Maps each analyzed field to its categorized version (adds 'category' property) + * + * Note: This is a best-effort type mapping. Runtime behavior handles: + * - Array fields: each item gets category + * - Single/Tree fields: root gets category, children recursively categorized + * - Undefined fields: remain undefined + * + * @template TConfig - The categorization config + * @template TAnalyzed - The analyzed context (input to categorization) + * + * @example + * type Config = CategorizationConfig<{ + * imports: CategorizationPhaseConfig<'vjs' | 'external'>; + * jsx: CategorizationPhaseConfig<'native' | 'component'>; + * }>; + * + * type Analyzed = { + * imports?: ImportUsage[]; + * jsx?: JSXUsage; + * }; + * + * type Result = ConfigToCategorizedContext; + * // Result: { + * // imports?: Array; + * // jsx?: JSXUsage & { category: 'native' | 'component' }; + * // } + */ +export type ConfigToCategorizedContext< + TConfig extends CategorizationConfig, + TAnalyzed, +> = { + [K in keyof TAnalyzed]: K extends keyof TConfig['phases'] + ? TAnalyzed[K] extends Array + ? Array + : TAnalyzed[K] extends AnalyzedEntry + ? TAnalyzed[K] & { category: string } + : TAnalyzed[K] + : TAnalyzed[K]; +}; + +// ============================================================================ +// Generic Config Types for Projection Phase +// ============================================================================ + +/** + * State-based projector function type + * Receives categorized context and accumulated state, returns typed output + * + * @template TOutput - The output type for this projector + * @template TCategorizedContext - The categorized context type (defaults to any for flexibility) + * @template TProjected - The projection state type (defaults to any for flexibility) + * @template TConfig - The config type (defaults to any for flexibility) + * + * @example + * type ProjectImports = StateProjector< + * ProjectedImportEntry[], + * CategorizedContext, + * ProjectionState, + * CompilerConfig + * >; + */ +export type StateProjector< + TOutput, + TCategorizedContext = any, + TProjected = any, + TConfig = any, +> = ( + context: TCategorizedContext, + prevState: Partial, + config: TConfig, +) => TOutput; + +/** + * Generic projection phase configuration + * Maps output keys to projector functions or static values + * Type-safe: ensures projector return types match projected state field types + * + * @template TProjected - The projected output shape (e.g., ProjectionState) + * + * @example + * type MyProjectionConfig = ProjectionPhaseConfig<{ + * imports: ProjectedImportEntry[]; + * elementName: string; + * styleVariableName: string; + * }>; + * // Each key can be: + * // - StateProjector function that returns the field type + * // - Static value of the field type + */ +export type ProjectionPhaseConfig = { + [K in keyof TProjected]?: + | StateProjector> + | NonNullable; +}; + +/** + * Generic projection configuration + * Builds upon a CategorizationConfig by adding projection phase + * Each config layer extends the previous: Analysis → Categorization → Projection + * + * @template TCategorizationConfig - The categorization config this builds upon + * @template TProjected - The projected output shape + * + * Note: This interface is structurally compatible with CategorizationConfig + * but adds optional projectionState field + * + * @example + * type MyAnalysisConfig = AnalysisConfig<{ + * imports: AnalysisPhaseConfig; + * }>; + * + * type MyCategorizationConfig = CategorizationConfig; + * + * type MyProjectionConfig = ProjectionConfig< + * MyCategorizationConfig, + * { imports: ProjectedImportEntry[]; html: ProjectedHTML[] } + * >; + * // Result includes phases from categorization + projectionState field + */ +export interface ProjectionConfig< + TCategorizationConfig extends CategorizationConfig, + TProjected, +> { + /** Phase configurations from categorization */ + phases: TCategorizationConfig['phases']; + + /** Projection phase configuration - maps output keys to projectors/values */ + projectionState?: ProjectionPhaseConfig; +} + +/** + * Generic compiler configuration + * Complete configuration for the entire compilation pipeline + * Extends ProjectionConfig by adding module composition function + * + * @template TCategorizationConfig - The categorization config this builds upon + * @template TProjected - The projected output shape + * + * This is the top-level config interface that compile() expects. + * Individual configs can extend this and add additional fields + * (e.g., classNameProjectors, custom projection helpers, etc.) + */ +export interface CompilerConfig< + TCategorizationConfig extends CategorizationConfig = CategorizationConfig, + TProjected = any, +> extends ProjectionConfig { + /** + * Module composition function + * Takes complete projection state and composes it into final output + * This is the final step that converts projected data into the target format + * + * @param projectionState - Complete projection state with all fields populated + * @returns Final module source code as a string + */ + composeModule?: (projectionState: TProjected) => string; +} diff --git a/packages/compiler/src/types.ts b/packages/compiler/src/types.ts new file mode 100644 index 000000000..2d56f0122 --- /dev/null +++ b/packages/compiler/src/types.ts @@ -0,0 +1,81 @@ +/** + * Generic Projection Types + * + * Truly generic types used across different compiler configurations + * These types define structured data formats (pseudo-AST) for projections + * Independent of any specific config or framework + */ + +// ============================================================================ +// Structured Import Types +// ============================================================================ + +/** + * Structured import - preserves import specifier information + * Supports all import types: named, default, namespace, side-effect + */ +export interface ProjectedImport { + type: 'import'; + source: string; + /** Optional - if undefined or empty array, it's a side-effect import */ + specifiers?: { + type: 'named' | 'namespace' | 'default'; + name: string; + alias?: string; + }[]; +} + +/** + * Structured comment - preserves comment style information + */ +export interface ProjectedComment { + type: 'comment'; + style: 'line' | 'block'; + /** string for single line, string[] for multi-line (each entry is a line) */ + value: string | string[]; +} + +/** + * Import entry - can be structured import, comment, or raw string + * String support maintained for backwards compatibility and simple cases + */ +export type ProjectedImportEntry = ProjectedImport | ProjectedComment | string; + +// ============================================================================ +// Structured HTML Types +// ============================================================================ + +/** + * Possible supported HTML attribute value types. + * NOTE: string[] accounts for token list-like attributes (e.g. class names) + */ +export type HTMLAttributeValue = string | number | boolean | string[]; + +/** + * Structured HTML element - preserves element structure information + * Generic pseudo-AST representation of HTML suitable for any output format + */ +export interface ProjectedHTML { + type: 'html'; + name: string; + attributes: Record; + /** + * Child HTML elements (recursive tree structure). + * NOTE: ProjectedHTML[] accounts for NodeList-like projections + * from a single JSXElement root (e.g. tooltips, popovers) + */ + children: (ProjectedHTML | (ProjectedHTML[]))[]; +} + +// ============================================================================ +// Generic Configuration +// ============================================================================ + +/** + * Projection configuration + * Generic placeholder for projection-phase configuration options + * Individual configs can extend this interface with specific options + */ +export interface ProjectionConfig { + // Future: transformer overrides, options, etc. +} diff --git a/packages/compiler/src/utils/component-names.ts b/packages/compiler/src/utils/component-names.ts new file mode 100644 index 000000000..e0fe56d4e --- /dev/null +++ b/packages/compiler/src/utils/component-names.ts @@ -0,0 +1,105 @@ +/** + * Component Name Utilities + * + * Utilities for converting between React component names and HTML element names + */ + +import { toKebabCase } from './string-transforms'; + +/** + * Convert React component name to HTML custom element name + * Converts PascalCase to kebab-case with media- prefix + * Handles compound components (TimeSlider.Root, TimeSlider.Track) + * + * @param componentName - React component name (e.g., 'PlayButton', 'TimeSlider.Root') + * @returns HTML element name (e.g., 'media-play-button', 'media-time-slider') + * + * @example + * componentNameToElementName('PlayButton') → 'media-play-button' + * componentNameToElementName('MediaContainer') → 'media-container' + * componentNameToElementName('TimeSlider') → 'media-time-slider' + * componentNameToElementName('TimeSlider.Root') → 'media-time-slider' (Root removed) + * componentNameToElementName('TimeSlider.Track') → 'media-time-slider-track' + */ +export function componentNameToElementName(componentName: string): string { + // Handle compound components (e.g., 'TimeSlider.Root', 'TimeSlider.Track') + if (componentName.includes('.')) { + const parts = componentName.split('.', 2); + const base = parts[0]; + const member = parts[1]; + + // Both parts must exist for valid compound component + if (!base || !member) { + throw new Error(`Invalid compound component name: ${componentName}`); + } + + // Convert base to kebab-case + const baseKebab = toKebabCase(base); + + // Root member maps to base element (no suffix) + if (member === 'Root') { + return baseKebab.startsWith('media-') ? baseKebab : `media-${baseKebab}`; + } + + // Other members become suffixes + const memberKebab = toKebabCase(member); + + const fullName = `${baseKebab}-${memberKebab}`; + return fullName.startsWith('media-') ? fullName : `media-${fullName}`; + } + + // Simple component: convert PascalCase to kebab-case + const kebab = toKebabCase(componentName); + + // Add media- prefix if not already present + if (kebab.startsWith('media-')) { + return kebab; + } + + return `media-${kebab}`; +} + +/** + * Derive skin class name from component name + * Converts React component name to HTML skin class name + * + * @param componentName - React component name (e.g., 'FrostedSkin', 'MinimalSkin') + * @returns Class name for skin element (e.g., 'MediaSkinFrostedElement', 'MediaSkinMinimalElement') + * + * @example + * componentNameToClassName('FrostedSkin') → 'MediaSkinFrostedElement' + * componentNameToClassName('MinimalSkin') → 'MediaSkinMinimalElement' + * componentNameToClassName('MySkin') → 'MediaSkinMyElement' + */ +export function componentNameToClassName(componentName: string): string { + // Remove "Skin" suffix if present + const baseName = componentName.endsWith('Skin') + ? componentName.slice(0, -4) + : componentName; + + return `MediaSkin${baseName}Element`; +} + +/** + * Derive custom element name for skin from component name + * Converts React component name to skin custom element tag name + * + * @param componentName - React component name (e.g., 'FrostedSkin', 'MinimalSkin') + * @returns Custom element name (e.g., 'media-skin-frosted', 'media-skin-minimal') + * + * @example + * componentNameToSkinElementName('FrostedSkin') → 'media-skin-frosted' + * componentNameToSkinElementName('MinimalSkin') → 'media-skin-minimal' + * componentNameToSkinElementName('MySkin') → 'media-skin-my' + */ +export function componentNameToSkinElementName(componentName: string): string { + // Remove "Skin" suffix if present + const baseName = componentName.endsWith('Skin') + ? componentName.slice(0, -4) + : componentName; + + // Convert to kebab-case + const kebab = toKebabCase(baseName); + + return `media-skin-${kebab}`; +} diff --git a/packages/compiler/src/utils/deep-merge.ts b/packages/compiler/src/utils/deep-merge.ts new file mode 100644 index 000000000..1ee2e8275 --- /dev/null +++ b/packages/compiler/src/utils/deep-merge.ts @@ -0,0 +1,63 @@ +/** + * Deep Merge Utility + * + * Generic deep merge for configuration objects + * Recursively merges nested objects, with source overriding target + */ + +/** + * Recursively make all properties optional + */ +export type DeepPartial = { + [K in keyof T]?: T[K] extends Record + ? DeepPartial + : T[K]; +}; + +/** + * Check if value is a plain object (not array, not null, not Date, etc.) + */ +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + && Object.prototype.toString.call(value) === '[object Object]' + ); +} + +/** + * Deep merge two objects + * Source values override target values + * Arrays are replaced (not merged) + * Functions and primitives are replaced + * + * @param target - Base object (must be exhaustive) + * @param source - Override object (recursively partial) + * @returns Merged object + */ +export function deepMerge>( + target: T, + source: DeepPartial, +): T { + const result = { ...target }; + + for (const key in source) { + if (!Object.prototype.hasOwnProperty.call(source, key)) { + continue; + } + + const sourceValue = source[key]; + const targetValue = result[key]; + + // If both are plain objects, merge recursively + if (isPlainObject(sourceValue) && isPlainObject(targetValue)) { + result[key] = deepMerge(targetValue, sourceValue); + } else { + // Otherwise, source replaces target (arrays, primitives, functions, null) + result[key] = sourceValue as T[Extract]; + } + } + + return result; +} diff --git a/packages/compiler/src/utils/formatters/html.ts b/packages/compiler/src/utils/formatters/html.ts new file mode 100644 index 000000000..72f354e91 --- /dev/null +++ b/packages/compiler/src/utils/formatters/html.ts @@ -0,0 +1,173 @@ +/** + * HTML Formatting Utilities + * + * Converts structured ProjectedHTML to HTML strings + * Separated from projection logic (similar to format-imports) + * Pure formatting - no projection logic + */ + +import type { HTMLAttributeValue, ProjectedHTML } from '../../types'; + +/** + * Formatting options for HTML output + */ +export interface FormattingOptions { + /** + * Initial depth level for indentation + * Depth 0 = no indent, depth 1 = one indent unit, etc. + */ + depth: number; + + /** + * String to use for one level of indentation + * Typically ' ' (2 spaces) or '\t' (tab) + */ + indentStyle: string; +} + +/** + * Format HTML attribute value + * Handles strings, numbers, booleans, and string arrays + */ +function formatAttributeValue(value: HTMLAttributeValue): string { + if (Array.isArray(value)) { + // Token list (e.g., class names) + return value.join(' '); + } + if (typeof value === 'boolean') { + // Boolean attributes are handled at the attribute level + return ''; + } + return String(value); +} + +/** + * Format HTML attributes to attribute string + * Returns string with leading space if non-empty + */ +function formatAttributes(attributes: Record): string { + const attrs: string[] = []; + + for (const [name, value] of Object.entries(attributes)) { + // Special case for text/comment nodes + if (name === 'value') { + continue; + } + + // Boolean attributes + if (typeof value === 'boolean') { + if (value) { + attrs.push(name); // Just the attribute name for true + } + // Skip false boolean attributes + continue; + } + + // Regular attributes + const formattedValue = formatAttributeValue(value); + attrs.push(`${name}="${formattedValue}"`); + } + + return attrs.length > 0 ? ` ${attrs.join(' ')}` : ''; +} + +/** + * Generate indentation string for given depth + */ +function indent(depth: number, style: string): string { + return style.repeat(depth); +} + +/** + * Check if element has only text content (no child elements) + */ +function hasOnlyTextContent(children: (ProjectedHTML | ProjectedHTML[])[]): boolean { + if (children.length === 0) return false; + if (children.length > 1) return false; + + const child = children[0]; + if (!child || Array.isArray(child)) return false; + + return child.name === '#text'; +} + +/** + * Format single ProjectedHTML element to HTML string + * Recursive - handles children with proper indentation + */ +export function formatHTMLElement( + element: ProjectedHTML, + depth: number, + options: FormattingOptions, +): string { + const indentStr = indent(depth, options.indentStyle); + + // Special handling for text nodes (no indentation - inline) + if (element.name === '#text') { + return element.attributes.value as string; + } + + // Special handling for comment nodes + if (element.name === '#comment') { + return `${indentStr}`; + } + + // Regular elements + const attributes = formatAttributes(element.attributes); + + // Empty elements + if (element.children.length === 0) { + return `${indentStr}<${element.name}${attributes}>`; + } + + // Elements with only text content - keep inline + if (hasOnlyTextContent(element.children)) { + const textNode = element.children[0] as ProjectedHTML; + const text = textNode.attributes.value as string; + return `${indentStr}<${element.name}${attributes}>${text}`; + } + + // Elements with child elements - format with newlines + const children = formatHTMLChildren(element.children, depth + 1, options); + return `${indentStr}<${element.name}${attributes}>\n${children}\n${indentStr}`; +} + +/** + * Format children array to HTML string + * Handles both ProjectedHTML and ProjectedHTML[] (for multi-element projections) + */ +function formatHTMLChildren( + children: (ProjectedHTML | ProjectedHTML[])[], + depth: number, + options: FormattingOptions, +): string { + return children + .map((child) => { + if (Array.isArray(child)) { + // Array of elements (e.g., from compound projections like tooltip trigger + tooltip) + // These are siblings at the same depth + return child.map(c => formatHTMLElement(c, depth, options)).join('\n'); + } + // Single element + return formatHTMLElement(child, depth, options); + }) + .join('\n'); +} + +/** + * Format ProjectedHTML array to HTML string + * Entry point - formats entire HTML tree + * + * @param elements - Array of ProjectedHTML elements to format + * @param options - Optional formatting options (defaults to depth 0, 2-space indent) + * @param options.depth - Starting indentation depth (default: 0) + * @param options.indentStyle - Indentation string to use (default: ' ') + */ +export function formatHTML( + elements: ProjectedHTML[], + { depth = 0, indentStyle = ' ' }: FormattingOptions = { depth: 0, indentStyle: ' ' }, +): string { + return elements + .map(el => formatHTMLElement(el, depth, { depth, indentStyle })) + .join('\n'); +} diff --git a/packages/compiler/src/utils/formatters/imports.ts b/packages/compiler/src/utils/formatters/imports.ts new file mode 100644 index 000000000..70861f772 --- /dev/null +++ b/packages/compiler/src/utils/formatters/imports.ts @@ -0,0 +1,73 @@ +/** + * Import Formatting Utilities + * + * Converts structured ProjectedImportEntry data to formatted import strings + * Pure formatting layer - no business logic + */ + +import type { ProjectedImportEntry } from '../../types'; + +/** + * Format a single ProjectedImportEntry to string + * Handles structured imports, comments, and raw strings + * + * @param entry - Import entry to format + * @returns Formatted import/comment string + */ +export function formatImportEntry(entry: ProjectedImportEntry): string { + // Handle raw string (backwards compatibility) + if (typeof entry === 'string') { + return entry; + } + + // Handle comment + if (entry.type === 'comment') { + if (entry.style === 'line') { + const value = typeof entry.value === 'string' ? entry.value : entry.value.join('\n// '); + return `// ${value}`; + } else { + // Block comment + const lines = typeof entry.value === 'string' ? [entry.value] : entry.value; + return `/*\n${lines.map(l => ` * ${l}`).join('\n')}\n */`; + } + } + + // Handle import + if (!entry.specifiers || entry.specifiers.length === 0) { + // Side-effect import: import '@/define/video-provider'; + return `import '${entry.source}';`; + } + + // Build specifier list + const specs = entry.specifiers.map((s) => { + if (s.type === 'default') { + // Default import + return s.name; + } + if (s.type === 'namespace') { + // Namespace import: * as name + return `* as ${s.name}`; + } + // Named import (with optional alias) + return s.alias ? `${s.name} as ${s.alias}` : s.name; + }); + + // Check if only default import (no braces) + if (specs.length === 1 && entry.specifiers[0]?.type === 'default') { + return `import ${specs[0]} from '${entry.source}';`; + } + + // Named/mixed imports (with braces) + return `import { ${specs.join(', ')} } from '${entry.source}';`; +} + +/** + * Format array of ProjectedImportEntry to final import string + * Converts structured entries to strings and joins with newlines + * + * @param entries - Import entries to format + * @returns Formatted imports joined with newlines + */ +export function formatImports(entries: ProjectedImportEntry[]): string { + return entries.map(formatImportEntry).join('\n'); +} diff --git a/packages/compiler/src/utils/string-transforms.ts b/packages/compiler/src/utils/string-transforms.ts new file mode 100644 index 000000000..a39369181 --- /dev/null +++ b/packages/compiler/src/utils/string-transforms.ts @@ -0,0 +1,22 @@ +/** + * String Transformation Utilities + * + * Utilities for transforming strings between different naming conventions + * Used primarily for JSX → HTML attribute name transformations + */ + +/** + * Convert camelCase/PascalCase to kebab-case + * + * Examples: + * dataTestId → data-test-id + * ariaLabel → aria-label + * PlayButton → play-button + * + * @param str - String in camelCase or PascalCase + * @returns String in kebab-case + */ +export function toKebabCase(str: string): string { + return str.replace(/[A-Z]/g, (match, offset) => + offset > 0 ? `-${match.toLowerCase()}` : match.toLowerCase()); +} diff --git a/packages/compiler/test/analysis/usage-analysis.test.ts b/packages/compiler/test/analysis/usage-analysis.test.ts new file mode 100644 index 000000000..5c6472eca --- /dev/null +++ b/packages/compiler/test/analysis/usage-analysis.test.ts @@ -0,0 +1,604 @@ +/** + * Usage Analysis Tests + * + * Tests Phase 1: Identification + * Verifies that usage analysis correctly extracts all usage patterns + */ + +import type { JSXUsage } from '../../src/configs/types'; +import { describe, expect, it } from 'vitest'; +import { defaultCompilerConfig } from '../../src/configs/videojs-react-skin'; +import { analyze } from '../../src/phases/analyze'; +import { createInitialContext } from '../utils'; + +// Helper functions for tree traversal +function findInTree(root: JSXUsage, predicate: (el: JSXUsage) => boolean): JSXUsage | undefined { + if (predicate(root)) return root; + for (const child of root.children) { + const found = findInTree(child, predicate); + if (found) return found; + } + return undefined; +} + +function findAllInTree(root: JSXUsage, predicate: (el: JSXUsage) => boolean): JSXUsage[] { + const results: JSXUsage[] = []; + if (predicate(root)) results.push(root); + for (const child of root.children) { + if ('node' in child) { + // Only recurse into JSXUsage elements + results.push(...findAllInTree(child, predicate)); + } + } + return results; +} + +function countAllElements(root: JSXUsage): number { + return 1 + root.children.reduce((sum, child) => { + if ('node' in child) { + // Only count JSXUsage elements + return sum + countAllElements(child); + } + return sum; + }, 0); +} + +describe('usage analysis', () => { + describe('import extraction', () => { + it('extracts named imports', () => { + const source = ` + import { PlayButton, MuteButton } from '@videojs/react'; + export default function Skin() { return
; } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.imports).toHaveLength(1); + expect(usage.imports[0]).toMatchObject({ + source: '@videojs/react', + specifiers: { + named: ['PlayButton', 'MuteButton'], + }, + }); + expect(usage.imports[0].node).toBeDefined(); + }); + + it('extracts default imports', () => { + const source = ` + import styles from './styles'; + export default function Skin() { return
; } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.imports).toHaveLength(1); + expect(usage.imports[0]).toMatchObject({ + source: './styles', + specifiers: { + default: 'styles', + named: [], + }, + }); + }); + + it('extracts namespace imports', () => { + const source = ` + import * as Icons from '@/icons'; + export default function Skin() { return
; } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.imports).toHaveLength(1); + expect(usage.imports[0]).toMatchObject({ + source: '@/icons', + specifiers: { + namespace: 'Icons', + named: [], + }, + }); + }); + + it('extracts mixed imports', () => { + const source = ` + import React, { useState } from 'react'; + export default function Skin() { return
; } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.imports).toHaveLength(1); + expect(usage.imports[0]).toMatchObject({ + source: 'react', + specifiers: { + default: 'React', + named: ['useState'], + }, + }); + }); + + it('extracts multiple icon imports', () => { + const source = ` + import { + PlayIcon, + PauseIcon, + FullscreenEnterIcon, + FullscreenExitIcon, + } from '@/icons'; + export default function Skin() { return
; } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.imports).toHaveLength(1); + expect(usage.imports[0].specifiers.named).toEqual([ + 'PlayIcon', + 'PauseIcon', + 'FullscreenEnterIcon', + 'FullscreenExitIcon', + ]); + }); + }); + + describe('jsx usage extraction', () => { + it('tracks which identifiers are used as JSX elements', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + export default function Skin() { + return ( +
+ + +
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // Tree has 3 total elements: div (root) + 2 PlayButtons (children) + expect(countAllElements(usage.jsx!)).toBe(3); + + const divRoot = usage.jsx; + const buttonUsages = findAllInTree(usage.jsx!, u => u.identifier === 'PlayButton'); + + expect(divRoot!.identifier).toBe('div'); + expect(divRoot!.node).toBeDefined(); + + expect(buttonUsages).toHaveLength(2); // Two separate instances + expect(buttonUsages[0].node).toBeDefined(); + expect(buttonUsages[1].node).toBeDefined(); + }); + + it('tracks compound component usage with separate entries', () => { + const source = ` + import { TimeSlider } from '@videojs/react'; + export default function Skin() { + return ( + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // Root is the top-level element, Track is its child + const rootUsage = usage.jsx; + const trackUsage = findInTree( + usage.jsx!, + u => u.identifier === 'TimeSlider' && u.member === 'Track', + ); + + expect(rootUsage!.identifier).toBe('TimeSlider'); + expect(rootUsage!.member).toBe('Root'); + expect(rootUsage!.node).toBeDefined(); + + expect(trackUsage).toBeDefined(); + expect(trackUsage!.node).toBeDefined(); + }); + + it('tracks multiple instances with distinct attributes separately', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + import styles from './styles'; + export default function Skin() { + return ( +
+ + +
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // Should have 2 separate PlayButton entries in tree + const playButtons = findAllInTree( + usage.jsx!, + el => el.identifier === 'PlayButton', + ); + expect(playButtons).toHaveLength(2); + + // Each should be distinct (different AST nodes) + expect(playButtons[0].node).toBeDefined(); + expect(playButtons[1].node).toBeDefined(); + expect(playButtons[0].node).not.toBe(playButtons[1].node); + + // Verify different classNames are tracked per instance + const primaryButton = usage.classNames.find( + cn => cn.component.identifier === 'PlayButton' && cn.type === 'member-expression' && cn.key === 'Primary', + ); + const secondaryButton = usage.classNames.find( + cn => cn.component.identifier === 'PlayButton' && cn.type === 'member-expression' && cn.key === 'Secondary', + ); + + expect(primaryButton).toBeDefined(); + expect(secondaryButton).toBeDefined(); + + // Verify they reference different component instances + expect(primaryButton!.component.node).not.toBe(secondaryButton!.component.node); + }); + }); + + describe('className Usage Extraction', () => { + it('extracts member expression className usage', () => { + const source = ` + import styles from './styles'; + export default function Skin() { + return
; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.classNames).toHaveLength(1); + expect(usage.classNames[0]).toMatchObject({ + type: 'member-expression', + identifier: 'styles', + key: 'Container', + component: { identifier: 'div' }, + }); + expect(usage.classNames[0].node).toBeDefined(); + expect(usage.classNames[0].component.node).toBeDefined(); + }); + + it('extracts string literal className usage', () => { + const source = ` + export default function Skin() { + return
; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.classNames).toHaveLength(1); + expect(usage.classNames[0]).toMatchObject({ + type: 'string-literal', + classes: ['button', 'primary'], + literalValue: 'button primary', + component: { identifier: 'div' }, + }); + }); + + it('extracts mixed template literal with member expressions and strings', () => { + const source = ` + import styles from './styles'; + export default function Skin() { + return
; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.classNames).toHaveLength(3); + + // Member expressions + const containerUsage = usage.classNames.find( + u => u.type === 'member-expression' && u.key === 'Container', + ); + expect(containerUsage).toBeDefined(); + + const buttonUsage = usage.classNames.find( + u => u.type === 'member-expression' && u.key === 'Button', + ); + expect(buttonUsage).toBeDefined(); + + // Clustered string literals + const literalUsage = usage.classNames.find( + u => u.type === 'string-literal', + ); + expect(literalUsage).toBeDefined(); + if (literalUsage?.type === 'string-literal') { + expect(literalUsage.classes).toEqual(['prefix', 'middle', 'suffix']); + } + }); + + it('extracts multiple style keys from template literals', () => { + const source = ` + import styles from './styles'; + export default function Skin() { + return ( +
+
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // 3 member expressions (Container, Layout, Button) + const memberExpressions = usage.classNames.filter( + u => u.type === 'member-expression', + ); + expect(memberExpressions).toHaveLength(3); + expect(memberExpressions.map(u => u.key)).toEqual(['Container', 'Layout', 'Button']); + expect(memberExpressions.every(u => u.identifier === 'styles')).toBe(true); + }); + + it('captures component info for className on compound components', () => { + const source = ` + import { TimeSlider } from '@videojs/react'; + import styles from './styles'; + export default function Skin() { + return ( + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.classNames).toHaveLength(2); + + const rootClassName = usage.classNames.find( + u => u.type === 'member-expression' && u.key === 'RangeRoot', + ); + expect(rootClassName).toMatchObject({ + type: 'member-expression', + identifier: 'styles', + key: 'RangeRoot', + component: { identifier: 'TimeSlider', member: 'Root' }, + }); + expect(rootClassName?.component.node).toBeDefined(); + + const trackClassName = usage.classNames.find( + u => u.type === 'member-expression' && u.key === 'RangeTrack', + ); + expect(trackClassName).toMatchObject({ + type: 'member-expression', + identifier: 'styles', + key: 'RangeTrack', + component: { identifier: 'TimeSlider', member: 'Track' }, + }); + expect(trackClassName?.component.node).toBeDefined(); + }); + }); + + describe('default export extraction', () => { + it('extracts component name and marks root JSX element', () => { + const source = ` + export default function MediaSkinSimple() { + return
Content
; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.defaultExport.componentName).toBe('MediaSkinSimple'); + expect(usage.defaultExport.node).toBeDefined(); + expect(usage.defaultExport.jsxElement).toBeDefined(); + + // Root element is stored directly in jsx field + expect(usage.jsx).toBeDefined(); + expect(usage.jsx!.identifier).toBe('div'); + }); + + it('handles arrow function exports and marks root', () => { + const source = ` + export default () =>
Content
; + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + expect(usage.defaultExport.componentName).toBe('UnknownComponent'); + expect(usage.defaultExport.jsxElement).toBeDefined(); + + // Root element is stored directly in jsx field + expect(usage.jsx).toBeDefined(); + expect(usage.jsx!.identifier).toBe('div'); + }); + }); + + describe('compound parent/ancestor relationships', () => { + it('identifies compound parent for non-compound element', () => { + const source = ` + import { Tooltip, PlayButton } from '@videojs/react'; + export default function Skin() { + return ( + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // Root is Tooltip.Trigger, PlayButton is its child + const tooltipTrigger = usage.jsx; + expect(tooltipTrigger!.identifier).toBe('Tooltip'); + expect(tooltipTrigger!.member).toBe('Trigger'); + + const playButton = findInTree(usage.jsx!, u => u.identifier === 'PlayButton'); + expect(playButton).toBeDefined(); + expect(playButton!.node).toBeDefined(); + }); + + it('identifies compound ancestor for compound element with same identifier', () => { + const source = ` + import { Tooltip } from '@videojs/react'; + export default function Skin() { + return ( + + + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // Root is Tooltip.Root + const tooltipRoot = usage.jsx; + expect(tooltipRoot!.identifier).toBe('Tooltip'); + expect(tooltipRoot!.member).toBe('Root'); + + // Trigger is child of Root + const tooltipTrigger = findInTree( + usage.jsx!, + u => u.identifier === 'Tooltip' && u.member === 'Trigger', + ); + expect(tooltipTrigger).toBeDefined(); + + // Popup is child of Trigger + const tooltipPopup = findInTree( + usage.jsx!, + u => u.identifier === 'Tooltip' && u.member === 'Popup', + ); + expect(tooltipPopup).toBeDefined(); + }); + + it('skips different-identifier compounds when finding ancestor', () => { + const source = ` + import { Tooltip, TimeSlider } from '@videojs/react'; + export default function Skin() { + return ( + + + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // Root is TimeSlider.Root + const timeSliderRoot = usage.jsx; + expect(timeSliderRoot!.identifier).toBe('TimeSlider'); + expect(timeSliderRoot!.member).toBe('Root'); + + // Track is nested under Tooltip.Trigger but still part of tree + const timeSliderTrack = findInTree( + usage.jsx!, + u => u.identifier === 'TimeSlider' && u.member === 'Track', + ); + expect(timeSliderTrack).toBeDefined(); + }); + + it('handles non-compound elements with no compound parent', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + export default function Skin() { + return ( +
+ +
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // Root is div + const div = usage.jsx; + expect(div!.identifier).toBe('div'); + + // PlayButton is child of div + const playButton = findInTree(usage.jsx!, u => u.identifier === 'PlayButton'); + expect(playButton).toBeDefined(); + }); + + it('handles compound root with no ancestor', () => { + const source = ` + import { Tooltip } from '@videojs/react'; + export default function Skin() { + return ; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // Root is Tooltip.Root with no children + const tooltipRoot = usage.jsx; + expect(tooltipRoot!.identifier).toBe('Tooltip'); + expect(tooltipRoot!.member).toBe('Root'); + expect(tooltipRoot!.children).toHaveLength(0); + }); + }); + + describe('complete integration', () => { + it('analyzes complete skin module', () => { + const source = ` + import type { PropsWithChildren } from 'react'; + import { PlayButton } from '@videojs/react'; + import { PlayIcon, PauseIcon } from '@/icons'; + import styles from './styles'; + + export default function MediaSkinSimple({ children }: PropsWithChildren) { + return ( +
+ {children} + + + + +
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + + // Verify imports + expect(usage.imports).toHaveLength(4); + expect(usage.imports.map(i => i.source)).toEqual([ + 'react', + '@videojs/react', + '@/icons', + './styles', + ]); + + // Verify JSX usage - collect all identifiers from tree + const allElements = findAllInTree(usage.jsx!, () => true); + const identifiers = allElements.map(u => + u.member ? `${u.identifier}.${u.member}` : u.identifier, + ).sort(); + expect(identifiers).toEqual([ + 'PauseIcon', + 'PlayButton', + 'PlayIcon', + 'div', + ]); + + // Verify className usage (member expressions only) + const memberExpressions = usage.classNames.filter( + u => u.type === 'member-expression', + ); + expect(memberExpressions).toHaveLength(2); + expect(memberExpressions.map(u => u.key)).toEqual(['Container', 'Button']); + + // Verify default export + expect(usage.defaultExport.componentName).toBe('MediaSkinSimple'); + }); + }); +}); diff --git a/packages/compiler/test/categorization/class-names.test.ts b/packages/compiler/test/categorization/class-names.test.ts new file mode 100644 index 000000000..88fab30ce --- /dev/null +++ b/packages/compiler/test/categorization/class-names.test.ts @@ -0,0 +1,156 @@ +/** + * className Categorization Tests + * + * Tests Phase 2: className categorization + * Verifies className values are correctly categorized for transformation + */ + +import { describe, expect, it } from 'vitest'; +import { defaultCompilerConfig } from '../../src/configs/videojs-react-skin'; +import { analyze } from '../../src/phases/analyze'; +import { categorize } from '../../src/phases/categorize'; +import { createInitialContext } from '../utils'; + +describe('className categorization', () => { + it('identifies component-match (exact match)', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + import styles from './styles'; + export default function Skin() { + return ; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const playButtonClassName = result.classNames.find( + cn => cn.type === 'member-expression' && cn.key === 'PlayButton', + ); + expect(playButtonClassName?.category).toBe('component-match'); + }); + + it('identifies generic-style for non-matching keys', () => { + const source = ` + import { PlayButton, MuteButton } from '@videojs/react'; + import styles from './styles'; + export default function Skin() { + return ( +
+ + +
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + // Both usages of Button are generic-style (don't match component name) + const buttonClassNames = result.classNames.filter( + cn => cn.type === 'member-expression' && cn.key === 'Button', + ); + expect(buttonClassNames).toHaveLength(2); + expect(buttonClassNames.every(cn => cn.category === 'generic-style')).toBe(true); + }); + + it('identifies generic-style for compound component keys', () => { + const source = ` + import { TimeSlider } from '@videojs/react'; + import styles from './styles'; + export default function Skin() { + return ( + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const rootClassName = result.classNames.find( + cn => cn.type === 'member-expression' && cn.key === 'SliderRoot', + ); + expect(rootClassName?.category).toBe('generic-style'); + + const trackClassName = result.classNames.find( + cn => cn.type === 'member-expression' && cn.key === 'SliderTrack', + ); + expect(trackClassName?.category).toBe('generic-style'); + }); + + it('identifies generic-style for layout containers', () => { + const source = ` + import styles from './styles'; + export default function Skin() { + return ( +
+
+
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const containerClassName = result.classNames.find( + cn => cn.type === 'member-expression' && cn.key === 'Container', + ); + expect(containerClassName?.category).toBe('generic-style'); + + const controlsClassName = result.classNames.find( + cn => cn.type === 'member-expression' && cn.key === 'Controls', + ); + expect(controlsClassName?.category).toBe('generic-style'); + }); + + it('handles mixed patterns in same skin', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + import styles from './styles'; + export default function Skin() { + return ( +
+ +
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const categories = new Map( + result.classNames + .filter(cn => cn.type === 'member-expression') + .map(cn => [cn.key, cn.category]), + ); + + expect(categories.get('Container')).toBe('generic-style'); + expect(categories.get('PlayButton')).toBe('component-match'); + expect(categories.get('Button')).toBe('generic-style'); + }); + + it('categorizes string literal className as literal-classes', () => { + const source = ` + export default function Skin() { + return
; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + expect(result.classNames).toHaveLength(1); + expect(result.classNames[0].type).toBe('string-literal'); + expect(result.classNames[0].category).toBe('literal-classes'); + + if (result.classNames[0].type === 'string-literal') { + expect(result.classNames[0].classes).toEqual(['button', 'primary', 'active']); + } + }); +}); diff --git a/packages/compiler/test/categorization/imports.test.ts b/packages/compiler/test/categorization/imports.test.ts new file mode 100644 index 000000000..0fc833f1e --- /dev/null +++ b/packages/compiler/test/categorization/imports.test.ts @@ -0,0 +1,142 @@ +/** + * Import Categorization Tests + * + * Tests Phase 2: Import categorization + * Verifies imports are correctly categorized based on usage + */ + +import { describe, expect, it } from 'vitest'; +import { defaultCompilerConfig } from '../../src/configs/videojs-react-skin'; +import { analyze } from '../../src/phases/analyze'; +import { categorize } from '../../src/phases/categorize'; +import { createInitialContext } from '../utils'; + +describe('import categorization', () => { + it('categorizes VJS component imports', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + export default function Skin() { + return ; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const vjsImport = result.imports.find(i => i.source === '@videojs/react'); + expect(vjsImport?.category).toBe('vjs-component'); + }); + + it('categorizes framework imports', () => { + const source = ` + import type { PropsWithChildren } from 'react'; + export default function Skin({ children }: PropsWithChildren) { + return
{children}
; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const reactImport = result.imports.find(i => i.source === 'react'); + expect(reactImport?.category).toBe('framework'); + }); + + it('categorizes style imports', () => { + const source = ` + import styles from './styles'; + export default function Skin() { + return
; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const styleImport = result.imports.find(i => i.source === './styles'); + expect(styleImport?.category).toBe('style'); + }); + + it('categorizes VJS core imports', () => { + const source = ` + import { formatTime } from '@videojs/utils'; + export default function Skin() { + return
; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const coreImport = result.imports.find(i => i.source === '@videojs/utils'); + expect(coreImport?.category).toBe('vjs-core'); + }); + + it('categorizes VJS icon imports', () => { + const source = ` + import { PlayIcon, PauseIcon } from '@/icons'; + export default function Skin() { + return ( +
+ + +
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const iconImport = result.imports.find(i => i.source === '@/icons'); + expect(iconImport?.category).toBe('vjs-icon'); + }); + + it('categorizes external imports', () => { + const source = ` + import { someUtility } from 'lodash'; + export default function Skin() { + return
; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const externalImport = result.imports.find(i => i.source === 'lodash'); + expect(externalImport?.category).toBe('external'); + }); + + it('handles multiple imports with different categories', () => { + const source = ` + import type { PropsWithChildren } from 'react'; + import { PlayButton } from '@videojs/react'; + import { PlayIcon } from '@/icons'; + import styles from './styles'; + + export default function Skin({ children }: PropsWithChildren) { + return ( +
+ + + +
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + expect(result.imports).toHaveLength(4); + + const categories = new Map( + result.imports.map(i => [i.source, i.category]), + ); + + expect(categories.get('react')).toBe('framework'); + expect(categories.get('@videojs/react')).toBe('vjs-component'); + expect(categories.get('@/icons')).toBe('vjs-icon'); // Icons get their own category + expect(categories.get('./styles')).toBe('style'); + }); +}); diff --git a/packages/compiler/test/categorization/jsx.test.ts b/packages/compiler/test/categorization/jsx.test.ts new file mode 100644 index 000000000..cf107c24a --- /dev/null +++ b/packages/compiler/test/categorization/jsx.test.ts @@ -0,0 +1,376 @@ +/** + * JSX Element Categorization Tests + * + * Tests Phase 2: JSX element categorization + * Verifies JSX elements are correctly categorized and pattern metadata linked + */ + +import type { CategorizedJSXUsage } from '../../src/configs/types'; +import { describe, expect, it } from 'vitest'; +import { defaultCompilerConfig } from '../../src/configs/videojs-react-skin'; +import { analyze } from '../../src/phases/analyze'; +import { categorize } from '../../src/phases/categorize'; +import { createInitialContext } from '../utils'; + +// Helper function for tree traversal +function findInTree(root: CategorizedJSXUsage, predicate: (el: CategorizedJSXUsage) => boolean): CategorizedJSXUsage | undefined { + if (predicate(root)) return root; + for (const child of root.children) { + if ('category' in child) { + // Only recurse into categorized JSXUsage elements + const found = findInTree(child, predicate); + if (found) return found; + } + } + return undefined; +} + +describe('jSX element categorization', () => { + it('categorizes native HTML elements', () => { + const source = ` + export default function Skin() { + return ( +
+ + Text +
+ ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const divElement = result.jsx; + const buttonElement = findInTree(result.jsx, el => el.identifier === 'button'); + const spanElement = findInTree(result.jsx, el => el.identifier === 'span'); + + expect(divElement?.category).toBe('native-element'); + expect(buttonElement?.category).toBe('native-element'); + expect(spanElement?.category).toBe('native-element'); + }); + + it('categorizes MediaContainer', () => { + const source = ` + import { MediaContainer } from '@videojs/react'; + export default function Skin() { + return ; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const mediaContainer = result.jsx; + + expect(mediaContainer?.category).toBe('media-container'); + }); + + it('categorizes compound component roots', () => { + const source = ` + import { TimeSlider } from '@videojs/react'; + export default function Skin() { + return ( + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const timeSliderRoot = result.jsx; + + expect(timeSliderRoot?.category).toBe('compound-root'); + expect(timeSliderRoot?.identifier).toBe('TimeSlider'); + expect(timeSliderRoot?.member).toBeUndefined(); + }); + + it('categorizes generic components', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + export default function Skin() { + return ; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const playButton = result.jsx; + + expect(playButton?.category).toBe('generic-component'); + }); + + it('categorizes Tooltip.Root', () => { + const source = ` + import { Tooltip } from '@videojs/react'; + export default function Skin() { + return ; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const tooltipRoot = result.jsx; + + expect(tooltipRoot?.category).toBe('tooltip-root'); + expect(tooltipRoot?.identifier).toBe('Tooltip'); + expect(tooltipRoot?.member).toBe('Root'); + }); + + it('categorizes Tooltip.Trigger with compound ancestor', () => { + const source = ` + import { Tooltip } from '@videojs/react'; + export default function Skin() { + return ( + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const tooltipRoot = result.jsx; + const tooltipTrigger = findInTree( + result.jsx, + el => el.identifier === 'Tooltip' && el.member === 'Trigger', + ); + + expect(tooltipRoot?.category).toBe('tooltip-root'); + expect(tooltipTrigger?.category).toBe('tooltip-trigger'); + }); + + it('categorizes Tooltip.Positioner', () => { + const source = ` + import { Tooltip } from '@videojs/react'; + export default function Skin() { + return ( + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const tooltipPositioner = findInTree( + result.jsx, + el => el.identifier === 'Tooltip' && el.member === 'Positioner', + ); + + expect(tooltipPositioner?.category).toBe('tooltip-positioner'); + }); + + it('categorizes Tooltip.Popup', () => { + const source = ` + import { Tooltip } from '@videojs/react'; + export default function Skin() { + return ( + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const tooltipPopup = findInTree( + result.jsx, + el => el.identifier === 'Tooltip' && el.member === 'Popup', + ); + + expect(tooltipPopup?.category).toBe('tooltip-popup'); + }); + + it('categorizes complete Tooltip pattern with relationships', () => { + const source = ` + import { Tooltip, PlayButton } from '@videojs/react'; + export default function Skin() { + return ( + + + + + + Play + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const tooltipRoot = result.jsx; + const tooltipTrigger = findInTree( + result.jsx, + el => el.identifier === 'Tooltip' && el.member === 'Trigger', + ); + const tooltipPositioner = findInTree( + result.jsx, + el => el.identifier === 'Tooltip' && el.member === 'Positioner', + ); + const tooltipPopup = findInTree( + result.jsx, + el => el.identifier === 'Tooltip' && el.member === 'Popup', + ); + const playButton = findInTree( + result.jsx, + el => el.identifier === 'PlayButton', + ); + + // Categories + expect(tooltipRoot?.category).toBe('tooltip-root'); + expect(tooltipTrigger?.category).toBe('tooltip-trigger'); + expect(tooltipPositioner?.category).toBe('tooltip-positioner'); + expect(tooltipPopup?.category).toBe('tooltip-popup'); + expect(playButton?.category).toBe('generic-component'); + }); + + it('categorizes Popover.Root', () => { + const source = ` + import { Popover } from '@videojs/react'; + export default function Skin() { + return ; + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const popoverRoot = result.jsx; + + expect(popoverRoot?.category).toBe('popover-root'); + }); + + it('categorizes Popover.Trigger with compound ancestor', () => { + const source = ` + import { Popover } from '@videojs/react'; + export default function Skin() { + return ( + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const popoverRoot = result.jsx; + const popoverTrigger = findInTree( + result.jsx, + el => el.identifier === 'Popover' && el.member === 'Trigger', + ); + + expect(popoverRoot?.category).toBe('popover-root'); + expect(popoverTrigger?.category).toBe('popover-trigger'); + }); + + it('categorizes complete Popover pattern with relationships', () => { + const source = ` + import { Popover, MuteButton } from '@videojs/react'; + export default function Skin() { + return ( + + + + + + Volume Controls + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + const popoverRoot = result.jsx; + const popoverTrigger = findInTree( + result.jsx, + el => el.identifier === 'Popover' && el.member === 'Trigger', + ); + const popoverPositioner = findInTree( + result.jsx, + el => el.identifier === 'Popover' && el.member === 'Positioner', + ); + const popoverPopup = findInTree( + result.jsx, + el => el.identifier === 'Popover' && el.member === 'Popup', + ); + const muteButton = findInTree( + result.jsx, + el => el.identifier === 'MuteButton', + ); + + // Categories + expect(popoverRoot?.category).toBe('popover-root'); + expect(popoverTrigger?.category).toBe('popover-trigger'); + expect(popoverPositioner?.category).toBe('popover-positioner'); + expect(popoverPopup?.category).toBe('popover-popup'); + expect(muteButton?.category).toBe('generic-component'); + }); + + it('categorizes mixed component types', () => { + const source = ` + import { Tooltip, PlayButton, TimeSlider, MediaContainer } from '@videojs/react'; + export default function Skin() { + return ( + + + + + + + + + + Play + + + + ); + } + `; + + const usage = analyze(createInitialContext(source), defaultCompilerConfig); + const result = categorize(usage, defaultCompilerConfig); + + // Check MediaContainer (root) + const mediaContainer = result.jsx; + expect(mediaContainer?.category).toBe('media-container'); + + // Check TimeSlider (compound root) + const timeSlider = findInTree( + result.jsx, + el => el.identifier === 'TimeSlider' && !el.member, + ); + expect(timeSlider?.category).toBe('compound-root'); + + // Check Tooltip pattern + const tooltipRoot = findInTree( + result.jsx, + el => el.identifier === 'Tooltip' && el.member === 'Root', + ); + expect(tooltipRoot?.category).toBe('tooltip-root'); + + // Check PlayButton (generic component) + const playButton = findInTree(result.jsx, el => el.identifier === 'PlayButton'); + expect(playButton?.category).toBe('generic-component'); + }); +}); diff --git a/packages/compiler/test/compile.test.ts b/packages/compiler/test/compile.test.ts new file mode 100644 index 000000000..436c13343 --- /dev/null +++ b/packages/compiler/test/compile.test.ts @@ -0,0 +1,141 @@ +/** + * End-to-End Compilation Tests + * + * Tests complete pipeline: Analysis → Categorization → Projection → Module Composition + * Verifies complete HTML skin module output + */ + +import { describe, expect, it } from 'vitest'; +import { compile } from '../src/compile'; +import { createSourceContext } from './utils'; + +describe('compile', () => { + it('compiles simple React skin to complete HTML module', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + import styles from './styles'; + + export default function SimpleSkin() { + return ( +
+ +
+ ); + } + `; + + const result = compile(createSourceContext(source)); + + // Verify imports section + expect(result).toContain(`import { MediaSkinElement } from '@/media/media-skin';`); + expect(result).toContain(`import { defineCustomElement } from '@/utils/custom-element';`); + expect(result).toContain(`import styles from './styles.css';`); + expect(result).toContain(`import '@/define/media-play-button';`); + + // Verify template function + expect(result).toContain('export function getTemplateHTML()'); + expect(result).toContain('$' + '{MediaSkinElement.getTemplateHTML()}'); + expect(result).toContain(''); + + // Verify class declaration + expect(result).toContain('export class MediaSkinSimpleElement extends MediaSkinElement'); + expect(result).toContain('static getTemplateHTML: () => string = getTemplateHTML'); + + // Verify custom element registration + expect(result).toContain(`defineCustomElement('media-skin-simple', MediaSkinSimpleElement);`); + }); + + it('compiles FrostedSkin with correct naming', () => { + const source = ` + import { PlayButton, MuteButton } from '@videojs/react'; + import { PlayIcon } from '@/icons'; + import styles from './styles'; + + export default function FrostedSkin() { + return ( +
+ + + + +
+ ); + } + `; + + const result = compile(createSourceContext(source)); + + // Verify derived names + expect(result).toContain('export class MediaSkinFrostedElement'); + expect(result).toContain(`defineCustomElement('media-skin-frosted', MediaSkinFrostedElement);`); + + // Verify icon import deduplicated + expect(result).toContain(`import '@/icons';`); + const iconMatches = result.match(/import '@\/icons';/g); + expect(iconMatches).toHaveLength(1); // Only once! + }); + + // External imports are currently unsupported + it.skip('removes framework imports and keeps external', () => { + const source = ` + import type { PropsWithChildren } from 'react'; + import { PlayButton } from '@videojs/react'; + import { clsx } from 'clsx'; + import styles from './styles'; + + export default function MySkin({ children }: PropsWithChildren) { + return
; + } + `; + + const result = compile(createSourceContext(source)); + + // Framework removed + expect(result).not.toContain('react'); + expect(result).not.toContain('PropsWithChildren'); + + // External kept + expect(result).toContain(`import { clsx } from 'clsx';`); + }); + + it('handles custom style variable name from context', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + import myStyles from './theme'; + + export default function CustomSkin() { + return
; + } + `; + + const result = compile(createSourceContext(source)); + + // Style import uses convention 'styles' (not 'myStyles') + expect(result).toContain(`import styles from './theme.css';`); + + // Template uses convention + expect(result).toContain(''); + }); + + it('includes base framework imports', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + import styles from './styles'; + + export default function TestSkin() { + return ; + } + `; + + const result = compile(createSourceContext(source)); + + // Verify base imports are always included + expect(result).toContain(`import { MediaSkinElement } from '@/media/media-skin';`); + expect(result).toContain(`import { defineCustomElement } from '@/utils/custom-element';`); + + // Base imports should come first + const mediaSkinPos = result.indexOf(`import { MediaSkinElement }`); + const playButtonPos = result.indexOf(`import '@/define/media-play-button';`); + expect(mediaSkinPos).toBeLessThan(playButtonPos); + }); +}); diff --git a/packages/compiler/test/components/current-time-display-simple.test.ts b/packages/compiler/test/components/current-time-display-simple.test.ts new file mode 100644 index 000000000..ed73e4f20 --- /dev/null +++ b/packages/compiler/test/components/current-time-display-simple.test.ts @@ -0,0 +1,40 @@ +/** + * Tests for: current-time-display-simple.tsx + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement } from '../helpers/dom'; + +describe('fixture: CurrentTimeDisplay - Simple', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/displays/current-time-display-simple.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-current-time-display element', () => { + expect(root.tagName.toLowerCase()).toBe('media-current-time-display'); + }); + + it('preserves className', () => { + expect(getClasses(root)).toContain('time'); + }); + + it('has no child elements', () => { + expect(root.children.length).toBe(0); + }); + + it('extracts className', () => { + expect(result.classNames).toContain('time'); + }); +}); diff --git a/packages/compiler/test/components/duration-display-simple.test.ts b/packages/compiler/test/components/duration-display-simple.test.ts new file mode 100644 index 000000000..2cc3bb078 --- /dev/null +++ b/packages/compiler/test/components/duration-display-simple.test.ts @@ -0,0 +1,36 @@ +/** + * Tests for: duration-display-simple.tsx + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement } from '../helpers/dom'; + +describe('fixture: DurationDisplay - Simple', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/displays/duration-display-simple.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-duration-display', () => { + expect(root.tagName.toLowerCase()).toBe('media-duration-display'); + }); + + it('preserves className', () => { + expect(getClasses(root)).toContain('duration'); + }); + + it('has no child elements', () => { + expect(root.children.length).toBe(0); + }); +}); diff --git a/packages/compiler/test/components/fullscreen-button-with-icons.test.ts b/packages/compiler/test/components/fullscreen-button-with-icons.test.ts new file mode 100644 index 000000000..3bc4ac645 --- /dev/null +++ b/packages/compiler/test/components/fullscreen-button-with-icons.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for: fullscreen-button-with-icons.tsx + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement, querySelector } from '../helpers/dom'; + +describe('fixture: FullscreenButton - With Icons', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/buttons/fullscreen-button-with-icons.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-fullscreen-button', () => { + expect(root.tagName.toLowerCase()).toBe('media-fullscreen-button'); + }); + + it('has fullscreen-btn class', () => { + expect(getClasses(root)).toContain('fullscreen-btn'); + }); + + it('has exactly 2 children', () => { + expect(root.children.length).toBe(2); + }); + + it('contains media-fullscreen-enter-icon', () => { + const icon = querySelector(root, 'media-fullscreen-enter-icon'); + expect(icon).toBeDefined(); + }); + + it('enter icon has correct class', () => { + const icon = querySelector(root, 'media-fullscreen-enter-icon'); + expect(getClasses(icon)).toContain('enter-icon'); + }); + + it('contains media-fullscreen-exit-icon', () => { + const icon = querySelector(root, 'media-fullscreen-exit-icon'); + expect(icon).toBeDefined(); + }); + + it('exit icon has correct class', () => { + const icon = querySelector(root, 'media-fullscreen-exit-icon'); + expect(getClasses(icon)).toContain('exit-icon'); + }); + + it('extracts all classNames', () => { + expect(result.classNames).toEqual(expect.arrayContaining([ + 'fullscreen-btn', + 'enter-icon', + 'exit-icon', + ])); + }); +}); diff --git a/packages/compiler/test/components/media-container-simple.test.ts b/packages/compiler/test/components/media-container-simple.test.ts new file mode 100644 index 000000000..25c174988 --- /dev/null +++ b/packages/compiler/test/components/media-container-simple.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for: media-container-simple.tsx + * Tests {children} → slot transformation + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement, querySelector } from '../helpers/dom'; + +describe('fixture: MediaContainer - Simple', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/containers/media-container-simple.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-container element', () => { + expect(root.tagName.toLowerCase()).toBe('media-container'); + }); + + it('preserves className', () => { + expect(getClasses(root)).toContain('container'); + }); + + it('has exactly 1 child (slot)', () => { + expect(root.children.length).toBe(1); + }); + + it('contains slot element', () => { + const slot = querySelector(root, 'slot'); + expect(slot.tagName.toLowerCase()).toBe('slot'); + }); + + it('slot has name="media" attribute', () => { + const slot = querySelector(root, 'slot'); + expect(slot.getAttribute('name')).toBe('media'); + }); + + it('slot has slot="media" attribute', () => { + const slot = querySelector(root, 'slot'); + expect(slot.getAttribute('slot')).toBe('media'); + }); + + it('{children} transforms to slot element', () => { + // Verify the specific slot pattern for children + const slot = root.querySelector('slot[name="media"][slot="media"]'); + expect(slot).not.toBeNull(); + }); +}); diff --git a/packages/compiler/test/components/mute-button-simple.test.ts b/packages/compiler/test/components/mute-button-simple.test.ts new file mode 100644 index 000000000..378429488 --- /dev/null +++ b/packages/compiler/test/components/mute-button-simple.test.ts @@ -0,0 +1,40 @@ +/** + * Tests for: mute-button-simple.tsx + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement } from '../helpers/dom'; + +describe('fixture: MuteButton - Simple', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/buttons/mute-button-simple.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-mute-button element', () => { + expect(root.tagName.toLowerCase()).toBe('media-mute-button'); + }); + + it('preserves className', () => { + expect(getClasses(root)).toContain('mute-btn'); + }); + + it('has no child elements', () => { + expect(root.children.length).toBe(0); + }); + + it('extracts className', () => { + expect(result.classNames).toContain('mute-btn'); + }); +}); diff --git a/packages/compiler/test/components/mute-button-with-icons.test.ts b/packages/compiler/test/components/mute-button-with-icons.test.ts new file mode 100644 index 000000000..61a14d27f --- /dev/null +++ b/packages/compiler/test/components/mute-button-with-icons.test.ts @@ -0,0 +1,75 @@ +/** + * Tests for: mute-button-with-icons.tsx + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement, querySelector } from '../helpers/dom'; + +describe('fixture: MuteButton - With Icons', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/buttons/mute-button-with-icons.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-mute-button', () => { + expect(root.tagName.toLowerCase()).toBe('media-mute-button'); + }); + + it('has mute-btn class', () => { + expect(getClasses(root)).toContain('mute-btn'); + }); + + it('has exactly 3 children (volume icons)', () => { + expect(root.children.length).toBe(3); + }); + + it('contains media-volume-high-icon', () => { + const icon = querySelector(root, 'media-volume-high-icon'); + expect(icon).toBeDefined(); + }); + + it('volume-high icon has correct class', () => { + const icon = querySelector(root, 'media-volume-high-icon'); + expect(getClasses(icon)).toContain('volume-high'); + }); + + it('contains media-volume-low-icon', () => { + const icon = querySelector(root, 'media-volume-low-icon'); + expect(icon).toBeDefined(); + }); + + it('volume-low icon has correct class', () => { + const icon = querySelector(root, 'media-volume-low-icon'); + expect(getClasses(icon)).toContain('volume-low'); + }); + + it('contains media-volume-off-icon', () => { + const icon = querySelector(root, 'media-volume-off-icon'); + expect(icon).toBeDefined(); + }); + + it('volume-off icon has correct class', () => { + const icon = querySelector(root, 'media-volume-off-icon'); + expect(getClasses(icon)).toContain('volume-off'); + }); + + it('extracts all classNames', () => { + expect(result.classNames).toEqual(expect.arrayContaining([ + 'mute-btn', + 'volume-high', + 'volume-low', + 'volume-off', + ])); + }); +}); diff --git a/packages/compiler/test/components/play-button-simple.test.ts b/packages/compiler/test/components/play-button-simple.test.ts new file mode 100644 index 000000000..95e3df843 --- /dev/null +++ b/packages/compiler/test/components/play-button-simple.test.ts @@ -0,0 +1,45 @@ +/** + * Tests for: play-button-simple.tsx + * One test per assertion for red/green TDD workflow + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement } from '../helpers/dom'; + +describe('fixture: PlayButton - Simple', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/buttons/play-button-simple.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-play-button element', () => { + expect(root.tagName.toLowerCase()).toBe('media-play-button'); + }); + + it('preserves className as class attribute', () => { + expect(getClasses(root)).toContain('play-btn'); + }); + + it('has no child elements (no icons)', () => { + expect(root.children.length).toBe(0); + }); + + it('extracts className for CSS generation', () => { + expect(result.classNames).toContain('play-btn'); + }); + + it('component name is extracted correctly', () => { + expect(result.componentName).toBe('TestFixture'); + }); +}); diff --git a/packages/compiler/test/components/play-button-with-icons.test.ts b/packages/compiler/test/components/play-button-with-icons.test.ts new file mode 100644 index 000000000..e5f4a5b28 --- /dev/null +++ b/packages/compiler/test/components/play-button-with-icons.test.ts @@ -0,0 +1,60 @@ +/** + * Tests for: play-button-with-icons.tsx + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement, querySelector } from '../helpers/dom'; + +describe('fixture: PlayButton - With Icons', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/buttons/play-button-with-icons.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-play-button element', () => { + expect(root.tagName.toLowerCase()).toBe('media-play-button'); + }); + + it('has play-btn class', () => { + expect(getClasses(root)).toContain('play-btn'); + }); + + it('has exactly 2 child elements', () => { + expect(root.children.length).toBe(2); + }); + + it('contains media-play-icon as first child', () => { + const playIcon = root.children[0]; + expect(playIcon?.tagName.toLowerCase()).toBe('media-play-icon'); + }); + + it('play icon has correct class', () => { + const playIcon = querySelector(root, 'media-play-icon'); + expect(getClasses(playIcon)).toContain('play-icon'); + }); + + it('contains media-pause-icon as second child', () => { + const pauseIcon = root.children[1]; + expect(pauseIcon?.tagName.toLowerCase()).toBe('media-pause-icon'); + }); + + it('pause icon has correct class', () => { + const pauseIcon = querySelector(root, 'media-pause-icon'); + expect(getClasses(pauseIcon)).toContain('pause-icon'); + }); + + it('extracts all classNames', () => { + expect(result.classNames).toEqual(expect.arrayContaining(['play-btn', 'play-icon', 'pause-icon'])); + }); +}); diff --git a/packages/compiler/test/components/play-icon-simple.test.ts b/packages/compiler/test/components/play-icon-simple.test.ts new file mode 100644 index 000000000..25e785f7d --- /dev/null +++ b/packages/compiler/test/components/play-icon-simple.test.ts @@ -0,0 +1,36 @@ +/** + * Tests for: play-icon-simple.tsx + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement } from '../helpers/dom'; + +describe('fixture: PlayIcon - Simple', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/icons/play-icon-simple.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-play-icon', () => { + expect(root.tagName.toLowerCase()).toBe('media-play-icon'); + }); + + it('preserves className', () => { + expect(getClasses(root)).toContain('icon'); + }); + + it('has no child elements', () => { + expect(root.children.length).toBe(0); + }); +}); diff --git a/packages/compiler/test/components/popover-with-slider.test.ts b/packages/compiler/test/components/popover-with-slider.test.ts new file mode 100644 index 000000000..865601e5c --- /dev/null +++ b/packages/compiler/test/components/popover-with-slider.test.ts @@ -0,0 +1,87 @@ +/** + * Tests for: popover-with-slider.tsx + */ + +import type { TestCompileResult } from '../helpers/compile'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement } from '../helpers/dom'; + +describe('fixture: Popover - With Slider', () => { + let result: TestCompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/interactive/popover-with-slider.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + describe('trigger element', () => { + it('button is extracted as first element', () => { + expect(root.tagName.toLowerCase()).toBe('media-mute-button'); + }); + + it('has commandfor attribute linking to popover', () => { + expect(root.getAttribute('commandfor')).toBe('mute-button-popover'); + }); + + it('has command="toggle-popover" attribute', () => { + expect(root.getAttribute('command')).toBe('toggle-popover'); + }); + + it('preserves button className', () => { + expect(getClasses(root)).toContain('btn'); + }); + }); + + describe('popover element', () => { + let popover: Element; + + beforeAll(() => { + const parser = new DOMParser(); + const doc = parser.parseFromString(result.html, 'text/html'); + const popoverEl = doc.querySelector('media-popover'); + if (!popoverEl) throw new Error('No popover found'); + popover = popoverEl; + }); + + it('creates media-popover as second element', () => { + expect(popover.tagName.toLowerCase()).toBe('media-popover'); + }); + + it('has matching ID for commandfor linking', () => { + expect(popover.getAttribute('id')).toBe('mute-button-popover'); + }); + + it('has popover="manual" attribute', () => { + expect(popover.getAttribute('popover')).toBe('manual'); + }); + + it('has open-on-hover attribute', () => { + expect(popover.hasAttribute('open-on-hover')).toBe(true); + }); + + it('preserves delay attribute', () => { + expect(popover.getAttribute('delay')).toBe('200'); + }); + + it('preserves close-delay attribute', () => { + expect(popover.getAttribute('close-delay')).toBe('100'); + }); + + it('preserves side attribute from Positioner', () => { + expect(popover.getAttribute('side')).toBe('top'); + }); + + it('contains volume slider as direct child', () => { + const slider = popover.querySelector('media-volume-slider'); + expect(slider).toBeDefined(); + }); + }); +}); diff --git a/packages/compiler/test/components/preview-time-display-simple.test.ts b/packages/compiler/test/components/preview-time-display-simple.test.ts new file mode 100644 index 000000000..d79d34ccf --- /dev/null +++ b/packages/compiler/test/components/preview-time-display-simple.test.ts @@ -0,0 +1,36 @@ +/** + * Tests for: preview-time-display-simple.tsx + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement } from '../helpers/dom'; + +describe('fixture: PreviewTimeDisplay - Simple', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/displays/preview-time-display-simple.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-preview-time-display', () => { + expect(root.tagName.toLowerCase()).toBe('media-preview-time-display'); + }); + + it('preserves className', () => { + expect(getClasses(root)).toContain('preview-time'); + }); + + it('has no child elements', () => { + expect(root.children.length).toBe(0); + }); +}); diff --git a/packages/compiler/test/components/time-slider-full.test.ts b/packages/compiler/test/components/time-slider-full.test.ts new file mode 100644 index 000000000..54db5f831 --- /dev/null +++ b/packages/compiler/test/components/time-slider-full.test.ts @@ -0,0 +1,130 @@ +/** + * Tests for: time-slider-full.tsx + * Tests full compound component structure + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement, querySelector } from '../helpers/dom'; + +describe('fixture: TimeSlider - Full Compound', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/sliders/time-slider-full.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + describe('component: Root element', () => { + it('transforms to media-time-slider', () => { + expect(root.tagName.toLowerCase()).toBe('media-time-slider'); + }); + + it('has slider class', () => { + expect(getClasses(root)).toContain('slider'); + }); + + it('has exactly 2 children (track + thumb)', () => { + expect(root.children.length).toBe(2); + }); + }); + + describe('track element', () => { + it('exists as media-time-slider-track', () => { + const track = querySelector(root, 'media-time-slider-track'); + expect(track).toBeDefined(); + }); + + it('is direct child of root', () => { + const track = querySelector(root, 'media-time-slider-track'); + expect(track.parentElement).toBe(root); + }); + + it('has track class', () => { + const track = querySelector(root, 'media-time-slider-track'); + expect(getClasses(track)).toContain('track'); + }); + + it('has exactly 2 children (progress + pointer)', () => { + const track = querySelector(root, 'media-time-slider-track'); + expect(track.children.length).toBe(2); + }); + }); + + describe('progress element', () => { + it('exists as media-time-slider-progress', () => { + const progress = querySelector(root, 'media-time-slider-progress'); + expect(progress).toBeDefined(); + }); + + it('is child of track', () => { + const track = querySelector(root, 'media-time-slider-track'); + const progress = querySelector(track, 'media-time-slider-progress'); + expect(progress.parentElement).toBe(track); + }); + + it('has progress class', () => { + const progress = querySelector(root, 'media-time-slider-progress'); + expect(getClasses(progress)).toContain('progress'); + }); + }); + + describe('pointer element', () => { + it('exists as media-time-slider-pointer', () => { + const pointer = querySelector(root, 'media-time-slider-pointer'); + expect(pointer).toBeDefined(); + }); + + it('is child of track', () => { + const track = querySelector(root, 'media-time-slider-track'); + const pointer = querySelector(track, 'media-time-slider-pointer'); + expect(pointer.parentElement).toBe(track); + }); + + it('has pointer class', () => { + const pointer = querySelector(root, 'media-time-slider-pointer'); + expect(getClasses(pointer)).toContain('pointer'); + }); + }); + + describe('thumb element', () => { + it('exists as media-time-slider-thumb', () => { + const thumb = querySelector(root, 'media-time-slider-thumb'); + expect(thumb).toBeDefined(); + }); + + it('is direct child of root (NOT track)', () => { + const thumb = querySelector(root, 'media-time-slider-thumb'); + expect(thumb.parentElement).toBe(root); + }); + + it('has thumb class', () => { + const thumb = querySelector(root, 'media-time-slider-thumb'); + expect(getClasses(thumb)).toContain('thumb'); + }); + }); + + describe('cSS extraction', () => { + it('extracts all classNames', () => { + expect(result.classNames).toEqual(expect.arrayContaining([ + 'slider', + 'track', + 'progress', + 'pointer', + 'thumb', + ])); + }); + + it('extracts exactly 5 classNames', () => { + expect(result.classNames.length).toBe(5); + }); + }); +}); diff --git a/packages/compiler/test/components/time-slider-root-only.test.ts b/packages/compiler/test/components/time-slider-root-only.test.ts new file mode 100644 index 000000000..402bd600d --- /dev/null +++ b/packages/compiler/test/components/time-slider-root-only.test.ts @@ -0,0 +1,49 @@ +/** + * Tests for: time-slider-root-only.tsx + * Critical: Tests Root → base element name mapping + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement } from '../helpers/dom'; + +describe('fixture: TimeSlider - Root Only', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/sliders/time-slider-root-only.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-time-slider (NOT media-time-slider-root)', () => { + expect(root.tagName.toLowerCase()).toBe('media-time-slider'); + }); + + it('does NOT have -root suffix', () => { + expect(root.tagName.toLowerCase()).not.toContain('root'); + }); + + it('preserves className', () => { + expect(getClasses(root)).toContain('slider'); + }); + + it('preserves orientation prop', () => { + expect(root.getAttribute('orientation')).toBe('horizontal'); + }); + + it('has no child elements (root only)', () => { + expect(root.children.length).toBe(0); + }); + + it('extracts className', () => { + expect(result.classNames).toContain('slider'); + }); +}); diff --git a/packages/compiler/test/components/tooltip-with-button.test.ts b/packages/compiler/test/components/tooltip-with-button.test.ts new file mode 100644 index 000000000..6fb17bb91 --- /dev/null +++ b/packages/compiler/test/components/tooltip-with-button.test.ts @@ -0,0 +1,77 @@ +import type { TestCompileResult } from '../helpers/compile'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement, querySelector } from '../helpers/dom'; + +describe('fixture: Tooltip - With Button', () => { + let result: TestCompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/interactive/tooltip-with-button.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + describe('trigger element', () => { + it('button is extracted as first element', () => { + expect(root.tagName.toLowerCase()).toBe('media-play-button'); + }); + + it('has commandfor attribute linking to tooltip', () => { + expect(root.getAttribute('commandfor')).toBe('play-button-tooltip'); + }); + + it('preserves button className', () => { + expect(getClasses(root)).toContain('btn'); + }); + + it('contains play icon as child', () => { + const icon = querySelector(root, 'media-play-icon'); + expect(icon).toBeDefined(); + }); + }); + + describe('tooltip element', () => { + let tooltip: Element; + + beforeAll(() => { + // Parse full HTML to get both elements + const parser = new DOMParser(); + const doc = parser.parseFromString(result.html, 'text/html'); + const tooltipEl = doc.querySelector('media-tooltip'); + if (!tooltipEl) throw new Error('No tooltip found'); + tooltip = tooltipEl; + }); + + it('creates media-tooltip as second element', () => { + expect(tooltip.tagName.toLowerCase()).toBe('media-tooltip'); + }); + + it('has matching ID for commandfor linking', () => { + expect(tooltip.getAttribute('id')).toBe('play-button-tooltip'); + }); + + it('has popover="manual" attribute', () => { + expect(tooltip.getAttribute('popover')).toBe('manual'); + }); + + it('preserves delay attribute from Root', () => { + expect(tooltip.getAttribute('delay')).toBe('500'); + }); + + it('preserves side attribute from Positioner', () => { + expect(tooltip.getAttribute('side')).toBe('top'); + }); + + it('contains popup content as direct children', () => { + const span = tooltip.querySelector('span'); + expect(span?.textContent).toBe('Play'); + }); + }); +}); diff --git a/packages/compiler/test/components/volume-slider-full.test.ts b/packages/compiler/test/components/volume-slider-full.test.ts new file mode 100644 index 000000000..9f61ce5ac --- /dev/null +++ b/packages/compiler/test/components/volume-slider-full.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for: volume-slider-full.tsx + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement, querySelector } from '../helpers/dom'; + +describe('fixture: VolumeSlider - Full Compound', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/sliders/volume-slider-full.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + describe('root element', () => { + it('transforms to media-volume-slider', () => { + expect(root.tagName.toLowerCase()).toBe('media-volume-slider'); + }); + + it('has slider class', () => { + expect(getClasses(root)).toContain('slider'); + }); + + it('has orientation attribute', () => { + expect(root.getAttribute('orientation')).toBe('vertical'); + }); + + it('has exactly 2 children (track + thumb)', () => { + expect(root.children.length).toBe(2); + }); + }); + + describe('track element', () => { + it('exists as media-volume-slider-track', () => { + const track = querySelector(root, 'media-volume-slider-track'); + expect(track).toBeDefined(); + }); + + it('is direct child of root', () => { + const track = querySelector(root, 'media-volume-slider-track'); + expect(track.parentElement).toBe(root); + }); + + it('has track class', () => { + const track = querySelector(root, 'media-volume-slider-track'); + expect(getClasses(track)).toContain('track'); + }); + + it('has exactly 1 child (progress)', () => { + const track = querySelector(root, 'media-volume-slider-track'); + expect(track.children.length).toBe(1); + }); + }); + + describe('progress element', () => { + it('exists as media-volume-slider-progress', () => { + const progress = querySelector(root, 'media-volume-slider-progress'); + expect(progress).toBeDefined(); + }); + + it('is child of track', () => { + const track = querySelector(root, 'media-volume-slider-track'); + const progress = querySelector(track, 'media-volume-slider-progress'); + expect(progress.parentElement).toBe(track); + }); + + it('has progress class', () => { + const progress = querySelector(root, 'media-volume-slider-progress'); + expect(getClasses(progress)).toContain('progress'); + }); + }); + + describe('thumb element', () => { + it('exists as media-volume-slider-thumb', () => { + const thumb = querySelector(root, 'media-volume-slider-thumb'); + expect(thumb).toBeDefined(); + }); + + it('is direct child of root', () => { + const thumb = querySelector(root, 'media-volume-slider-thumb'); + expect(thumb.parentElement).toBe(root); + }); + + it('has thumb class', () => { + const thumb = querySelector(root, 'media-volume-slider-thumb'); + expect(getClasses(thumb)).toContain('thumb'); + }); + }); + + describe('compiler CSS extraction', () => { + it('extracts all classNames', () => { + expect(result.classNames).toEqual(expect.arrayContaining([ + 'slider', + 'track', + 'progress', + 'thumb', + ])); + }); + + it('extracts exactly 4 classNames', () => { + expect(result.classNames.length).toBe(4); + }); + }); +}); diff --git a/packages/compiler/test/components/volume-slider-root-only.test.ts b/packages/compiler/test/components/volume-slider-root-only.test.ts new file mode 100644 index 000000000..dd7ed3ff5 --- /dev/null +++ b/packages/compiler/test/components/volume-slider-root-only.test.ts @@ -0,0 +1,45 @@ +/** + * Tests for: volume-slider-root-only.tsx + * Critical: Tests Root → base element name mapping + */ + +import type { CompileResult } from '../../src'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { getClasses, parseElement } from '../helpers/dom'; + +describe('fixture: VolumeSlider - Root Only', () => { + let result: CompileResult; + let root: Element; + + beforeAll(() => { + const source = readFileSync( + join(__dirname, '../fixtures/components/sliders/volume-slider-root-only.tsx'), + 'utf-8', + ); + result = compile(source); + root = parseElement(result.html); + }); + + it('transforms to media-volume-slider (NOT media-volume-slider-root)', () => { + expect(root.tagName.toLowerCase()).toBe('media-volume-slider'); + }); + + it('does NOT have -root suffix', () => { + expect(root.tagName.toLowerCase()).not.toContain('root'); + }); + + it('preserves className', () => { + expect(getClasses(root)).toContain('slider'); + }); + + it('preserves orientation attribute', () => { + expect(root.getAttribute('orientation')).toBe('vertical'); + }); + + it('has no child elements', () => { + expect(root.children.length).toBe(0); + }); +}); diff --git a/packages/compiler/test/fixtures/components/buttons/fullscreen-button-with-icons.tsx b/packages/compiler/test/fixtures/components/buttons/fullscreen-button-with-icons.tsx new file mode 100644 index 000000000..f5a387ae1 --- /dev/null +++ b/packages/compiler/test/fixtures/components/buttons/fullscreen-button-with-icons.tsx @@ -0,0 +1,15 @@ +/** + * FullscreenButton - With Icons + */ + +import { FullscreenButton } from '@videojs/react'; +import { FullscreenEnterIcon, FullscreenExitIcon } from '@videojs/react/icons'; + +export default function TestFixture() { + return ( + + + + + ); +} diff --git a/packages/compiler/test/fixtures/components/buttons/mute-button-simple.tsx b/packages/compiler/test/fixtures/components/buttons/mute-button-simple.tsx new file mode 100644 index 000000000..64cefb616 --- /dev/null +++ b/packages/compiler/test/fixtures/components/buttons/mute-button-simple.tsx @@ -0,0 +1,9 @@ +/** + * MuteButton - Simple (no icons) + */ + +import { MuteButton } from '@videojs/react'; + +export default function TestFixture() { + return ; +} diff --git a/packages/compiler/test/fixtures/components/buttons/mute-button-with-icons.tsx b/packages/compiler/test/fixtures/components/buttons/mute-button-with-icons.tsx new file mode 100644 index 000000000..56b18f769 --- /dev/null +++ b/packages/compiler/test/fixtures/components/buttons/mute-button-with-icons.tsx @@ -0,0 +1,16 @@ +/** + * MuteButton - With All Volume Icons + */ + +import { MuteButton } from '@videojs/react'; +import { VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@videojs/react/icons'; + +export default function TestFixture() { + return ( + + + + + + ); +} diff --git a/packages/compiler/test/fixtures/components/buttons/play-button-simple.tsx b/packages/compiler/test/fixtures/components/buttons/play-button-simple.tsx new file mode 100644 index 000000000..d2b06569e --- /dev/null +++ b/packages/compiler/test/fixtures/components/buttons/play-button-simple.tsx @@ -0,0 +1,9 @@ +/** + * PlayButton - Simple (no icons) + */ + +import { PlayButton } from '@videojs/react'; + +export default function TestFixture() { + return ; +} diff --git a/packages/compiler/test/fixtures/components/buttons/play-button-with-icons.tsx b/packages/compiler/test/fixtures/components/buttons/play-button-with-icons.tsx new file mode 100644 index 000000000..39ca6f57e --- /dev/null +++ b/packages/compiler/test/fixtures/components/buttons/play-button-with-icons.tsx @@ -0,0 +1,15 @@ +/** + * PlayButton - With Icons + */ + +import { PlayButton } from '@videojs/react'; +import { PauseIcon, PlayIcon } from '@videojs/react/icons'; + +export default function TestFixture() { + return ( + + + + + ); +} diff --git a/packages/compiler/test/fixtures/components/containers/media-container-simple.tsx b/packages/compiler/test/fixtures/components/containers/media-container-simple.tsx new file mode 100644 index 000000000..f0e45bccf --- /dev/null +++ b/packages/compiler/test/fixtures/components/containers/media-container-simple.tsx @@ -0,0 +1,14 @@ +/** + * MediaContainer - Simple with children + */ + +import type { PropsWithChildren } from 'react'; +import { MediaContainer } from '@videojs/react'; + +export default function TestFixture({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/packages/compiler/test/fixtures/components/displays/current-time-display-simple.tsx b/packages/compiler/test/fixtures/components/displays/current-time-display-simple.tsx new file mode 100644 index 000000000..5fa365131 --- /dev/null +++ b/packages/compiler/test/fixtures/components/displays/current-time-display-simple.tsx @@ -0,0 +1,9 @@ +/** + * CurrentTimeDisplay - Simple + */ + +import { CurrentTimeDisplay } from '@videojs/react'; + +export default function TestFixture() { + return ; +} diff --git a/packages/compiler/test/fixtures/components/displays/duration-display-simple.tsx b/packages/compiler/test/fixtures/components/displays/duration-display-simple.tsx new file mode 100644 index 000000000..695373074 --- /dev/null +++ b/packages/compiler/test/fixtures/components/displays/duration-display-simple.tsx @@ -0,0 +1,9 @@ +/** + * DurationDisplay - Simple + */ + +import { DurationDisplay } from '@videojs/react'; + +export default function TestFixture() { + return ; +} diff --git a/packages/compiler/test/fixtures/components/displays/preview-time-display-simple.tsx b/packages/compiler/test/fixtures/components/displays/preview-time-display-simple.tsx new file mode 100644 index 000000000..a969e980b --- /dev/null +++ b/packages/compiler/test/fixtures/components/displays/preview-time-display-simple.tsx @@ -0,0 +1,9 @@ +/** + * PreviewTimeDisplay - Simple + */ + +import { PreviewTimeDisplay } from '@videojs/react'; + +export default function TestFixture() { + return ; +} diff --git a/packages/compiler/test/fixtures/components/icons/play-icon-simple.tsx b/packages/compiler/test/fixtures/components/icons/play-icon-simple.tsx new file mode 100644 index 000000000..d86e8f9f0 --- /dev/null +++ b/packages/compiler/test/fixtures/components/icons/play-icon-simple.tsx @@ -0,0 +1,9 @@ +/** + * PlayIcon - Simple + */ + +import { PlayIcon } from '@videojs/react/icons'; + +export default function TestFixture() { + return ; +} diff --git a/packages/compiler/test/fixtures/components/interactive/popover-with-slider.tsx b/packages/compiler/test/fixtures/components/interactive/popover-with-slider.tsx new file mode 100644 index 000000000..2ff74a5ab --- /dev/null +++ b/packages/compiler/test/fixtures/components/interactive/popover-with-slider.tsx @@ -0,0 +1,21 @@ +import { MuteButton, Popover, VolumeSlider } from '@videojs/react'; + +export default function TestFixture() { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/packages/compiler/test/fixtures/components/interactive/tooltip-with-button.tsx b/packages/compiler/test/fixtures/components/interactive/tooltip-with-button.tsx new file mode 100644 index 000000000..4375e670d --- /dev/null +++ b/packages/compiler/test/fixtures/components/interactive/tooltip-with-button.tsx @@ -0,0 +1,19 @@ +import { PlayButton, Tooltip } from '@videojs/react'; +import { PlayIcon } from '@videojs/react/icons'; + +export default function TestFixture() { + return ( + + + + + + + + + Play + + + + ); +} diff --git a/packages/compiler/test/fixtures/components/sliders/time-slider-full.tsx b/packages/compiler/test/fixtures/components/sliders/time-slider-full.tsx new file mode 100644 index 000000000..ec17ef285 --- /dev/null +++ b/packages/compiler/test/fixtures/components/sliders/time-slider-full.tsx @@ -0,0 +1,18 @@ +/** + * TimeSlider - Full Compound Component + * Tests all subcomponents + */ + +import { TimeSlider } from '@videojs/react'; + +export default function TestFixture() { + return ( + + + + + + + + ); +} diff --git a/packages/compiler/test/fixtures/components/sliders/time-slider-root-only.tsx b/packages/compiler/test/fixtures/components/sliders/time-slider-root-only.tsx new file mode 100644 index 000000000..a43edf71f --- /dev/null +++ b/packages/compiler/test/fixtures/components/sliders/time-slider-root-only.tsx @@ -0,0 +1,15 @@ +/** + * TimeSlider - Root Only + * Tests: Root → base name, orientation prop + */ + +import { TimeSlider } from '@videojs/react'; + +export default function TestFixture() { + return ( + + ); +} diff --git a/packages/compiler/test/fixtures/components/sliders/volume-slider-full.tsx b/packages/compiler/test/fixtures/components/sliders/volume-slider-full.tsx new file mode 100644 index 000000000..dcebddb11 --- /dev/null +++ b/packages/compiler/test/fixtures/components/sliders/volume-slider-full.tsx @@ -0,0 +1,17 @@ +/** + * VolumeSlider - Full Compound Component + * Tests all subcomponents + */ + +import { VolumeSlider } from '@videojs/react'; + +export default function TestFixture() { + return ( + + + + + + + ); +} diff --git a/packages/compiler/test/fixtures/components/sliders/volume-slider-root-only.tsx b/packages/compiler/test/fixtures/components/sliders/volume-slider-root-only.tsx new file mode 100644 index 000000000..3b6037319 --- /dev/null +++ b/packages/compiler/test/fixtures/components/sliders/volume-slider-root-only.tsx @@ -0,0 +1,15 @@ +/** + * VolumeSlider - Root Only + * Tests: Root → base name, orientation prop + */ + +import { VolumeSlider } from '@videojs/react'; + +export default function TestFixture() { + return ( + + ); +} diff --git a/packages/compiler/test/fixtures/skins/frosted/FrostedSkin.tsx b/packages/compiler/test/fixtures/skins/frosted/FrostedSkin.tsx new file mode 100644 index 000000000..90d8877a7 --- /dev/null +++ b/packages/compiler/test/fixtures/skins/frosted/FrostedSkin.tsx @@ -0,0 +1,125 @@ +/** + * Frosted Skin + * + * Exact copy from packages/react/src/skins/frosted/FrostedSkin.tsx + * Updated imports to use @videojs/react package exports + */ + +import type { PropsWithChildren } from 'react'; + +import { + CurrentTimeDisplay, + DurationDisplay, + FullscreenButton, + MediaContainer, + MuteButton, + PlayButton, + Popover, + PreviewTimeDisplay, + TimeSlider, + Tooltip, + VolumeSlider, +} from '@videojs/react'; + +import { + FullscreenEnterIcon, + FullscreenExitIcon, + PauseIcon, + PlayIcon, + VolumeHighIcon, + VolumeLowIcon, + VolumeOffIcon, +} from '@videojs/react/icons'; + +import styles from './styles'; + +type SkinProps = PropsWithChildren<{ + className?: string; +}>; + +export default function FrostedSkin({ children, className = '' }: SkinProps): JSX.Element { + return ( + + {children} + +
+ +
+ + + + + + + + + + Play + Pause + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Enter Fullscreen + Exit Fullscreen + + + +
+ + ); +} diff --git a/packages/compiler/test/fixtures/skins/frosted/styles.ts b/packages/compiler/test/fixtures/skins/frosted/styles.ts new file mode 100644 index 000000000..f506e15ee --- /dev/null +++ b/packages/compiler/test/fixtures/skins/frosted/styles.ts @@ -0,0 +1,150 @@ +import type { FrostedSkinStyles } from './types'; + +// NOTE: Removing import to sidestep for compiler complexity (CJP) +// import { cn } from '../../utils/cn'; +// A (very crude) utility to merge class names +// Usually I'd use something like `clsx` or `classnames` but this is ok for our simple use case. +// It just makes the billions of Tailwind classes a little easier to read. +function cn(...classes: (string | undefined)[]): string { + return classes.filter(Boolean).join(' '); +} + +const styles: FrostedSkinStyles = { + MediaContainer: cn( + 'vjs', // Scope preflight + 'vjs:relative vjs:isolate vjs:@container/root vjs:group/root vjs:overflow-clip vjs:bg-black vjs:rounded-(--vjs-border-radius,2rem)', + // Base typography + 'vjs:text-[0.8125rem]', + // Fancy borders + 'vjs:after:absolute vjs:after:inset-0 vjs:after:ring-black/10 vjs:after:ring-1 vjs:dark:after:ring-white/10 vjs:after:ring-inset vjs:after:z-10 vjs:after:pointer-events-none vjs:after:rounded-[inherit]', + 'vjs:before:absolute vjs:before:inset-px vjs:before:rounded-[inherit] vjs:before:ring-white/15 vjs:before:ring-1 vjs:before:ring-inset vjs:before:z-10 vjs:before:pointer-events-none vjs:dark:before:ring-0', + // Prevent rounded corners in fullscreen + 'vjs:[&:fullscreen]:rounded-none', + // Ensure the nested video inherits the radius + 'vjs:[&_video]:w-full vjs:[&_video]:h-full', + ), + Overlay: cn( + 'vjs:opacity-0 vjs:delay-500 vjs:duration-300 vjs:rounded-[inherit] vjs:absolute vjs:inset-0 vjs:pointer-events-none vjs:bg-gradient-to-t vjs:from-black/50 vjs:via-black/20 vjs:to-transparent vjs:transition-opacity vjs:backdrop-saturate-150 vjs:backdrop-brightness-90', + // ------------------------------------ + // FIXME: This is crude temporary logic, we'll improve it later I guess with a [data-show-controls] attribute or something? + 'vjs:has-[+.controls_[data-paused]]:opacity-100 vjs:has-[+.controls_[data-paused]]:delay-0 vjs:has-[+.controls_[data-paused]]:duration-100', + 'vjs:has-[+.controls_[aria-expanded="true"]]:opacity-100 vjs:has-[+.controls_[aria-expanded="true"]]:delay-0 vjs:has-[+.controls_[aria-expanded="true"]]:duration-100', + 'vjs:group-hover/root:opacity-100 vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-100', + // ------------------------------------ + ), + Surface: cn( + 'vjs:bg-white/10 vjs:backdrop-blur-3xl vjs:backdrop-saturate-150 vjs:backdrop-brightness-90', + // Ring and shadow + 'vjs:ring vjs:ring-white/10 vjs:ring-inset vjs:shadow-sm vjs:shadow-black/15', + // Border to enhance contrast on lighter videos + 'vjs:after:absolute vjs:after:inset-0 vjs:after:ring vjs:after:rounded-[inherit] vjs:after:ring-black/15 vjs:after:pointer-events-none vjs:after:z-10', + // Reduced transparency for users with preference + // XXX: This requires a Tailwind custom variant (see 1 below) + 'vjs:reduced-transparency:bg-black/70 vjs:reduced-transparency:ring-black vjs:reduced-transparency:after:ring-white/20', + // High contrast mode + 'vjs:contrast-more:bg-black/90 vjs:contrast-more:ring-black vjs:contrast-more:after:ring-white/20', + ), + Controls: cn( + // ------------------------------------ + // FIXME: Temporary className hook for above logic in the overlay. Can be removed once have a proper way to handle controls visibility. + 'controls', + // ------------------------------------ + 'vjs:@container/controls vjs:absolute vjs:inset-x-3 vjs:bottom-3 vjs:rounded-full vjs:flex vjs:items-center vjs:p-1 vjs:gap-0.5 vjs:text-white', + // Animation + 'vjs:transition vjs:will-change-[transform,scale,filter,opacity] vjs:origin-bottom vjs:ease-out', + // ------------------------------------ + // FIXME: Temporary hide/show logic, related to above. + 'vjs:scale-90 vjs:opacity-0 vjs:blur-sm vjs:delay-500 vjs:duration-300', + 'vjs:has-[[data-paused]]:scale-100 vjs:has-[[data-paused]]:opacity-100 vjs:has-[[data-paused]]:blur-none vjs:has-[[data-paused]]:delay-0 vjs:has-[[data-paused]]:duration-100', + 'vjs:has-[[aria-expanded="true"]]:scale-100 vjs:has-[[aria-expanded="true"]]:opacity-100 vjs:has-[[aria-expanded="true"]]:blur-none vjs:has-[[aria-expanded="true"]]:delay-0 vjs:has-[[aria-expanded="true"]]:duration-100', + 'vjs:group-hover/root:scale-100 vjs:group-hover/root:opacity-100 vjs:group-hover/root:blur-none vjs:group-hover/root:delay-0 vjs:group-hover/root:duration-100', + // ------------------------------------ + ), + Icon: cn('icon vjs:[&_path]:transition-transform vjs:[&_path]:ease-out'), + Button: cn( + 'vjs:group/button vjs:cursor-pointer vjs:relative vjs:shrink-0 vjs:transition-[color,background,outline-offset] vjs:select-none vjs:p-2 vjs:rounded-full', + // Background/foreground + 'vjs:bg-transparent vjs:text-white/90', + // Hover and focus states + 'vjs:hover:no-underline vjs:hover:bg-white/10 vjs:hover:text-white vjs:focus-visible:no-underline vjs:focus-visible:bg-white/10 vjs:focus-visible:text-white', + // Focus state + 'vjs:-outline-offset-2 vjs:focus-visible:outline-2 vjs:focus-visible:outline-offset-2 vjs:focus-visible:outline-blue-500', + // Disabled state + 'vjs:disabled:grayscale vjs:disabled:opacity-50 vjs:disabled:cursor-not-allowed', + // Loading state + 'vjs:aria-busy:pointer-events-none vjs:aria-busy:cursor-not-allowed', + // Expanded state + 'vjs:aria-expanded:bg-white/10 vjs:aria-expanded:text-white', + ), + IconButton: cn( + 'vjs:grid vjs:[&_.icon]:[grid-area:1/1]', + 'vjs:[&_.icon]:shrink-0 vjs:[&_.icon]:transition-opacity vjs:[&_.icon]:duration-150 vjs:[&_.icon]:ease-linear vjs:[&_.icon]:drop-shadow-[0_1px_0_var(--tw-shadow-color)] vjs:[&_.icon]:shadow-black/25', + ), + PlayIcon: cn('vjs:opacity-0 vjs:group-data-paused/button:opacity-100'), + PauseIcon: cn('vjs:group-data-paused/button:opacity-0'), + PlayTooltipPopup: cn( + 'vjs:[&_.pause-tooltip]:inline vjs:[&_.play-tooltip]:hidden', + 'vjs:data-paused:[&_.pause-tooltip]:hidden vjs:data-paused:[&_.play-tooltip]:inline', + ), + PlayTooltip: cn('play-tooltip'), + PauseTooltip: cn('pause-tooltip'), + VolumeHighIcon: cn('vjs:hidden vjs:group-data-[volume-level=high]/button:inline vjs:group-data-[volume-level=medium]/button:inline'), + VolumeLowIcon: cn('vjs:hidden vjs:group-data-[volume-level=low]/button:inline'), + VolumeOffIcon: cn('vjs:hidden vjs:group-data-[volume-level=off]/button:inline'), + FullscreenEnterIcon: cn( + 'vjs:group-data-fullscreen/button:hidden', + 'vjs:group-hover/button:[&_.arrow-1]:-translate-x-px vjs:group-hover/button:[&_.arrow-1]:-translate-y-px', + 'vjs:group-hover/button:[&_.arrow-2]:translate-x-px vjs:group-hover/button:[&_.arrow-2]:translate-y-px', + ), + FullscreenExitIcon: cn( + 'vjs:hidden vjs:group-data-fullscreen/button:inline', + 'vjs:[&_.arrow-1]:-translate-x-px vjs:[&_.arrow-1]:-translate-y-px', + 'vjs:[&_.arrow-2]:translate-x-px vjs:[&_.arrow-2]:translate-y-px', + 'vjs:group-hover/button:[&_.arrow-1]:translate-0', + 'vjs:group-hover/button:[&_.arrow-2]:translate-0', + ), + FullscreenTooltipPopup: cn( + 'vjs:[&_.fullscreen-enter-tooltip]:inline vjs:data-fullscreen:[&_.fullscreen-enter-tooltip]:hidden', + 'vjs:[&_.fullscreen-exit-tooltip]:hidden vjs:data-fullscreen:[&_.fullscreen-exit-tooltip]:inline', + ), + FullscreenEnterTooltip: cn('fullscreen-enter-tooltip'), + FullscreenExitTooltip: cn('fullscreen-exit-tooltip'), + TimeControls: cn('vjs:flex-1 vjs:flex vjs:items-center vjs:gap-3 vjs:px-1.5'), + TimeDisplay: cn('vjs:tabular-nums vjs:text-shadow-2xs/25'), + SliderRoot: cn( + 'vjs:group/slider vjs:outline-0 vjs:flex vjs:items-center vjs:justify-center vjs:flex-1 vjs:relative vjs:rounded-full', + 'vjs:data-[orientation=horizontal]:h-5 vjs:data-[orientation=horizontal]:min-w-20', + 'vjs:data-[orientation=vertical]:w-5 vjs:data-[orientation=vertical]:h-20', + ), + SliderTrack: cn( + 'vjs:relative vjs:select-none vjs:transition-[outline-offset] vjs:rounded-[inherit] vjs:bg-white/20 vjs:ring-1 vjs:ring-black/5', + 'vjs:data-[orientation=horizontal]:w-full vjs:data-[orientation=horizontal]:h-1', + 'vjs:data-[orientation=vertical]:w-1', + 'vjs:-outline-offset-2 vjs:group-focus-visible/slider:outline-2 vjs:group-focus-visible/slider:outline-offset-6 vjs:group-focus-visible/slider:outline-blue-500', + ), + SliderProgress: cn('vjs:bg-white vjs:rounded-[inherit]'), + // TODO: Work out what we want to do here. + SliderPointer: cn('vjs:bg-white/20 vjs:rounded-[inherit]'), + SliderThumb: cn( + 'vjs:bg-white vjs:z-10 vjs:select-none vjs:ring vjs:ring-black/10 vjs:rounded-full vjs:shadow-sm vjs:shadow-black/15 vjs:opacity-0 vjs:transition-[opacity,height,width] vjs:ease-out', + 'vjs:group-hover/slider:opacity-100 vjs:group-focus-within/slider:opacity-100', + 'vjs:size-2.5 vjs:active:size-3 vjs:group-active/slider:size-3', + 'vjs:data-[orientation=horizontal]:hover:cursor-ew-resize', + 'vjs:data-[orientation=vertical]:hover:cursor-ns-resize', + ), + PopupAnimation: cn( + // Animation + // XXX: We can't use transforms since floating UI uses them for positioning. + 'vjs:transition-[transform,scale,opacity,filter] vjs:origin-bottom vjs:duration-200 vjs:data-instant:duration-0', + 'vjs:data-starting-style:scale-0 vjs:data-starting-style:opacity-0 vjs:data-starting-style:blur-sm', + 'vjs:data-ending-style:scale-0 vjs:data-ending-style:opacity-0 vjs:data-ending-style:blur-sm', + ), + PopoverPopup: cn('vjs:relative vjs:px-1 vjs:py-3 vjs:rounded-full'), + TooltipPopup: cn('vjs:whitespace-nowrap vjs:rounded-full vjs:text-white vjs:text-xs vjs:@7xl/root:text-sm vjs:px-2.5 vjs:py-1'), +}; + +/* +[1] @custom-variant reduced-transparency @media (prefers-reduced-transparency: reduce); +*/ + +export default styles; diff --git a/packages/compiler/test/fixtures/skins/simple/SimpleSkin.tsx b/packages/compiler/test/fixtures/skins/simple/SimpleSkin.tsx new file mode 100644 index 000000000..07c0de53a --- /dev/null +++ b/packages/compiler/test/fixtures/skins/simple/SimpleSkin.tsx @@ -0,0 +1,34 @@ +/** + * Example: Minimal Skin + * + * Simple skin with basic controls for testing compilation + * Uses actual @videojs/react package exports + */ + +import type { PropsWithChildren } from 'react'; +import { MediaContainer, PlayButton, TimeSlider } from '@videojs/react'; + +const styles = { + Container: 'container', + Controls: 'controls', + PlayButton: 'play-button', + SliderRoot: 'slider-root', + SliderTrack: 'slider-track', + SliderProgress: 'slider-progress', +}; + +export default function SimpleSkin({ children }: PropsWithChildren): JSX.Element { + return ( + + {children} +
+ + + + + + +
+
+ ); +} diff --git a/packages/compiler/test/helpers/compile.ts b/packages/compiler/test/helpers/compile.ts new file mode 100644 index 000000000..6bf0b2603 --- /dev/null +++ b/packages/compiler/test/helpers/compile.ts @@ -0,0 +1,87 @@ +/** + * Test Compilation Helper + * + * Wraps new pipeline to provide test-friendly result format + * Exposes intermediate results (html, classNames, etc.) for detailed testing + */ + +import { analyze, categorize, composeModule, defaultCompilerConfig, projectModule } from '../../src'; +import { formatHTML } from '../../src/utils/formatters/html'; +import { formatImports } from '../../src/utils/formatters/imports'; +import { createInitialContext } from '../utils'; + +/** + * Compile result for testing + * Exposes intermediate pipeline results + */ +export interface TestCompileResult { + /** Projected HTML (without module wrapper) */ + html: string; + /** Extracted class names from analysis */ + classNames: string[]; + /** Complete compiled module */ + module: string; + /** Module imports (formatted string with newlines) */ + imports: string; + /** Removed imports (framework imports that were filtered out) */ + removedImports: string[]; + /** Component name from default export */ + componentName: string; + /** Projected CSS content */ + css: string; +} + +/** + * Compile React skin for testing + * Returns intermediate results for detailed assertions + * + * @param source - React skin source code + * @returns Test-friendly compile result + */ +export function compileForTest(source: string): TestCompileResult { + // Create initial context with default projection state + const initialContext = createInitialContext(source); + + // Run through 3-phase pipeline + const analyzedContext = analyze(initialContext, defaultCompilerConfig); + const categorizedContext = categorize( + { ...analyzedContext, projectionState: initialContext.projectionState }, + defaultCompilerConfig, + ); + const projectedContext = projectModule(categorizedContext, defaultCompilerConfig); + + // Extract classNames from categorized context // Apply same rules as resolveClassName: omit component-match, kebab-case generic-style + // Use projectors to transform className keys (same logic as resolveClassName) + const classNames = (categorizedContext.classNames ?? []).reduce((acc, cn) => { + const projector = defaultCompilerConfig.classNameProjectors[cn.category]; + if (!projector) { + return acc; + } + return projector(acc, cn, categorizedContext); + }, []); + + // Sort alphabetically for consistent output + classNames.sort(); + + // Track removed imports (framework imports that were filtered out) + const removedImports: string[] = []; + for (const imp of categorizedContext.imports ?? []) { + if (imp.category === 'framework') { + removedImports.push(imp.source); + } + } + + // Format imports and HTML for test output + const imports = formatImports(projectedContext.projectionState.imports); + const html = formatHTML(projectedContext.projectionState.html); + + return { + html, + classNames, + module: composeModule(projectedContext.projectionState), + imports, + removedImports, + componentName: categorizedContext.defaultExport.componentName, + css: projectedContext.projectionState.css || '', + }; +} diff --git a/packages/compiler/test/helpers/dom.ts b/packages/compiler/test/helpers/dom.ts new file mode 100644 index 000000000..98b72da68 --- /dev/null +++ b/packages/compiler/test/helpers/dom.ts @@ -0,0 +1,68 @@ +/** + * DOM Testing Helpers + * + * Utilities for parsing and querying compiled HTML in tests + */ + +/** + * Parse HTML string into DOM + */ +export function parseHTML(html: string): Document { + const parser = new DOMParser(); + return parser.parseFromString(html, 'text/html'); +} + +/** + * Parse HTML and return the first element in body + */ +export function parseElement(html: string): Element { + const doc = parseHTML(html); + const element = doc.body.firstElementChild; + + if (!element) { + throw new Error('No element found in HTML'); + } + + return element; +} + +/** + * Query selector with better error message + */ +export function querySelector(parent: Element | Document, selector: string): Element { + const element = parent.querySelector(selector); + + if (!element) { + throw new Error(`Element not found: ${selector}\n\nHTML:\n${parent instanceof Element ? parent.outerHTML : parent.body.innerHTML}`); + } + + return element; +} + +/** + * Query all matching elements + */ +export function querySelectorAll(parent: Element | Document, selector: string): Element[] { + return Array.from(parent.querySelectorAll(selector)); +} + +/** + * Check if element has class + */ +export function hasClass(element: Element, className: string): boolean { + return element.classList.contains(className); +} + +/** + * Get all classes from element + */ +export function getClasses(element: Element): string[] { + return Array.from(element.classList); +} + +/** + * Check if element exists with optional assertion message + */ +export function elementExists(parent: Element | Document, selector: string): boolean { + return parent.querySelector(selector) !== null; +} diff --git a/packages/compiler/test/imports.test.ts b/packages/compiler/test/imports.test.ts new file mode 100644 index 000000000..1be313022 --- /dev/null +++ b/packages/compiler/test/imports.test.ts @@ -0,0 +1,135 @@ +/** + * Tests for import transformation + */ + +import { describe, expect, it } from 'vitest'; +import { compileForTest as compile } from './helpers/compile'; + +describe('import Transformation', () => { + describe('react imports', () => { + it('removes React import', () => { + const source = ` + import { useState } from 'react'; + export default () =>
; + `; + + const result = compile(source); + expect(result.removedImports).toContain('react'); + expect(result.imports).not.toContain(expect.stringContaining('react')); + }); + + it('removes React type imports', () => { + const source = ` + import type { PropsWithChildren } from 'react'; + export default () =>
; + `; + + const result = compile(source); + expect(result.removedImports).toContain('react'); + }); + }); + + describe('@videojs/react component imports', () => { + it('transforms single component import', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + export default () => ; + `; + + const result = compile(source); + expect(result.imports).toContain(`import '@/define/media-play-button';`); + }); + + it('transforms multiple component imports', () => { + const source = ` + import { PlayButton, MuteButton, TimeSlider } from '@videojs/react'; + export default () =>
; + `; + + const result = compile(source); + expect(result.imports).toContain(`import '@/define/media-play-button';`); + expect(result.imports).toContain(`import '@/define/media-mute-button';`); + expect(result.imports).toContain(`import '@/define/media-time-slider';`); + }); + + it('transforms MediaContainer correctly', () => { + const source = ` + import { MediaContainer } from '@videojs/react'; + export default () => ; + `; + + const result = compile(source); + expect(result.imports).toContain(`import '@/define/media-container';`); + }); + }); + + describe('@videojs/react/icons imports', () => { + it('transforms icon imports to single icons import', () => { + const source = ` + import { PlayIcon, PauseIcon } from '@videojs/react/icons'; + export default () =>
; + `; + + const result = compile(source); + expect(result.imports).toContain(`import '@/icons';`); + }); + }); + + describe('style imports', () => { + it('transforms .ts style import to .css', () => { + const source = ` + import styles from './styles.ts'; + export default () =>
; + `; + + const result = compile(source); + expect(result.imports).toContain(`import styles from './styles.css';`); + }); + + it('transforms /styles import to /styles.css', () => { + const source = ` + import styles from './styles'; + export default () =>
; + `; + + const result = compile(source); + expect(result.imports).toContain(`import styles from './styles.css';`); + }); + }); + + describe('complete example', () => { + it('transforms all imports in realistic component', () => { + const source = ` + import type { PropsWithChildren } from 'react'; + import { PlayButton, TimeSlider } from '@videojs/react'; + import { PlayIcon, PauseIcon } from '@videojs/react/icons'; + import styles from './styles'; + + export default function TestSkin({ children }: PropsWithChildren) { + return ( +
+ {children} + + + +
+ ); + } + `; + + const result = compile(source); + + // Should have HTML imports + expect(result.imports).toContain(`import '@/define/media-play-button';`); + expect(result.imports).toContain(`import '@/define/media-time-slider';`); + expect(result.imports).toContain(`import '@/icons';`); + expect(result.imports).toContain(`import styles from './styles.css';`); + + // Should remove React + expect(result.removedImports).toContain('react'); + + // Should NOT have React imports in output + expect(result.imports).not.toContain('react'); + }); + }); +}); diff --git a/packages/compiler/test/jsx-transform.test.ts b/packages/compiler/test/jsx-transform.test.ts new file mode 100644 index 000000000..c494f6cc1 --- /dev/null +++ b/packages/compiler/test/jsx-transform.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from 'vitest'; +import { compileForTest as compile } from './helpers/compile'; + +describe('jSX Transformation', () => { + describe('element Name Transformation', () => { + it('transforms custom elements to kebab-case with media- prefix', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + export default function TestSkin() { + return ; + } + `; + + const result = compile(source); + expect(result.html).toBe(''); + }); + + it('transforms compound components correctly', () => { + const source = ` + import { TimeSlider } from '@videojs/react'; + export default function TestSkin() { + return ; + } + `; + + const result = compile(source); + // Root maps to base element name without -root suffix + expect(result.html).toBe( + '\n \n', + ); + }); + + it('preserves built-in elements unchanged', () => { + const source = ` + export default function TestSkin() { + return
; + } + `; + + const result = compile(source); + expect(result.html).toBe('
\n \n
'); + }); + }); + + describe('attribute Transformation', () => { + it('transforms className to class', () => { + const source = ` + export default function TestSkin() { + return
; + } + `; + + const result = compile(source); + expect(result.html).toBe('
'); + expect(result.classNames).toContain('container'); + }); + + it('extracts className from member expression', () => { + const source = ` + const styles = { Button: 'button' }; + export default function TestSkin() { + return
; + } + `; + + const result = compile(source); + expect(result.html).toBe('
'); + expect(result.classNames).toContain('button'); + }); + + it('converts camelCase attributes to kebab-case', () => { + const source = ` + export default function TestSkin() { + return
; + } + `; + + const result = compile(source); + expect(result.html).toContain('data-test-id="test"'); + expect(result.html).toContain('aria-label="Label"'); + }); + + it('handles numeric attribute values', () => { + const source = ` + export default function TestSkin() { + return
; + } + `; + + const result = compile(source); + expect(result.html).toBe('
'); + }); + + it('handles boolean attribute values', () => { + const source = ` + export default function TestSkin() { + return '); + }); + + it('handles boolean attributes without value', () => { + const source = ` + export default function TestSkin() { + return '); + }); + }); + + describe('children Transformation', () => { + it('transforms {children} to slot element', () => { + const source = ` + export default function TestSkin({ children }) { + return
{children}
; + } + `; + + const result = compile(source); + expect(result.html).toBe('
\n \n
'); + }); + + it('preserves text content', () => { + const source = ` + export default function TestSkin() { + return ; + } + `; + + const result = compile(source); + expect(result.html).toBe(''); + }); + + it('handles nested elements', () => { + const source = ` + export default function TestSkin() { + return ( +
+ +
+ ); + } + `; + + const result = compile(source); + expect(result.html).toBe( + '
\n \n
', + ); + }); + }); + + describe('self-Closing Elements', () => { + it('converts self-closing elements to explicit closing tags', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + export default function TestSkin() { + return ; + } + `; + + const result = compile(source); + expect(result.html).toBe(''); + }); + + it('handles self-closing built-in elements', () => { + const source = ` + export default function TestSkin() { + return ; + } + `; + + const result = compile(source); + expect(result.html).toBe(''); + }); + }); + + describe('complex Examples', () => { + it('handles compound components with className and children', () => { + const source = ` + import { TimeSlider } from '@videojs/react'; + const styles = { + Root: 'root', + Track: 'track', + Progress: 'progress' + }; + + export default function TestSkin({ children }) { + return ( +
+ {children} + + + + + +
+ ); + } + `; + + const result = compile(source); + + expect(result.html).toContain(''); + expect(result.html).toContain(''); + expect(result.html).toContain(''); + expect(result.html).toContain(''); + expect(result.classNames).toEqual(expect.arrayContaining(['root', 'track', 'progress'])); + }); + + it('handles mixed built-in and custom elements', () => { + const source = ` + import { PlayButton, MuteButton, VolumeSlider } from '@videojs/react'; + export default function TestSkin() { + return ( +
+ +
+ + + + +
+
+ ); + } + `; + + const result = compile(source); + + expect(result.html).toContain('
'); + expect(result.html).toContain(''); + expect(result.html).toContain(''); + expect(result.html).toContain(''); + expect(result.classNames).toEqual(expect.arrayContaining(['wrapper', 'play-btn', 'controls'])); + }); + }); + + describe('cSS Generation', () => { + it.skip('generates placeholder CSS for extracted classNames (CSS generates template reference, not actual rules)', () => { + const source = ` + export default function TestSkin() { + return
; + } + `; + + const result = compile(source); + + expect(result.css).toContain('.button {'); + expect(result.css).toContain('.container {'); + expect(result.css).toContain('/* TODO: Add styles */'); + }); + + it('sorts classNames alphabetically', () => { + const source = ` + export default function TestSkin() { + return ( +
+
+
+
+
+ ); + } + `; + + const result = compile(source); + const classOrder = result.classNames; + + expect(classOrder).toEqual(['apple', 'mango', 'zebra']); + }); + + it.skip('handles empty classNames gracefully (CSS generates template reference, not comments)', () => { + const source = ` + export default function TestSkin() { + return
No classes
; + } + `; + + const result = compile(source); + + expect(result.classNames).toEqual([]); + expect(result.css).toContain('/* No classes found */'); + }); + }); + + describe('component Name Extraction', () => { + it('extracts component name from default export', () => { + const source = ` + export default function MediaSkinMinimal() { + return
; + } + `; + + const result = compile(source); + expect(result.componentName).toBe('MediaSkinMinimal'); + }); + + it('handles arrow function components', () => { + const source = ` + export default () =>
; + `; + + const result = compile(source); + // Arrow functions without name will be 'UnknownComponent' + expect(result.componentName).toBe('UnknownComponent'); + expect(result.html).toBe('
'); + }); + }); +}); diff --git a/packages/compiler/test/projection/format-imports.test.ts b/packages/compiler/test/projection/format-imports.test.ts new file mode 100644 index 000000000..182b9f134 --- /dev/null +++ b/packages/compiler/test/projection/format-imports.test.ts @@ -0,0 +1,256 @@ +import type { ProjectedImportEntry } from '../../src/types'; +import { describe, expect, it } from 'vitest'; +import { formatImportEntry, formatImports } from '../../src/utils/formatters/imports'; + +/** + * Test suite for import formatting functions + * Verifies structured ProjectedImportEntry → string conversion + */ +describe('import Formatting', () => { + describe('formatImportEntry', () => { + describe('raw Strings', () => { + it('passes through raw string unchanged', () => { + const entry = 'import { foo } from \'bar\';'; + expect(formatImportEntry(entry)).toBe(entry); + }); + }); + + describe('comments', () => { + it('formats line comment', () => { + const entry: ProjectedImportEntry = { + type: 'comment', + style: 'line', + value: 'This is a comment', + }; + + expect(formatImportEntry(entry)).toBe('// This is a comment'); + }); + + it('formats multi-line line comment', () => { + const entry: ProjectedImportEntry = { + type: 'comment', + style: 'line', + value: ['Line 1', 'Line 2', 'Line 3'], + }; + + expect(formatImportEntry(entry)).toBe('// Line 1\n// Line 2\n// Line 3'); + }); + + it('formats block comment', () => { + const entry: ProjectedImportEntry = { + type: 'comment', + style: 'block', + value: 'This is a block comment', + }; + + expect(formatImportEntry(entry)).toBe('/*\n * This is a block comment\n */'); + }); + + it('formats multi-line block comment', () => { + const entry: ProjectedImportEntry = { + type: 'comment', + style: 'block', + value: ['Line 1', 'Line 2', 'Line 3'], + }; + + expect(formatImportEntry(entry)).toBe('/*\n * Line 1\n * Line 2\n * Line 3\n */'); + }); + }); + + describe('side-Effect Imports', () => { + it('formats import with no specifiers', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: '@/define/video-provider', + specifiers: [], + }; + + expect(formatImportEntry(entry)).toBe('import \'@/define/video-provider\';'); + }); + + it('formats import with undefined specifiers', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: '@/icons', + }; + + expect(formatImportEntry(entry)).toBe('import \'@/icons\';'); + }); + }); + + describe('default Imports', () => { + it('formats default import without braces', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: './styles.css', + specifiers: [{ type: 'default', name: 'styles' }], + }; + + expect(formatImportEntry(entry)).toBe('import styles from \'./styles.css\';'); + }); + }); + + describe('named Imports', () => { + it('formats single named import', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: '@/media/media-skin', + specifiers: [{ type: 'named', name: 'MediaSkinElement' }], + }; + + expect(formatImportEntry(entry)).toBe('import { MediaSkinElement } from \'@/media/media-skin\';'); + }); + + it('formats multiple named imports', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: 'react', + specifiers: [ + { type: 'named', name: 'useState' }, + { type: 'named', name: 'useEffect' }, + { type: 'named', name: 'useCallback' }, + ], + }; + + expect(formatImportEntry(entry)).toBe( + 'import { useState, useEffect, useCallback } from \'react\';', + ); + }); + + it('formats named import with alias', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: 'some-lib', + specifiers: [{ type: 'named', name: 'foo', alias: 'bar' }], + }; + + expect(formatImportEntry(entry)).toBe('import { foo as bar } from \'some-lib\';'); + }); + + it('formats mixed named imports with and without aliases', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: 'lib', + specifiers: [ + { type: 'named', name: 'a' }, + { type: 'named', name: 'b', alias: 'B' }, + { type: 'named', name: 'c' }, + ], + }; + + expect(formatImportEntry(entry)).toBe('import { a, b as B, c } from \'lib\';'); + }); + }); + + describe('namespace Imports', () => { + it('formats namespace import', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: 'utils', + specifiers: [{ type: 'namespace', name: 'Utils' }], + }; + + expect(formatImportEntry(entry)).toBe('import { * as Utils } from \'utils\';'); + }); + }); + + describe('mixed Imports', () => { + it('formats default + named imports', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: 'react', + specifiers: [ + { type: 'default', name: 'React' }, + { type: 'named', name: 'useState' }, + ], + }; + + expect(formatImportEntry(entry)).toBe('import { React, useState } from \'react\';'); + }); + + it('formats all types together', () => { + const entry: ProjectedImportEntry = { + type: 'import', + source: 'lib', + specifiers: [ + { type: 'default', name: 'Lib' }, + { type: 'namespace', name: 'NS' }, + { type: 'named', name: 'foo' }, + { type: 'named', name: 'bar', alias: 'baz' }, + ], + }; + + expect(formatImportEntry(entry)).toBe( + 'import { Lib, * as NS, foo, bar as baz } from \'lib\';', + ); + }); + }); + }); + + describe('formatImports', () => { + it('formats array of mixed entries', () => { + const entries: ProjectedImportEntry[] = [ + { + type: 'import', + source: '@/media/media-skin', + specifiers: [{ type: 'named', name: 'MediaSkinElement' }], + }, + { + type: 'import', + source: './styles.css', + specifiers: [{ type: 'default', name: 'styles' }], + }, + { + type: 'comment', + style: 'line', + value: 'Side-effect imports', + }, + { + type: 'import', + source: '@/define/video-provider', + specifiers: [], + }, + 'import { foo } from \'bar\';', // Raw string + ]; + + const result = formatImports(entries); + + expect(result).toBe( + 'import { MediaSkinElement } from \'@/media/media-skin\';\n' + + 'import styles from \'./styles.css\';\n' + + '// Side-effect imports\n' + + 'import \'@/define/video-provider\';\n' + + 'import { foo } from \'bar\';', + ); + }); + + it('handles empty array', () => { + const entries: ProjectedImportEntry[] = []; + const result = formatImports(entries); + + expect(result).toBe(''); + }); + + it('produces output ready for composeModule', () => { + // Verify that formatted output is a single string ready to use + const entries: ProjectedImportEntry[] = [ + { + type: 'import', + source: '@/media/media-skin', + specifiers: [{ type: 'named', name: 'MediaSkinElement' }], + }, + { + type: 'import', + source: '@/utils/custom-element', + specifiers: [{ type: 'named', name: 'defineCustomElement' }], + }, + ]; + + const result = formatImports(entries); + + expect(result).toBe( + 'import { MediaSkinElement } from \'@/media/media-skin\';\nimport { defineCustomElement } from \'@/utils/custom-element\';', + ); + }); + }); +}); diff --git a/packages/compiler/test/projection/imports.test.ts b/packages/compiler/test/projection/imports.test.ts new file mode 100644 index 000000000..2941ace49 --- /dev/null +++ b/packages/compiler/test/projection/imports.test.ts @@ -0,0 +1,312 @@ +import type { CategorizedContext, CategorizedImport } from '../../src/configs/types'; +import type { ProjectedImportEntry } from '../../src/types'; +import { describe, expect, it } from 'vitest'; +import { projectImports } from '../../src/configs/videojs-react-skin/projectors/imports'; + +/** + * Test suite for imports projection + * Verifies projection produces correct ProjectedImportEntry[] output + */ +describe('imports Projection', () => { + function createContext(imports: CategorizedImport[]): CategorizedContext { + return { + imports, + classNames: [], + jsx: { + type: 'JSXElement', + name: 'div', + attributes: {}, + children: [], + category: 'native-element', + }, + defaultExport: { + name: 'TestComponent', + category: 'react-functional-component', + }, + projectionState: {}, + }; + } + + describe('base Imports', () => { + it('always includes MediaSkinElement and defineCustomElement', () => { + const context = createContext([]); + const result = projectImports(context, {}); + + // Base imports + video-provider comment + import = 4 entries + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ + type: 'import', + source: '@/media/media-skin', + specifiers: [{ type: 'named', name: 'MediaSkinElement' }], + }); + expect(result[1]).toEqual({ + type: 'import', + source: '@/utils/custom-element', + specifiers: [{ type: 'named', name: 'defineCustomElement' }], + }); + // video-provider comment and import always included + expect(result[2]).toMatchObject({ type: 'comment' }); + expect(result[3]).toMatchObject({ type: 'import', source: '@/define/video-provider' }); + }); + }); + + describe('style Imports', () => { + it('transforms TypeScript style import to CSS', () => { + const styleImport: CategorizedImport = { + source: './styles.ts', + specifiers: { named: [], default: 'styles' }, + category: 'style', + }; + + const context = createContext([styleImport]); + const result = projectImports(context, { styleVariableName: 'styles' }); + + const cssImport = result.find( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source.endsWith('.css'), + ); + + expect(cssImport).toBeDefined(); + expect(cssImport?.source).toBe('./styles.css'); + expect(cssImport?.specifiers).toEqual([{ type: 'default', name: 'styles' }]); + }); + + it('transforms style import without extension', () => { + const styleImport: CategorizedImport = { + source: './styles', + specifiers: { named: [], default: 'styles' }, + category: 'style', + }; + + const context = createContext([styleImport]); + const result = projectImports(context, { styleVariableName: 'styles' }); + + const cssImport = result.find( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source.endsWith('.css'), + ); + + expect(cssImport?.source).toBe('./styles.css'); + }); + }); + + describe('vJS Component Imports', () => { + it('transforms component imports to side-effect define imports', () => { + const componentImport: CategorizedImport = { + source: '@videojs/react', + specifiers: { named: ['PlayButton', 'MuteButton'], default: undefined }, + category: 'vjs-component', + }; + + const context = createContext([componentImport]); + const result = projectImports(context, {}); + + // Should include video-provider comment and import + const comment = result.find( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'comment', + ); + expect(comment).toBeDefined(); + expect(comment?.style).toBe('line'); + expect(comment?.value).toContain('video-provider'); + + const videoProviderImport = result.find( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source === '@/define/video-provider', + ); + expect(videoProviderImport).toBeDefined(); + expect(videoProviderImport?.specifiers).toEqual([]); + + // Should include component define imports (sorted) + const playButtonImport = result.find( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source === '@/define/media-play-button', + ); + expect(playButtonImport).toBeDefined(); + expect(playButtonImport?.specifiers).toEqual([]); + + const muteButtonImport = result.find( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source === '@/define/media-mute-button', + ); + expect(muteButtonImport).toBeDefined(); + }); + + it('sorts component imports alphabetically by element name', () => { + const componentImport: CategorizedImport = { + source: '@videojs/react', + specifiers: { named: ['VolumeSlider', 'PlayButton', 'MuteButton'], default: undefined }, + category: 'vjs-component', + }; + + const context = createContext([componentImport]); + const result = projectImports(context, {}); + + const defineImports = result.filter( + (entry): entry is Extract => + typeof entry === 'object' + && entry.type === 'import' + && entry.source.startsWith('@/define/') + && entry.source !== '@/define/video-provider', + ); + + expect(defineImports[0].source).toBe('@/define/media-mute-button'); + expect(defineImports[1].source).toBe('@/define/media-play-button'); + expect(defineImports[2].source).toBe('@/define/media-volume-slider'); + }); + }); + + describe('vJS Icon Imports', () => { + it('collapses multiple icon imports to single side-effect import', () => { + const iconImport1: CategorizedImport = { + source: '@videojs/react/icons', + specifiers: { named: ['PlayIcon', 'PauseIcon'], default: undefined }, + category: 'vjs-icon', + }; + + const iconImport2: CategorizedImport = { + source: '@/icons', + specifiers: { named: ['VolumeIcon'], default: undefined }, + category: 'vjs-icon', + }; + + const context = createContext([iconImport1, iconImport2]); + const result = projectImports(context, {}); + + const iconImports = result.filter( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source === '@/icons', + ); + + // Should only have ONE @/icons import despite multiple source imports + expect(iconImports).toHaveLength(1); + expect(iconImports[0].specifiers).toEqual([]); + }); + }); + + describe('framework Imports', () => { + it('removes framework imports (React, PropsWithChildren, etc.)', () => { + const frameworkImport: CategorizedImport = { + source: 'react', + specifiers: { named: ['useState', 'useEffect'], default: 'React' }, + category: 'framework', + }; + + const context = createContext([frameworkImport]); + const result = projectImports(context, {}); + + // Should NOT include any react imports + const reactImport = result.find( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source === 'react', + ); + + expect(reactImport).toBeUndefined(); + }); + }); + + describe('vJS Core Imports', () => { + it('removes vjs-core imports (base imports added separately)', () => { + const coreImport: CategorizedImport = { + source: '@videojs/core', + specifiers: { named: ['MediaSkinElement'], default: undefined }, + category: 'vjs-core', + }; + + const context = createContext([coreImport]); + const result = projectImports(context, {}); + + // Should NOT include the categorized core import + // (base imports are always added, but not from the categorized imports) + const coreImports = result.filter( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source === '@videojs/core', + ); + + expect(coreImports).toHaveLength(0); + }); + }); + + // External imports are currently unsupported + describe.skip('external Imports', () => { + it('preserves external imports as-is', () => { + const externalImport: CategorizedImport = { + source: 'some-external-lib', + specifiers: { named: ['foo', 'bar'], default: 'lib' }, + category: 'external', + }; + + const context = createContext([externalImport]); + const result = projectImports(context, {}); + + const external = result.find( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source === 'some-external-lib', + ); + + expect(external).toBeDefined(); + expect(external?.specifiers).toContainEqual({ type: 'default', name: 'lib' }); + expect(external?.specifiers).toContainEqual({ type: 'named', name: 'foo' }); + expect(external?.specifiers).toContainEqual({ type: 'named', name: 'bar' }); + }); + }); + + describe('complete Integration', () => { + it('produces correct structure for realistic skin imports', () => { + const imports: CategorizedImport[] = [ + { + source: 'react', + specifiers: { named: [], default: 'React' }, + category: 'framework', + }, + { + source: './styles.ts', + specifiers: { named: [], default: 'styles' }, + category: 'style', + }, + { + source: '@videojs/react', + specifiers: { named: ['PlayButton', 'MediaContainer'], default: undefined }, + category: 'vjs-component', + }, + { + source: '@videojs/react/icons', + specifiers: { named: ['PlayIcon', 'PauseIcon'], default: undefined }, + category: 'vjs-icon', + }, + ]; + + const context = createContext(imports); + const result = projectImports(context, {}); + + // Verify structure order: + // 1. Base imports (2) + expect(result[0]).toMatchObject({ type: 'import', source: '@/media/media-skin' }); + expect(result[1]).toMatchObject({ type: 'import', source: '@/utils/custom-element' }); + + // 2. Style import (1) + expect(result[2]).toMatchObject({ type: 'import', source: './styles.css' }); + + // 3. video-provider comment + import (2) + expect(result[3]).toMatchObject({ type: 'comment' }); + expect(result[4]).toMatchObject({ type: 'import', source: '@/define/video-provider' }); + + // 4. Component define imports (2, sorted) + expect(result[5]).toMatchObject({ type: 'import', source: '@/define/media-container' }); + expect(result[6]).toMatchObject({ type: 'import', source: '@/define/media-play-button' }); + + // 5. Icons import (1) + expect(result[7]).toMatchObject({ type: 'import', source: '@/icons' }); + + // Total: 8 entries + expect(result).toHaveLength(8); + + // Framework import should NOT be present + const reactImport = result.find( + (entry): entry is Extract => + typeof entry === 'object' && entry.type === 'import' && entry.source === 'react', + ); + expect(reactImport).toBeUndefined(); + }); + }); +}); diff --git a/packages/compiler/test/projection/static-values.test.ts b/packages/compiler/test/projection/static-values.test.ts new file mode 100644 index 000000000..4c14d7947 --- /dev/null +++ b/packages/compiler/test/projection/static-values.test.ts @@ -0,0 +1,76 @@ +import type { CompilerConfig } from '../../src/configs/types'; +import { describe, expect, it } from 'vitest'; +import { analyze, categorize, defaultCompilerConfig, projectModule } from '../../src'; +import { createInitialContext } from '../utils'; + +describe('projection State Static Values', () => { + it('supports static values in projectionState config', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + export default function TestSkin() { + return ; + } + `; + + const context = createInitialContext(source); + const analyzedContext = analyze(context, defaultCompilerConfig); + const categorizedContext = categorize( + { ...analyzedContext, projectionState: context.projectionState }, + defaultCompilerConfig, + ); + + // Create custom config with a static CSS value + const customConfig: CompilerConfig = { + ...defaultCompilerConfig, + projectionState: { + ...defaultCompilerConfig.projectionState, + css: 'static-styles-value', // Static value instead of function + }, + }; + + const result = projectModule(categorizedContext, customConfig); + + // Verify static value was applied + expect(result.projectionState.css).toBe('static-styles-value'); + }); + + it('supports mixing static values and functions', () => { + const source = ` + import { PlayButton } from '@videojs/react'; + export default function TestSkin() { + return ; + } + `; + + const context = createInitialContext(source); + const analyzedContext = analyze(context, defaultCompilerConfig); + const categorizedContext = categorize( + { ...analyzedContext, projectionState: context.projectionState }, + defaultCompilerConfig, + ); + + // Mix static values and functions + const customConfig: CompilerConfig = { + ...defaultCompilerConfig, + projectionState: { + ...defaultCompilerConfig.projectionState, + css: 'static-css', // Static + elementClassName: 'StaticElement', // Static + elementName: 'static-element', // Static + // imports and html remain as functions + }, + }; + + const result = projectModule(categorizedContext, customConfig); + + // Verify static values were applied + expect(result.projectionState.css).toBe('static-css'); + expect(result.projectionState.elementClassName).toBe('StaticElement'); + expect(result.projectionState.elementName).toBe('static-element'); + + // Verify functions still work + expect(result.projectionState.imports).toBeDefined(); + expect(result.projectionState.imports?.length).toBeGreaterThan(0); + expect(result.projectionState.html).toBeDefined(); + }); +}); diff --git a/packages/compiler/test/skins/frosted-skin.test.ts b/packages/compiler/test/skins/frosted-skin.test.ts new file mode 100644 index 000000000..b76311054 --- /dev/null +++ b/packages/compiler/test/skins/frosted-skin.test.ts @@ -0,0 +1,153 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; + +describe('skin: Frosted', () => { + it('compiles actual frosted skin from react package', () => { + const source = readFileSync( + join(__dirname, '../fixtures/skins/frosted/FrostedSkin.tsx'), + 'utf-8', + ); + + const result = compile(source); + + // Component name + expect(result.componentName).toBe('FrostedSkin'); + + // Container and children + expect(result.html).toContain(''); + + // Overlay (simple div) + expect(result.html).toContain(''); + expect(result.html).not.toContain(''); + expect(result.html).not.toContain(''); + expect(result.html).not.toContain(''); + + // Mute button (wrapped in Popover) + expect(result.html).toContain(' { + const source = ` + export default function TestSkin() { + const styles = { A: 'class-a', B: 'class-b', C: 'class-c' }; + return ( +
+
+ ); + } + `; + + const result = compile(source); + + // Template literals are not fully resolved at compile time + // This is expected - we just extract what we can + expect(result.html).toContain('
'); + expect(result.html).toContain(' { + const componentTests = [ + { react: 'PlayButton', html: 'media-play-button' }, + { react: 'MuteButton', html: 'media-mute-button' }, + { react: 'FullscreenButton', html: 'media-fullscreen-button' }, + { react: 'MediaContainer', html: 'media-container' }, + { react: 'CurrentTimeDisplay', html: 'media-current-time-display' }, + { react: 'DurationDisplay', html: 'media-duration-display' }, + { react: 'PreviewTimeDisplay', html: 'media-preview-time-display' }, + { react: 'PlayIcon', html: 'media-play-icon' }, + { react: 'PauseIcon', html: 'media-pause-icon' }, + { react: 'VolumeHighIcon', html: 'media-volume-high-icon' }, + { react: 'VolumeLowIcon', html: 'media-volume-low-icon' }, + { react: 'VolumeOffIcon', html: 'media-volume-off-icon' }, + { react: 'FullscreenEnterIcon', html: 'media-fullscreen-enter-icon' }, + { react: 'FullscreenExitIcon', html: 'media-fullscreen-exit-icon' }, + ]; + + componentTests.forEach(({ react, html }) => { + const source = `import { ${react} } from '@videojs/react'; +export default function Test() { return <${react} />; }`; + const result = compile(source); + expect(result.html).toBe(`<${html}>`); + }); + }); + + it('verifies all slider compound components transform correctly', () => { + const compoundTests = [ + // TimeSlider + { react: 'TimeSlider.Root', html: 'media-time-slider', base: 'TimeSlider' }, + { react: 'TimeSlider.Track', html: 'media-time-slider-track', base: 'TimeSlider' }, + { react: 'TimeSlider.Progress', html: 'media-time-slider-progress', base: 'TimeSlider' }, + { react: 'TimeSlider.Pointer', html: 'media-time-slider-pointer', base: 'TimeSlider' }, + { react: 'TimeSlider.Thumb', html: 'media-time-slider-thumb', base: 'TimeSlider' }, + // VolumeSlider + { react: 'VolumeSlider.Root', html: 'media-volume-slider', base: 'VolumeSlider' }, + { react: 'VolumeSlider.Track', html: 'media-volume-slider-track', base: 'VolumeSlider' }, + { react: 'VolumeSlider.Progress', html: 'media-volume-slider-progress', base: 'VolumeSlider' }, + { react: 'VolumeSlider.Thumb', html: 'media-volume-slider-thumb', base: 'VolumeSlider' }, + ]; + + compoundTests.forEach(({ react, html, base }) => { + const source = `import { ${base} } from '@videojs/react'; +export default function Test() { return <${react} />; }`; + const result = compile(source); + expect(result.html).toBe(`<${html}>`); + }); + }); +}); diff --git a/packages/compiler/test/skins/simple-skin.test.ts b/packages/compiler/test/skins/simple-skin.test.ts new file mode 100644 index 000000000..3e51a8043 --- /dev/null +++ b/packages/compiler/test/skins/simple-skin.test.ts @@ -0,0 +1,145 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { compileForTest as compile } from '../helpers/compile'; +import { elementExists, getClasses, parseElement, querySelector, querySelectorAll } from '../helpers/dom'; + +describe('skin: Simple', () => { + it('compiles SimpleSkin.tsx with correct DOM structure', () => { + const source = readFileSync( + join(__dirname, '../fixtures/skins/simple/SimpleSkin.tsx'), + 'utf-8', + ); + + const result = compile(source); + const root = parseElement(result.html); + + // Root element + expect(root.tagName.toLowerCase()).toBe('media-container'); + expect(getClasses(root)).toContain('container'); + + // Children slot + const slot = querySelector(root, 'slot[name="media"]'); + expect(slot.getAttribute('slot')).toBe('media'); + + // Controls container + const controls = querySelector(root, 'div.controls'); + expect(controls).toBeDefined(); + + // Play button - className is PlayButton (component-match, omitted) + const playButton = querySelector(controls, 'media-play-button'); + expect(playButton).toBeDefined(); + + // Time slider - verify Root → base element (no -root suffix) + const timeSlider = querySelector(controls, 'media-time-slider'); + expect(getClasses(timeSlider)).toContain('slider-root'); // CSS module KEY (SliderRoot → slider-root) + + // Time slider track + const track = querySelector(timeSlider, 'media-time-slider-track'); + expect(getClasses(track)).toContain('slider-track'); // CSS module KEY (SliderTrack → slider-track) + + // Time slider progress + const progress = querySelector(track, 'media-time-slider-progress'); + expect(getClasses(progress)).toContain('slider-progress'); // CSS module KEY (SliderProgress → slider-progress) + + // Verify no media-time-slider-root exists (should be media-time-slider) + expect(elementExists(root, 'media-time-slider-root')).toBe(false); + }); + + it('verifies all expected elements exist in DOM tree', () => { + const source = readFileSync( + join(__dirname, '../fixtures/skins/simple/SimpleSkin.tsx'), + 'utf-8', + ); + + const result = compile(source); + const root = parseElement(result.html); + + // Check root itself + expect(root.tagName.toLowerCase()).toBe('media-container'); + + const expectedSelectors = [ + 'slot[name="media"]', + 'div.controls', + 'media-play-button', + 'media-time-slider', // Not media-time-slider-root! + 'media-time-slider-track', + 'media-time-slider-progress', + ]; + + expectedSelectors.forEach((selector) => { + expect( + elementExists(root, selector), + `Expected element to exist: ${selector}`, + ).toBe(true); + }); + }); + + it('verifies DOM nesting structure', () => { + const source = readFileSync( + join(__dirname, '../fixtures/skins/simple/SimpleSkin.tsx'), + 'utf-8', + ); + + const result = compile(source); + const root = parseElement(result.html); + + // Verify parent-child relationships + const controls = querySelector(root, 'div.controls'); + const playButton = querySelector(controls, 'media-play-button'); + const timeSlider = querySelector(controls, 'media-time-slider'); + + // Play button should be direct child of controls + expect(playButton.parentElement).toBe(controls); + + // Time slider should be direct child of controls + expect(timeSlider.parentElement).toBe(controls); + + // Track should be child of time slider + const track = querySelector(timeSlider, 'media-time-slider-track'); + expect(track.parentElement).toBe(timeSlider); + + // Progress should be child of track + const progress = querySelector(track, 'media-time-slider-progress'); + expect(progress.parentElement).toBe(track); + }); + + it('verifies all classNames are extracted', () => { + const source = readFileSync( + join(__dirname, '../fixtures/skins/simple/SimpleSkin.tsx'), + 'utf-8', + ); + + const result = compile(source); + + // Expected classes: CSS module KEYS (not values), kebab-cased, component-match omitted + const expectedClasses = [ + 'container', // Container (generic-style, kebab-case) + 'controls', // Controls (generic-style, kebab-case) + // play-button omitted (component-match) + 'slider-progress', // SliderProgress (generic-style, kebab-case) + 'slider-root', // SliderRoot (generic-style, kebab-case) + 'slider-track', // SliderTrack (generic-style, kebab-case) + ]; + + expect(result.classNames).toEqual(expectedClasses); + + // Verify classes exist in DOM + const root = parseElement(result.html); + + // Include root element in search + const allElements = [root, ...querySelectorAll(root, '*')]; + const domClasses = new Set(); + + allElements.forEach((el) => { + getClasses(el).forEach(cls => domClasses.add(cls)); + }); + + expectedClasses.forEach((className) => { + expect( + domClasses.has(className), + `Expected class to exist in DOM: ${className}`, + ).toBe(true); + }); + }); +}); diff --git a/packages/compiler/test/utils.ts b/packages/compiler/test/utils.ts new file mode 100644 index 000000000..80206028d --- /dev/null +++ b/packages/compiler/test/utils.ts @@ -0,0 +1,47 @@ +/** + * Test Utilities + * + * Helper functions for testing the compiler + */ + +import type { SourceContext } from '../src/phases/types'; + +/** + * Create a SourceContext for testing + * Used throughout tests - analyze() will add projectionState + * + * @param source - Source code to compile + * @param overrides - Optional overrides for context fields + * @returns SourceContext ready for compilation + * + * @example + * const context = createInitialContext(`import { PlayButton } from '@videojs/react';`); + * const result = analyze(context); + */ +export function createInitialContext( + source: string, + overrides?: Partial, +): SourceContext { + return { + input: { + source, + path: undefined, + ...overrides?.input, + }, + }; +} + +/** + * Alias for createInitialContext + * Used for end-to-end compilation tests + * + * @param source - Source code to compile + * @param overrides - Optional overrides + * @returns SourceContext + */ +export function createSourceContext( + source: string, + overrides?: Partial, +): SourceContext { + return createInitialContext(source, overrides); +} diff --git a/packages/compiler/tsconfig.json b/packages/compiler/tsconfig.json new file mode 100644 index 000000000..9c566478c --- /dev/null +++ b/packages/compiler/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/compiler/tsdown.config.ts b/packages/compiler/tsdown.config.ts new file mode 100644 index 000000000..35a4bb4c7 --- /dev/null +++ b/packages/compiler/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { + index: './src/index.ts', + }, + platform: 'node', + format: 'esm', + sourcemap: true, + clean: true, + dts: { + oxc: true, + }, +}); diff --git a/packages/compiler/vitest.config.ts b/packages/compiler/vitest.config.ts new file mode 100644 index 000000000..527089358 --- /dev/null +++ b/packages/compiler/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + }, +}); diff --git a/packages/html/src/skins/frosted/index.ts b/packages/html/src/skins/frosted/index.ts index 0ccf21609..f15bb3166 100644 --- a/packages/html/src/skins/frosted/index.ts +++ b/packages/html/src/skins/frosted/index.ts @@ -29,8 +29,8 @@ export function getTemplateHTML() {
- - + + ${styles} + + +
Placeholder HTML
+ `; +} + +export class MediaSkinFrostedElement extends MediaSkinElement { + static getTemplateHTML: () => string = getTemplateHTML; +} + +defineCustomElement('media-skin-frosted', MediaSkinFrostedElement); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdb732626..850ac0047 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,52 @@ importers: specifier: ^7.1.6 version: 7.1.6(@types/node@22.18.6)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + packages/compiler: + dependencies: + '@babel/core': + specifier: ^7.23.0 + version: 7.28.4 + '@babel/parser': + specifier: ^7.23.0 + version: 7.28.4 + '@babel/traverse': + specifier: ^7.23.0 + version: 7.28.4 + '@babel/types': + specifier: ^7.23.0 + version: 7.28.4 + devDependencies: + '@types/babel__core': + specifier: ^7.20.5 + version: 7.20.5 + '@types/babel__traverse': + specifier: ^7.20.5 + version: 7.28.0 + '@types/node': + specifier: ^20.0.0 + version: 20.19.24 + '@types/react': + specifier: ^18.0.0 + version: 18.3.24 + '@videojs/react': + specifier: workspace:* + version: link:../react + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + react: + specifier: ^18.0.0 + version: 18.3.1 + tsdown: + specifier: ^0.15.9 + version: 0.15.9(typescript@5.9.3) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@20.19.24)(@vitest/ui@3.2.4)(jsdom@25.0.1)(lightningcss@1.30.2) + packages/core: dependencies: '@videojs/utils': @@ -507,6 +553,9 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@asamuzakjp/css-color@4.0.5': resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} @@ -605,10 +654,6 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -622,11 +667,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -655,10 +695,6 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - '@base-ui-components/react@1.0.0-beta.4': resolution: {integrity: sha512-sPYKj26gbFHD2ZsrMYqQshXnMuomBodzPn+d0dDxWieTj232XCQ9QGt9fU9l5SDGC9hi8s24lDlg9FXPSI7T8A==} engines: {node: '>=14.0.0'} @@ -812,9 +848,6 @@ packages: '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} - '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -826,102 +859,204 @@ packages: resolution: {integrity: sha512-smMc5pDht/UVsCD3hhw/a/e/p8m0RdRYiluXToVfd+d4yaQQh7nn9bACjkk6nXJvat7EWPAxuFkMEFfrxeGa3Q==} engines: {node: '>=20.11.0'} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.10': resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.10': resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.10': resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.10': resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.10': resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.10': resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.10': resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.10': resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.10': resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.10': resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.10': resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.10': resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.10': resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.10': resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.10': resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.10': resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.10': resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} engines: {node: '>=18'} @@ -934,6 +1069,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.10': resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} engines: {node: '>=18'} @@ -946,6 +1087,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.10': resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} engines: {node: '>=18'} @@ -958,24 +1105,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.10': resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.10': resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.10': resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.10': resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} engines: {node: '>=18'} @@ -1144,134 +1315,64 @@ packages: cpu: [arm64] os: [darwin] - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - '@img/sharp-darwin-x64@0.34.4': resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.3': resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.3': resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.3': resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.3': resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.3': resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.3': resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.3': resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.3': resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - '@img/sharp-linux-arm64@0.34.4': resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1279,13 +1380,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-arm@0.34.4': resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1293,13 +1387,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - '@img/sharp-linux-ppc64@0.34.4': resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1307,20 +1394,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@img/sharp-linux-s390x@0.34.4': resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1328,13 +1401,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@img/sharp-linux-x64@0.34.4': resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1342,13 +1408,6 @@ packages: os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.4': resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1356,13 +1415,6 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.4': resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1370,59 +1422,29 @@ packages: os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - '@img/sharp-wasm32@0.34.4': resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.4': resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - '@img/sharp-win32-ia32@0.34.4': resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - '@img/sharp-win32-x64@0.34.4': resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1435,6 +1457,10 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1976,6 +2002,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@stylistic/eslint-plugin@5.4.0': resolution: {integrity: sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2703,6 +2732,9 @@ packages: vitest: optional: true + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -2720,12 +2752,21 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} @@ -2734,6 +2775,9 @@ packages: peerDependencies: vitest: 3.2.4 + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -2773,6 +2817,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -2878,6 +2926,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2918,6 +2969,9 @@ packages: async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -3021,12 +3075,16 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} - caniuse-lite@1.0.30001759: - resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + caniuse-lite@1.0.30001743: + resolution: {integrity: sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -3054,6 +3112,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -3117,6 +3178,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -3248,6 +3313,10 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + cssstyle@5.3.1: resolution: {integrity: sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==} engines: {node: '>=20'} @@ -3266,6 +3335,10 @@ packages: resolution: {integrity: sha512-hpA5C/YrPjucXypHPPc0oJ1l9Hf6wWbiOL7Ik42cxnsUOhWiCB/fylKbKqqJalW9FgkNQCw16YO8uW9Hs0Iy1A==} engines: {node: '>=4'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-urls@6.0.0: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} @@ -3305,6 +3378,10 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -3327,6 +3404,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + deprecation@2.3.1: resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} @@ -3346,10 +3427,6 @@ packages: resolution: {integrity: sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==} engines: {node: '>=8'} - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - deterministic-object-hash@2.0.2: resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} engines: {node: '>=18'} @@ -3363,6 +3440,10 @@ packages: dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@5.2.0: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} @@ -3504,6 +3585,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.10: resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} engines: {node: '>=18'} @@ -3913,6 +3999,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + expect-type@1.2.2: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} @@ -4016,6 +4106,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -4050,6 +4144,9 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -4058,6 +4155,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -4225,6 +4326,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -4410,6 +4515,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -4497,6 +4606,15 @@ packages: resolution: {integrity: sha512-F9GQ+F1ZU6qvSrZV8fNFpjDNf614YzR2eF6S0+XbDjAcUI28FSoXnYZFjQmb1kFx3rrJb5PnxUH3/Yti6fcM+g==} engines: {node: '>=12.0.0'} + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsdom@27.0.0: resolution: {integrity: sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==} engines: {node: '>=20'} @@ -4734,6 +4852,10 @@ packages: resolution: {integrity: sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ==} engines: {node: '>=20.0.0'} + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + local-pkg@1.1.2: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} @@ -4787,6 +4909,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -4815,9 +4940,6 @@ packages: magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} @@ -4903,6 +5025,9 @@ packages: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -5019,6 +5144,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -5153,9 +5290,16 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.22: + resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -5200,6 +5344,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -5226,6 +5374,10 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + p-limit@6.2.0: resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} engines: {node: '>=18'} @@ -5294,6 +5446,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -5311,9 +5467,15 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.1: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} @@ -5398,6 +5560,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -5449,6 +5615,9 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -5642,6 +5811,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -5719,10 +5891,6 @@ packages: resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5889,6 +6057,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -5901,6 +6073,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -6003,6 +6178,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -6011,13 +6190,24 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.0.17: resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==} + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + tldts@7.0.17: resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==} hasBin: true @@ -6034,6 +6224,10 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tough-cookie@6.0.0: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} @@ -6041,6 +6235,10 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -6152,6 +6350,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@4.2.0: resolution: {integrity: sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w==} engines: {node: '>=16'} @@ -6356,11 +6558,47 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@6.3.6: resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6449,6 +6687,31 @@ packages: vite: optional: true + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6493,6 +6756,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webidl-conversions@8.0.0: resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} engines: {node: '>=20'} @@ -6505,6 +6772,10 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@15.1.0: resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} engines: {node: '>=20'} @@ -6759,6 +7030,14 @@ snapshots: package-manager-detector: 1.3.0 tinyexec: 1.0.1 + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@asamuzakjp/css-color@4.0.5': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -6967,8 +7246,6 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.4': @@ -6980,10 +7257,6 @@ snapshots: dependencies: '@babel/types': 7.28.4 - '@babel/parser@7.28.5': - dependencies: - '@babel/types': 7.28.5 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.4)': dependencies: '@babel/core': 7.28.4 @@ -7019,11 +7292,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@base-ui-components/react@1.0.0-beta.4(@types/react@19.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 @@ -7217,11 +7485,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 @@ -7243,81 +7506,150 @@ snapshots: esquery: 1.6.0 jsdoc-type-pratt-parser: 5.4.0 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.10': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.10': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.10': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.10': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.10': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.10': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.10': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.10': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.10': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.10': optional: true - '@esbuild/linux-ia32@0.25.10': + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.10': + optional: true + + '@esbuild/linux-loong64@0.21.5': optional: true '@esbuild/linux-loong64@0.25.10': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.10': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.10': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.10': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.10': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.10': optional: true '@esbuild/netbsd-arm64@0.25.10': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.10': optional: true '@esbuild/openbsd-arm64@0.25.10': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.10': optional: true '@esbuild/openharmony-arm64@0.25.10': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.10': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.10': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.10': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.10': optional: true @@ -7544,181 +7876,87 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.3 optional: true - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - '@img/sharp-darwin-x64@0.34.4': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.3 optional: true - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - '@img/sharp-libvips-darwin-arm64@1.2.3': optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - '@img/sharp-libvips-darwin-x64@1.2.3': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - '@img/sharp-libvips-linux-arm64@1.2.3': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - '@img/sharp-libvips-linux-arm@1.2.3': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - '@img/sharp-libvips-linux-ppc64@1.2.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - '@img/sharp-libvips-linux-s390x@1.2.3': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - '@img/sharp-libvips-linux-x64@1.2.3': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - '@img/sharp-linux-arm64@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.3 optional: true - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - '@img/sharp-linux-arm@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.3 optional: true - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - '@img/sharp-linux-ppc64@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.3 optional: true - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - '@img/sharp-linux-s390x@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.3 optional: true - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - '@img/sharp-linux-x64@0.34.4': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.3 optional: true - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - '@img/sharp-linuxmusl-arm64@0.34.4': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - '@img/sharp-linuxmusl-x64@0.34.4': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.3 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - '@img/sharp-wasm32@0.34.4': dependencies: '@emnapi/runtime': 1.5.0 optional: true - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.7.1 - optional: true - '@img/sharp-win32-arm64@0.34.4': optional: true - '@img/sharp-win32-arm64@0.34.5': - optional: true - '@img/sharp-win32-ia32@0.34.4': optional: true - '@img/sharp-win32-ia32@0.34.5': - optional: true - '@img/sharp-win32-x64@0.34.4': optional: true - '@img/sharp-win32-x64@0.34.5': - optional: true - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7734,6 +7972,10 @@ snapshots: '@istanbuljs/schema@0.1.3': {} + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8172,6 +8414,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sinclair/typebox@0.27.8': {} + '@stylistic/eslint-plugin@5.4.0(eslint@9.37.0(jiti@2.6.1))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.6.1)) @@ -8511,7 +8755,7 @@ snapshots: '@types/conventional-commits-parser@5.0.1': dependencies: - '@types/node': 22.18.6 + '@types/node': 20.19.24 '@types/debug@4.1.12': dependencies: @@ -8527,7 +8771,7 @@ snapshots: '@types/fontkit@2.0.8': dependencies: - '@types/node': 22.18.6 + '@types/node': 20.19.24 '@types/hast@3.0.4': dependencies: @@ -8584,7 +8828,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 22.18.6 + '@types/node': 20.19.24 '@types/turndown@5.0.6': {} @@ -8933,6 +9177,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -8953,18 +9203,34 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.19 + pathe: 1.1.2 + pretty-format: 29.7.0 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.19 pathe: 2.0.3 + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 @@ -8980,6 +9246,13 @@ snapshots: tinyrainbow: 2.0.0 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.6)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(yaml@2.8.1) + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -8988,7 +9261,7 @@ snapshots: '@vue/compiler-core@3.5.21': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.28.4 '@vue/shared': 3.5.21 entities: 4.5.0 estree-walker: 2.0.2 @@ -9001,13 +9274,13 @@ snapshots: '@vue/compiler-sfc@3.5.21': dependencies: - '@babel/parser': 7.28.5 + '@babel/parser': 7.28.4 '@vue/compiler-core': 3.5.21 '@vue/compiler-dom': 3.5.21 '@vue/compiler-ssr': 3.5.21 '@vue/shared': 3.5.21 estree-walker: 2.0.2 - magic-string: 0.30.21 + magic-string: 0.30.19 postcss: 8.5.6 source-map-js: 1.2.1 @@ -9037,6 +9310,10 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + acorn@8.15.0: {} agent-base@7.1.4: {} @@ -9165,6 +9442,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@1.1.0: {} + assertion-error@2.0.1: {} ast-kit@2.1.3: @@ -9309,6 +9588,8 @@ snapshots: async-sema@3.1.1: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -9378,7 +9659,7 @@ snapshots: browserslist@4.26.2: dependencies: baseline-browser-mapping: 2.8.6 - caniuse-lite: 1.0.30001759 + caniuse-lite: 1.0.30001743 electron-to-chromium: 1.5.222 node-releases: 2.0.21 update-browserslist-db: 1.1.3(browserslist@4.26.2) @@ -9410,10 +9691,20 @@ snapshots: camelcase@8.0.0: {} - caniuse-lite@1.0.30001759: {} + caniuse-lite@1.0.30001743: {} ccount@2.0.1: {} + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -9439,6 +9730,10 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + check-error@2.1.1: {} chevrotain@7.1.1: @@ -9490,6 +9785,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@14.0.1: {} @@ -9610,6 +9909,11 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + cssstyle@5.3.1(postcss@8.5.6): dependencies: '@asamuzakjp/css-color': 4.0.5 @@ -9626,6 +9930,11 @@ snapshots: dashify@2.0.0: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -9663,6 +9972,10 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -9683,6 +9996,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + deprecation@2.3.1: {} dequal@2.0.3: {} @@ -9693,9 +10008,6 @@ snapshots: detect-libc@2.1.0: {} - detect-libc@2.1.2: - optional: true - deterministic-object-hash@2.0.2: dependencies: base-64: 1.0.0 @@ -9708,6 +10020,8 @@ snapshots: dfa@1.2.0: {} + diff-sequences@29.6.3: {} + diff@5.2.0: {} diff@8.0.2: {} @@ -9905,6 +10219,32 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.10: optionalDependencies: '@esbuild/aix-ppc64': 0.25.10 @@ -9962,7 +10302,7 @@ snapshots: '@next/eslint-plugin-next': 16.0.1 eslint: 9.37.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.37.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.37.0(jiti@2.6.1)) @@ -9994,7 +10334,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.37.0(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10019,14 +10359,14 @@ snapshots: dependencies: eslint: 9.37.0(jiti@2.6.1) - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.37.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.37.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -10092,7 +10432,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.37.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -10557,6 +10897,18 @@ snapshots: eventemitter3@5.0.1: {} + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + expect-type@1.2.2: {} exsolve@1.0.7: {} @@ -10665,6 +11017,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + format@0.2.2: {} fs.realpath@1.0.0: {} @@ -10691,6 +11051,8 @@ snapshots: get-east-asian-width@1.4.0: {} + get-func-name@2.0.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10709,6 +11071,8 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@8.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -10973,6 +11337,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@5.0.0: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -11143,6 +11509,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@3.0.0: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -11233,6 +11601,34 @@ snapshots: jsdoc-type-pratt-parser@5.4.0: {} + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.4 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.22 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsdom@27.0.0(postcss@8.5.6): dependencies: '@asamuzakjp/dom-selector': 6.6.1 @@ -11435,6 +11831,11 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + local-pkg@0.5.1: + dependencies: + mlly: 1.8.0 + pkg-types: 1.3.1 + local-pkg@1.1.2: dependencies: mlly: 1.8.0 @@ -11483,6 +11884,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + loupe@3.2.1: {} lower-case@2.0.2: @@ -11507,10 +11912,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.3.5: dependencies: '@babel/parser': 7.28.4 @@ -11715,6 +12116,8 @@ snapshots: meow@12.1.1: {} + merge-stream@2.0.0: {} + merge2@1.4.1: {} micromark-core-commonmark@2.0.3: @@ -11993,6 +12396,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} min-indent@1.0.1: {} @@ -12048,7 +12459,7 @@ snapshots: dependencies: '@next/env': 16.0.7 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001759 + caniuse-lite: 1.0.30001743 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -12063,7 +12474,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.7 '@next/swc-win32-x64-msvc': 16.0.7 babel-plugin-react-compiler: 1.0.0 - sharp: 0.34.5 + sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -12073,7 +12484,7 @@ snapshots: dependencies: '@next/env': 16.0.7 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001759 + caniuse-lite: 1.0.30001743 postcss: 8.4.31 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) @@ -12088,7 +12499,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.7 '@next/swc-win32-x64-msvc': 16.0.7 babel-plugin-react-compiler: 1.0.0 - sharp: 0.34.5 + sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -12122,10 +12533,16 @@ snapshots: normalize-path@3.0.0: {} + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 + nwsapi@2.2.22: {} + object-assign@4.1.1: {} object-deep-merge@1.0.5: @@ -12184,6 +12601,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -12219,6 +12640,10 @@ snapshots: dependencies: yocto-queue: 1.2.1 + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.1 + p-limit@6.2.0: dependencies: yocto-queue: 1.2.1 @@ -12292,6 +12717,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -12305,8 +12732,12 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@1.1.1: {} + pathval@2.0.1: {} picocolors@1.1.1: {} @@ -12385,6 +12816,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + prismjs@1.30.0: {} prompts@2.4.2: @@ -12429,6 +12866,8 @@ snapshots: react-is@17.0.2: {} + react-is@18.3.1: {} + react-refresh@0.17.0: {} react@18.3.1: @@ -12729,6 +13168,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.0 fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} + rrweb-cssom@0.8.0: {} run-parallel@1.2.0: @@ -12839,38 +13280,6 @@ snapshots: '@img/sharp-win32-ia32': 0.34.4 '@img/sharp-win32-x64': 0.34.4 - sharp@0.34.5: - dependencies: - '@img/colour': 1.0.0 - detect-libc: 2.1.2 - semver: 7.7.3 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - optional: true - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -13079,6 +13488,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@3.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -13087,6 +13498,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -13190,14 +13605,24 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@0.8.4: {} + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} + tinyspy@2.2.1: {} + tinyspy@4.0.4: {} + tldts-core@6.1.86: {} + tldts-core@7.0.17: {} + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + tldts@7.0.17: dependencies: tldts-core: 7.0.17 @@ -13212,12 +13637,20 @@ snapshots: totalist@3.0.1: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tough-cookie@6.0.0: dependencies: tldts: 7.0.17 tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -13318,6 +13751,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.1.0: {} + type-fest@4.2.0: {} type-fest@4.41.0: {} @@ -13540,6 +13975,24 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@1.6.1(@types/node@20.19.24)(lightningcss@1.30.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.24)(lightningcss@1.30.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@3.2.4(@types/node@22.18.6)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1): dependencies: cac: 6.7.14 @@ -13561,6 +14014,16 @@ snapshots: - tsx - yaml + vite@5.4.21(@types/node@20.19.24)(lightningcss@1.30.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.52.0 + optionalDependencies: + '@types/node': 20.19.24 + fsevents: 2.3.3 + lightningcss: 1.30.2 + vite@6.3.6(@types/node@22.18.6)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1): dependencies: esbuild: 0.25.10 @@ -13595,6 +14058,42 @@ snapshots: optionalDependencies: vite: 6.3.6(@types/node@22.18.6)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + vitest@1.6.1(@types/node@20.19.24)(@vitest/ui@3.2.4)(jsdom@25.0.1)(lightningcss@1.30.2): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.4 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.19 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.9.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.24)(lightningcss@1.30.2) + vite-node: 1.6.1(@types/node@20.19.24)(lightningcss@1.30.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.24 + '@vitest/ui': 3.2.4(vitest@3.2.4) + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.6)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 @@ -13659,6 +14158,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + webidl-conversions@8.0.0: {} whatwg-encoding@3.1.1: @@ -13667,6 +14168,11 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@15.1.0: dependencies: tr46: 6.0.0