From 5dcbd12a0406c2f18eafbd399b7f9a98de81e938 Mon Sep 17 00:00:00 2001 From: TylonHH Date: Thu, 14 May 2026 20:44:30 +0200 Subject: [PATCH] Add HTML design preview export --- README.md | 7 +- docs/spec.md | 2 + packages/cli/src/commands/export.ts | 17 +- packages/cli/src/linter/html/handler.test.ts | 84 ++++ packages/cli/src/linter/html/handler.ts | 503 +++++++++++++++++++ packages/cli/src/linter/html/spec.ts | 23 + packages/cli/src/linter/index.ts | 2 + packages/cli/src/linter/spec-gen/spec.mdx | 2 + 8 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/linter/html/handler.test.ts create mode 100644 packages/cli/src/linter/html/handler.ts create mode 100644 packages/cli/src/linter/html/spec.ts diff --git a/README.md b/README.md index 3cea273..ba77435 100644 --- a/README.md +++ b/README.md @@ -261,12 +261,13 @@ Export DESIGN.md tokens to other formats. npx @google/design.md export --format json-tailwind DESIGN.md > tailwind.theme.json npx @google/design.md export --format css-tailwind DESIGN.md > theme.css npx @google/design.md export --format dtcg DESIGN.md > tokens.json +npx @google/design.md export --format html DESIGN.md > preview.html ``` | Option | Type | Default | Description | |:-------|:-----|:--------|:------------| | `file` | positional | required | Path to DESIGN.md (or `-` for stdin) | -| `--format` | `json-tailwind` \| `css-tailwind` \| `tailwind` \| `dtcg` | required | Output format | +| `--format` | `json-tailwind` \| `css-tailwind` \| `tailwind` \| `dtcg` \| `html` | required | Output format | | Format | Output | Description | |:-------|:-------|:------------| @@ -274,6 +275,7 @@ npx @google/design.md export --format dtcg DESIGN.md > tokens.json | `css-tailwind` | CSS | Tailwind v4 `@theme { ... }` block with CSS custom properties | | `tailwind` | JSON | Alias for `json-tailwind` | | `dtcg` | JSON | W3C Design Tokens Format Module | +| `html` | HTML | Human-readable static preview site for reviewing the design settings in a browser | ### `spec` @@ -327,6 +329,7 @@ DESIGN.md tokens are inspired by the [W3C Design Token Format](https://www.desig - **Tailwind v3 config (JSON)** — `npx @google/design.md export --format json-tailwind DESIGN.md` — emits a `theme.extend` JSON object for `tailwind.config.js`. `--format tailwind` is a backwards-compatible alias. - **Tailwind v4 theme (CSS)** — `npx @google/design.md export --format css-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-*`). - **DTCG tokens.json** ([W3C Design Tokens Format Module](https://tr.designtokens.org/format/)) — `npx @google/design.md export --format dtcg DESIGN.md` +- **HTML preview** — `npx @google/design.md export --format html DESIGN.md > preview.html` — emits a polished static site that renders colors, type, components, spacing, and radius tokens for quick human review. ## Status @@ -335,4 +338,4 @@ The DESIGN.md format is at version `alpha`. The spec, token schema, and CLI are ## Disclaimer This project is not eligible for the [Google Open Source Software Vulnerability -Rewards Program](https://bughunters.google.com/open-source-security). \ No newline at end of file +Rewards Program](https://bughunters.google.com/open-source-security). diff --git a/docs/spec.md b/docs/spec.md index 874f300..e3529c1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -7,6 +7,8 @@ DESIGN.md is a self-contained, plain-text representation of a design system. It A DESIGN.md file contains two parts: An optional YAML frontmatter, and a markdown body. The YAML front matter contains machine-readable design tokens. The markdown body sections provide human-readable design rationale and guidance. Prose may use descriptive color names (e.g., "Midnight Forest Green") that correspond to systematic token names (e.g., `primary`). The tokens are the normative values; the prose provides context for how to apply them. +The CLI can also emit a human-readable HTML preview from the same tokens. This preview is intentionally static, so it can be opened directly in a browser or committed alongside example systems for visual review. + # Design Tokens DESIGN.md may embed design tokens in a structured format. The system that we use to describe design tokens is inspired by the diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 1bb50d5..256440d 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, HtmlPreviewEmitterHandler, TailwindEmitterHandler, TailwindV4EmitterHandler, serializeTailwindV4 } 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', 'html'] 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; `html` emits a readable static preview page.', }, args: { file: { @@ -85,6 +85,17 @@ export default defineCommand({ } console.log(JSON.stringify(result.data, null, 2)); + } else if (format === 'html') { + const handler = new HtmlPreviewEmitterHandler(); + const result = handler.execute(report.designSystem); + + if (!result.success) { + console.error(JSON.stringify({ error: result.error.message })); + process.exitCode = 1; + return; + } + + console.log(result.data); } process.exitCode = report.summary.errors > 0 ? 1 : 0; diff --git a/packages/cli/src/linter/html/handler.test.ts b/packages/cli/src/linter/html/handler.test.ts new file mode 100644 index 0000000..b7ba95e --- /dev/null +++ b/packages/cli/src/linter/html/handler.test.ts @@ -0,0 +1,84 @@ +// 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, expect, test } from 'bun:test'; +import { HtmlPreviewEmitterHandler } from './handler.js'; +import type { DesignSystemState, ResolvedColor, ResolvedDimension, ResolvedTypography } from '../model/spec.js'; + +function makeColor(hex: string, luminance = 0): ResolvedColor { + return { type: 'color', hex, r: 0, g: 0, b: 0, luminance }; +} + +function makeDim(value: number, unit: string): ResolvedDimension { + return { type: 'dimension', value, unit }; +} + +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, + }; +} + +describe('HtmlPreviewEmitterHandler', () => { + test('renders a complete static HTML preview', () => { + const body: ResolvedTypography = { + type: 'typography', + fontFamily: 'Public Sans', + fontSize: makeDim(16, 'px'), + fontWeight: 400, + lineHeight: makeDim(24, 'px'), + }; + + const result = new HtmlPreviewEmitterHandler().execute(emptyState({ + name: 'Heritage', + description: 'Editorial design system', + colors: new Map([ + ['primary', makeColor('#1a1c1e')], + ['neutral', makeColor('#f7f5f2', 0.9)], + ]), + typography: new Map([['body-md', body]]), + rounded: new Map([['sm', makeDim(4, 'px')]]), + spacing: new Map([['md', makeDim(16, 'px')]]), + })); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toContain(''); + expect(result.data).toContain('Heritage Preview'); + expect(result.data).toContain('Editorial design system'); + expect(result.data).toContain('body-md'); + expect(result.data).toContain('#1a1c1e'); + }); + + test('escapes token names and descriptions before rendering HTML', () => { + const result = new HtmlPreviewEmitterHandler().execute(emptyState({ + name: '', + description: 'Use "quoted" text', + colors: new Map([['primary'); + expect(result.data).toContain('<script>alert(1)</script>'); + expect(result.data).toContain('Use "quoted" text'); + expect(result.data).toContain('primary<script>'); + }); +}); diff --git a/packages/cli/src/linter/html/handler.ts b/packages/cli/src/linter/html/handler.ts new file mode 100644 index 0000000..d36ca6b --- /dev/null +++ b/packages/cli/src/linter/html/handler.ts @@ -0,0 +1,503 @@ +// 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 { + ComponentDef, + DesignSystemState, + ResolvedColor, + ResolvedDimension, + ResolvedTypography, + ResolvedValue, +} from '../model/spec.js'; +import type { HtmlPreviewEmitterResult, HtmlPreviewEmitterSpec } from './spec.js'; + +export class HtmlPreviewEmitterHandler implements HtmlPreviewEmitterSpec { + execute(state: DesignSystemState): HtmlPreviewEmitterResult { + try { + return { success: true, data: renderHtmlPreview(state) }; + } catch (error) { + return { success: false, error: error instanceof Error ? error : new Error(String(error)) }; + } + } +} + +export function renderHtmlPreview(state: DesignSystemState): string { + const name = state.name || 'DESIGN.md Preview'; + const colors = Array.from(state.colors.entries()); + const typography = Array.from(state.typography.entries()); + const spacing = Array.from(state.spacing.entries()); + const rounded = Array.from(state.rounded.entries()); + const components = Array.from(state.components.entries()); + const primary = tokenColor(state, 'primary') || colors[0]?.[1] || null; + const neutral = tokenColor(state, 'neutral') || tokenColor(state, 'background') || tokenColor(state, 'surface') || null; + const text = tokenColor(state, 'on-background') || tokenColor(state, 'on-surface') || tokenColor(state, 'primary') || primary; + const accent = tokenColor(state, 'tertiary') || tokenColor(state, 'secondary') || primary; + const primaryCss = primary ? colorToCss(primary) : '#1a1c1e'; + const neutralCss = neutral ? colorToCss(neutral) : '#f7f5f2'; + const textCss = text ? colorToCss(text) : '#1a1c1e'; + const accentCss = accent ? colorToCss(accent) : '#b8422e'; + const headingTypography = typography.find(([key]) => /display|headline|h1/i.test(key))?.[1] || typography[0]?.[1]; + const bodyTypography = typography.find(([key]) => /body|paragraph|text/i.test(key))?.[1] || typography[0]?.[1]; + + return ` + + + + + ${escapeHtml(name)} Preview + + + +
+
+
+

${escapeHtml(name)}

+

${escapeHtml(state.description || `A readable browser preview generated from ${name} design tokens. Use it to inspect color, typography, components, spacing, and shape decisions before applying the system in production UI.`)}

+
+ ${colors.length} colors + ${typography.length} type styles + ${components.length} components + ${spacing.length + rounded.length} layout tokens +
+
+ +
+ ${colors.length ? renderColorSection(colors) : ''} + ${typography.length ? renderTypographySection(typography) : ''} + ${components.length ? renderComponentsSection(components) : ''} + ${spacing.length || rounded.length ? renderScalesSection(spacing, rounded) : ''} +
+ + +`; +} + +function renderColorSection(colors: Array<[string, ResolvedColor]>): string { + return `
+
+

Color

+

Every token is shown as a named swatch with its resolved CSS value, so humans can scan the palette without reading YAML.

+
+
+ ${colors.map(([name, color]) => `
+
+
+ ${escapeHtml(name)} + ${escapeHtml(colorToCss(color))} +
+
`).join('\n ')} +
+
`; +} + +function renderTypographySection(typography: Array<[string, ResolvedTypography]>): string { + return `
+
+

Typography

+

Type tokens render as real text specimens with their configured family, size, weight, line height, and tracking.

+
+
+ ${typography.map(([name, typo]) => `
+
${escapeHtml(name)}
${escapeHtml(typeMeta(typo))}
+

Sphinx of black quartz, judge my vow.

+
`).join('\n ')} +
+
`; +} + +function renderComponentsSection(components: Array<[string, ComponentDef]>): string { + return `
+
+

Components

+

Component tokens are translated into small interface samples to make interaction colors, shape, and spacing easier to judge.

+
+
+ ${components.map(([name, component]) => `
+
${escapeHtml(componentLabel(name))}
+
${escapeHtml(name)}
+
`).join('\n ')} +
+
`; +} + +function renderScalesSection(spacing: Array<[string, ResolvedDimension]>, rounded: Array<[string, ResolvedDimension]>): string { + return `
+
+

Layout

+

Spacing and radius scales appear at visual size, giving the design system a quick tactile check.

+
+
+ ${spacing.length ? `
+

Spacing

+ ${spacing.map(([name, dim]) => `
+ ${escapeHtml(name)} +
+ ${escapeHtml(dimToCss(dim))} +
`).join('\n ')} +
` : ''} + ${rounded.length ? `
+

Radius

+ ${rounded.map(([name, dim]) => `
+ ${escapeHtml(name)} +
+ ${escapeHtml(dimToCss(dim))} +
`).join('\n ')} +
` : ''} +
+
`; +} + +function tokenColor(state: DesignSystemState, key: string): ResolvedColor | null { + return state.colors.get(key) || null; +} + +function colorToCss(color: ResolvedColor): string { + return color.hex; +} + +function bestTextOn(color: ResolvedColor | null | undefined): string { + if (!color) return '#ffffff'; + return color.luminance > 0.45 ? '#111111' : '#ffffff'; +} + +function dimToCss(dim: ResolvedDimension): string { + return `${dim.value}${dim.unit}`; +} + +function typeStyle(typo: ResolvedTypography): string { + const declarations = [ + typo.fontFamily ? `font-family: ${fontStack(typo)}` : '', + typo.fontSize ? `font-size: ${dimToCss(typo.fontSize)}` : '', + typo.fontWeight ? `font-weight: ${typo.fontWeight}` : '', + typo.lineHeight ? `line-height: ${dimToCss(typo.lineHeight)}` : '', + typo.letterSpacing ? `letter-spacing: ${dimToCss(typo.letterSpacing)}` : '', + ].filter(Boolean); + return declarations.join('; '); +} + +function typeMeta(typo: ResolvedTypography): string { + return [ + typo.fontFamily, + typo.fontSize ? dimToCss(typo.fontSize) : undefined, + typo.fontWeight ? String(typo.fontWeight) : undefined, + ].filter(Boolean).join(' / '); +} + +function fontStack(typo: ResolvedTypography | undefined): string { + if (!typo?.fontFamily) return 'ui-sans-serif, system-ui, sans-serif'; + return `"${typo.fontFamily.replaceAll('"', '')}", ui-sans-serif, system-ui, sans-serif`; +} + +function componentStyle(component: ComponentDef): string { + const declarations: string[] = []; + const background = component.properties.get('backgroundColor'); + const color = component.properties.get('textColor'); + const rounded = component.properties.get('rounded'); + const padding = component.properties.get('padding'); + const height = component.properties.get('height'); + const width = component.properties.get('width'); + const typography = component.properties.get('typography'); + + if (background) declarations.push(`background: ${valueToCss(background)}`); + if (color) declarations.push(`color: ${valueToCss(color)}`); + if (rounded) declarations.push(`border-radius: ${valueToCss(rounded)}`); + if (padding) declarations.push(`padding: ${valueToCss(padding)}`); + if (height) declarations.push(`min-height: ${valueToCss(height)}`); + if (width) declarations.push(`width: ${valueToCss(width)}`); + if (typography && typeof typography === 'object' && 'type' in typography && typography.type === 'typography') { + declarations.push(typeStyle(typography)); + } + + return declarations.join('; '); +} + +function valueToCss(value: ResolvedValue): string { + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + if (value.type === 'color') return colorToCss(value); + if (value.type === 'dimension') return dimToCss(value); + return typeStyle(value); +} + +function componentLabel(name: string): string { + if (/button/i.test(name)) return 'Button'; + if (/input|field/i.test(name)) return 'Input text'; + if (/card|container/i.test(name)) return 'Card'; + if (/label|metric/i.test(name)) return 'Label'; + return 'Component'; +} + +function scaleWidth(dim: ResolvedDimension): string { + const px = dim.unit === 'rem' ? dim.value * 16 : dim.unit === 'em' ? dim.value * 16 : dim.value; + return `${Math.max(8, Math.min(220, px))}px`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function escapeAttribute(value: string): string { + return escapeHtml(value).replaceAll('\n', ' '); +} diff --git a/packages/cli/src/linter/html/spec.ts b/packages/cli/src/linter/html/spec.ts new file mode 100644 index 0000000..849f902 --- /dev/null +++ b/packages/cli/src/linter/html/spec.ts @@ -0,0 +1,23 @@ +// 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 { DesignSystemState } from '../model/spec.js'; + +export interface HtmlPreviewEmitterSpec { + execute(state: DesignSystemState): HtmlPreviewEmitterResult; +} + +export type HtmlPreviewEmitterResult = + | { success: true; data: string } + | { success: false; error: Error }; diff --git a/packages/cli/src/linter/index.ts b/packages/cli/src/linter/index.ts index 6dccb43..21354c2 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 { HtmlPreviewEmitterResult } from './html/spec.js'; // ── Advanced linting ─────────────────────────────────────────────── export { runLinter, preEvaluate } from './linter/runner.js'; @@ -50,5 +51,6 @@ 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 { HtmlPreviewEmitterHandler, renderHtmlPreview } from './html/handler.js'; export { fixSectionOrder } from './fixer/handler.js'; export type { FixerInput, FixerResult } from './fixer/spec.js'; diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index 92cb358..127d31a 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -7,6 +7,8 @@ DESIGN.md is a self-contained, plain-text representation of a design system. It A DESIGN.md file contains two parts: An optional YAML frontmatter, and a markdown body. The YAML front matter contains machine-readable design tokens. The markdown body sections provide human-readable design rationale and guidance. Prose may use descriptive color names (e.g., "Midnight Forest Green") that correspond to systematic token names (e.g., `primary`). The tokens are the normative values; the prose provides context for how to apply them. +The CLI can also emit a human-readable HTML preview from the same tokens. This preview is intentionally static, so it can be opened directly in a browser or committed alongside example systems for visual review. + # Design Tokens DESIGN.md may embed design tokens in a structured format. The system that we use to describe design tokens is inspired by the