Files
gstack/lib/design-md.ts
T
Garry TanandClaude Fable 5.1 e49d1eea49 feat(lib): open DESIGN.md reader/writer + gstack-design-md bin
lib/design-md.ts implements the open DESIGN.md format (google-labs-code/
design.md, Apache-2.0): YAML front matter with the five token groups (colors,
typography, rounded, spacing, components) and eight canonical `##` sections
in spec order (Overview, Colors, Typography, Layout, Elevation & Depth,
Shapes, Components, Do's and Don'ts), aliases mapped, extras preserved after
them in their original order. parseDesignMd never throws (unparsable front
matter → `unknown` with a reason); renderDesignMd re-emits the preserved front
matter bytes and only `convert` writes fresh YAML through a small block-style
emitter (Bun.YAML.stringify is flow style); upsertSection splices the body
only; tokensFlat resolves `{path}` references to primitives and reports group,
self, dangling, and cyclic refs as DESIGN_MD_TOKEN_REF_INVALID. convertLegacy
turns gstack's pre-spec DESIGN.md into the open format: Product Context and
Aesthetic Direction fold into Overview, Typography roles become
display/body/label/mono tokens (mono carries fontFeature: tnum), Color hexes
become colors (mode-qualified labels keep their qualifier; strategy lines are
not colors), the Spacing scale and Layout radii become spacing and rounded,
Motion / Grain Texture / Decisions Log survive as extras. The format marker
lives inside the file: a YAML comment on line 2 of a spec file, an HTML
comment on line 1 of a legacy file.

bin/gstack-design-md.ts: `check` (DESIGN_MD_FORMAT + marker), `convert
[--write]` (backup to DESIGN.md.legacy.bak, temp+rename, refuses ambiguous
input with DESIGN_MD_CONVERT_REFUSED), `tokens` (flat JSON), `mark
<spec|legacy-keep>`. Exit 3 + DESIGN_MD_INTERNAL_ERROR is a gstack bug.

design/src/memory.ts: updateDesignMd upserts "Extracted Design Language"
through the lib (front matter bytes untouched, canonical order kept, section
replaced on rerun) and creates a spec skeleton with tokens from the extraction
when no file exists; readDesignConstraints leads with the flat tokens and the
Overview for spec files. The design binary still bundles.

test/design-md.test.ts pins all of it against gstack's own DESIGN.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:22:25 +00:00

424 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/design-md.ts — read and write DESIGN.md in the open DESIGN.md format.
//
// Implements the DESIGN.md specification (google-labs-code/design.md, Google LLC,
// Apache-2.0): YAML front matter carrying the design tokens, a markdown body in
// eight canonical `##` sections. See NOTICE.md. Pure module: no I/O, no imports
// from scripts/; bin/gstack-design-md.ts and design/src/memory.ts do the file work.
//
// text ──► parseDesignMd ──► DesignMdDoc { frontmatterText (bytes preserved), frontmatter, marker,
// preamble, sections[] }
// ──► detectFormat ──► spec | legacy | unknown | missing (+ reason)
// ──► convertLegacy ──► gstack's pre-spec DESIGN.md (Product Context, Aesthetic Direction,
// Typography, Color, Spacing, Layout, Motion, Decisions Log) becomes
// tokens + canonical sections; Motion and Decisions Log survive as extras
// ──► upsertSection ──► body-only splice; front matter bytes are never re-emitted
// ──► renderDesignMd ──► marker, front matter, preamble, canonical sections in order, extras
// ──► tokensFlat ──► "colors.primary" → "#F59E0B"; {path} refs resolved to primitives
//
// Format marker (the user's one-time conversion answer, persisted in the file):
// spec files: line 1 `---`, line 2 `# gstack: design-md-format=spec` (a YAML comment, so
// parsers that require `---` on line 1 keep working)
// legacy files: line 1 `<!-- gstack: design-md-format=legacy-keep -->`
import { SENTINEL } from './design-detect-contract';
export const CANONICAL_SECTIONS = [
'Overview', 'Colors', 'Typography', 'Layout', 'Elevation & Depth', 'Shapes', 'Components', "Do's and Don'ts",
] as const;
export type CanonicalSection = (typeof CANONICAL_SECTIONS)[number];
/** Spec aliases (and a few punctuation variants) → canonical heading. */
export const SECTION_ALIASES: Record<string, CanonicalSection> = {
'brand & style': 'Overview',
'brand and style': 'Overview',
'layout & spacing': 'Layout',
'layout and spacing': 'Layout',
'elevation': 'Elevation & Depth',
'elevation and depth': 'Elevation & Depth',
"do's and don'ts": "Do's and Don'ts",
'dos and donts': "Do's and Don'ts",
"dos and donts": "Do's and Don'ts",
};
export const TOKEN_GROUPS = ['colors', 'typography', 'rounded', 'spacing', 'components'] as const;
export type TokenGroup = (typeof TOKEN_GROUPS)[number];
export const FORMAT_MARKER_PREFIX = 'gstack: design-md-format=';
export type FormatChoice = 'spec' | 'legacy-keep';
export type DesignMdFormat = 'spec' | 'legacy' | 'unknown' | 'missing';
/** Headings that identify gstack's pre-spec DESIGN.md. */
export const LEGACY_HEADINGS = ['Product Context', 'Aesthetic Direction', 'Color', 'Spacing', 'Decisions Log'];
export interface Section {
heading: string;
/** canonical name when the heading (or an alias) is one of the eight */
canonical?: CanonicalSection;
/** body text between this heading and the next `##`, without the trailing blank run */
body: string;
}
export interface DesignMdDoc {
/** raw YAML between the fences, bytes preserved (null when no front matter) */
frontmatterText: string | null;
/** parsed YAML (null when absent or unparsable) */
frontmatter: Record<string, unknown> | null;
frontmatterError?: string;
marker: FormatChoice | null;
/** text between the front matter (or file start) and the first `##` heading, trimmed */
preamble: string;
sections: Section[];
}
// ── Parsing ──────────────────────────────────────────────────────────────────
function canonicalFor(heading: string): CanonicalSection | undefined {
const key = heading.trim().toLowerCase();
const direct = CANONICAL_SECTIONS.find(c => c.toLowerCase() === key);
return direct ?? SECTION_ALIASES[key];
}
function parseYaml(text: string): { value: Record<string, unknown> | null; error?: string } {
try {
const v = (Bun as unknown as { YAML: { parse(s: string): unknown } }).YAML.parse(text);
if (v === null || v === undefined) return { value: {} };
if (typeof v !== 'object' || Array.isArray(v)) return { value: null, error: 'front matter is not a mapping' };
return { value: v as Record<string, unknown> };
} catch (e) {
return { value: null, error: (e as Error).message.split('\n')[0].slice(0, 200) };
}
}
export function parseDesignMd(text: string): DesignMdDoc {
const src = text.replace(/\r\n/g, '\n');
let rest = src;
let marker: FormatChoice | null = null;
let frontmatterText: string | null = null;
let frontmatter: Record<string, unknown> | null = null;
let frontmatterError: string | undefined;
const legacyMarker = rest.match(/^<!--\s*gstack: design-md-format=(spec|legacy-keep)\s*-->\n?/);
if (legacyMarker) {
marker = legacyMarker[1] as FormatChoice;
rest = rest.slice(legacyMarker[0].length);
}
if (rest.startsWith('---\n')) {
const end = rest.indexOf('\n---', 4);
if (end !== -1 && (rest[end + 4] === '\n' || end + 4 === rest.length)) {
frontmatterText = rest.slice(4, end + 1);
const m = frontmatterText.match(/^# gstack: design-md-format=(spec|legacy-keep)\s*$/m);
if (m) marker = m[1] as FormatChoice;
const parsed = parseYaml(frontmatterText);
frontmatter = parsed.value;
frontmatterError = parsed.error;
rest = rest.slice(end + 5);
}
}
const lines = rest.split('\n');
const sections: Section[] = [];
const preambleLines: string[] = [];
let cur: { heading: string; lines: string[] } | null = null;
let inFence = false;
for (const line of lines) {
if (/^```/.test(line)) inFence = !inFence;
const h = !inFence ? line.match(/^## (.+?)\s*$/) : null;
if (h) {
if (cur) sections.push(finish(cur));
cur = { heading: h[1], lines: [] };
} else if (cur) {
cur.lines.push(line);
} else {
preambleLines.push(line);
}
}
if (cur) sections.push(finish(cur));
return { frontmatterText, frontmatter, frontmatterError, marker, preamble: preambleLines.join('\n').trim(), sections };
function finish(c: { heading: string; lines: string[] }): Section {
const canonical = canonicalFor(c.heading);
return { heading: c.heading, ...(canonical ? { canonical } : {}), body: c.lines.join('\n').replace(/\s+$/, '') };
}
}
// ── Format detection ─────────────────────────────────────────────────────────
export function isLegacyGstackFormat(doc: DesignMdDoc): boolean {
const headings = new Set(doc.sections.map(s => s.heading.trim().toLowerCase()));
const hits = LEGACY_HEADINGS.filter(h => headings.has(h.toLowerCase())).length;
return doc.frontmatterText === null && hits >= 2;
}
export function hasSpecFrontmatter(doc: DesignMdDoc): boolean {
if (!doc.frontmatter) return false;
return TOKEN_GROUPS.some(g => g in doc.frontmatter!) || 'name' in doc.frontmatter;
}
export function detectFormat(doc: DesignMdDoc | null): { format: DesignMdFormat; reason?: string } {
if (!doc) return { format: 'missing' };
if (doc.frontmatterText !== null && doc.frontmatter === null) {
return { format: 'unknown', reason: `front matter does not parse: ${doc.frontmatterError ?? 'unknown error'}` };
}
const spec = hasSpecFrontmatter(doc);
const legacyHeadings = doc.sections.filter(s => ['product context', 'aesthetic direction'].includes(s.heading.trim().toLowerCase())).length > 0;
if (spec && legacyHeadings) return { format: 'unknown', reason: 'ambiguous (legacy headings and front matter both present)' };
if (spec) return { format: 'spec' };
if (isLegacyGstackFormat(doc)) return { format: 'legacy' };
if (doc.frontmatterText !== null) return { format: 'unknown', reason: 'front matter carries none of the five token groups' };
return { format: 'unknown', reason: 'no front matter and no gstack legacy headings' };
}
// ── YAML block emitter ───────────────────────────────────────────────────────
function needsQuotes(s: string): boolean {
return s === '' || /^[\s#&*!|>'"%@`{[\]},:?-]|[:#]\s|\s$|^(true|false|null|yes|no|on|off|~)$|^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/i.test(s);
}
function yamlScalar(v: unknown): string {
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (v === null || v === undefined) return '""';
const s = String(v);
return needsQuotes(s) ? JSON.stringify(s) : s;
}
/** Block-style YAML for nested mappings of scalars (Bun.YAML.stringify emits flow style). */
export function emitYamlBlock(obj: Record<string, unknown>, indent = 0): string {
const pad = ' '.repeat(indent);
const out: string[] = [];
for (const [k, v] of Object.entries(obj)) {
const key = needsQuotes(k) ? JSON.stringify(k) : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
out.push(`${pad}${key}:`);
out.push(emitYamlBlock(v as Record<string, unknown>, indent + 2));
} else if (Array.isArray(v)) {
out.push(`${pad}${key}:`);
for (const item of v) out.push(`${pad} - ${yamlScalar(item)}`);
} else {
out.push(`${pad}${key}: ${yamlScalar(v)}`);
}
}
return out.join('\n');
}
// ── Rendering ────────────────────────────────────────────────────────────────
export interface RenderOptions {
/** emit fresh front matter from `frontmatter` instead of the preserved bytes (convert only) */
emitFrontmatter?: boolean;
}
export function renderDesignMd(doc: DesignMdDoc, opts: RenderOptions = {}): string {
const parts: string[] = [];
const fm = opts.emitFrontmatter && doc.frontmatter ? emitYamlBlock(doc.frontmatter) + '\n' : doc.frontmatterText;
if (fm !== null) {
const body = fm.replace(/^# gstack: design-md-format=(spec|legacy-keep)\s*\n/m, '');
parts.push('---');
if (doc.marker) parts.push(`# ${FORMAT_MARKER_PREFIX}${doc.marker}`);
parts.push(body.replace(/\n$/, ''));
parts.push('---');
if (doc.preamble) parts.push('', doc.preamble);
} else {
if (doc.marker) parts.push(`<!-- ${FORMAT_MARKER_PREFIX}${doc.marker} -->`);
if (doc.preamble) parts.push(doc.preamble);
}
const canonical = CANONICAL_SECTIONS
.map(c => doc.sections.find(s => s.canonical === c))
.filter((s): s is Section => Boolean(s));
const extras = doc.sections.filter(s => !s.canonical);
for (const s of [...canonical, ...extras]) {
parts.push('', `## ${s.canonical ?? s.heading}`);
if (s.body.trim()) parts.push('', s.body.trim());
}
return parts.join('\n').replace(/^\n+/, '') + '\n';
}
/** Replace or add a section; canonical names slot into spec order, extras append. Body-only: front matter bytes untouched. */
export function upsertSection(doc: DesignMdDoc, heading: string, body: string): DesignMdDoc {
const canonical = canonicalFor(heading);
const sections = doc.sections.map(s => ({ ...s }));
const idx = sections.findIndex(s => (canonical ? s.canonical === canonical : s.heading.trim().toLowerCase() === heading.trim().toLowerCase()));
const next: Section = { heading: canonical ?? heading, ...(canonical ? { canonical } : {}), body: body.replace(/\s+$/, '') };
if (idx >= 0) sections[idx] = next; else sections.push(next);
return { ...doc, sections };
}
export function setMarker(doc: DesignMdDoc, marker: FormatChoice): DesignMdDoc {
return { ...doc, marker };
}
// ── Tokens ───────────────────────────────────────────────────────────────────
export interface FlatTokens {
tokens: Record<string, string>;
errors: string[];
}
/** Flatten the five token groups to dotted paths; resolve `{path}` references to primitives. */
export function tokensFlat(frontmatter: Record<string, unknown> | null): FlatTokens {
const tokens: Record<string, string> = {};
const errors: string[] = [];
if (!frontmatter) return { tokens, errors };
const raw: Record<string, unknown> = {};
const walk = (prefix: string, v: unknown) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
for (const [k, x] of Object.entries(v as Record<string, unknown>)) walk(prefix ? `${prefix}.${k}` : k, x);
} else if (v !== null && v !== undefined && !Array.isArray(v)) {
raw[prefix] = v;
}
};
for (const g of TOKEN_GROUPS) if (g in frontmatter) walk(g, frontmatter[g]);
const groups = new Set(Object.keys(raw).map(k => k.split('.').slice(0, -1).join('.')).filter(Boolean));
for (const [k, v] of Object.entries(raw)) {
const s = String(v);
const ref = s.match(/^\{([a-zA-Z0-9_.-]+)\}$/);
if (!ref) { tokens[k] = s; continue; }
const target = ref[1];
if (target === k) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (self-reference)`); continue; }
if (groups.has(target) || TOKEN_GROUPS.includes(target as TokenGroup)) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (refers to a group, not a primitive)`); continue; }
let seen = 0;
let cur: unknown = raw[target];
let curKey = target;
while (typeof cur === 'string' && /^\{[a-zA-Z0-9_.-]+\}$/.test(cur) && seen < 8) {
curKey = cur.slice(1, -1);
cur = raw[curKey];
seen++;
}
if (cur === undefined) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (no such token)`); continue; }
if (typeof cur === 'string' && /^\{/.test(cur)) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (reference cycle)`); continue; }
tokens[k] = String(cur);
}
return { tokens, errors };
}
// ── Legacy conversion ────────────────────────────────────────────────────────
function slug(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'token';
}
/** Legacy Color bullets whose label names a strategy or a mode, not a color. */
const NOT_COLOR_LABELS = new Set(['approach', 'semantic', 'dark mode', 'light mode', 'neutrals', 'contrast', 'strategy']);
function bullets(body: string): Array<{ key: string; value: string }> {
const out: Array<{ key: string; value: string }> = [];
for (const line of body.split('\n')) {
const m = line.match(/^\s*-\s+\*\*(.+?):?\*\*:?\s*(.*)$/);
if (m) out.push({ key: m[1].trim().replace(/:$/, ''), value: m[2].trim() });
}
return out;
}
const HEX = /#[0-9a-fA-F]{3,8}\b/;
function sectionBody(doc: DesignMdDoc, heading: string): string | undefined {
return doc.sections.find(s => s.heading.trim().toLowerCase() === heading.toLowerCase())?.body;
}
function firstFontName(value: string): string | undefined {
const m = value.match(/^([A-Z][A-Za-z0-9 ]+?)(?:\s*\(|\s+—|\s+-\s|,|$)/);
return m ? m[1].trim() : undefined;
}
/**
* Convert gstack's pre-spec DESIGN.md into the open format. Product Context and
* Aesthetic Direction fold into Overview; Typography roles become
* typography.display/body/label/mono; Color hexes become colors; the Spacing
* scale becomes spacing; the Layout border radii become rounded; everything else
* (Motion, Decisions Log, Grain Texture, ...) survives as an extra section in
* its original order. Idempotent: converting the render again changes nothing.
*/
export function convertLegacy(doc: DesignMdDoc, opts: { name?: string } = {}): DesignMdDoc {
const title = doc.preamble.match(/^#\s+(.+)$/m)?.[1]?.trim();
const name = opts.name ?? (title ? title.replace(/^Design System\s*[—–-]\s*/i, '').trim() : 'Design System');
const fm: Record<string, unknown> = { name };
const overview: string[] = [];
const product = sectionBody(doc, 'Product Context');
const aesthetic = sectionBody(doc, 'Aesthetic Direction');
if (product) overview.push(product.trim());
if (aesthetic) overview.push(aesthetic.trim());
// Typography
const typo = sectionBody(doc, 'Typography');
const typography: Record<string, Record<string, string>> = {};
if (typo) {
const roleMap: Array<[RegExp, string]> = [
[/^display/i, 'display'], [/^hero/i, 'display'], [/^body/i, 'body'], [/^ui/i, 'label'], [/^label/i, 'label'],
[/^data/i, 'mono'], [/^code/i, 'mono'], [/^mono/i, 'mono'],
];
for (const b of bullets(typo)) {
const role = roleMap.find(([re]) => re.test(b.key))?.[1];
if (!role || typography[role]) continue;
if (/same as/i.test(b.value)) { const src = b.value.match(/same as (\w+)/i)?.[1]?.toLowerCase(); if (src && typography[src]) typography[role] = { ...typography[src] }; continue; }
const family = firstFontName(b.value);
if (!family) continue;
const t: Record<string, string> = { fontFamily: family };
if (role === 'mono') t.fontFeature = 'tnum';
typography[role] = t;
}
}
if (Object.keys(typography).length) fm.typography = typography;
// Colors
const color = sectionBody(doc, 'Color') ?? sectionBody(doc, 'Colors');
const colors: Record<string, string> = {};
if (color) {
for (const line of color.split('\n')) {
const hex = line.match(HEX)?.[0];
if (!hex) continue;
const label = (line.match(/\*\*(.+?):?\*\*/)?.[1] ?? line.match(/^\s*-\s*([^:]+):/)?.[1])?.replace(/:$/, '').trim();
if (!label || NOT_COLOR_LABELS.has(label.toLowerCase())) continue;
const key = slug(label);
if (!(key in colors)) colors[key] = hex;
}
// semantic line: "success #22C55E, warning #F59E0B, ..."
const semantic = color.match(/\*\*Semantic:\*\*\s*(.+)$/m)?.[1];
if (semantic) for (const m of semantic.matchAll(/([a-z]+)\s+(#[0-9a-fA-F]{3,8})/g)) if (!(m[1] in colors)) colors[m[1]] = m[2];
}
if (Object.keys(colors).length) fm.colors = colors;
// Spacing scale "2xs(2px) xs(4px) ..."
const spacingBody = sectionBody(doc, 'Spacing');
const spacing: Record<string, string> = {};
if (spacingBody) {
const scale = spacingBody.match(/\*\*Scale:\*\*\s*(.+)$/m)?.[1];
if (scale) for (const m of scale.matchAll(/([0-9a-z]+)\(([^)]+)\)/g)) spacing[m[1]] = /px|rem|em$/.test(m[2]) ? m[2] : `${m[2]}px`;
}
if (Object.keys(spacing).length) fm.spacing = spacing;
// Border radius "sm:4px, md:8px, lg:12px, full:9999px"
const layoutBody = sectionBody(doc, 'Layout');
const rounded: Record<string, string> = {};
if (layoutBody) {
const radius = layoutBody.match(/\*\*Border radius:\*\*\s*(.+)$/m)?.[1];
if (radius) for (const m of radius.matchAll(/([a-z0-9]+):\s*([0-9.]+(?:px|rem|em))/g)) rounded[m[1]] = m[2];
}
if (Object.keys(rounded).length) fm.rounded = rounded;
const consumed = new Set(['product context', 'aesthetic direction', 'typography', 'color', 'colors', 'spacing', 'layout']);
const sections: Section[] = [];
sections.push({ heading: 'Overview', canonical: 'Overview', body: overview.join('\n\n') || '(no product context recorded)' });
if (color) sections.push({ heading: 'Colors', canonical: 'Colors', body: color.trim() });
if (typo) sections.push({ heading: 'Typography', canonical: 'Typography', body: typo.trim() });
const layoutParts = [layoutBody?.trim(), spacingBody ? `### Spacing\n${spacingBody.trim()}` : undefined].filter(Boolean) as string[];
if (layoutParts.length) sections.push({ heading: 'Layout', canonical: 'Layout', body: layoutParts.join('\n\n') });
for (const s of doc.sections) {
if (consumed.has(s.heading.trim().toLowerCase())) continue;
sections.push(s.canonical ? { ...s } : { heading: s.heading, body: s.body });
}
return {
frontmatterText: emitYamlBlock(fm) + '\n',
frontmatter: fm,
marker: 'spec',
preamble: title ? `# ${title}` : doc.preamble,
sections,
};
}
/** A minimal spec-format document (used when a tool must create DESIGN.md from scratch). */
export function specSkeleton(name: string, frontmatter: Record<string, unknown>, sections: Array<{ heading: string; body: string }>): DesignMdDoc {
const fm = { name, ...frontmatter };
const doc: DesignMdDoc = { frontmatterText: emitYamlBlock(fm) + '\n', frontmatter: fm, marker: 'spec', preamble: `# ${name}`, sections: [] };
return sections.reduce((d, s) => upsertSection(d, s.heading, s.body), doc);
}