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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ npx @google/design.md spec --rules-only --format json

## Linting Rules

The linter runs eleven rules against a parsed DESIGN.md. Each rule produces findings at a fixed severity level.
The linter runs twelve rules against a parsed DESIGN.md. Each rule produces findings at a fixed severity level.

| Rule | Severity | What it checks |
|:-----|:---------|:---------------|
Expand All @@ -332,6 +332,7 @@ The linter runs eleven rules against a parsed DESIGN.md. Each rule produces find
| `unknown-key` | warning | A top-level YAML key looks like a typo of a known schema key (e.g. `colours:` → `colors:`); custom extension keys stay silent |
| `token-like-ignored` | warning | An unknown top-level key has token-like values (e.g. hex colors, font families, dimensions) suggesting it was dropped or misspelled |
| `omitted-rules` | info | Validates the `omitted` configuration mapping for unknown or redundant sections |
| `prose-token-leak` | warning | Literal token values (like hex colors or CSS dimensions) duplicated in the markdown prose body |

### Programmatic API

Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

DESIGN.md is a self-contained, plain-text representation of a design system. It defines the visual identity of a brand and product, thereby ensuring that these stylistic choices can be followed across design sessions and between different AI agents and tools. As a human-readable, open-format document, it serves as a living source of truth that both humans and AI can understand and refine.

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.
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. To avoid documentation drift and maintain a single source of truth, the markdown prose body must not duplicate literal token values (such as hex colors or CSS dimensions). Prose should instead reference tokens by name (e.g., `{colors.primary}`) or describe their perceptual roles. Literal values are permitted only inside YAML frontmatter, code blocks, and inline code.

# Design Tokens

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,6 @@ describe('spec command', () => {
const output = JSON.parse(outputStr);
expect(output.spec).toBeDefined();
expect(output.rules).toBeDefined();
expect(output.rules.length).toBe(11);
expect(output.rules.length).toBe(12);
});
});
3 changes: 3 additions & 0 deletions packages/cli/src/linter/linter/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { missingTypographyRule } from './missing-typography.js';
import { unknownKeyRule } from './unknown-key.js';
import { tokenLikeIgnoredRule } from './token-like-ignored.js';
import { omittedRule } from './omitted.js';
import { proseTokenLeakRule } from './prose-token-leak.js';

/** The default set of lint rule descriptors, in order. */
export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
Expand All @@ -40,6 +41,7 @@ export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
unknownKeyRule,
tokenLikeIgnoredRule,
omittedRule,
proseTokenLeakRule,
];

/** Converts a RuleDescriptor into a LintRule by injecting severity into findings. */
Expand Down Expand Up @@ -68,4 +70,5 @@ export { unknownKey } from './unknown-key.js';
export { sectionOrder } from './section-order.js';
export { tokenLikeIgnored } from './token-like-ignored.js';
export { omittedRule as omitted } from './omitted.js';
export { proseTokenLeak } from './prose-token-leak.js';
export type { LintRule } from './types.js';
111 changes: 111 additions & 0 deletions packages/cli/src/linter/linter/rules/prose-token-leak.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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 { proseTokenLeak } from './prose-token-leak.js';
import { buildState } from './test-helpers.js';

describe('proseTokenLeak', () => {
it('warns when literal colors or dimensions are found in markdown prose', () => {
const state = buildState({
documentSections: [
{
heading: 'Colors',
content: 'The background should be #ffffff and padding is 12px.',
},
],
});
const findings = proseTokenLeak(state);
expect(findings.length).toBe(2);
expect(findings[0]!.message).toContain("Literal hex color '#ffffff'");
expect(findings[0]!.path).toBe('sections.Colors');
expect(findings[1]!.message).toContain("Literal dimension '12px'");
expect(findings[1]!.path).toBe('sections.Colors');
});

it('warns when literal functional colors (rgb, rgba, hsl, hsla) are found in markdown prose', () => {
const state = buildState({
documentSections: [
{
heading: 'Colors',
content: 'We use rgb(255, 0, 0) and hsl(120, 100%, 50%) for status states.',
},
],
});
const findings = proseTokenLeak(state);
expect(findings.length).toBe(2);
expect(findings[0]!.message).toContain("Literal functional color 'rgb(255, 0, 0)'");
expect(findings[1]!.message).toContain("Literal functional color 'hsl(120, 100%, 50%)'");
});

it('passes when tokens are referenced using brackets', () => {
const state = buildState({
documentSections: [
{
heading: 'Colors',
content: 'The background should be {colors.primary} and padding is {spacing.md}.',
},
],
});
expect(proseTokenLeak(state).length).toBe(0);
});

it('passes when values are inside fenced code blocks', () => {
const state = buildState({
documentSections: [
{
heading: 'Colors',
content: 'Example styling block:\n```yaml\nprimary: "#ffffff"\npadding: 12px\n```\n',
},
],
});
expect(proseTokenLeak(state).length).toBe(0);
});

it('passes when values are inside inline backticks', () => {
const state = buildState({
documentSections: [
{
heading: 'Colors',
content: 'Do not use `#ffffff` or `12px` directly in text.',
},
],
});
expect(proseTokenLeak(state).length).toBe(0);
});

it('passes when relative anchors and HTML links are used', () => {
const state = buildState({
documentSections: [
{
heading: 'Overview',
content: 'Check the [color contrast page](#abc) or click <a href="#cba">here</a>.',
},
],
});
expect(proseTokenLeak(state).length).toBe(0);
});

it('ignores bare numbers without unit suffixes', () => {
const state = buildState({
documentSections: [
{
heading: 'Overview',
content: 'We define 3 typography scales and 4 colors here.',
},
],
});
expect(proseTokenLeak(state).length).toBe(0);
});
});
99 changes: 99 additions & 0 deletions packages/cli/src/linter/linter/rules/prose-token-leak.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// 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';
import type { RuleDescriptor, RuleFinding } from './types.js';

// CSS hex color: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA
const HEX_COLOR_RE = /#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;

// CSS functional color notations: rgb(), rgba(), hsl(), hsla()
const FUNCTIONAL_COLOR_RE = /\b(rgba?|hsla?)\([^\)]+\)/g;

// CSS dimension: number + unit suffix (px, rem, em, pt, mm, cm, in, vh, vw, %)
const CSS_DIMENSION_RE = /\b\d*\.?\d+(px|rem|em|pt|mm|cm|in|vh|vw|%)\b/g;

export function proseTokenLeak(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
const docs = state.documentSections ?? [];

for (const section of docs) {
let content = section.content;

// 0. Strip YAML frontmatter block from prelude if present
if (!section.heading) {
content = content.replace(/^---[\s\S]*?^---/m, '');
}

// 1. Strip markdown links (e.g. [label](#abc)) to prevent matching anchor hashes
content = content.replace(/\[[\s\S]*?\]\([\s\S]*?\)/g, '');

// 2. Strip HTML tags (e.g. <a href="#abc">) to prevent matching HTML attribute values
content = content.replace(/<[^>]*>/g, '');

// 3. Strip HTML comments
content = content.replace(/<!--[\s\S]*?-->/g, '');

// 4. Strip fenced code blocks
content = content.replace(/```[\s\S]*?```/g, '');

// 5. Strip inline code backticks
content = content.replace(/`[\s\S]*?`/g, '');

// 6. Strip markdown headings (e.g. ## Colors)
content = content.replace(/^#+.*$/gm, '');

// 7. Strip curly-brace token references (e.g. {colors.primary}) so they are allowed
content = content.replace(/\{[^{}]+\}/g, '');

// Check for hex color leaks
let match;
// Reset regex indices
HEX_COLOR_RE.lastIndex = 0;
FUNCTIONAL_COLOR_RE.lastIndex = 0;
CSS_DIMENSION_RE.lastIndex = 0;

while ((match = HEX_COLOR_RE.exec(content)) !== null) {
findings.push({
path: section.heading ? `sections.${section.heading}` : 'prelude',
message: `Literal hex color '${match[0]}' found in markdown prose under '${section.heading || 'prelude'}'. Literal values cause documentation drift. Reference the token by name (e.g., '{colors.primary}') instead.`,
});
}

// Check for functional color leaks
while ((match = FUNCTIONAL_COLOR_RE.exec(content)) !== null) {
findings.push({
path: section.heading ? `sections.${section.heading}` : 'prelude',
message: `Literal functional color '${match[0]}' found in markdown prose under '${section.heading || 'prelude'}'. Literal values cause documentation drift. Reference the token by name (e.g., '{colors.primary}') instead.`,
});
}

// Check for dimension leaks
while ((match = CSS_DIMENSION_RE.exec(content)) !== null) {
findings.push({
path: section.heading ? `sections.${section.heading}` : 'prelude',
message: `Literal dimension '${match[0]}' found in markdown prose under '${section.heading || 'prelude'}'. Literal values cause documentation drift. Reference the token by name instead.`,
});
}
}

return findings;
}

export const proseTokenLeakRule: RuleDescriptor = {
name: 'prose-token-leak',
severity: 'warning',
description: 'Prose token leak — warns when a literal color or dimension token value is written in the markdown prose body.',
run: proseTokenLeak,
};
2 changes: 1 addition & 1 deletion packages/cli/src/linter/linter/rules/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('LintRule type', () => {
});

it('has all rules in DEFAULT_RULE_DESCRIPTORS', () => {
expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(11);
expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(12);
DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => {
expect(rule.name).toBeTruthy();
expect(rule.severity).toBeTruthy();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/linter/model/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ export class ModelHandler implements ModelSpec {
components,
symbolTable,
sections: input.sections,
documentSections: input.documentSections,
unknownKeys,
unknownKeyValues,
},
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/linter/model/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export interface DesignSystemState {
symbolTable: Map<string, ResolvedValue>;
/** Markdown heading names found in the document */
sections?: string[] | undefined;
/** Full content of each section, including heading and body */
documentSections?: Array<{ heading: string; content: string }> | undefined;
/** Top-level YAML keys that are not part of the known schema */
unknownKeys?: string[] | undefined;
/** Raw YAML values for unknown top-level keys, keyed by the unknown key name */
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/linter/spec-gen/spec.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { frontmatterExample, colorsExample, typographyExample, componentsExample

DESIGN.md is a self-contained, plain-text representation of a design system. It defines the visual identity of a brand and product, thereby ensuring that these stylistic choices can be followed across design sessions and between different AI agents and tools. As a human-readable, open-format document, it serves as a living source of truth that both humans and AI can understand and refine.

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.
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. To avoid documentation drift and maintain a single source of truth, the markdown prose body must not duplicate literal token values (such as hex colors or CSS dimensions). Prose should instead reference tokens by name (e.g., `{colors.primary}`) or describe their perceptual roles. Literal values are permitted only inside YAML frontmatter, code blocks, and inline code.

# Design Tokens

Expand Down