From a238f11843c68bd7b1bf68bdd904829e1e8a7db6 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:05:19 -0700 Subject: [PATCH] feat: add CSS custom properties export format (--format css-vars) --- packages/cli/src/commands/export.test.ts | 72 ++++++++++ packages/cli/src/commands/export.ts | 23 ++- .../cli/src/linter/css-vars/handler.test.ts | 135 ++++++++++++++++++ packages/cli/src/linter/css-vars/handler.ts | 64 +++++++++ packages/cli/src/linter/css-vars/serialize.ts | 36 +++++ packages/cli/src/linter/css-vars/spec.ts | 49 +++++++ packages/cli/src/linter/index.ts | 3 + 7 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/export.test.ts create mode 100644 packages/cli/src/linter/css-vars/handler.test.ts create mode 100644 packages/cli/src/linter/css-vars/handler.ts create mode 100644 packages/cli/src/linter/css-vars/serialize.ts create mode 100644 packages/cli/src/linter/css-vars/spec.ts diff --git a/packages/cli/src/commands/export.test.ts b/packages/cli/src/commands/export.test.ts new file mode 100644 index 0000000..fa34074 --- /dev/null +++ b/packages/cli/src/commands/export.test.ts @@ -0,0 +1,72 @@ +// 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, beforeEach, afterEach, spyOn } from 'bun:test'; +import { fileURLToPath } from 'node:url'; +import exportCommand from './export.js'; + +const FIXTURE_PATH = fileURLToPath(new URL('../linter/fixtures/DESIGN-test.md', import.meta.url)); + +describe('export command', () => { + let logSpy: any; + let errorSpy: any; + + beforeEach(() => { + process.exitCode = undefined; + logSpy = spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + process.exitCode = 0; + }); + + it('outputs css-vars custom properties with an optional prefix', async () => { + await exportCommand.run!({ + args: { + file: FIXTURE_PATH, + format: 'css-vars', + prefix: 'ds', + }, + } as any); + + expect(errorSpy.mock.calls.length).toBe(0); + expect(logSpy.mock.calls.length).toBe(1); + const output = logSpy.mock.calls[0][0]; + + expect(output).toStartWith(':root {\n'); + expect(output).toContain(' --ds-color-primary: #006b5a;'); + expect(output).toContain(' --ds-spacing-unit: 8px;'); + expect(output).toContain(' --ds-rounded-sm: 0.25rem;'); + expect(output).toEndWith('}\n'); + expect(process.exitCode).toBe(0); + }); + + it('errors with exit code 1 for invalid export formats', async () => { + await exportCommand.run!({ + args: { + file: FIXTURE_PATH, + format: 'not-a-format', + }, + } as any); + + expect(logSpy.mock.calls.length).toBe(0); + expect(errorSpy.mock.calls.length).toBe(1); + const error = JSON.parse(errorSpy.mock.calls[0][0]); + expect(error.error).toContain('Invalid format "not-a-format"'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 1bb50d5..b216b2e 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, TailwindV4EmitterHandler, serializeTailwindV4 } from '../linter/index.js'; +import { lint, TailwindEmitterHandler, TailwindV4EmitterHandler, serializeTailwindV4, CssVarsEmitterHandler, serializeCssVars } from '../linter/index.js'; import { DtcgEmitterHandler } from '../linter/dtcg/handler.js'; import { readInput } from '../utils.js'; -const FORMATS = ['css-tailwind', 'json-tailwind', 'tailwind', 'dtcg'] as const; +const FORMATS = ['css-tailwind', 'json-tailwind', 'tailwind', 'dtcg', 'css-vars'] as const; type ExportFormat = typeof FORMATS[number]; export default defineCommand({ meta: { name: 'export', - description: 'Export DESIGN.md tokens to other formats. `css-tailwind` emits Tailwind v4 CSS @theme; `json-tailwind` emits Tailwind v3 theme.extend JSON; `tailwind` is an alias for `json-tailwind`; `dtcg` emits W3C Design Tokens.', + description: 'Export DESIGN.md tokens to other formats. `css-tailwind` emits Tailwind v4 CSS @theme; `json-tailwind` emits Tailwind v3 theme.extend JSON; `tailwind` is an alias for `json-tailwind`; `dtcg` emits W3C Design Tokens; `css-vars` emits CSS custom properties.', }, args: { file: { @@ -36,9 +36,15 @@ export default defineCommand({ description: `Output format: ${FORMATS.join(', ')}`, required: true, }, + prefix: { + type: 'string', + description: 'Optional CSS custom property prefix for css-vars output.', + required: false, + }, }, async run({ args }) { const format = args.format as string; + const prefix = typeof args.prefix === 'string' ? args.prefix : undefined; // Validate --format against closed enum if (!FORMATS.includes(format as ExportFormat)) { @@ -85,6 +91,17 @@ export default defineCommand({ } console.log(JSON.stringify(result.data, null, 2)); + } else if (format === 'css-vars') { + const handler = new CssVarsEmitterHandler(); + const result = handler.execute(report.designSystem); + + if (!result.success) { + console.error(JSON.stringify({ error: result.error.message })); + process.exitCode = 1; + return; + } + + console.log(serializeCssVars(result.data.declarations, { prefix })); } process.exitCode = report.summary.errors > 0 ? 1 : 0; diff --git a/packages/cli/src/linter/css-vars/handler.test.ts b/packages/cli/src/linter/css-vars/handler.test.ts new file mode 100644 index 0000000..bebd472 --- /dev/null +++ b/packages/cli/src/linter/css-vars/handler.test.ts @@ -0,0 +1,135 @@ +// 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, test, expect } from 'bun:test'; +import { CssVarsEmitterHandler } from './handler.js'; +import { serializeCssVars } from './serialize.js'; +import type { DesignSystemState, ResolvedColor, ResolvedDimension } from '../model/spec.js'; + +function emptyState(overrides?: Partial): DesignSystemState { + return { + colors: new Map(), + typography: new Map(), + rounded: new Map(), + spacing: new Map(), + components: new Map(), + symbolTable: new Map(), + ...overrides, + }; +} + +function makeColor(hex: string, r: number, g: number, b: number): ResolvedColor { + return { type: 'color', hex, r, g, b, luminance: 0 }; +} + +function makeDim(value: number, unit: string): ResolvedDimension { + return { type: 'dimension', value, unit }; +} + +describe('CssVarsEmitterHandler', () => { + const handler = new CssVarsEmitterHandler(); + + test('empty state produces valid empty :root block', () => { + const result = handler.execute(emptyState()); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.declarations).toEqual([]); + expect(serializeCssVars(result.data.declarations)).toBe(':root {\n}\n'); + }); + + test('colors emit --color-* declarations with lowercase hex values', () => { + const state = emptyState({ + colors: new Map([ + ['primary', makeColor('#1A1C1E', 0x1A, 0x1C, 0x1E)], + ['white', makeColor('#FFFFFF', 255, 255, 255)], + ]), + }); + + const result = handler.execute(state); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(serializeCssVars(result.data.declarations)).toBe( + ':root {\n' + + ' --color-primary: #1a1c1e;\n' + + ' --color-white: #ffffff;\n' + + '}\n', + ); + }); + + test('spacing and rounded dimensions preserve numeric values and units', () => { + const state = emptyState({ + spacing: new Map([ + ['sm', makeDim(8, 'px')], + ['md', makeDim(1, 'rem')], + ]), + rounded: new Map([ + ['card', makeDim(12, 'px')], + ]), + }); + + const result = handler.execute(state); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(serializeCssVars(result.data.declarations)).toBe( + ':root {\n' + + ' --spacing-sm: 8px;\n' + + ' --spacing-md: 1rem;\n' + + ' --rounded-card: 12px;\n' + + '}\n', + ); + }); + + test('nested token names collapse dots to hyphens for valid CSS property names', () => { + const state = emptyState({ + colors: new Map([ + ['background.light', makeColor('#FFFFFF', 255, 255, 255)], + ]), + spacing: new Map([ + ['gap.lg', makeDim(24, 'px')], + ]), + }); + + const result = handler.execute(state); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(serializeCssVars(result.data.declarations)).toBe( + ':root {\n' + + ' --color-background-light: #ffffff;\n' + + ' --spacing-gap-lg: 24px;\n' + + '}\n', + ); + }); + + test('prefix option adds a custom prefix to emitted property names', () => { + const state = emptyState({ + colors: new Map([ + ['primary', makeColor('#1A1C1E', 0x1A, 0x1C, 0x1E)], + ]), + }); + + const result = handler.execute(state); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(serializeCssVars(result.data.declarations, { prefix: 'ds' })).toBe( + ':root {\n' + + ' --ds-color-primary: #1a1c1e;\n' + + '}\n', + ); + }); +}); diff --git a/packages/cli/src/linter/css-vars/handler.ts b/packages/cli/src/linter/css-vars/handler.ts new file mode 100644 index 0000000..d25b6e9 --- /dev/null +++ b/packages/cli/src/linter/css-vars/handler.ts @@ -0,0 +1,64 @@ +// 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 { CssVarDeclaration, CssVarsEmitterSpec, CssVarsEmitterResult } from './spec.js'; +import type { DesignSystemState, ResolvedDimension } from '../model/spec.js'; + +/** + * Pure function mapping DesignSystemState → CSS custom property declarations. + * No side effects. + */ +export class CssVarsEmitterHandler implements CssVarsEmitterSpec { + execute(state: DesignSystemState): CssVarsEmitterResult { + const declarations: CssVarDeclaration[] = []; + + for (const [name, color] of state.colors) { + declarations.push({ + name: `color-${this.cssSafe(name)}`, + value: color.hex.toLowerCase(), + }); + } + + this.mapDimensionGroup(declarations, 'spacing', state.spacing); + this.mapDimensionGroup(declarations, 'rounded', state.rounded); + + return { success: true, data: { declarations } }; + } + + private mapDimensionGroup( + declarations: CssVarDeclaration[], + group: 'spacing' | 'rounded', + dims: Map, + ): void { + for (const [name, dim] of dims) { + declarations.push({ + name: `${group}-${this.cssSafe(name)}`, + value: this.dimToString(dim), + }); + } + } + + private dimToString(dim: ResolvedDimension): string { + return `${dim.value}${dim.unit}`; + } + + /** + * Make a token name safe for a CSS custom property. Nested tokens flatten to + * dotted keys (e.g. `background.light`); a literal dot makes a browser drop + * the declaration, so collapse dots to hyphens. + */ + private cssSafe(name: string): string { + return name.replace(/\./g, '-'); + } +} diff --git a/packages/cli/src/linter/css-vars/serialize.ts b/packages/cli/src/linter/css-vars/serialize.ts new file mode 100644 index 0000000..5439943 --- /dev/null +++ b/packages/cli/src/linter/css-vars/serialize.ts @@ -0,0 +1,36 @@ +// 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 { CssVarDeclaration } from './spec.js'; + +export interface SerializeCssVarsOptions { + prefix?: string | undefined; +} + +/** + * Serialize declarations to a CSS `:root { ... }` block string. + * Pure function — no I/O. Values are emitted verbatim. + */ +export function serializeCssVars( + declarations: CssVarDeclaration[], + options: SerializeCssVarsOptions = {}, +): string { + const variablePrefix = options.prefix ? `${options.prefix}-` : ''; + const lines = declarations.map( + declaration => ` --${variablePrefix}${declaration.name}: ${declaration.value};`, + ); + + if (lines.length === 0) return ':root {\n}\n'; + return `:root {\n${lines.join('\n')}\n}\n`; +} diff --git a/packages/cli/src/linter/css-vars/spec.ts b/packages/cli/src/linter/css-vars/spec.ts new file mode 100644 index 0000000..d116747 --- /dev/null +++ b/packages/cli/src/linter/css-vars/spec.ts @@ -0,0 +1,49 @@ +// 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'; + +export const CssVarDeclarationSchema = z.object({ + name: z.string(), + value: z.string(), +}); + +export type CssVarDeclaration = z.infer; + +// ── Result ───────────────────────────────────────────────────────── + +export const CssVarsEmitterResultSchema = z.discriminatedUnion('success', [ + z.object({ + success: z.literal(true), + data: z.object({ + declarations: z.array(CssVarDeclarationSchema), + }), + }), + z.object({ + success: z.literal(false), + error: z.object({ + code: z.string(), + message: z.string(), + }), + }), +]); + +export type CssVarsEmitterResult = z.infer; + +// ── Interface ────────────────────────────────────────────────────── + +export interface CssVarsEmitterSpec { + execute(state: DesignSystemState): CssVarsEmitterResult; +} diff --git a/packages/cli/src/linter/index.ts b/packages/cli/src/linter/index.ts index 2cf5037..a683f53 100644 --- a/packages/cli/src/linter/index.ts +++ b/packages/cli/src/linter/index.ts @@ -30,6 +30,7 @@ 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'; +export type { CssVarsEmitterResult, CssVarDeclaration } from './css-vars/spec.js'; // ── Advanced linting ─────────────────────────────────────────────── export { runLinter, preEvaluate } from './linter/runner.js'; @@ -52,5 +53,7 @@ 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 { CssVarsEmitterHandler } from './css-vars/handler.js'; +export { serializeCssVars } from './css-vars/serialize.js'; export { fixSectionOrder } from './fixer/handler.js'; export type { FixerInput, FixerResult } from './fixer/spec.js';