From b55eb1a0fd2e2d545b2e3fe86301bd0b2b34bee7 Mon Sep 17 00:00:00 2001 From: Rahim Date: Fri, 26 Jun 2026 00:20:01 -0700 Subject: [PATCH] refactor(compiler): promote styles pipeline --- packages/compiler/package.json | 4 + packages/compiler/src/diagnostics.ts | 43 ++ packages/compiler/src/index.ts | 2 + packages/compiler/src/styles/analyze.ts | 33 +- packages/compiler/src/styles/class-list.ts | 64 ++ packages/compiler/src/styles/index.ts | 54 ++ .../src/{tailwind => styles}/naming.ts | 45 +- packages/compiler/src/styles/pipeline.ts | 253 +++++++ .../{tailwind => styles}/tests/naming.test.ts | 5 +- .../src/styles/tests/pipeline.test.ts | 72 ++ .../tests/token-module.test.ts} | 6 +- packages/compiler/src/styles/token-env.ts | 162 +++++ .../evaluator.ts => styles/token-module.ts} | 12 +- packages/compiler/src/tailwind/css/assets.ts | 41 ++ .../src/tailwind/{emit.ts => css/render.ts} | 14 +- packages/compiler/src/tailwind/index.ts | 26 +- packages/compiler/src/tailwind/plugin.ts | 631 +++++------------- .../compiler/src/tailwind/tests/emit.test.ts | 4 +- .../src/tailwind/tests/output-modes.test.ts | 2 +- .../src/tailwind/tests/plugin.test.ts | 195 ++---- .../src/tailwind/tests/utility-css.test.ts | 16 + packages/compiler/src/tailwind/utility-css.ts | 109 ++- .../compiler/src/tests/diagnostics.test.ts | 2 +- .../src/transforms/drop-unused-locals.ts | 2 +- packages/compiler/tsdown.config.ts | 1 + packages/core/src/dom/ui/button.ts | 2 + 26 files changed, 1084 insertions(+), 716 deletions(-) create mode 100644 packages/compiler/src/styles/class-list.ts rename packages/compiler/src/{tailwind => styles}/naming.ts (80%) create mode 100644 packages/compiler/src/styles/pipeline.ts rename packages/compiler/src/{tailwind => styles}/tests/naming.test.ts (98%) create mode 100644 packages/compiler/src/styles/tests/pipeline.test.ts rename packages/compiler/src/{tailwind/tests/evaluator.test.ts => styles/tests/token-module.test.ts} (97%) create mode 100644 packages/compiler/src/styles/token-env.ts rename packages/compiler/src/{tailwind/evaluator.ts => styles/token-module.ts} (97%) create mode 100644 packages/compiler/src/tailwind/css/assets.ts rename packages/compiler/src/tailwind/{emit.ts => css/render.ts} (98%) diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 05668c25..71c7c8d6 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -19,6 +19,10 @@ "types": "./dist/bundlers/vite.d.ts", "default": "./dist/bundlers/vite.js" }, + "./styles": { + "types": "./dist/styles/index.d.ts", + "default": "./dist/styles/index.js" + }, "./tailwind": { "types": "./dist/tailwind/index.d.ts", "default": "./dist/tailwind/index.js" diff --git a/packages/compiler/src/diagnostics.ts b/packages/compiler/src/diagnostics.ts index 9e5b4278..6c2112bb 100644 --- a/packages/compiler/src/diagnostics.ts +++ b/packages/compiler/src/diagnostics.ts @@ -23,6 +23,49 @@ export interface DiagnosticLocation { sourceText?: string | undefined; } +export interface DiagnosticErrorDetails extends DiagnosticLocation { + diagnosticCode?: string | undefined; + plugin?: string | undefined; +} + +/** Error type that carries compiler diagnostic metadata through transform failures. */ +export class DiagnosticError extends Error { + public readonly diagnosticCode: string; + public readonly fileName?: string; + public readonly file?: string; + public readonly line?: number; + public readonly column?: number; + public readonly endLine?: number; + public readonly endColumn?: number; + public readonly sourceText?: string; + public readonly plugin?: string; + + constructor(message: string, location?: DiagnosticErrorDetails | string | undefined, line?: number) { + super(message); + this.name = 'DiagnosticError'; + + if (typeof location === 'string') { + this.fileName = location; + this.file = location; + if (line !== undefined) this.line = line; + this.diagnosticCode = 'compiler-diagnostic'; + return; + } + + this.diagnosticCode = location?.diagnosticCode ?? 'compiler-diagnostic'; + if (location?.file) { + this.fileName = location.file; + this.file = location.file; + } + if (location?.line !== undefined) this.line = location.line; + if (location?.column !== undefined) this.column = location.column; + if (location?.endLine !== undefined) this.endLine = location.endLine; + if (location?.endColumn !== undefined) this.endColumn = location.endColumn; + if (location?.sourceText) this.sourceText = location.sourceText; + if (location?.plugin) this.plugin = location.plugin; + } +} + export interface FormatDiagnosticOptions { color?: boolean | undefined; cwd?: string | undefined; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index c9712177..3c1b1498 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -15,6 +15,8 @@ export { } from './config'; export { compilerDiagnosticToJsonEvent, + DiagnosticError, + type DiagnosticErrorDetails, type DiagnosticFormat, type DiagnosticJsonEvent, type DiagnosticJsonFrameLine, diff --git a/packages/compiler/src/styles/analyze.ts b/packages/compiler/src/styles/analyze.ts index 905c1793..66e8ec53 100644 --- a/packages/compiler/src/styles/analyze.ts +++ b/packages/compiler/src/styles/analyze.ts @@ -57,13 +57,24 @@ export interface AnalyzeStylesOptions { visit: StyleVisitor; } +export function readStyleAttribute(element: JsxElementLike): StyleAttributeInfo | undefined { + const attrs = ts.isJsxElement(element) ? element.openingElement.attributes : element.attributes; + const classNameAttr = findClassNameAttribute(attrs); + if (!classNameAttr) return undefined; + + const expression = readAttributeExpression(classNameAttr); + if (!expression) return undefined; + + return decompose(element, classNameAttr, expression); +} + /** * TS transformer that walks every JSX `className` attribute and invokes a * visitor with structural info. The visitor decides whether to replace the * attribute's value (returning a new expression) or leave it alone. * * The visitor is purely structural — it does not know about Tailwind or - * any other style system. Higher-level plugins (e.g. `tailwindPlugin`) + * any other style system. Higher-level plugins (e.g. `tailwind`) * compose it. */ export function analyzeStyles(options: AnalyzeStylesOptions): ts.TransformerFactory { @@ -93,19 +104,13 @@ function visitJsxElement( visit: StyleVisitor, context: ts.TransformationContext ): JsxElementLike { - const attrs = ts.isJsxElement(element) ? element.openingElement.attributes : element.attributes; - const classNameAttr = findClassNameAttribute(attrs); - if (!classNameAttr) return element; - - const expression = readAttributeExpression(classNameAttr); - if (!expression) return element; - - const info: StyleAttributeInfo = decompose(element, classNameAttr, expression); + const info = readStyleAttribute(element); + if (!info) return element; const replacement = visit(info, factory); if (replacement === undefined) return element; - return rewriteAttribute(element, classNameAttr, replacement, factory, context); + return rewriteStyleAttribute(info, replacement, factory, context); } function findClassNameAttribute(attrs: ts.JsxAttributes): ts.JsxAttribute | undefined { @@ -197,13 +202,13 @@ function readDottedPath(expr: ts.Expression): readonly string[] | null { return null; } -function rewriteAttribute( - element: JsxElementLike, - attribute: ts.JsxAttribute, +export function rewriteStyleAttribute( + info: StyleAttributeInfo, replacement: ts.Expression, factory: ts.NodeFactory, - _context: ts.TransformationContext + _context?: ts.TransformationContext | undefined ): JsxElementLike { + const { attribute, element } = info; const newAttribute = factory.updateJsxAttribute( attribute, attribute.name, diff --git a/packages/compiler/src/styles/class-list.ts b/packages/compiler/src/styles/class-list.ts new file mode 100644 index 00000000..4ebbf387 --- /dev/null +++ b/packages/compiler/src/styles/class-list.ts @@ -0,0 +1,64 @@ +import type ts from 'typescript'; +import type { StyleSegment } from './analyze'; +import { resolveTokenPath } from './token-env'; +import type { TokenValue } from './token-module'; + +export interface ResolvedExtractUtilities { + utilities: readonly string[]; + passThrough: readonly ts.Expression[]; +} + +export function collectUtilities(segments: readonly StyleSegment[], env: Map): 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; +} + +export function flattenToLiteral(segments: readonly StyleSegment[], env: Map): string | null { + const utilities = collectUtilities(segments, env); + if (utilities === null) return null; + return utilities.join(' '); +} + +export function collectExtractUtilities( + segments: readonly StyleSegment[], + env: Map +): ResolvedExtractUtilities { + const utilities: string[] = []; + const passThrough: ts.Expression[] = []; + + for (const seg of segments) { + if (seg.kind === 'literal') { + pushUtilities(utilities, seg.value); + continue; + } + if (seg.kind === 'token') { + const literal = resolveTokenPath(seg.path, env); + if (literal !== null) { + pushUtilities(utilities, literal); + continue; + } + } + passThrough.push(seg.node); + } + + return { utilities, passThrough }; +} + +function pushUtilities(out: string[], raw: string): void { + for (const u of raw.split(/\s+/)) { + if (u.length > 0) out.push(u); + } +} diff --git a/packages/compiler/src/styles/index.ts b/packages/compiler/src/styles/index.ts index c3131e4b..b086d8d1 100644 --- a/packages/compiler/src/styles/index.ts +++ b/packages/compiler/src/styles/index.ts @@ -1,8 +1,62 @@ +export { DiagnosticError, type DiagnosticErrorDetails } from '../diagnostics'; export { type AnalyzeStylesOptions, analyzeStyles, + readStyleAttribute, + rewriteStyleAttribute, type StyleAttributeInfo, + type StyleAttributeOpaqueInfo, + type StyleAttributeSegmentsInfo, type StyleSegment, type StyleVisitor, type StyleVisitorResult, } from './analyze'; +export { + collectExtractUtilities, + collectUtilities, + flattenToLiteral, + type ResolvedExtractUtilities, +} from './class-list'; +export { + type DeriveClassNameOptions, + type DerivedClassName, + deriveClassName, + type NameContext, + type ResolveName, +} from './naming'; +export { + type ClassNameReferenceData, + type ClassNameStyleReference, + type CssBundle, + classNameScanner, + defineStylingPlugin, + isClassNameStyleReference, + type StyleReference, + type StyleResolution, + type StyleTransformResult, + type StylingAsset, + type StylingConfigContext, + type StylingContextBase, + type StylingEmitContext, + type StylingGenerateContext, + type StylingOptions, + type StylingPlugin, + type StylingPluginEnforce, + type StylingRenderContext, + type StylingResolveContext, + type StylingScanContext, + type StylingTransformContext, + styling, +} from './pipeline'; +export { + buildTokenEnv, + type ResolveTokenModule, + resolveTokenPath, + type TokenEnv, +} from './token-env'; +export { + clearTokenModuleCache, + loadTokenModule, + TokenEvaluationError, + type TokenValue, +} from './token-module'; diff --git a/packages/compiler/src/tailwind/naming.ts b/packages/compiler/src/styles/naming.ts similarity index 80% rename from packages/compiler/src/tailwind/naming.ts rename to packages/compiler/src/styles/naming.ts index a428121d..16482f29 100644 --- a/packages/compiler/src/tailwind/naming.ts +++ b/packages/compiler/src/styles/naming.ts @@ -1,7 +1,7 @@ import { kebabCase } from '@videojs/utils/string'; -import { type DiagnosticLocation, diagnosticLocationFromNode } from '../diagnostics'; +import { DiagnosticError, diagnosticLocationFromNode } from '../diagnostics'; import { type JsxElementLike, tagName } from '../jsx'; -import type { StyleSegment } from '../styles'; +import type { StyleSegment } from './analyze'; /** Result of deriving a CSS class name for a JSX element. */ export interface DerivedClassName { @@ -57,45 +57,6 @@ export interface DeriveClassNameOptions { tokenRoots?: ReadonlySet; } -/** - * Diagnostic thrown when no rule matches — typically a bare HTML element - * with arbitrary class strings and no token-path indication of intent. - * Resolution is up to the consumer (move classes onto a component, - * extract a token, or customize `resolve.name`). - */ -export class DiagnosticError extends Error { - public readonly diagnosticCode: string; - public readonly fileName?: string; - public readonly line?: number; - public readonly column?: number; - public readonly endLine?: number; - public readonly endColumn?: number; - public readonly sourceText?: string; - - constructor( - message: string, - location?: (DiagnosticLocation & { diagnosticCode?: string | undefined }) | string | undefined, - line?: number - ) { - super(message); - this.name = 'DiagnosticError'; - if (typeof location === 'string') { - this.fileName = location; - if (line !== undefined) this.line = line; - this.diagnosticCode = 'tailwind-diagnostic'; - return; - } - - this.diagnosticCode = location?.diagnosticCode ?? 'tailwind-diagnostic'; - if (location?.file) this.fileName = location.file; - if (location?.line !== undefined) this.line = location.line; - if (location?.column !== undefined) this.column = location.column; - if (location?.endLine !== undefined) this.endLine = location.endLine; - if (location?.endColumn !== undefined) this.endColumn = location.endColumn; - if (location?.sourceText) this.sourceText = location.sourceText; - } -} - /** * Derive a semantic CSS class name for a JSX element. * @@ -156,7 +117,7 @@ export function deriveClassName(opts: DeriveClassNameOptions): DerivedClassName `Resolve by: (a) using a JSX component instead of <${tag}>, ` + `(b) extracting the classes into a single token reference, ` + `or (c) customizing \`resolve.name\`.`, - { ...diagnosticLocationFromNode(opts.element), diagnosticCode: 'tailwind-class-name' } + { ...diagnosticLocationFromNode(opts.element), diagnosticCode: 'style-class-name' } ); } diff --git a/packages/compiler/src/styles/pipeline.ts b/packages/compiler/src/styles/pipeline.ts new file mode 100644 index 00000000..a3d2e4aa --- /dev/null +++ b/packages/compiler/src/styles/pipeline.ts @@ -0,0 +1,253 @@ +import ts from 'typescript'; +import type { CompilerAsset, CompilerContext, CompilerPipelineStep, CompilerPlugin } from '../config'; +import type { JsxElementLike } from '../jsx'; +import { readStyleAttribute, type StyleAttributeInfo } from './analyze'; + +export type StylingPluginEnforce = 'pre' | 'post'; + +export interface StyleReference { + kind: string; + element: JsxElementLike; + node?: ts.Node | undefined; + data: Data; +} + +export interface StyleResolution { + kind: string; + reference: StyleReference; + data: Data; +} + +export interface StyleTransformResult { + element?: JsxElementLike | undefined; +} + +export type StylingAsset = CompilerAsset; + +export interface CssBundle { + assets: StylingAsset[]; + meta: Map; +} + +export interface StylingContextBase { + compiler: CompilerContext; + plugins: readonly StylingPlugin[]; + meta: Map; +} + +export interface StylingConfigContext extends StylingContextBase {} + +export interface StylingScanContext extends StylingContextBase { + sourceFile: ts.SourceFile; + factory: ts.NodeFactory; +} + +export interface StylingResolveContext extends StylingContextBase { + sourceFile: ts.SourceFile; + factory: ts.NodeFactory; +} + +export interface StylingTransformContext extends StylingContextBase { + sourceFile: ts.SourceFile; + factory: ts.NodeFactory; + currentElement: JsxElementLike; +} + +export interface StylingGenerateContext extends StylingContextBase {} + +export interface StylingRenderContext extends StylingContextBase {} + +export interface StylingEmitContext extends StylingContextBase {} + +export interface StylingPlugin { + name: string; + enforce?: StylingPluginEnforce | undefined; + config?(context: StylingConfigContext): void | Promise; + scan?( + element: JsxElementLike, + context: StylingScanContext + ): StyleReference | readonly StyleReference[] | null | undefined; + resolve?(reference: StyleReference, context: StylingResolveContext): StyleResolution | null | undefined; + transform?(resolution: StyleResolution, context: StylingTransformContext): StyleTransformResult | null | undefined; + generate?(bundle: CssBundle, context: StylingGenerateContext): void | Promise; + render?( + bundle: CssBundle, + context: StylingRenderContext + ): readonly StylingAsset[] | null | undefined | Promise; + emit?(assets: readonly StylingAsset[], context: StylingEmitContext): void | Promise; +} + +export interface StylingOptions { + plugins?: readonly StylingPlugin[] | undefined; +} + +export interface ClassNameReferenceData { + info: StyleAttributeInfo; +} + +export interface ClassNameStyleReference extends StyleReference { + kind: 'className'; +} + +export function defineStylingPlugin(plugin: Plugin): Plugin { + return plugin; +} + +export function classNameScanner(): StylingPlugin { + return defineStylingPlugin({ + name: 'class-name-scanner', + scan(element) { + const info = readStyleAttribute(element); + if (!info) return null; + return { + kind: 'className', + element, + node: info.attribute, + data: { info }, + } satisfies ClassNameStyleReference; + }, + }); +} + +export function isClassNameStyleReference(reference: StyleReference): reference is ClassNameStyleReference { + return reference.kind === 'className' && isClassNameReferenceData(reference.data); +} + +export function styling(options: StylingOptions = {}): CompilerPlugin { + return { + name: 'styling', + async setup(compiler): Promise { + const plugins = orderStylingPlugins(options.plugins ?? []); + const meta = new Map(); + const base = { compiler, plugins, meta } satisfies StylingContextBase; + const bundle: CssBundle = { assets: [], meta }; + + for (const plugin of plugins) { + await plugin.config?.(base); + } + + return { + transform: createStylingTransform(plugins, base), + async finish() { + for (const plugin of plugins) { + await plugin.generate?.(bundle, base); + } + + let assets: readonly StylingAsset[] | null | undefined; + for (const plugin of plugins) { + assets = await plugin.render?.(bundle, base); + if (assets != null) break; + } + + const finalAssets = assets ?? bundle.assets; + for (const plugin of plugins) { + await plugin.emit?.(finalAssets, base); + } + for (const asset of finalAssets) { + compiler.addAsset(asset); + } + }, + }; + }, + }; +} + +function createStylingTransform( + plugins: readonly StylingPlugin[], + base: StylingContextBase +): ts.TransformerFactory { + return (transformContext) => { + const factory = transformContext.factory; + + return (sourceFile) => { + const scanContext = { ...base, sourceFile, factory } satisfies StylingScanContext; + const resolveContext = { ...base, sourceFile, factory } satisfies StylingResolveContext; + + const visitNode = (node: ts.Node): ts.Node => { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { + const visited = visitJsxElement(node as JsxElementLike, plugins, scanContext, resolveContext, base); + return ts.visitEachChild(visited, visitNode, transformContext); + } + return ts.visitEachChild(node, visitNode, transformContext); + }; + + return ts.visitEachChild(sourceFile, visitNode, transformContext); + }; + }; +} + +function visitJsxElement( + element: JsxElementLike, + plugins: readonly StylingPlugin[], + scanContext: StylingScanContext, + resolveContext: StylingResolveContext, + base: StylingContextBase +): JsxElementLike { + let current = element; + const references: StyleReference[] = []; + + for (const plugin of plugins) { + const scanned = plugin.scan?.(current, scanContext); + references.push(...normalizeReferences(scanned)); + } + + for (const reference of references) { + const resolution = resolveReference(reference, plugins, resolveContext); + if (!resolution) continue; + + const transformContext = { + ...base, + sourceFile: scanContext.sourceFile, + factory: scanContext.factory, + currentElement: current, + } satisfies StylingTransformContext; + const result = transformResolution(resolution, plugins, transformContext); + if (result?.element) current = result.element; + } + + return current; +} + +function resolveReference( + reference: StyleReference, + plugins: readonly StylingPlugin[], + context: StylingResolveContext +): StyleResolution | null { + for (const plugin of plugins) { + const resolution = plugin.resolve?.(reference, context); + if (resolution != null) return resolution; + } + return null; +} + +function transformResolution( + resolution: StyleResolution, + plugins: readonly StylingPlugin[], + context: StylingTransformContext +): StyleTransformResult | null { + for (const plugin of plugins) { + const result = plugin.transform?.(resolution, context); + if (result != null) return result; + } + return null; +} + +function normalizeReferences(value: StyleReference | readonly StyleReference[] | null | undefined): StyleReference[] { + if (value == null) return []; + return isStyleReferenceArray(value) ? [...value] : [value]; +} + +function isStyleReferenceArray(value: StyleReference | readonly StyleReference[]): value is readonly StyleReference[] { + return Array.isArray(value); +} + +function orderStylingPlugins(plugins: readonly StylingPlugin[]): StylingPlugin[] { + const pre = plugins.filter((plugin) => plugin.enforce === 'pre'); + const normal = plugins.filter((plugin) => plugin.enforce === undefined); + const post = plugins.filter((plugin) => plugin.enforce === 'post'); + return [...pre, ...normal, ...post]; +} + +function isClassNameReferenceData(value: unknown): value is ClassNameReferenceData { + return typeof value === 'object' && value !== null && 'info' in value; +} diff --git a/packages/compiler/src/tailwind/tests/naming.test.ts b/packages/compiler/src/styles/tests/naming.test.ts similarity index 98% rename from packages/compiler/src/tailwind/tests/naming.test.ts rename to packages/compiler/src/styles/tests/naming.test.ts index 8be86bb9..08cbbf54 100644 --- a/packages/compiler/src/tailwind/tests/naming.test.ts +++ b/packages/compiler/src/styles/tests/naming.test.ts @@ -1,9 +1,10 @@ import ts from 'typescript'; import { describe, expect, it } from 'vitest'; +import { DiagnosticError } from '../../diagnostics'; import type { JsxElementLike } from '../../jsx'; import { parse } from '../../parse'; -import type { StyleSegment } from '../../styles'; -import { DiagnosticError, deriveClassName } from '../naming'; +import type { StyleSegment } from '../analyze'; +import { deriveClassName } from '../naming'; /** Parse a tiny TSX snippet and return its first JsxElement / JsxSelfClosingElement. */ function firstElement(source: string): JsxElementLike { diff --git a/packages/compiler/src/styles/tests/pipeline.test.ts b/packages/compiler/src/styles/tests/pipeline.test.ts new file mode 100644 index 00000000..5e04546a --- /dev/null +++ b/packages/compiler/src/styles/tests/pipeline.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { compile } from '../../compile'; +import { rewriteStyleAttribute, type StyleAttributeInfo } from '../analyze'; +import { classNameScanner, defineStylingPlugin, isClassNameStyleReference, styling } from '../pipeline'; + +describe('styling pipeline', () => { + it('scans, resolves, and transforms style references', async () => { + const { code } = await compile(`function App(){ return