Files
v10/packages/compiler/src/tailwind/plugin.ts
T

594 lines
24 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs';
import { basename, dirname, extname, isAbsolute, join, resolve as resolvePath } from 'node:path';
import ts from 'typescript';
import type { CompilerContext, StylePipeline } from '../config';
import { diagnosticLocationFromNode } from '../diagnostics';
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';
import {
type CompiledRule,
type EmittedCss,
emitCss,
type HoistOptions,
type RegisteredPropertiesOptions,
} from './emit';
import { EvaluationError, loadTokenModule, type TokenValue } from './evaluator';
import { type DeriveClassNameOptions, DiagnosticError, deriveClassName, type NameTransform } from './naming';
/** Styling mode for Tailwind-backed className handling. */
export type TailwindMode =
/** Pass-through. JSX `className` values stay as authored. No CSS emitted. */
| 'preserve'
/**
* Flatten every `cn(...)` call and dotted token reference to a single
* literal utility string on each `className` prop. No CSS emitted; token
* imports become unused (handled by `dropUnusedImports`).
*/
| 'inline'
/**
* Rewrite each `className` value to a semantic CSS class name and return CSS
* as compiler assets.
*/
| 'extract';
/** Per-rule annotation hook; lets the consumer assign a `bag` for split-mode emission. */
export type BagFor = (info: { className: string; segments: readonly StyleSegment[] }) => string | undefined;
export type ResolveTokenModule = (specifier: string, fromFile: string) => string | null | undefined;
export interface TailwindOptions {
/** Styling mode. Defaults to `'preserve'`. */
mode?: TailwindMode | undefined;
/** Loaded Tailwind v4 design system. Required for `'extract'` unless `input` is provided. */
design?: DesignSystem | undefined;
/** Tailwind CSS entry used to load the design system when `design` is omitted. */
input?: string | undefined;
/** CSS asset name for `'extract'`. Defaults to the compiled source basename with `.css`. */
output?: string | undefined;
/** Resolve bare token imports in skin sources to token modules on disk. Relative imports use the default resolver. */
resolveTokenModule?: ResolveTokenModule | undefined;
/**
* Hook for shaping the final class name (see `NameTransform`). Only used
* by `'extract'`. Identity by default.
*/
transformName?: NameTransform;
/**
* Per-tag / per-token-path class-name overrides. Only used by `'extract'`.
*/
overrides?: Record<string, string>;
/**
* Optional helper that decides which split-mode `bag` a rule belongs to.
* Only used by `'extract'`. Returns `undefined` to leave the rule
* unbagged.
*/
bagFor?: BagFor;
/** Options forwarded to `emitCss` for `'extract'`. */
emit?: { mode?: 'merged' | 'split'; baseCss?: readonly string[]; configDir?: string };
/**
* Hoist uniform CSS variable declarations to a single root rule. See
* `HoistOptions`. Forwarded to the internal `emitCss` call in extract mode.
*
* Pass `false` to disable. Plugin consumers driving `emitCss` themselves
* should pass the same value through.
*/
hoistVars?: false | HoistOptions;
/**
* Inline matching CSS custom properties into their consumers, dropping
* the matching declarations. Same shape as `EmitCssOptions['inlineVars']`:
*
* - `true` — inline `--tw-*` (Tailwind's internal registered variables).
* - `RegExp` — inline any `--name` matching.
* - omitted — no inlining.
*
* Forwarded to the internal `emitCss` call in extract mode.
*/
inlineVars?: true | RegExp;
/**
* Handle Tailwind's `@property`-registered variables (`--tw-content`, etc.) that
* are referenced but never set, so the output isn't broken (e.g.
* `content: var(--tw-content)`). See `RegisteredPropertiesOptions` — choose
* `'emit'` (ship `@property` rules) or `'inline'` (bake initial-values in),
* with an optional `resolve` hook for the per-property config.
*
* Forwarded to the internal `emitCss` call in extract mode.
*/
properties?: RegisteredPropertiesOptions;
}
interface TailwindTransformOptions extends Omit<TailwindOptions, 'input' | 'output'> {
mode: TailwindMode;
sourcePath?: string | undefined;
onRules?: ((rules: readonly CompiledRule[]) => void) | undefined;
}
export function tailwind(options: TailwindOptions = {}): StylePipeline {
return {
name: 'tailwind',
async setup(context) {
const mode = options.mode ?? 'preserve';
if (mode === 'preserve') return {};
if (mode === 'inline') {
return {
transform: tailwindPlugin({ ...options, mode, sourcePath: context.filename }),
};
}
const design = await resolveDesignSystem(options, context);
const rules: CompiledRule[] = [];
return {
transform: tailwindPlugin({
...options,
mode,
design,
sourcePath: context.filename,
onRules: (nextRules) => {
rules.push(...nextRules);
},
}),
async finish() {
if (rules.length === 0) return;
const emitted = await emitCss({
rules,
...(options.emit ?? {}),
...(options.hoistVars !== undefined ? { hoist: options.hoistVars } : {}),
...(options.inlineVars !== undefined ? { inlineVars: options.inlineVars } : {}),
...(options.properties ? { properties: options.properties } : {}),
resolveThemeVar: (name) => design.resolveThemeVar(name),
...(options.hoistVars ? { themeSelector: options.hoistVars.rootSelector } : {}),
});
addCssAssets(context, options.output, emitted);
},
};
},
};
}
/**
* TS transformer that rewrites JSX `className` attributes per the chosen
* Tailwind target. Built on top of `analyzeStyles` (generic JSX walker) +
* `decompose` + `deriveClassName` + `emitCss`. Token references are resolved
* by statically evaluating the imported token module — see `evaluator.ts`.
*/
export function tailwindPlugin(options: TailwindTransformOptions): ts.TransformerFactory<ts.SourceFile> {
if (options.mode === 'preserve') {
return () => (sourceFile) => sourceFile;
}
if (options.mode === 'inline') {
return inlinedPlugin(options);
}
if (!options.design) {
throw new Error('@videojs/compiler: tailwind extract mode requires `design` or `input`');
}
return vanillaCssPlugin({ ...options, design: options.design });
}
/* ─────────────────────────────────────────────────────────────────────────
* Target: tailwind-inlined
* ───────────────────────────────────────────────────────────────────────── */
function inlinedPlugin(options: TailwindTransformOptions): ts.TransformerFactory<ts.SourceFile> {
const env = buildTokenEnv(options.sourcePath, options.resolveTokenModule);
return (transformContext) => {
return (sourceFile) => {
const visit: StyleVisitor = (info, factory) => {
if (info.kind !== 'segments' || !info.segments) return undefined;
const flat = flattenToLiteral(info.segments, env.values);
if (flat === null) return undefined;
return factory.createStringLiteral(flat);
};
return analyzeStyles({ visit })(transformContext)(sourceFile);
};
};
}
/* ─────────────────────────────────────────────────────────────────────────
* Target: vanilla-css
* ───────────────────────────────────────────────────────────────────────── */
function vanillaCssPlugin(
options: TailwindTransformOptions & { design: DesignSystem }
): ts.TransformerFactory<ts.SourceFile> {
const { design, transformName, overrides, bagFor, onRules } = options;
const env = buildTokenEnv(options.sourcePath, options.resolveTokenModule);
return (transformContext) => {
return (sourceFile) => {
const rules: CompiledRule[] = [];
// Per derived class name, the sorted utility signature of the first
// element that produced it. Lets us detect when two *different* source
// elements collapse onto the same class name with *different* styles —
// emitCss would silently merge their declarations into one rule.
const signatures = new Map<string, string>();
const visit: StyleVisitor = (info, factory) => {
if (info.kind !== 'segments' || !info.segments) return undefined;
// Capture the narrowed segments so the closures below keep the type.
const segments = info.segments;
const naming: DeriveClassNameOptions = {
element: info.element,
segments,
...(transformName ? { transformName } : {}),
...(overrides ? { overrides } : {}),
...(env.hasSource ? { tokenNamespaces: env.namespaces, tokenRoots: env.roots } : {}),
};
const derived = deriveClassName(naming);
// Resolve each segment against the env. Literals resolve to themselves;
// tokens resolve via path walking; opaques and unresolved tokens are
// *passed through* — those are runtime expressions (e.g. a `className`
// prop the consumer composes onto the element). We rewrite the
// classname to the derived semantic name and wrap any pass-throughs in
// a `cn(...)` call so composition is preserved.
const passThrough: ts.Expression[] = [];
const preserved: string[] = [];
// Rule-producing utilities only. Preserved marker classes stay on the
// element and don't participate in generated CSS rule merging.
const ruleUtilities: string[] = [];
// Compile one utility: emit a rule when it produces declarations,
// otherwise *preserve* it as a literal class. Utilities that yield no
// declarations are markers (`group`, `peer`, `group/<name>`) or classes
// Tailwind doesn't recognize — dropping them would silently break every
// descendant `group-*` / `peer-*` variant that targets the marker.
const handleUtility = (utility: string): void => {
const css = decompose(utility, design);
if (css && css.declarations.length > 0) {
ruleUtilities.push(utility);
rules.push(buildCompiledRule(derived.className, css, segments, bagFor));
return;
}
if (!preserved.includes(utility)) preserved.push(utility);
};
for (const seg of segments) {
if (seg.kind === 'literal') {
for (const utility of seg.value.split(/\s+/)) {
if (utility) handleUtility(utility);
}
continue;
}
if (seg.kind === 'token') {
const literal = resolveTokenPath(seg.path, env.values);
if (literal !== null) {
for (const utility of literal.split(/\s+/)) {
if (utility) handleUtility(utility);
}
continue;
}
// Fall through — token didn't resolve, treat as pass-through.
}
passThrough.push(seg.node);
}
// Collision guard: two distinct elements that derive the same class
// name must resolve to the same utilities. Identical recurrences (e.g.
// many `<Tooltip.Popup>` with the same token) share a signature and are
// fine; differing ones would merge conflicting declarations into one
// rule, so we fail loudly with a fixable diagnostic.
if (ruleUtilities.length > 0) {
const signature = [...ruleUtilities].sort().join(' ');
const previous = signatures.get(derived.className);
if (previous === undefined) {
signatures.set(derived.className, signature);
} else if (previous !== signature) {
throw collisionError(info.element, derived.className, previous, signature);
}
}
const baseName = preserved.length > 0 ? `${derived.className} ${preserved.join(' ')}` : derived.className;
if (passThrough.length === 0) {
return factory.createStringLiteral(baseName);
}
return factory.createCallExpression(factory.createIdentifier('cn'), undefined, [
factory.createStringLiteral(baseName),
...passThrough,
]);
};
const transformed = analyzeStyles({ visit })(transformContext)(sourceFile);
if (rules.length === 0) return transformed;
onRules?.(rules);
return transformed;
};
};
}
async function resolveDesignSystem(options: TailwindOptions, context: CompilerContext): Promise<DesignSystem> {
if (options.design) return options.design;
if (!options.input) {
throw new Error('@videojs/compiler: tailwind extract mode requires `input` when `design` is not provided');
}
const input = isAbsolute(options.input) ? options.input : resolvePath(context.configDir, options.input);
return loadDesignSystem(input);
}
function addCssAssets(context: CompilerContext, output: string | undefined, emitted: EmittedCss): void {
if (emitted.kind === 'merged') {
context.addAsset({
type: 'css',
fileName: output ?? defaultCssFileName(context),
source: emitted.css,
sourceFile: context.filename,
});
return;
}
const indexFile = output ?? defaultCssFileName(context);
context.addAsset({ type: 'css', fileName: indexFile, source: emitted.index, sourceFile: context.filename });
const dir = dirname(indexFile);
for (const [bag, source] of emitted.bags) {
context.addAsset({
type: 'css',
fileName: join(dir, `${bag || 'index'}.css`),
source,
sourceFile: context.filename,
});
}
}
function defaultCssFileName(context: CompilerContext): string {
const file = basename(context.outputFile ?? context.filename);
const ext = extname(file);
return `${ext ? file.slice(0, -ext.length) : file}.css`;
}
/* ─────────────────────────────────────────────────────────────────────────
* Token environment
* ───────────────────────────────────────────────────────────────────────── */
/**
* Discover the token-namespace imports in the skin source and evaluate each
* referenced module on disk. Also folds local `const X = cn(<resolvable>)`
* declarations into the env so JSX `className={X}` references resolve.
*
* Reads + reparses the source file from disk rather than walking the in-flight
* SourceFile — earlier transforms in the pipeline (e.g. `transformImports`)
* may have rewritten relative specifiers to bare ones, which would defeat the
* on-disk module resolution we need here.
*
* If `sourcePath` is undefined or unreadable, returns an empty map; the plugin
* then leaves token-bearing className expressions alone.
*/
interface TokenEnv {
values: Map<string, TokenValue>;
namespaces: Set<string>;
roots: Set<string>;
hasSource: boolean;
}
function buildTokenEnv(sourcePath: string | undefined, resolveTokenModule?: ResolveTokenModule | undefined): TokenEnv {
const env: TokenEnv = {
values: new Map<string, TokenValue>(),
namespaces: new Set<string>(),
roots: new Set<string>(),
hasSource: false,
};
if (!sourcePath || !existsSync(sourcePath)) return env;
env.hasSource = true;
const source = readFileSync(sourcePath, 'utf8');
const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
// First pass: import declarations.
for (const stmt of sourceFile.statements) {
if (!ts.isImportDeclaration(stmt)) continue;
const specifier = stmt.moduleSpecifier;
if (!ts.isStringLiteral(specifier)) continue;
const id = specifier.text;
const absolutePath = resolveTokenImport(id, sourcePath, resolveTokenModule);
if (!absolutePath) continue;
let exports: Record<string, TokenValue>;
try {
exports = loadTokenModule(absolutePath);
} catch (error) {
if (error instanceof EvaluationError) {
// Token grammar violation — skip this import so the className is
// treated as opaque rather than crashing the build.
continue;
}
throw error;
}
const clause = stmt.importClause;
if (!clause) continue;
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const spec of clause.namedBindings.elements) {
const sourceName = spec.propertyName?.text ?? spec.name.text;
const localName = spec.name.text;
const value = exports[sourceName];
if (value !== undefined) {
setTokenValue(env, localName, value);
if (isTokenNamespaceImport(sourceName, localName, value)) env.namespaces.add(localName);
}
}
continue;
}
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
env.namespaces.add(clause.namedBindings.name.text);
setTokenValue(env, clause.namedBindings.name.text, exports as TokenValue);
}
}
// Second pass: top-level `const X = <expr>` declarations whose RHS resolves
// statically against the env. Lets skins write
// const iconButton = cn(styles.button.base, styles.button.icon);
// and reference `iconButton` in `className={iconButton}` without losing
// the resolution.
for (const stmt of sourceFile.statements) {
if (!ts.isVariableStatement(stmt)) continue;
for (const decl of stmt.declarationList.declarations) {
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
const value = tryEvaluateLocal(decl.initializer, env.values);
if (value !== null) setTokenValue(env, decl.name.text, value);
}
}
return env;
}
function setTokenValue(env: TokenEnv, name: string, value: TokenValue): void {
env.values.set(name, value);
env.roots.add(name);
}
function isTokenNamespaceImport(sourceName: string, localName: string, value: TokenValue): boolean {
if (typeof value === 'string') return false;
return sourceName === 'tokens' || sourceName === 'styles' || localName === 'tokens' || localName === 'styles';
}
/**
* Evaluate a local declaration's RHS against `env`. Supports `cn(...)` calls,
* dotted access, identifier lookup, and string literals — same surface as the
* token-module evaluator, but without nested object literals (skins don't
* declare those locally) and without recursion across files.
*/
function tryEvaluateLocal(node: ts.Expression, env: Map<string, TokenValue>): TokenValue | null {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
if (ts.isIdentifier(node)) {
const v = env.get(node.text);
return v ?? null;
}
if (ts.isPropertyAccessExpression(node)) {
const root = tryEvaluateLocal(node.expression, env);
if (root === null || typeof root === 'string') return null;
if (!ts.isIdentifier(node.name)) return null;
const next = root[node.name.text];
return next ?? null;
}
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'cn') {
const parts: string[] = [];
for (const arg of node.arguments) {
const v = tryEvaluateLocal(arg, env);
if (v === null || typeof v !== 'string') return null;
if (v) parts.push(v);
}
return parts.join(' ');
}
if (ts.isParenthesizedExpression(node)) return tryEvaluateLocal(node.expression, env);
return null;
}
const MODULE_EXTENSIONS = ['.ts', '.tsx', '/index.ts', '/index.tsx'] as const;
function resolveTokenImport(
specifier: string,
fromFile: string,
resolveTokenModule?: ResolveTokenModule | undefined
): string | null {
if (specifier.startsWith('.')) return resolveModulePath(specifier, fromFile);
const resolved = resolveTokenModule?.(specifier, fromFile);
if (!resolved) return null;
return isAbsolute(resolved) ? resolved : resolvePath(dirname(fromFile), resolved);
}
function resolveModulePath(specifier: string, fromFile: string): string | null {
const base = isAbsolute(specifier) ? specifier : resolvePath(dirname(fromFile), specifier);
for (const ext of MODULE_EXTENSIONS) {
const candidate = `${base}${ext}`;
if (existsSync(candidate)) return candidate;
}
return null;
}
/* ─────────────────────────────────────────────────────────────────────────
* Segment → utility resolution
* ───────────────────────────────────────────────────────────────────────── */
/**
* Resolve every segment to a string. Returns `null` for opaque expressions
* or token paths that can't be walked against the env (so the caller can
* leave the source unchanged).
*/
function collectUtilities(segments: readonly StyleSegment[], env: Map<string, TokenValue>): string[] | null {
const out: string[] = [];
for (const seg of segments) {
if (seg.kind === 'literal') {
pushUtilities(out, seg.value);
continue;
}
if (seg.kind === 'token') {
const literal = resolveTokenPath(seg.path, env);
if (literal === null) return null;
pushUtilities(out, literal);
continue;
}
return null;
}
return out;
}
function flattenToLiteral(segments: readonly StyleSegment[], env: Map<string, TokenValue>): string | null {
const utilities = collectUtilities(segments, env);
if (utilities === null) return null;
return utilities.join(' ');
}
/**
* Walk `path` (e.g. `['styles', 'button', 'icon']`) against the env. The head
* segment is the local namespace name (or a top-level local const); subsequent
* segments index into the resolved object. Returns `null` if the path doesn't
* resolve to a string.
*/
function resolveTokenPath(path: readonly string[], env: Map<string, TokenValue>): string | null {
if (path.length === 0) return null;
const [head, ...rest] = path;
const root = env.get(head!);
if (root === undefined) return null;
let cursor: TokenValue = root;
for (const key of rest) {
if (typeof cursor === 'string') return null;
const next = cursor[key];
if (next === undefined) return null;
cursor = next;
}
return typeof cursor === 'string' ? cursor : null;
}
function pushUtilities(out: string[], raw: string): void {
for (const u of raw.split(/\s+/)) {
if (u.length > 0) out.push(u);
}
}
function buildCompiledRule(
className: string,
utility: UtilityCss,
segments: readonly StyleSegment[],
bagFor: BagFor | undefined
): CompiledRule {
const bag = bagFor?.({ className, segments });
return bag === undefined ? { className, utility } : { className, utility, bag };
}
function collisionError(element: ts.Node, className: string, first: string, next: string): DiagnosticError {
const tag = tagName(element as Parameters<typeof tagName>[0]);
return new DiagnosticError(
`vanilla-css: class name '${className}' is derived from elements with different styles` +
`.\n` +
` <${tag}> resolves to: ${next}\n` +
` an earlier element resolved to: ${first}\n` +
`Merging these would put conflicting declarations in a single '.${className}' rule. ` +
`Disambiguate with a distinct token, a distinct component, or an \`overrides\` entry.`,
{ ...diagnosticLocationFromNode(element), diagnosticCode: 'tailwind-class-collision' }
);
}