refactor(compiler)!: consolidate jsx surface

This commit is contained in:
Rahim
2026-06-19 20:13:11 -07:00
parent f64acdfd15
commit 5fcab69d34
31 changed files with 165 additions and 172 deletions
+11 -11
View File
@@ -16,20 +16,20 @@
"default": "./dist/index.js"
},
"./vite": {
"types": "./dist/plugins/vite.d.ts",
"default": "./dist/plugins/vite.js"
"types": "./dist/bundlers/vite.d.ts",
"default": "./dist/bundlers/vite.js"
},
"./ast": {
"types": "./dist/ast/index.d.ts",
"default": "./dist/ast/index.js"
"./jsx": {
"types": "./dist/jsx/index.d.ts",
"default": "./dist/jsx/index.js"
},
"./matchers": {
"types": "./dist/matchers/index.d.ts",
"default": "./dist/matchers/index.js"
"./diagnostics": {
"types": "./dist/diagnostics.d.ts",
"default": "./dist/diagnostics.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
"./transforms": {
"types": "./dist/transforms/index.d.ts",
"default": "./dist/transforms/index.js"
},
"./styles": {
"types": "./dist/styles/index.d.ts",
-3
View File
@@ -1,3 +0,0 @@
export { type ParseOptions, type ParseResult, parse } from '../parse';
export { type AddImportContext, type AddImportRef, addNamedImport } from '../transforms/add-import';
export { type ImportRef, type ImportRewriteOptions, type ImportRule, transformImports } from '../transforms/imports';
+2 -2
View File
@@ -6,7 +6,7 @@ import {
type CompilerDiagnostic,
type CompilerPipelineStep,
type CompilerTransform,
react,
jsx,
} from './config';
import { fatalDiagnosticFromError, withDiagnosticSource } from './diagnostics';
import { parse } from './parse';
@@ -59,7 +59,7 @@ const printer = ts.createPrinter({
export async function compile(source: string, options: CompileOptions = {}): Promise<CompileResult> {
const filename = options.filename ?? 'input.tsx';
const config = options.config ?? {};
const target = config.target ?? react();
const target = config.target ?? jsx();
const assets: CompilerAsset[] = [];
const diagnostics: CompilerDiagnostic[] = [];
const context: CompilerContext = {
+5 -8
View File
@@ -41,11 +41,8 @@ export interface StylePipeline {
setup(context: CompilerContext): CompilerPipelineStep | Promise<CompilerPipelineStep>;
}
/**
* Per-target compile configuration. Currently only `react` is shipped, but
* the shape is extensible for `html`/etc.
*/
export interface ReactTargetOptions {
/** Per-target compile configuration for JSX transforms. */
export interface JsxTargetOptions {
/** Per-source-module rewrite rules. */
imports?: Record<string, ImportRule> | undefined;
/** Transforms applied in order after `transformImports`. */
@@ -53,7 +50,7 @@ export interface ReactTargetOptions {
}
export interface CompilerTarget {
name: 'react' | 'html';
name: 'jsx';
imports?: Record<string, ImportRule> | undefined;
transforms?: readonly CompilerTransform[] | undefined;
}
@@ -68,9 +65,9 @@ export function defineConfig<const Config extends CompilerConfig>(config: Config
return config;
}
export function react(options: ReactTargetOptions = {}): CompilerTarget {
export function jsx(options: JsxTargetOptions = {}): CompilerTarget {
return {
name: 'react',
name: 'jsx',
...(options.imports ? { imports: options.imports } : {}),
...(options.transforms ? { transforms: options.transforms } : {}),
};
-32
View File
@@ -8,37 +8,5 @@ export {
type CompilerTarget,
type CompilerTransform,
defineConfig,
type ReactTargetOptions,
react,
type StylePipeline,
} from './config';
export {
compilerDiagnosticToJsonEvent,
type DiagnosticFormat,
type DiagnosticJsonEvent,
type DiagnosticJsonFrameLine,
type DiagnosticLocation,
type DiagnosticSummaryJsonEvent,
diagnosticLocationFromNode,
diagnosticSummaryToJsonEvent,
type FormatDiagnosticOptions,
formatCompilerDiagnostic,
formatCompilerDiagnosticJsonLine,
formatDiagnosticSummaryJsonLine,
LogLevel,
type LogLevelName,
mapLogLevelStringToNumber,
mapLogLevelToString,
shouldUseColor,
} from './diagnostics';
export { type TailwindMode, type TailwindOptions, tailwind } from './tailwind';
export type { ImportRef, ImportRule } from './transforms/imports';
export {
accessPath,
type JsxChildReplacement,
jsxExpression,
propertyAccess,
type ReplaceJsxChildOptions,
readStringAttribute,
replaceJsxChild,
} from './transforms/jsx';
+17
View File
@@ -0,0 +1,17 @@
export { type JsxTargetOptions, jsx } from '../config';
export type { ImportRef } from '../transforms/imports';
export { hasChild } from './matchers/has-child';
export { anyTag, byTag, type JsxElementLike, type Matcher, tagName } from './matchers/tag';
export { type AddPropImportRef, type AddPropOptions, addProp } from './transforms/add-prop';
export { type ChildAsPropOptions, childAsProp } from './transforms/child-as-prop';
export {
accessPath,
type JsxChildReplacement,
jsxExpression,
propertyAccess,
type ReplaceJsxChildOptions,
readStringAttribute,
replaceJsxChild,
} from './transforms/jsx';
export { type ReplaceOptions, replace } from './transforms/replace';
export { type WrapOptions, wrap } from './transforms/wrap';
@@ -1,6 +1,6 @@
import ts from 'typescript';
/** A JSX element that helpers can transform either an open/close pair or self-closing. */
/** A JSX element that helpers can transform: either an open/close pair or self-closing. */
export type JsxElementLike = ts.JsxElement | ts.JsxSelfClosingElement;
/** Predicate over a JSX element. Single shape across `replace`, `wrap`, `childAsProp`, `addProp`. */
@@ -16,7 +16,7 @@ function readTag(name: ts.JsxTagNameExpression): string {
if (ts.isIdentifier(name)) return name.text;
if (ts.isPropertyAccessExpression(name))
return `${readTag(name.expression as ts.JsxTagNameExpression)}.${name.name.text}`;
// ThisExpression / JsxNamespacedName uncommon in our skins; fall back to source text.
// ThisExpression / JsxNamespacedName: uncommon in our skins; fall back to source text.
return name.getText();
}
@@ -1,6 +1,6 @@
import ts from 'typescript';
import { type AddImportContext, addNamedImport } from '../../transforms/add-import';
import type { JsxElementLike, Matcher } from '../matchers';
import { type AddImportContext, addNamedImport } from '../transforms/add-import';
export interface AddPropImportRef {
source: string;
@@ -11,7 +11,7 @@ export interface ChildAsPropOptions {
* named prop (turning the element into a self-closing form):
*
* <Tooltip.Trigger><PlayButton/></Tooltip.Trigger>
* <Tooltip.Trigger render={<PlayButton/>}/>
* -> <Tooltip.Trigger render={<PlayButton/>}/>
*
* Skips no-op cases:
* - element is already self-closing
@@ -1,10 +1,11 @@
import ts from 'typescript';
import { type AddImportContext, addNamedImport } from '../../transforms/add-import';
import type { ImportRef } from '../../transforms/imports';
import type { JsxElementLike, Matcher } from '../matchers';
import { type AddImportContext, type AddImportRef, addNamedImport } from './add-import';
export interface ReplaceOptions {
match: Matcher;
with: AddImportRef;
with: ImportRef;
/** Reshape the new element's attributes from the original's. Defaults to passthrough. */
mapProps?: (original: ts.JsxAttributes, factory: ts.NodeFactory) => ts.JsxAttributes;
/** Reshape the new element's children from the original's. Defaults to passthrough (open form only). */
@@ -1,15 +1,16 @@
import ts from 'typescript';
import { type AddImportContext, addNamedImport } from '../../transforms/add-import';
import type { ImportRef } from '../../transforms/imports';
import type { JsxElementLike, Matcher } from '../matchers';
import { type AddImportContext, type AddImportRef, addNamedImport } from './add-import';
export interface WrapOptions {
match: Matcher;
with: AddImportRef;
with: ImportRef;
}
/**
* Wrap a matched JSX subtree with another component:
* <Match/> <Wrapper><Match/></Wrapper>
* <Match/> -> <Wrapper><Match/></Wrapper>
* The wrapper's import is added if missing.
*/
export function wrap(opts: WrapOptions, ctx: AddImportContext = {}): ts.TransformerFactory<ts.SourceFile> {
-11
View File
@@ -1,11 +0,0 @@
/**
* React-target plugins for `@videojs/compiler`. Houses the framework-pattern
* helpers that lower constrained JSX into React-friendly component shapes.
* Re-exports `replace` and `wrap` for convenience so a config can import
* everything from one subpath.
*/
export { type ReplaceOptions, replace } from '../transforms/replace';
export { type WrapOptions, wrap } from '../transforms/wrap';
export { type AddPropImportRef, type AddPropOptions, addProp } from './add-prop';
export { type ChildAsPropOptions, childAsProp } from './child-as-prop';
+1 -1
View File
@@ -1,5 +1,5 @@
import ts from 'typescript';
import type { JsxElementLike } from '../matchers';
import type { JsxElementLike } from '../jsx';
/**
* A single segment within a `className` attribute. Either a literal class
@@ -1,7 +1,7 @@
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
import { compile } from '../../compile';
import { react } from '../../config';
import { jsx } from '../../config';
import type { StyleAttributeInfo, StyleAttributeSegmentsInfo, StyleSegment } from '../analyze';
import { analyzeStyles } from '../analyze';
@@ -9,7 +9,7 @@ async function collectSegments(source: string): Promise<StyleAttributeInfo[]> {
const collected: StyleAttributeInfo[] = [];
await compile(source, {
config: {
target: react({
target: jsx({
transforms: [
analyzeStyles({
visit: (info) => {
@@ -25,7 +25,7 @@ async function collectSegments(source: string): Promise<StyleAttributeInfo[]> {
}
const compileWithTransform = (source: string, transform: ReturnType<typeof analyzeStyles>) =>
compile(source, { config: { target: react({ transforms: [transform] }) } });
compile(source, { config: { target: jsx({ transforms: [transform] }) } });
const collapse = (s: string): string => s.replace(/\s+/g, '');
@@ -109,7 +109,7 @@ describe('analyzeStyles — decomposition', () => {
const infos: StyleAttributeInfo[] = [];
await compile(source, {
config: {
target: react({
target: jsx({
transforms: [
analyzeStyles({
mergeFn: 'twMerge',
+1 -2
View File
@@ -1,7 +1,6 @@
import { kebabCase } from '@videojs/utils/string';
import { type DiagnosticLocation, diagnosticLocationFromNode } from '../diagnostics';
import type { JsxElementLike } from '../matchers';
import { tagName } from '../matchers';
import { type JsxElementLike, tagName } from '../jsx';
import type { StyleSegment } from '../styles';
/** Result of deriving a CSS class name for a JSX element. */
+1 -1
View File
@@ -3,7 +3,7 @@ import { basename, dirname, extname, isAbsolute, join, resolve as resolvePath }
import ts from 'typescript';
import type { CompilerContext, StylePipeline } from '../config';
import { diagnosticLocationFromNode } from '../diagnostics';
import { tagName } from '../matchers';
import { tagName } from '../jsx';
import { analyzeStyles, type StyleSegment, type StyleVisitor } from '../styles';
import { decompose, type UtilityCss } from './decompose';
import { type DesignSystem, loadDesignSystem } from './design-system';
@@ -1,6 +1,6 @@
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
import type { JsxElementLike } from '../../matchers';
import type { JsxElementLike } from '../../jsx';
import { parse } from '../../parse';
import type { StyleSegment } from '../../styles';
import { DiagnosticError, deriveClassName } from '../naming';
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { compile as compileSource } from '../../compile';
import { type CompilerTransform, react } from '../../config';
import { type CompilerTransform, jsx } from '../../config';
import type { DesignSystem } from '../design-system';
import { loadDesignSystem } from '../design-system';
import type { CompiledRule } from '../emit';
@@ -50,10 +50,10 @@ const compile = (
source: string,
options: {
filename?: string | undefined;
target?: 'react' | undefined;
target?: 'jsx' | undefined;
plugins?: readonly CompilerTransform[] | undefined;
} = {}
) => compileSource(source, { filename: options.filename, config: { target: react({ transforms: options.plugins }) } });
) => compileSource(source, { filename: options.filename, config: { target: jsx({ transforms: options.plugins }) } });
const compileTailwind = (source: string, options: Parameters<typeof tailwind>[0], filename?: string) =>
compileSource(source, { filename, config: { styles: tailwind(options) } });
@@ -62,7 +62,7 @@ describe('tailwindPlugin — mode: preserve', () => {
it('preserves static className values', async () => {
const source = `function App(){ return <Foo className="flex items-center"/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'preserve' })],
});
expect(code).toContain('"flex items-center"');
@@ -72,7 +72,7 @@ describe('tailwindPlugin — mode: preserve', () => {
const source = `function App(){ return <Foo className="flex"/>; }`;
let called = 0;
await compile(source, {
target: 'react',
target: 'jsx',
plugins: [
tailwindPlugin({
design,
@@ -91,7 +91,7 @@ describe('tailwindPlugin — mode: inline', () => {
it('preserves static className values', async () => {
const source = `function App(){ return <Foo className="flex items-center"/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'inline' })],
});
expect(code).toContain('"flex items-center"');
@@ -100,7 +100,7 @@ describe('tailwindPlugin — mode: inline', () => {
it('folds static cn calls', async () => {
const source = `function App(){ return <Foo className={cn('flex', 'items-center', 'gap-2')}/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'inline' })],
});
expect(code).toContain('"flex items-center gap-2"');
@@ -119,7 +119,7 @@ function App(){ return <Foo className={cn('flex', styles.button.base)}/>; }`;
const sourcePath = writeFixture('skin.tsx', source);
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
filename: sourcePath,
plugins: [tailwindPlugin({ design, mode: 'inline', sourcePath })],
});
@@ -131,7 +131,7 @@ function App(){ return <Foo className={cn('flex', styles.button.base)}/>; }`;
function App(){ return <Foo className={cn('flex', styles.unknown)}/>; }`;
const sourcePath = writeFixture('skin.tsx', source);
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
filename: sourcePath,
plugins: [tailwindPlugin({ design, mode: 'inline', sourcePath })],
});
@@ -141,7 +141,7 @@ function App(){ return <Foo className={cn('flex', styles.unknown)}/>; }`;
it('leaves dynamic cn calls untouched', async () => {
const source = `function App(){ return <Foo className={cn('flex', isOn && 'on')}/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'inline' })],
});
expect(code).toMatch(/cn\(/);
@@ -152,7 +152,7 @@ describe('tailwindPlugin — mode: extract', () => {
it('replaces static utilities with component class names', async () => {
const source = `function App(){ return <PlayButton className="flex items-center"/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
expect(code).toContain('"play-button"');
@@ -162,7 +162,7 @@ describe('tailwindPlugin — mode: extract', () => {
it('preserves group marker classes', async () => {
const source = `function App(){ return <PlayButton className="group"/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
// `group` produces no declarations but is required by descendant
@@ -174,7 +174,7 @@ describe('tailwindPlugin — mode: extract', () => {
const source = `function App(){ return <PlayButton className={cn('flex', 'group')}/>; }`;
let captured: readonly CompiledRule[] | undefined;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [
tailwindPlugin({
design,
@@ -194,7 +194,7 @@ describe('tailwindPlugin — mode: extract', () => {
it('keeps dynamic cn expressions', async () => {
const source = `function App(){ return <PlayButton className={cn('group', extra)}/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
expect(code).toMatch(/cn\("play-button group",\s*extra\)/);
@@ -204,7 +204,7 @@ describe('tailwindPlugin — mode: extract', () => {
const source = `function App(){ return <div><SeekIcon className="flex"/><SeekIcon className="block"/></div>; }`;
await expect(
compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
})
).rejects.toThrow(/class name 'seek-icon' is derived from elements with different styles/);
@@ -213,7 +213,7 @@ describe('tailwindPlugin — mode: extract', () => {
it('allows preserved marker classes next to matching generated styles', async () => {
const source = `function App(){ return <div><Menu.Item className={cn('flex', 'legacy-submenu')}/><Menu.Item className="flex"/></div>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
expect(code).toContain('"menu-item legacy-submenu"');
@@ -223,7 +223,7 @@ describe('tailwindPlugin — mode: extract', () => {
it('handles duplicate component styles', async () => {
const source = `function App(){ return <div><PlayButton className="flex"/><PlayButton className="flex"/></div>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
expect(code).toContain('"play-button"');
@@ -232,7 +232,7 @@ describe('tailwindPlugin — mode: extract', () => {
it('derives class names from style member expressions', async () => {
const source = `function App(){ return <div className={styles.bufferingIndicator}/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
expect(code).toContain('"buffering-indicator"');
@@ -250,7 +250,7 @@ function App(){ return <div className={slider.root}/>; }`;
const { code } = await compile(source, {
filename: sourcePath,
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
});
expect(code).toContain('"slider-root"');
@@ -270,7 +270,7 @@ function App(){ return <div><ChevronIcon className={cn(icon, menu.chevron)}/><Ch
const { code } = await compile(source, {
filename: sourcePath,
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
});
expect(code).toContain('"menu-chevron"');
@@ -280,7 +280,7 @@ function App(){ return <div><ChevronIcon className={cn(icon, menu.chevron)}/><Ch
it('derives class names from single imported token identifiers', async () => {
const source = `function App(){ return <div className={buttonGroupStart}/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
expect(code).toContain('"button-group-start"');
@@ -289,7 +289,7 @@ function App(){ return <div><ChevronIcon className={cn(icon, menu.chevron)}/><Ch
it('prefers style token names over reusable component tag names', async () => {
const source = `function App(){ return <Menu.Trigger className={styles.menu.item}/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
expect(code).toContain('"menu-item"');
@@ -306,7 +306,7 @@ function App(){ return <span className={cn(styles.seek.label, styles.seek.labelB
const sourcePath = writeFixture('skin.tsx', source);
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
filename: sourcePath,
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
});
@@ -327,7 +327,7 @@ function App({ type, className }){
const sourcePath = writeFixture('skin.tsx', source);
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
filename: sourcePath,
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
});
@@ -338,7 +338,7 @@ function App({ type, className }){
it('keeps a single simple literal utility as the class name for bare HTML', async () => {
const source = `function App(){ return <div className="grow"/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
@@ -348,7 +348,7 @@ function App({ type, className }){
it('applies component class overrides', async () => {
const source = `function App(){ return <PlayButton className="flex"/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [
tailwindPlugin({
design,
@@ -363,7 +363,7 @@ function App({ type, className }){
it('applies transformed generated class names', async () => {
const source = `function App(){ return <PlayButton className="flex"/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [
tailwindPlugin({
design,
@@ -379,7 +379,7 @@ function App({ type, className }){
const source = `function App(){ return <Foo className="flex"/>; }`;
let captured: readonly CompiledRule[] | undefined;
await compile(source, {
target: 'react',
target: 'jsx',
plugins: [
tailwindPlugin({
design,
@@ -400,7 +400,7 @@ function App({ type, className }){
const source = `function App(){ return <Foo className={cn('flex', 'opacity-50')}/>; }`;
let captured: readonly CompiledRule[] | undefined;
await compile(source, {
target: 'react',
target: 'jsx',
plugins: [
tailwindPlugin({
design,
@@ -429,7 +429,7 @@ function App(){ return <Foo className={styles.button}/>; }`;
let captured: readonly CompiledRule[] | undefined;
await compile(source, {
target: 'react',
target: 'jsx',
filename: sourcePath,
plugins: [
tailwindPlugin({
@@ -459,7 +459,7 @@ function App(){ return <Foo className={styles.button}/>; }`;
let captured: readonly CompiledRule[] | undefined;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
filename: sourcePath,
plugins: [
tailwindPlugin({
@@ -482,7 +482,7 @@ function App(){ return <Foo className={styles.button}/>; }`;
const source = `function App(){ return <PlayButton className="flex"/>; }`;
let captured: readonly CompiledRule[] | undefined;
await compile(source, {
target: 'react',
target: 'jsx',
plugins: [
tailwindPlugin({
design,
@@ -501,7 +501,7 @@ function App(){ return <Foo className={styles.button}/>; }`;
const source = `function App(){ return <Foo className={isOn ? 'a' : 'b'}/>; }`;
let captured: readonly CompiledRule[] | undefined;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [
tailwindPlugin({
design,
@@ -531,7 +531,7 @@ function App(){ return <PlayButton className={iconButton}/>; }`;
let captured: readonly CompiledRule[] | undefined;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
filename: sourcePath,
plugins: [
tailwindPlugin({
@@ -553,7 +553,7 @@ function App(){ return <PlayButton className={iconButton}/>; }`;
it('preserves dynamic cn suffixes after extraction', async () => {
const source = `function App({ extra }){ return <PlayButton className={cn('flex', extra)}/>; }`;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [tailwindPlugin({ design, mode: 'extract' })],
});
expect(code).toMatch(/cn\("play-button",\s*extra\)/);
@@ -565,7 +565,7 @@ function App(){ return <PlayButton className={iconButton}/>; }`;
}`;
let captured: readonly CompiledRule[] | undefined;
const { code } = await compile(source, {
target: 'react',
target: 'jsx',
plugins: [
tailwindPlugin({
design,
+45 -32
View File
@@ -1,8 +1,21 @@
import { describe, expect, it } from 'vitest';
import { accessPath, compile, jsxExpression, type ReactTargetOptions, react, replaceJsxChild } from '..';
import { parse } from '../ast';
import { anyTag, byTag, hasChild } from '../matchers';
import { addProp, childAsProp, replace, wrap } from '../react';
import { compile } from '..';
import {
accessPath,
addProp,
anyTag,
byTag,
childAsProp,
hasChild,
type JsxElementLike,
type JsxTargetOptions,
jsx,
jsxExpression,
replace,
replaceJsxChild,
wrap,
} from '../jsx';
import { parse } from '../parse';
/**
* The TS printer emits `<A />` (space before slash) collapse all whitespace
@@ -10,8 +23,8 @@ import { addProp, childAsProp, replace, wrap } from '../react';
*/
const collapse = (s: string): string => s.replace(/\s+/g, '');
const compileReact = (source: string, options: ReactTargetOptions = {}) =>
compile(source, { config: { target: react(options) } });
const compileJsx = (source: string, options: JsxTargetOptions = {}) =>
compile(source, { config: { target: jsx(options) } });
describe('parse', () => {
it('produces a TSX SourceFile with parent pointers set', () => {
@@ -24,7 +37,7 @@ describe('parse', () => {
describe('compile (no transforms)', () => {
it('round-trips a simple TSX module', async () => {
const source = `import { Foo } from 'bar';\nexport function App() { return <Foo/>; }\n`;
const { code } = await compileReact(source);
const { code } = await compileJsx(source);
// Identifier and JSX preserved; quote/whitespace style is whatever the printer decides.
expect(code).toContain('Foo');
expect(code).toContain('bar');
@@ -35,16 +48,16 @@ describe('compile (no transforms)', () => {
describe('compile (transformImports — bare-string rule)', () => {
it('rewrites the module specifier and leaves identifiers untouched', async () => {
const source = `import { PlayIcon } from '@videojs/icons/components';\nconst _x = PlayIcon;`;
const { code } = await compileReact(source, {
imports: { '@videojs/icons/components': '@videojs/icons/react' },
const { code } = await compileJsx(source, {
imports: { '@videojs/icons/components': '@videojs/icons/jsx' },
});
expect(code).toContain(`import { PlayIcon } from "@videojs/icons/react"`);
expect(code).toContain(`import { PlayIcon } from "@videojs/icons/jsx"`);
});
it('leaves unrelated imports untouched', async () => {
const source = `import { Other } from 'unrelated';\nimport { PlayIcon } from '@videojs/icons/components';\nconst _ = [Other, PlayIcon];`;
const { code } = await compileReact(source, {
imports: { '@videojs/icons/components': '@videojs/icons/react' },
const { code } = await compileJsx(source, {
imports: { '@videojs/icons/components': '@videojs/icons/jsx' },
});
expect(code).toMatch(/from ['"]unrelated['"]/);
expect(code).toContain('PlayIcon');
@@ -54,7 +67,7 @@ describe('compile (transformImports — bare-string rule)', () => {
describe('compile (transformImports — function rule)', () => {
it('rewrites per-identifier source and bucket-merges by resolved target', async () => {
const source = `import { Alpha, Beta } from '@fixture/components';\nconst _ = [Alpha, Beta];`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
imports: {
'@fixture/components': (name) => ({ source: `./ui/${name.toLowerCase()}`, name }),
},
@@ -65,7 +78,7 @@ describe('compile (transformImports — function rule)', () => {
it('renames identifiers when the rule returns a different `name`', async () => {
const source = `import { OldName } from 'src';\nconst _ = OldName;`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
imports: { src: (_name) => ({ source: 'dst', name: 'NewName' }) },
});
expect(code).toContain(`import { NewName as OldName } from "dst"`);
@@ -75,7 +88,7 @@ describe('compile (transformImports — function rule)', () => {
describe('replace', () => {
it('substitutes a matched element with a new tag and adds the import', async () => {
const source = `function App(){ return <Old foo="bar"/>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [replace({ match: byTag('Old'), with: { source: 'pkg', name: 'New' } })],
});
expect(code).toContain(`<New foo="bar"`);
@@ -84,7 +97,7 @@ describe('replace', () => {
it('preserves children when matching an open element', async () => {
const source = `function App(){ return <Old><span/></Old>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [replace({ match: byTag('Old'), with: { source: 'pkg', name: 'New' } })],
});
expect(collapse(code)).toContain(collapse(`<New><span/></New>`));
@@ -94,7 +107,7 @@ describe('replace', () => {
describe('wrap', () => {
it('wraps a matched element with a new tag and adds the import', async () => {
const source = `function App(){ return <Inner/>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [wrap({ match: byTag('Inner'), with: { source: 'pkg', name: 'Outer' } })],
});
expect(collapse(code)).toContain(collapse(`<Outer><Inner/></Outer>`));
@@ -105,7 +118,7 @@ describe('wrap', () => {
describe('childAsProp', () => {
it('lifts a single JSX-element child into the named prop', async () => {
const source = `function App(){ return <T><B/></T>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [childAsProp({ match: byTag('T'), prop: 'render' })],
});
expect(collapse(code)).toContain(collapse(`<T render={<B/>}/>`));
@@ -113,7 +126,7 @@ describe('childAsProp', () => {
it('skips when prop is already set', async () => {
const source = `function App(){ return <T render={<X/>}><B/></T>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [childAsProp({ match: byTag('T'), prop: 'render' })],
});
expect(collapse(code)).toContain(collapse(`<X/>`));
@@ -122,7 +135,7 @@ describe('childAsProp', () => {
it('skips when there are multiple JSX-element children', async () => {
const source = `function App(){ return <T><A/><B/></T>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [childAsProp({ match: byTag('T'), prop: 'render' })],
});
expect(collapse(code)).toContain(collapse(`<T><A/><B/></T>`));
@@ -130,7 +143,7 @@ describe('childAsProp', () => {
it('matches an array of tags via anyTag', async () => {
const source = `function App(){ return <><T1><A/></T1><T2><B/></T2></>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [childAsProp({ match: anyTag(['T1', 'T2']), prop: 'render' })],
});
const trimmed = collapse(code);
@@ -142,7 +155,7 @@ describe('childAsProp', () => {
describe('replaceJsxChild', () => {
it('replaces matched JSX children with expression helpers', async () => {
const source = `function App({ values }){ return <Container><Token name="poster-image"/></Container>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [
replaceJsxChild({
match: byTag('Token'),
@@ -158,7 +171,7 @@ describe('replaceJsxChild', () => {
describe('addProp', () => {
it('emits a JSX value by default and adds the import', async () => {
const source = `function App(){ return <PlayButton/>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [
addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } }),
],
@@ -169,7 +182,7 @@ describe('addProp', () => {
it('emits a bare reference when kind is "ref"', async () => {
const source = `function App(){ return <PlayButton/>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [
addProp({
match: byTag('PlayButton'),
@@ -183,7 +196,7 @@ describe('addProp', () => {
it('skips elements where the prop is already set', async () => {
const source = `function App(){ return <PlayButton render={<X/>}/>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [
addProp({ match: byTag('PlayButton'), prop: 'render', value: { source: './button', name: 'Button' } }),
],
@@ -194,7 +207,7 @@ describe('addProp', () => {
it('overwrites the existing prop when overwrite is true', async () => {
const source = `function App(){ return <PlayButton render={<X/>}/>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [
addProp({
match: byTag('PlayButton'),
@@ -211,7 +224,7 @@ describe('addProp', () => {
describe('matchers', () => {
it('byTag supports dotted tags', async () => {
const source = `function App(){ return <Popover.Root foo="bar"/>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [replace({ match: byTag('Popover.Root'), with: { source: 'pkg', name: 'NewRoot' } })],
});
expect(code).toContain(`<NewRoot`);
@@ -219,12 +232,12 @@ describe('matchers', () => {
it('byTag honours `when` refinement', async () => {
const source = `function App(){ return <><Foo a="1"/><Foo a="2"/></>; }`;
const isA1 = (node: import('../matchers').JsxElementLike) => {
const isA1 = (node: JsxElementLike) => {
const attrs = 'attributes' in node ? node.attributes : (node as never);
const props = (attrs as { properties?: ReadonlyArray<{ initializer?: { text?: string } }> }).properties ?? [];
return props.some((p) => p.initializer?.text === '1');
};
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [replace({ match: byTag('Foo', { when: isA1 }), with: { source: 'pkg', name: 'Bar' } })],
});
expect(code).toContain(`<Bar a="1"`);
@@ -233,7 +246,7 @@ describe('matchers', () => {
it('hasChild matches direct children only by default', async () => {
const source = `function App(){ return <><A><B/></A><A><div><B/></div></A></>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [replace({ match: byTag('A', { when: hasChild(byTag('B')) }), with: { source: 'p', name: 'Z' } })],
});
// First <A> has direct <B/> child → replaced; second <A> has only a nested <B/> → not replaced.
@@ -243,7 +256,7 @@ describe('matchers', () => {
it('hasChild with deep:true matches descendants', async () => {
const source = `function App(){ return <A><div><B/></div></A>; }`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [
replace({
match: byTag('A', { when: hasChild(byTag('B'), { deep: true }) }),
@@ -258,7 +271,7 @@ describe('matchers', () => {
const source = `function App(){
return <><Outer><Inner><Target/></Inner></Outer><Outer><Inner><Other/></Inner></Outer></>;
}`;
const { code } = await compileReact(source, {
const { code } = await compileJsx(source, {
transforms: [
replace({
match: byTag('Outer', { when: hasChild(byTag('Inner', { when: hasChild(byTag('Target')) })) }),
@@ -1,12 +1,13 @@
import type ts from 'typescript';
import { describe, expect, it } from 'vitest';
import { CompilerError, compile, react } from '..';
import { CompilerError, compile } from '..';
import {
diagnosticLocationFromNode,
formatCompilerDiagnostic,
formatCompilerDiagnosticJsonLine,
formatDiagnosticSummaryJsonLine,
} from '../diagnostics';
import { jsx } from '../jsx';
import { DiagnosticError } from '../tailwind';
describe('formatCompilerDiagnostic', () => {
@@ -96,7 +97,7 @@ describe('CompilerError diagnostics', () => {
try {
await compile(`export function App(){ return <Foo/>; }`, {
filename: '/workspace/skin.tsx',
config: { target: react({ transforms: [transform()] }) },
config: { target: jsx({ transforms: [transform()] }) },
});
throw new Error('Expected compile to fail');
} catch (error) {
@@ -2,9 +2,9 @@ import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { beforeAll, describe, expect, it } from 'vitest';
import { compile, type ImportRule, react } from '..';
import { anyTag, byTag, hasChild } from '../matchers';
import { childAsProp, replace } from '../react';
import { compile } from '..';
import { anyTag, byTag, childAsProp, hasChild, jsx, replace } from '../jsx';
import type { ImportRule } from '../transforms';
const __dirname = dirname(fileURLToPath(import.meta.url));
const skinSource = resolve(__dirname, 'fixtures/video-skin.tsx');
@@ -12,12 +12,12 @@ const skinSource = resolve(__dirname, 'fixtures/video-skin.tsx');
/**
* End-to-end smoke test: feed a representative constrained-JSX video skin
* (vendored under `fixtures/`) through `compile()` with the same shape
* a React package build hook uses, and sanity-check the output's structural
* a package build hook uses, and sanity-check the output's structural
* shape. Snapshot-style assertions intentionally use `.toContain` over a full
* snapshot to keep the test resilient to incidental whitespace differences
* from the TS printer.
*/
describe('integration: default/video skin → React', () => {
describe('integration: default/video skin → JSX', () => {
const source = readFileSync(skinSource, 'utf8');
let code = '';
@@ -26,14 +26,14 @@ describe('integration: default/video skin → React', () => {
source: `./src/ui/${name.replace(/^[A-Z]/, (m) => m.toLowerCase()).replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`,
name,
}),
'@fixture/icons/components': '@fixture/icons/react',
'@fixture/icons/components': '@fixture/icons/jsx',
'../tailwind': '@videojs/skins/default/tailwind',
};
beforeAll(async () => {
const result = await compile(source, {
config: {
target: react({
target: jsx({
imports,
transforms: [
replace({
@@ -60,7 +60,7 @@ describe('integration: default/video skin → React', () => {
});
it('rewrites icon component imports', () => {
expect(code).toContain('@fixture/icons/react');
expect(code).toContain('@fixture/icons/jsx');
expect(code).not.toContain('@fixture/icons/components');
});
+1 -1
View File
@@ -3,7 +3,7 @@ import ts from 'typescript';
/**
* Per-identifier rewrite target. `source` may be either a bare specifier
* (`@videojs/icons/react`) or a relative path. Relative paths are resolved
* (`@fixture/icons/jsx`) or a relative path. Relative paths are resolved
* against the configured `configDir` and re-projected as a relative path from
* the output file at print time.
*/
+10
View File
@@ -0,0 +1,10 @@
export { type AddImportContext, type AddImportRef, addNamedImport } from './add-import';
export { dropUnusedImports } from './drop-unused-imports';
export { dropUnusedLocals } from './drop-unused-locals';
export {
type ImportRef,
type ImportRewriteOptions,
type ImportRule,
resolveRelative,
transformImports,
} from './imports';
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { compile } from '../../compile';
import { react } from '../../config';
import { jsx } from '../../config';
import { dropUnusedImports } from '../drop-unused-imports';
const wrap = async (source: string): Promise<string> =>
(await compile(source, { config: { target: react({ transforms: [dropUnusedImports()] }) } })).code;
(await compile(source, { config: { target: jsx({ transforms: [dropUnusedImports()] }) } })).code;
describe('dropUnusedImports', () => {
it('does not count intrinsic JSX tag names as import references', async () => {
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { compile } from '../../compile';
import { react } from '../../config';
import { jsx } from '../../config';
import { dropUnusedLocals } from '../drop-unused-locals';
const wrap = async (source: string): Promise<string> =>
(await compile(source, { config: { target: react({ transforms: [dropUnusedLocals()] }) } })).code;
(await compile(source, { config: { target: jsx({ transforms: [dropUnusedLocals()] }) } })).code;
describe('dropUnusedLocals', () => {
it('drops an unused cn() local', async () => {
+4 -4
View File
@@ -4,12 +4,12 @@ export default defineConfig({
entry: {
index: './src/index.ts',
cli: './src/cli.ts',
'plugins/vite': './src/plugins/vite.ts',
'ast/index': './src/ast/index.ts',
'matchers/index': './src/matchers/index.ts',
'react/index': './src/react/index.ts',
'bundlers/vite': './src/bundlers/vite.ts',
diagnostics: './src/diagnostics.ts',
'jsx/index': './src/jsx/index.ts',
'styles/index': './src/styles/index.ts',
'tailwind/index': './src/tailwind/index.ts',
'transforms/index': './src/transforms/index.ts',
},
platform: 'neutral',
format: 'es',