From ba704dfecaab5d2c9cff95db44652c3356e6140d Mon Sep 17 00:00:00 2001 From: sbrsubuvga Date: Fri, 24 Apr 2026 16:44:27 +0530 Subject: [PATCH] feat: add Tailwind CSS v4 export support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `--format tailwind` export that emits a CSS `@theme { ... }` block using Tailwind v4's native CSS-variable token namespaces (--color-*, --font-*, --text-*, --leading-*, --tracking-*, --font-weight-*, --radius-*, --spacing-*). The previous v3 JSON output is preserved under the explicit `--format tailwind-v3` name. Since the project is pre-1.0 (0.1.1), renaming `tailwind` to mean 'latest' follows the convention most tooling uses for unversioned names. New module: packages/cli/src/linter/tailwind/v4/ - spec.ts — types and Zod schema for v4 theme data - handler.ts — DesignSystemState → v4 theme data - serialize.ts — v4 theme data → CSS @theme string Tests: 17 new tests (handler + serializer + fixture). --- README.md | 10 +- packages/cli/src/commands/export.ts | 17 +- packages/cli/src/linter/index.ts | 3 + .../src/linter/tailwind/v4/fixture.test.ts | 47 +++++ .../src/linter/tailwind/v4/handler.test.ts | 163 ++++++++++++++++++ .../cli/src/linter/tailwind/v4/handler.ts | 106 ++++++++++++ .../src/linter/tailwind/v4/serialize.test.ts | 96 +++++++++++ .../cli/src/linter/tailwind/v4/serialize.ts | 45 +++++ packages/cli/src/linter/tailwind/v4/spec.ts | 55 ++++++ 9 files changed, 535 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/linter/tailwind/v4/fixture.test.ts create mode 100644 packages/cli/src/linter/tailwind/v4/handler.test.ts create mode 100644 packages/cli/src/linter/tailwind/v4/handler.ts create mode 100644 packages/cli/src/linter/tailwind/v4/serialize.test.ts create mode 100644 packages/cli/src/linter/tailwind/v4/serialize.ts create mode 100644 packages/cli/src/linter/tailwind/v4/spec.ts diff --git a/README.md b/README.md index c7d9f0b5..ef254d76 100644 --- a/README.md +++ b/README.md @@ -222,17 +222,18 @@ Exit code `1` if regressions are detected (more errors or warnings in the "after ### `export` -Export DESIGN.md tokens to other formats (tailwind, dtcg). +Export DESIGN.md tokens to other formats (tailwind, tailwind-v3, dtcg). `tailwind` targets the latest Tailwind (v4, CSS `@theme`); use `tailwind-v3` for legacy `tailwind.config.js` output. ```bash -npx @google/design.md export --format tailwind DESIGN.md > tailwind.theme.json +npx @google/design.md export --format tailwind DESIGN.md > theme.css +npx @google/design.md export --format tailwind-v3 DESIGN.md > tailwind.theme.json npx @google/design.md export --format dtcg DESIGN.md > tokens.json ``` | Option | Type | Default | Description | |:-------|:-----|:--------|:------------| | `file` | positional | required | Path to DESIGN.md (or `-` for stdin) | -| `--format` | `tailwind` \| `dtcg` | required | Output format | +| `--format` | `tailwind` \| `tailwind-v3` \| `dtcg` | required | Output format | ### `spec` @@ -283,7 +284,8 @@ console.log(report.designSystem); // Parsed DesignSystemState DESIGN.md tokens are inspired by the [W3C Design Token Format](https://www.designtokens.org/). The `export` command converts tokens to other formats: -- **Tailwind theme config** — `npx @google/design.md export --format tailwind DESIGN.md` +- **Tailwind CSS (v4, default)** — `npx @google/design.md export --format tailwind DESIGN.md` — emits a CSS `@theme { ... }` block using Tailwind v4's CSS-variable token namespaces (`--color-*`, `--font-*`, `--text-*`, `--leading-*`, `--tracking-*`, `--font-weight-*`, `--radius-*`, `--spacing-*`). Output is CSS text. +- **Tailwind v3 config** — `npx @google/design.md export --format tailwind-v3 DESIGN.md` — emits the legacy `tailwind.config.js` `theme.extend` JSON shape for Tailwind v3. - **DTCG tokens.json** ([W3C Design Tokens Format Module](https://tr.designtokens.org/format/)) — `npx @google/design.md export --format dtcg DESIGN.md` ## Status diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index bf14d7cf..61a0534a 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -13,17 +13,17 @@ // limitations under the License. import { defineCommand } from 'citty'; -import { lint, TailwindEmitterHandler } from '../linter/index.js'; +import { lint, TailwindEmitterHandler, TailwindV4EmitterHandler, serializeTailwindV4 } from '../linter/index.js'; import { DtcgEmitterHandler } from '../linter/dtcg/handler.js'; import { readInput } from '../utils.js'; -const FORMATS = ['tailwind', 'dtcg'] as const; +const FORMATS = ['tailwind', 'tailwind-v3', 'dtcg'] as const; type ExportFormat = typeof FORMATS[number]; export default defineCommand({ meta: { name: 'export', - description: 'Export DESIGN.md tokens to other formats (tailwind, dtcg).', + description: 'Export DESIGN.md tokens to other formats (tailwind, tailwind-v3, dtcg). `tailwind` targets the latest (v4) CSS @theme syntax; use `tailwind-v3` for legacy tailwind.config.js output.', }, args: { file: { @@ -53,6 +53,17 @@ export default defineCommand({ const report = lint(content); if (format === 'tailwind') { + const handler = new TailwindV4EmitterHandler(); + const result = handler.execute(report.designSystem); + + if (!result.success) { + console.error(JSON.stringify({ error: result.error.message })); + process.exitCode = 1; + return; + } + + console.log(serializeTailwindV4(result.data.theme)); + } else if (format === 'tailwind-v3') { const handler = new TailwindEmitterHandler(); const result = handler.execute(report.designSystem); diff --git a/packages/cli/src/linter/index.ts b/packages/cli/src/linter/index.ts index 598c9e1e..6dccb439 100644 --- a/packages/cli/src/linter/index.ts +++ b/packages/cli/src/linter/index.ts @@ -28,6 +28,7 @@ export type { } from './model/spec.js'; export type { Finding, Severity } from './linter/spec.js'; export type { TailwindEmitterResult, TailwindThemeExtend } from './tailwind/spec.js'; +export type { TailwindV4EmitterResult, TailwindV4ThemeData } from './tailwind/v4/spec.js'; export type { DtcgEmitterResult, DtcgTokenFile } from './dtcg/spec.js'; // ── Advanced linting ─────────────────────────────────────────────── @@ -46,6 +47,8 @@ export { } from './linter/rules/index.js'; export { contrastRatio } from './model/handler.js'; export { TailwindEmitterHandler } from './tailwind/handler.js'; +export { TailwindV4EmitterHandler } from './tailwind/v4/handler.js'; +export { serializeToCss as serializeTailwindV4 } from './tailwind/v4/serialize.js'; export { DtcgEmitterHandler } from './dtcg/handler.js'; export { fixSectionOrder } from './fixer/handler.js'; export type { FixerInput, FixerResult } from './fixer/spec.js'; diff --git a/packages/cli/src/linter/tailwind/v4/fixture.test.ts b/packages/cli/src/linter/tailwind/v4/fixture.test.ts new file mode 100644 index 00000000..50af6c97 --- /dev/null +++ b/packages/cli/src/linter/tailwind/v4/fixture.test.ts @@ -0,0 +1,47 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { lint } from '../../lint.js'; +import { TailwindV4EmitterHandler } from './handler.js'; +import { serializeToCss } from './serialize.js'; + +describe('Tailwind v4 export against real fixtures', () => { + it('produces a valid @theme block from examples/paws-and-paths/DESIGN.md', () => { + const fixturePath = join(import.meta.dir, '../../../../../../examples/paws-and-paths/DESIGN.md'); + const content = readFileSync(fixturePath, 'utf8'); + const report = lint(content); + + const handler = new TailwindV4EmitterHandler(); + const result = handler.execute(report.designSystem); + if (!result.success) throw new Error(`Handler failed: ${result.error.message}`); + + const css = serializeToCss(result.data.theme); + + // Wraps in @theme block + expect(css.startsWith('@theme {\n')).toBe(true); + expect(css.endsWith('}\n')).toBe(true); + + // Contains expected v4 CSS variable prefixes (paws-and-paths has many colors + typography + spacing) + expect(css).toContain('--color-'); + expect(css).toContain('--spacing-'); + expect(css).toContain('--radius-'); + + // Non-empty body + const bodyLines = css.split('\n').filter(l => l.trim().startsWith('--')); + expect(bodyLines.length).toBeGreaterThan(10); + }); +}); diff --git a/packages/cli/src/linter/tailwind/v4/handler.test.ts b/packages/cli/src/linter/tailwind/v4/handler.test.ts new file mode 100644 index 00000000..df7bca2a --- /dev/null +++ b/packages/cli/src/linter/tailwind/v4/handler.test.ts @@ -0,0 +1,163 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'bun:test'; +import { TailwindV4EmitterHandler } from './handler.js'; +import { ModelHandler } from '../../model/handler.js'; +import type { ParsedDesignSystem } from '../../parser/spec.js'; + +const emitter = new TailwindV4EmitterHandler(); +const modelHandler = new ModelHandler(); + +function buildState(overrides: Partial = {}) { + const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides }; + const result = modelHandler.execute(parsed); + const hasErrors = result.findings.some(d => d.severity === 'error'); + if (hasErrors) { + throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`); + } + return result.designSystem; +} + +describe('TailwindV4EmitterHandler', () => { + describe('colors mapping', () => { + it('maps resolved colors to theme.colors keyed by token name', () => { + const state = buildState({ + colors: { primary: '#647D66', secondary: '#ff0000' }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + expect(result.data.theme.colors?.['primary']).toBe('#647d66'); + expect(result.data.theme.colors?.['secondary']).toBe('#ff0000'); + }); + }); + + describe('typography mapping', () => { + it('splits typography into four separate categories', () => { + const state = buildState({ + typography: { + 'headline-lg': { + fontFamily: 'Google Sans Display', + fontSize: '42px', + fontWeight: 500, + lineHeight: '50px', + letterSpacing: '1.2px', + }, + }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + const theme = result.data.theme; + + expect(theme.fontFamily?.['headline-lg']).toBe('"Google Sans Display"'); + expect(theme.fontSize?.['headline-lg']).toBe('42px'); + expect(theme.lineHeight?.['headline-lg']).toBe('50px'); + expect(theme.letterSpacing?.['headline-lg']).toBe('1.2px'); + expect(theme.fontWeight?.['headline-lg']).toBe('500'); + }); + + it('only populates categories for fields present on the token', () => { + const state = buildState({ + typography: { + 'body-lg': { + fontFamily: 'Roboto', + fontSize: '14px', + fontWeight: 400, + lineHeight: '20px', + }, + }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + const theme = result.data.theme; + + expect(theme.fontFamily?.['body-lg']).toBe('"Roboto"'); + expect(theme.fontSize?.['body-lg']).toBe('14px'); + expect(theme.lineHeight?.['body-lg']).toBe('20px'); + expect(theme.fontWeight?.['body-lg']).toBe('400'); + expect(theme.letterSpacing?.['body-lg']).toBeUndefined(); + }); + + it('escapes embedded quotes and backslashes in font-family values', () => { + const state = buildState({ + typography: { + fancy: { fontFamily: 'Fancy "Font"', fontSize: '16px' }, + }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + expect(result.data.theme.fontFamily?.['fancy']).toBe('"Fancy \\"Font\\""'); + }); + }); + + describe('dimensions mapping', () => { + it('maps rounded to borderRadius and spacing to spacing', () => { + const state = buildState({ + rounded: { regular: '4px', lg: '8px', full: '9999px' }, + spacing: { 'gutter-s': '8px', 'gutter-l': '16px' }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + const theme = result.data.theme; + + expect(theme.borderRadius?.['regular']).toBe('4px'); + expect(theme.borderRadius?.['lg']).toBe('8px'); + expect(theme.borderRadius?.['full']).toBe('9999px'); + expect(theme.spacing?.['gutter-s']).toBe('8px'); + expect(theme.spacing?.['gutter-l']).toBe('16px'); + }); + }); + + describe('empty state', () => { + it('returns success with an empty theme object', () => { + const state = buildState({}); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + expect(result.data.theme).toBeDefined(); + }); + }); + + describe('invalid token names', () => { + it('fails when a color token name contains a dot', () => { + const state = buildState({ + colors: { primary: '#000000' }, + }); + // Inject an invalid name directly into the Map to simulate an invalid token + state.colors.set('primary.surface', { + type: 'color', hex: '#ffffff', r: 255, g: 255, b: 255, luminance: 1, + }); + const result = emitter.execute(state); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe('INVALID_TOKEN_NAME'); + expect(result.error.message).toContain('primary.surface'); + }); + + it('fails when a spacing token name starts with a digit', () => { + const state = buildState({}); + state.spacing.set('1bad', { type: 'dimension', value: 4, unit: 'px' }); + const result = emitter.execute(state); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe('INVALID_TOKEN_NAME'); + }); + + it('fails when a token name contains a space', () => { + const state = buildState({}); + state.rounded.set('has space', { type: 'dimension', value: 4, unit: 'px' }); + const result = emitter.execute(state); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/linter/tailwind/v4/handler.ts b/packages/cli/src/linter/tailwind/v4/handler.ts new file mode 100644 index 00000000..22bfcf37 --- /dev/null +++ b/packages/cli/src/linter/tailwind/v4/handler.ts @@ -0,0 +1,106 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { TailwindV4EmitterSpec, TailwindV4EmitterResult, TailwindV4ThemeData } from './spec.js'; +import type { DesignSystemState, ResolvedDimension } from '../../model/spec.js'; + +const VALID_TOKEN_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/; + +/** + * Pure function mapping DesignSystemState → Tailwind v4 theme data. + * Token names are validated against CSS-identifier rules. Font-family values + * are wrapped in double quotes, with embedded `"` and `\` escaped. + */ +export class TailwindV4EmitterHandler implements TailwindV4EmitterSpec { + execute(state: DesignSystemState): TailwindV4EmitterResult { + const theme: TailwindV4ThemeData = {}; + + // Validate every token name before emitting anything. + const allNames: string[] = [ + ...state.colors.keys(), + ...state.typography.keys(), + ...state.rounded.keys(), + ...state.spacing.keys(), + ]; + for (const name of allNames) { + if (!VALID_TOKEN_NAME.test(name)) { + return { + success: false, + error: { + code: 'INVALID_TOKEN_NAME', + message: `Token name "${name}" is not a valid CSS identifier for Tailwind v4 export (must match /^[a-zA-Z][a-zA-Z0-9-]*$/).`, + }, + }; + } + } + + // Colors + if (state.colors.size > 0) { + const colors: Record = {}; + for (const [name, color] of state.colors) { + colors[name] = color.hex; + } + theme.colors = colors; + } + + // Typography — split into 4 sibling categories + const fontFamily: Record = {}; + const fontSize: Record = {}; + const lineHeight: Record = {}; + const letterSpacing: Record = {}; + const fontWeight: Record = {}; + for (const [name, typo] of state.typography) { + if (typo.fontFamily) fontFamily[name] = cssStringLiteral(typo.fontFamily); + if (typo.fontSize) fontSize[name] = dimToString(typo.fontSize); + if (typo.lineHeight) lineHeight[name] = dimToString(typo.lineHeight); + if (typo.letterSpacing) letterSpacing[name] = dimToString(typo.letterSpacing); + if (typo.fontWeight !== undefined) fontWeight[name] = String(typo.fontWeight); + } + if (Object.keys(fontFamily).length > 0) theme.fontFamily = fontFamily; + if (Object.keys(fontSize).length > 0) theme.fontSize = fontSize; + if (Object.keys(lineHeight).length > 0) theme.lineHeight = lineHeight; + if (Object.keys(letterSpacing).length > 0) theme.letterSpacing = letterSpacing; + if (Object.keys(fontWeight).length > 0) theme.fontWeight = fontWeight; + + // borderRadius + spacing + if (state.rounded.size > 0) { + theme.borderRadius = mapDimensions(state.rounded); + } + if (state.spacing.size > 0) { + theme.spacing = mapDimensions(state.spacing); + } + + return { success: true, data: { theme } }; + } +} + +function mapDimensions(dims: Map): Record { + const out: Record = {}; + for (const [name, dim] of dims) { + out[name] = dimToString(dim); + } + return out; +} + +function dimToString(dim: ResolvedDimension): string { + return `${dim.value}${dim.unit}`; +} + +/** + * Wrap a string value in double quotes, escaping embedded `\` and `"`. + * Produces a CSS-safe string literal suitable for font-family values. + */ +function cssStringLiteral(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} diff --git a/packages/cli/src/linter/tailwind/v4/serialize.test.ts b/packages/cli/src/linter/tailwind/v4/serialize.test.ts new file mode 100644 index 00000000..17efff9b --- /dev/null +++ b/packages/cli/src/linter/tailwind/v4/serialize.test.ts @@ -0,0 +1,96 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from 'bun:test'; +import { serializeToCss } from './serialize.js'; +import type { TailwindV4ThemeData } from './spec.js'; + +describe('serializeToCss', () => { + it('emits an empty @theme block for empty data', () => { + const out = serializeToCss({}); + expect(out).toBe('@theme {\n}\n'); + }); + + it('emits a single color declaration', () => { + const data: TailwindV4ThemeData = { colors: { primary: '#647d66' } }; + expect(serializeToCss(data)).toBe('@theme {\n --color-primary: #647d66;\n}\n'); + }); + + it('uses the correct CSS-variable prefix per category', () => { + const data: TailwindV4ThemeData = { + colors: { primary: '#000000' }, + fontFamily: { 'headline-lg': '"Roboto"' }, + fontSize: { 'headline-lg': '42px' }, + lineHeight: { 'headline-lg': '50px' }, + letterSpacing: { 'headline-lg': '1.2px' }, + fontWeight: { 'headline-lg': '500' }, + borderRadius: { regular: '4px' }, + spacing: { 'gutter-s': '8px' }, + }; + const out = serializeToCss(data); + expect(out).toContain('--color-primary: #000000;'); + expect(out).toContain('--font-headline-lg: "Roboto";'); + expect(out).toContain('--text-headline-lg: 42px;'); + expect(out).toContain('--leading-headline-lg: 50px;'); + expect(out).toContain('--tracking-headline-lg: 1.2px;'); + expect(out).toContain('--font-weight-headline-lg: 500;'); + expect(out).toContain('--radius-regular: 4px;'); + expect(out).toContain('--spacing-gutter-s: 8px;'); + }); + + it('emits categories in fixed order: colors → fontFamily → fontSize → lineHeight → letterSpacing → fontWeight → borderRadius → spacing', () => { + const data: TailwindV4ThemeData = { + spacing: { s: '8px' }, + colors: { primary: '#000000' }, + borderRadius: { r: '4px' }, + fontFamily: { f: '"X"' }, + }; + const out = serializeToCss(data); + const colorIdx = out.indexOf('--color-primary'); + const fontFamilyIdx = out.indexOf('--font-f'); + const radiusIdx = out.indexOf('--radius-r'); + const spacingIdx = out.indexOf('--spacing-s'); + expect(colorIdx).toBeLessThan(fontFamilyIdx); + expect(fontFamilyIdx).toBeLessThan(radiusIdx); + expect(radiusIdx).toBeLessThan(spacingIdx); + }); + + it('skips empty categories (no blank lines)', () => { + const data: TailwindV4ThemeData = { + colors: { primary: '#000000' }, + fontFamily: {}, + spacing: { s: '8px' }, + }; + const out = serializeToCss(data); + // no blank body lines + expect(out).not.toMatch(/\n\s*\n/); + expect(out).toBe('@theme {\n --color-primary: #000000;\n --spacing-s: 8px;\n}\n'); + }); + + it('preserves insertion order of keys within a category', () => { + const colors: Record = {}; + colors['zulu'] = '#000000'; + colors['alpha'] = '#ffffff'; + const out = serializeToCss({ colors }); + expect(out.indexOf('--color-zulu')).toBeLessThan(out.indexOf('--color-alpha')); + }); + + it('emits font-family values verbatim (already quoted)', () => { + const data: TailwindV4ThemeData = { + fontFamily: { body: '"Google Sans Display"' }, + }; + const out = serializeToCss(data); + expect(out).toContain('--font-body: "Google Sans Display";'); + }); +}); diff --git a/packages/cli/src/linter/tailwind/v4/serialize.ts b/packages/cli/src/linter/tailwind/v4/serialize.ts new file mode 100644 index 00000000..49e6a51d --- /dev/null +++ b/packages/cli/src/linter/tailwind/v4/serialize.ts @@ -0,0 +1,45 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { TailwindV4ThemeData } from './spec.js'; + +// Category → CSS-variable prefix. Iteration order of this array is the output order. +const CATEGORIES: ReadonlyArray = [ + ['colors', '--color-'], + ['fontFamily', '--font-'], + ['fontSize', '--text-'], + ['lineHeight', '--leading-'], + ['letterSpacing', '--tracking-'], + ['fontWeight', '--font-weight-'], + ['borderRadius', '--radius-'], + ['spacing', '--spacing-'], +]; + +/** + * Serialize a Tailwind v4 theme data object to a CSS `@theme { ... }` block string. + * Pure function — no I/O. Values are emitted verbatim (font-family quoting must + * be done by the handler before calling this). + */ +export function serializeToCss(data: TailwindV4ThemeData): string { + const lines: string[] = []; + for (const [category, prefix] of CATEGORIES) { + const entries = data[category]; + if (!entries) continue; + for (const [name, value] of Object.entries(entries)) { + lines.push(` ${prefix}${name}: ${value};`); + } + } + if (lines.length === 0) return '@theme {\n}\n'; + return `@theme {\n${lines.join('\n')}\n}\n`; +} diff --git a/packages/cli/src/linter/tailwind/v4/spec.ts b/packages/cli/src/linter/tailwind/v4/spec.ts new file mode 100644 index 00000000..7d0b9f73 --- /dev/null +++ b/packages/cli/src/linter/tailwind/v4/spec.ts @@ -0,0 +1,55 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { z } from 'zod'; +import type { DesignSystemState } from '../../model/spec.js'; + +// ── TAILWIND v4 THEME DATA SCHEMA ──────────────────────────────────── +// Category-keyed record of CSS-variable name → value strings. +// The serializer flattens this into an `@theme { ... }` block. +export const TailwindV4ThemeDataSchema = z.object({ + colors: z.record(z.string()).optional(), + fontFamily: z.record(z.string()).optional(), + fontSize: z.record(z.string()).optional(), + lineHeight: z.record(z.string()).optional(), + letterSpacing: z.record(z.string()).optional(), + fontWeight: z.record(z.string()).optional(), + borderRadius: z.record(z.string()).optional(), + spacing: z.record(z.string()).optional(), +}); + +export type TailwindV4ThemeData = z.infer; + +export const TailwindV4EmitterResultSchema = z.discriminatedUnion('success', [ + z.object({ + success: z.literal(true), + data: z.object({ + theme: TailwindV4ThemeDataSchema, + }), + }), + z.object({ + success: z.literal(false), + error: z.object({ + code: z.string(), + message: z.string(), + }), + }), +]); + +export type TailwindV4EmitterResult = z.infer; + +// ── INTERFACE ────────────────────────────────────────────────────── +export interface TailwindV4EmitterSpec { + execute(state: DesignSystemState): TailwindV4EmitterResult; +}