Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/cli/src/commands/export.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 20 additions & 3 deletions packages/cli/src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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)) {
Expand Down Expand Up @@ -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;
Expand Down
135 changes: 135 additions & 0 deletions packages/cli/src/linter/css-vars/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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',
);
});
});
64 changes: 64 additions & 0 deletions packages/cli/src/linter/css-vars/handler.ts
Original file line number Diff line number Diff line change
@@ -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<string, ResolvedDimension>,
): 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, '-');
}
}
36 changes: 36 additions & 0 deletions packages/cli/src/linter/css-vars/serialize.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
Loading