diff --git a/README.md b/README.md index ed855c5..1f840e8 100644 --- a/README.md +++ b/README.md @@ -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 | |:-----|:---------|:---------------| @@ -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 diff --git a/docs/spec.md b/docs/spec.md index 5995e54..34f852c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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 diff --git a/packages/cli/src/commands/spec.test.ts b/packages/cli/src/commands/spec.test.ts index b7113ec..9ec8a97 100644 --- a/packages/cli/src/commands/spec.test.ts +++ b/packages/cli/src/commands/spec.test.ts @@ -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); }); }); diff --git a/packages/cli/src/linter/linter/rules/index.ts b/packages/cli/src/linter/linter/rules/index.ts index 596a78e..1a2d491 100644 --- a/packages/cli/src/linter/linter/rules/index.ts +++ b/packages/cli/src/linter/linter/rules/index.ts @@ -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[] = [ @@ -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. */ @@ -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'; diff --git a/packages/cli/src/linter/linter/rules/prose-token-leak.test.ts b/packages/cli/src/linter/linter/rules/prose-token-leak.test.ts new file mode 100644 index 0000000..ce5a781 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/prose-token-leak.test.ts @@ -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 here.', + }, + ], + }); + 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); + }); +}); diff --git a/packages/cli/src/linter/linter/rules/prose-token-leak.ts b/packages/cli/src/linter/linter/rules/prose-token-leak.ts new file mode 100644 index 0000000..fe32e44 --- /dev/null +++ b/packages/cli/src/linter/linter/rules/prose-token-leak.ts @@ -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. ) to prevent matching HTML attribute values + content = content.replace(/<[^>]*>/g, ''); + + // 3. Strip HTML comments + content = content.replace(//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, +}; diff --git a/packages/cli/src/linter/linter/rules/types.test.ts b/packages/cli/src/linter/linter/rules/types.test.ts index 5df8445..9368ad7 100644 --- a/packages/cli/src/linter/linter/rules/types.test.ts +++ b/packages/cli/src/linter/linter/rules/types.test.ts @@ -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(); diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index 9ea1193..dc0e980 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -220,6 +220,7 @@ export class ModelHandler implements ModelSpec { components, symbolTable, sections: input.sections, + documentSections: input.documentSections, unknownKeys, unknownKeyValues, }, diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts index c84b09b..b601761 100644 --- a/packages/cli/src/linter/model/spec.ts +++ b/packages/cli/src/linter/model/spec.ts @@ -82,6 +82,8 @@ export interface DesignSystemState { symbolTable: Map; /** 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 */ diff --git a/packages/cli/src/linter/spec-gen/spec.mdx b/packages/cli/src/linter/spec-gen/spec.mdx index c9e417d..29cae3c 100644 --- a/packages/cli/src/linter/spec-gen/spec.mdx +++ b/packages/cli/src/linter/spec-gen/spec.mdx @@ -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