mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
688 lines
23 KiB
TypeScript
688 lines
23 KiB
TypeScript
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
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, jsx } from '../../config';
|
|
import type { DesignSystem } from '../design-system';
|
|
import { loadDesignSystem } from '../design-system';
|
|
import type { CompiledRule } from '../emit';
|
|
import { clearTokenModuleCache } from '../evaluator';
|
|
import { tailwind, tailwindPlugin } from '../plugin';
|
|
|
|
const MINIMAL_CSS = `
|
|
@import "tailwindcss";
|
|
|
|
@theme {
|
|
--color-brand: oklch(0.7 0.2 250);
|
|
}
|
|
`;
|
|
|
|
let design: DesignSystem;
|
|
|
|
beforeAll(async () => {
|
|
const cssDir = mkdtempSync(join(tmpdir(), 'compiler-tw-plugin-'));
|
|
const cssPath = join(cssDir, 'tailwind.css');
|
|
writeFileSync(cssPath, MINIMAL_CSS, 'utf8');
|
|
design = await loadDesignSystem(cssPath);
|
|
}, 30_000);
|
|
|
|
let workDir: string;
|
|
|
|
beforeEach(() => {
|
|
workDir = mkdtempSync(join(tmpdir(), 'compiler-tw-fixture-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearTokenModuleCache();
|
|
});
|
|
|
|
const writeFixture = (relative: string, content: string): string => {
|
|
const abs = join(workDir, relative);
|
|
mkdirSync(join(abs, '..'), { recursive: true });
|
|
writeFileSync(abs, content, 'utf8');
|
|
return abs;
|
|
};
|
|
|
|
const collapse = (s: string): string => s.replace(/\s+/g, '');
|
|
|
|
const compile = (
|
|
source: string,
|
|
options: {
|
|
filename?: string | undefined;
|
|
target?: 'jsx' | undefined;
|
|
plugins?: readonly CompilerTransform[] | undefined;
|
|
} = {}
|
|
) => 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: { plugins: [tailwind(options)] } });
|
|
|
|
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: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'preserve' })],
|
|
});
|
|
expect(code).toContain('"flex items-center"');
|
|
});
|
|
|
|
it('skips extracted rule callbacks', async () => {
|
|
const source = `function App(){ return <Foo className="flex"/>; }`;
|
|
let called = 0;
|
|
await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'preserve',
|
|
onRules: () => {
|
|
called++;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(called).toBe(0);
|
|
});
|
|
});
|
|
|
|
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: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'inline' })],
|
|
});
|
|
expect(code).toContain('"flex items-center"');
|
|
});
|
|
|
|
it('folds static className arrays', async () => {
|
|
const source = `function App(){ return <Foo className={['flex', 'items-center', 'gap-2']}/>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'inline' })],
|
|
});
|
|
expect(code).toContain('"flex items-center gap-2"');
|
|
expect(code).not.toMatch(/className=\{\[/);
|
|
});
|
|
|
|
it('resolves imported token objects', async () => {
|
|
writeFixture(
|
|
'tokens.ts',
|
|
`export const tokens = { button: { base: ['rounded', 'p-2'] } };
|
|
`
|
|
);
|
|
const source = `import { tokens as styles } from './tokens';
|
|
function App(){ return <Foo className={['flex', styles.button.base]}/>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
filename: sourcePath,
|
|
plugins: [tailwindPlugin({ design, mode: 'inline', sourcePath })],
|
|
});
|
|
expect(code).toContain('"flex rounded p-2"');
|
|
});
|
|
|
|
it('leaves unresolved imports untouched', async () => {
|
|
const source = `import { tokens as styles } from './missing';
|
|
function App(){ return <Foo className={['flex', styles.unknown]}/>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
filename: sourcePath,
|
|
plugins: [tailwindPlugin({ design, mode: 'inline', sourcePath })],
|
|
});
|
|
expect(code).toMatch(/className=\{\[/);
|
|
});
|
|
|
|
it('leaves dynamic className arrays untouched', async () => {
|
|
const source = `function App(){ return <Foo className={['flex', isOn && 'on']}/>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'inline' })],
|
|
});
|
|
expect(code).toMatch(/className=\{\[/);
|
|
});
|
|
});
|
|
|
|
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: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
expect(code).toContain('"play-button"');
|
|
expect(code).not.toContain('"flex items-center"');
|
|
});
|
|
|
|
it('preserves group marker classes', async () => {
|
|
const source = `function App(){ return <PlayButton className="group"/>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
// `group` produces no declarations but is required by descendant
|
|
// `group-*` variants, so it must survive on the element.
|
|
expect(code).toContain('"play-button group"');
|
|
});
|
|
|
|
it('extracts className array utilities and preserves group marker classes', async () => {
|
|
const source = `function App(){ return <PlayButton className={['flex', 'group']}/>; }`;
|
|
let captured: readonly CompiledRule[] | undefined;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
onRules: (rules) => {
|
|
captured = rules;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(code).toContain('"play-button group"');
|
|
expect(captured).toBeDefined();
|
|
expect(captured!.map((r) => r.className)).toContain('play-button');
|
|
expect(captured!.flatMap((r) => r.utility.declarations)).toContainEqual({ property: 'display', value: 'flex' });
|
|
});
|
|
|
|
it('keeps dynamic className array expressions', async () => {
|
|
const source = `function App(){ return <PlayButton className={['group', extra]}/>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
expect(code).toMatch(/className=\{\["play-button group",\s*extra\]\}/);
|
|
});
|
|
|
|
it('throws on generated class style collisions', async () => {
|
|
const source = `function App(){ return <div><SeekIcon className="flex"/><SeekIcon className="block"/></div>; }`;
|
|
await expect(
|
|
compile(source, {
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
})
|
|
).rejects.toThrow(/class name 'seek-icon' is derived from elements with different styles/);
|
|
});
|
|
|
|
it('allows preserved marker classes next to matching generated styles', async () => {
|
|
const source = `function App(){ return <div><Menu.Item className={['flex', 'legacy-submenu']}/><Menu.Item className="flex"/></div>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
expect(code).toContain('"menu-item legacy-submenu"');
|
|
expect(code).toContain('"menu-item"');
|
|
});
|
|
|
|
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: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
expect(code).toContain('"play-button"');
|
|
});
|
|
|
|
it('derives class names from style member expressions', async () => {
|
|
const source = `function App(){ return <div className={styles.bufferingIndicator}/>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
expect(code).toContain('"buffering-indicator"');
|
|
});
|
|
|
|
it('preserves named token import roots in class names', async () => {
|
|
writeFixture(
|
|
'tokens.ts',
|
|
`export const slider = { root: 'flex' };
|
|
`
|
|
);
|
|
const source = `import { slider } from './tokens';
|
|
function App(){ return <div className={slider.root}/>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
const { code } = await compile(source, {
|
|
filename: sourcePath,
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
|
|
});
|
|
expect(code).toContain('"slider-root"');
|
|
});
|
|
|
|
it('uses known token roots to disambiguate reused component tags', async () => {
|
|
writeFixture(
|
|
'tokens.ts',
|
|
`export const icon = 'inline-block';
|
|
export const menu = { chevron: 'size-3' };
|
|
export const inputFeedback = { bubble: { shownSeek: 'block' } };
|
|
`
|
|
);
|
|
const source = `import { icon, inputFeedback, menu } from './tokens';
|
|
function App(){ return <div><ChevronIcon className={[icon, menu.chevron]}/><ChevronIcon className={[icon, inputFeedback.bubble.shownSeek]}/></div>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
const { code } = await compile(source, {
|
|
filename: sourcePath,
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
|
|
});
|
|
expect(code).toContain('"menu-chevron"');
|
|
expect(code).toContain('"input-feedback-bubble-shown-seek"');
|
|
});
|
|
|
|
it('derives class names from single imported token identifiers', async () => {
|
|
const source = `function App(){ return <div className={buttonGroupStart}/>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
expect(code).toContain('"button-group-start"');
|
|
});
|
|
|
|
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: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
expect(code).toContain('"menu-item"');
|
|
});
|
|
|
|
it('derives bare HTML class names from the most specific token path', async () => {
|
|
writeFixture(
|
|
'tokens.ts',
|
|
`export const tokens = { seek: { label: 'text-xs', labelBackward: 'left-0' } };
|
|
`
|
|
);
|
|
const source = `import { tokens as styles } from './tokens';
|
|
function App(){ return <span className={[styles.seek.label, styles.seek.labelBackward]}/>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
filename: sourcePath,
|
|
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
|
|
});
|
|
|
|
expect(code).toContain('"seek-label-backward"');
|
|
});
|
|
|
|
it('derives bare HTML class names from tokens when runtime segments are present', async () => {
|
|
writeFixture(
|
|
'tokens.ts',
|
|
`export const tokens = { slider: { fill: { base: 'absolute', fill: 'bg-white', buffer: 'bg-white/40' } } };
|
|
`
|
|
);
|
|
const source = `import { tokens as styles } from './tokens';
|
|
function App({ type, className }){
|
|
return <div className={[styles.slider.fill.base, type === 'fill' ? styles.slider.fill.fill : styles.slider.fill.buffer, className]}/>;
|
|
}`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
filename: sourcePath,
|
|
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
|
|
});
|
|
|
|
expect(code).toContain('"slider-fill-base"');
|
|
});
|
|
|
|
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: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
|
|
expect(code).toContain('"grow"');
|
|
});
|
|
|
|
it('applies resolved generated class names', async () => {
|
|
const source = `function App(){ return <PlayButton className="flex"/>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
resolve: {
|
|
name: (ctx) => `app-${ctx.defaultName}`,
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(code).toContain('"app-play-button"');
|
|
});
|
|
|
|
it('lets resolve.name choose token names for component elements', async () => {
|
|
const source = `function App(){ return <PlayButton className={styles.button.icon}/>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
resolve: {
|
|
name: (ctx) => ctx.tokenName ?? ctx.defaultName,
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(code).toContain('"button-icon"');
|
|
});
|
|
|
|
it('lets resolve.name choose component names over known token roots', async () => {
|
|
writeFixture(
|
|
'tokens.ts',
|
|
`export const menu = { chevron: 'size-3' };
|
|
`
|
|
);
|
|
const source = `import { menu } from './tokens';
|
|
function App(){ return <ChevronIcon className={menu.chevron}/>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
filename: sourcePath,
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
sourcePath,
|
|
resolve: {
|
|
name: (ctx) => ctx.componentName ?? ctx.defaultName,
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(code).toContain('"chevron-icon"');
|
|
});
|
|
|
|
it('reports extracted rules through onRules', async () => {
|
|
const source = `function App(){ return <Foo className="flex"/>; }`;
|
|
let captured: readonly CompiledRule[] | undefined;
|
|
await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
onRules: (rules) => {
|
|
captured = rules;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(captured).toBeDefined();
|
|
expect(captured!.length).toBeGreaterThan(0);
|
|
expect(captured![0]!.className).toBe('foo');
|
|
expect(captured![0]!.utility.declarations).toContainEqual({ property: 'display', value: 'flex' });
|
|
});
|
|
|
|
it('emits one rule per extracted utility', async () => {
|
|
const source = `function App(){ return <Foo className={['flex', 'opacity-50']}/>; }`;
|
|
let captured: readonly CompiledRule[] | undefined;
|
|
await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
onRules: (rules) => {
|
|
captured = rules;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(captured!.length).toBe(2);
|
|
expect(captured![0]!.className).toBe('foo');
|
|
expect(captured![1]!.className).toBe('foo');
|
|
});
|
|
|
|
it('resolves imported tokens before extraction', async () => {
|
|
writeFixture(
|
|
'tokens.ts',
|
|
`export const tokens = { button: ['flex', 'gap-2'] };
|
|
`
|
|
);
|
|
const source = `import { tokens as styles } from './tokens';
|
|
function App(){ return <Foo className={styles.button}/>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
let captured: readonly CompiledRule[] | undefined;
|
|
await compile(source, {
|
|
target: 'jsx',
|
|
filename: sourcePath,
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
sourcePath,
|
|
onRules: (rules) => {
|
|
captured = rules;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(captured!.length).toBe(2);
|
|
expect(captured![0]!.className).toBe('button');
|
|
});
|
|
|
|
it('resolves imported tokens with explicit extensions before extraction', async () => {
|
|
writeFixture(
|
|
'tokens.ts',
|
|
`export const tokens = { button: 'flex' };
|
|
`
|
|
);
|
|
const source = `import { tokens as styles } from './tokens.ts';
|
|
function App(){ return <Foo className={styles.button}/>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
filename: sourcePath,
|
|
plugins: [tailwindPlugin({ design, mode: 'extract', sourcePath })],
|
|
});
|
|
expect(code).toContain('"button"');
|
|
});
|
|
|
|
it('resolves bare token imports through a configured resolver', async () => {
|
|
const tokenPath = writeFixture(
|
|
'tokens.ts',
|
|
`export const tokens = { button: ['flex', 'gap-2'] };
|
|
`
|
|
);
|
|
const source = `import { tokens as styles } from '@fixture/tokens';
|
|
function App(){ return <Foo className={styles.button}/>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
let captured: readonly CompiledRule[] | undefined;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
filename: sourcePath,
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
sourcePath,
|
|
resolve: {
|
|
tokenModule: (specifier) => (specifier === '@fixture/tokens' ? tokenPath : null),
|
|
},
|
|
onRules: (rules) => {
|
|
captured = rules;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(code).toContain('"button"');
|
|
expect(captured!.length).toBe(2);
|
|
});
|
|
|
|
it('assigns rule groups with resolve.group', async () => {
|
|
const source = `function App(){ return <PlayButton className="flex"/>; }`;
|
|
let captured: readonly CompiledRule[] | undefined;
|
|
await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
resolve: {
|
|
group: ({ className }) => (className.startsWith('play-') ? 'controls' : undefined),
|
|
},
|
|
onRules: (rules) => {
|
|
captured = rules;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(captured![0]!.group).toBe('controls');
|
|
});
|
|
|
|
it('skips dynamic conditional class expressions', async () => {
|
|
const source = `function App(){ return <Foo className={isOn ? 'a' : 'b'}/>; }`;
|
|
let captured: readonly CompiledRule[] | undefined;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
onRules: (rules) => {
|
|
captured = rules;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(captured).toBeUndefined();
|
|
expect(code).toContain('isOn');
|
|
});
|
|
|
|
it('resolves local className arrays and imported token members', async () => {
|
|
writeFixture(
|
|
'tokens.ts',
|
|
`export const tokens = { button: { base: 'flex', icon: 'w-4 h-4' } };
|
|
`
|
|
);
|
|
const source = `import { tokens as styles } from './tokens';
|
|
const iconButton = [styles.button.base, styles.button.icon];
|
|
function App(){ return <PlayButton className={iconButton}/>; }`;
|
|
const sourcePath = writeFixture('skin.tsx', source);
|
|
|
|
let captured: readonly CompiledRule[] | undefined;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
filename: sourcePath,
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
sourcePath,
|
|
onRules: (rules) => {
|
|
captured = rules;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(code).toContain('"icon-button"');
|
|
expect(captured!.length).toBe(3);
|
|
const utilities = captured!.map((r) => r.utility.utility).sort();
|
|
expect(utilities).toEqual(['flex', 'h-4', 'w-4']);
|
|
});
|
|
|
|
it('preserves dynamic className suffixes after extraction', async () => {
|
|
const source = `function App({ extra }){ return <PlayButton className={['flex', extra]}/>; }`;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [tailwindPlugin({ design, mode: 'extract' })],
|
|
});
|
|
expect(code).toMatch(/className=\{\["play-button",\s*extra\]\}/);
|
|
});
|
|
|
|
it('extracts parent and child element class names', async () => {
|
|
const source = `function App(){
|
|
return <PlayButton className="flex"><PlayIcon className="opacity-50"/></PlayButton>;
|
|
}`;
|
|
let captured: readonly CompiledRule[] | undefined;
|
|
const { code } = await compile(source, {
|
|
target: 'jsx',
|
|
plugins: [
|
|
tailwindPlugin({
|
|
design,
|
|
mode: 'extract',
|
|
onRules: (rules) => {
|
|
captured = rules;
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
expect(captured!.length).toBe(2);
|
|
const names = captured!.map((r) => r.className);
|
|
expect(names).toContain('play-button');
|
|
expect(names).toContain('play-icon');
|
|
expect(collapse(code)).toContain(collapse(`<PlayButton className="play-button">`));
|
|
expect(collapse(code)).toContain(collapse(`<PlayIcon className="play-icon"/>`));
|
|
});
|
|
|
|
it('returns CSS assets in extract mode', async () => {
|
|
const source = `function App(){ return <Foo className="flex"/>; }`;
|
|
const { assets } = await compileTailwind(source, { mode: 'extract', design });
|
|
const css = assets[0]!.source;
|
|
expect(collapse(css)).toContain(collapse('.foo{display:flex;}'));
|
|
});
|
|
|
|
it('preserves multi-branch utilities in extracted CSS assets', async () => {
|
|
const source = `function App(){ return <Foo className="container"/>; }`;
|
|
const { assets } = await compileTailwind(source, { mode: 'extract', design });
|
|
const css = assets[0]!.source;
|
|
expect(collapse(css)).toContain(collapse('.foo{width:100%;}'));
|
|
expect(css).toMatch(/@media[^{]+{\s*\.foo\s*{\s*max-width:/);
|
|
});
|
|
|
|
it('emits referenced theme variables in extracted CSS', async () => {
|
|
// `p-4` lowers to `padding: calc(var(--spacing) * 4)` — the output must
|
|
// define `--spacing` so it resolves without a separate Tailwind theme.
|
|
const source = `function App(){ return <Foo className="p-4"/>; }`;
|
|
const { assets } = await compileTailwind(source, {
|
|
mode: 'extract',
|
|
design,
|
|
vars: { hoist: { rootSelector: '[data-skin="x"]' } },
|
|
});
|
|
const css = assets[0]!.source;
|
|
expect(css).toMatch(/\[data-skin="x"\]\s*{[^}]*--spacing:/);
|
|
});
|
|
|
|
it('forwards the `properties` option (inline) so --tw-content resolves', async () => {
|
|
// `after:absolute` emits `content: var(--tw-content)` with no setter.
|
|
const source = `function App(){ return <Foo className="after:absolute"/>; }`;
|
|
const { assets } = await compileTailwind(source, {
|
|
mode: 'extract',
|
|
design,
|
|
vars: { properties: { mode: 'inline' } },
|
|
});
|
|
const css = assets[0]!.source;
|
|
expect(css).not.toMatch(/var\(--tw-content\)/);
|
|
expect(collapse(css)).toContain(collapse('content: "";'));
|
|
});
|
|
|
|
it('forwards the `vars.inline` option', async () => {
|
|
const source = `function App(){ return <Foo className="shadow-sm"/>; }`;
|
|
const { assets } = await compileTailwind(source, {
|
|
mode: 'extract',
|
|
design,
|
|
vars: { inline: true },
|
|
});
|
|
const css = assets[0]!.source;
|
|
expect(css).not.toMatch(/--tw-shadow:/);
|
|
expect(css).not.toMatch(/var\(--tw-shadow[),]/);
|
|
});
|
|
});
|