Skip to content
Merged
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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
Expand Down
17 changes: 14 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 } 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: {
Expand Down Expand Up @@ -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);

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/linter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────
Expand All @@ -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';
47 changes: 47 additions & 0 deletions packages/cli/src/linter/tailwind/v4/fixture.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
163 changes: 163 additions & 0 deletions packages/cli/src/linter/tailwind/v4/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<ParsedDesignSystem> = {}) {
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);
});
});
});
Loading
Loading