Files
gstack/lib/design-md.ts
T
Garry TanandClaude Fable 5.1 ae5a5298e0 fix(design-md): text-level edits keep CRLF, one section-boundary rule, control characters quoted
- insertMarker and spliceSection normalized every line ending to LF, so a CRLF
  DESIGN.md came back rewritten beyond the one line they promised to touch.
  Both detect the file's dominant line ending and restore it.
- parseDesignMd and spliceSection each walked headings with their own fence
  tracking; they now share headingLines (and upsertSection shares
  headingMatches). An unclosed ``` is treated as prose for that file: it used
  to swallow every later section on a splice.
- A token value carrying a control character (an LLM-extracted font family
  with an embedded newline) was emitted as a bare multi-line scalar that
  Bun.YAML rejects, turning a freshly written DESIGN.md into
  frontmatter-unparsable; needsQuotes routes it through the quoted form.
- The marker-line regex variants are built once beside YAML_MARKER_RE; the
  dead setMarker export and a no-op ternary are gone; LEGACY_HEADINGS derives
  from the identity list; the header diagram names the text-level editors as
  the write path for user-owned files; the bin validates and prints the mark
  choices from FORMAT_CHOICES.

Tests: CRLF round-trips for both editors, a fenced ## inside a section and an
unclosed fence, and a newline-bearing scalar parsing back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 18:05:21 +00:00

512 lines
26 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 on the parsed doc (files gstack writes from scratch)
// ──► renderDesignMd ──► marker, front matter, preamble, canonical sections in order, extras
// text ──► spliceSection / insertMarker ──► text-level edits of a file the USER owns: one section
// body or one marker line changes, every other byte and
// the file's line endings are untouched (the `mark` verb,
// the design binary's extraction section)
// ──► 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 const FORMAT_CHOICES = ['spec', 'legacy-keep'] as const;
export type FormatChoice = (typeof FORMAT_CHOICES)[number];
const MARKER_RE_BODY = FORMAT_MARKER_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(' + FORMAT_CHOICES.join('|') + ')';
/** `<!-- gstack: design-md-format=... -->` on line 1 (legacy files) */
const LEGACY_MARKER_RE = new RegExp('^<!--\\s*' + MARKER_RE_BODY + '\\s*-->\\n?');
/** `# gstack: design-md-format=...` as a YAML comment inside the front matter (spec files) */
const YAML_MARKER_RE = new RegExp('^# ' + MARKER_RE_BODY + '\\s*$', 'm');
/** The marker line inside front matter, newline included (renderDesignMd drops it before re-emitting the marker itself). */
const YAML_MARKER_LINE_RE = new RegExp(YAML_MARKER_RE.source + '\\n', 'm');
/** A front matter opener immediately followed by the marker line (insertMarker replaces the old choice). */
const FRONTMATTER_OPEN_WITH_MARKER_RE = new RegExp('^---\\n' + YAML_MARKER_RE.source.replace(/^\^/, '') + '\\n', 'm');
/** Maximum `{path}` reference hops before a chain counts as a cycle. */
export const TOKEN_REF_MAX_HOPS = 8;
/** Headings that mark gstack's pre-spec file by themselves (either one is enough evidence of a legacy shape). */
export const LEGACY_IDENTITY_HEADINGS = ['Product Context', 'Aesthetic Direction'] as const;
export type DesignMdFormat = 'spec' | 'legacy' | 'unknown' | 'missing';
/** Machine-readable reason for an `unknown` (or `missing`) verdict; `reason` is the prose. */
export type FormatCode = 'spec' | 'legacy' | 'missing' | 'frontmatter-unparsable' | 'ambiguous' | 'no-token-groups' | 'no-shape';
/** Headings that identify gstack's pre-spec DESIGN.md. */
export const LEGACY_HEADINGS = [...LEGACY_IDENTITY_HEADINGS, '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(LEGACY_MARKER_RE);
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(YAML_MARKER_RE);
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 heads = headingLines(lines);
const preambleLines = lines.slice(0, heads[0]?.index ?? lines.length);
const sections: Section[] = heads.map((h, k) => {
const canonical = canonicalFor(h.heading);
const body = lines.slice(h.index + 1, heads[k + 1]?.index ?? lines.length).join('\n').replace(/\s+$/, '');
return { heading: h.heading, ...(canonical ? { canonical } : {}), body };
});
return { frontmatterText, frontmatter, frontmatterError, marker, preamble: preambleLines.join('\n').trim(), sections };
}
/**
* The `## ` headings of a body, with code fences skipped. The one section-
* boundary rule, shared by parseDesignMd and spliceSection so they cannot drift.
* An unclosed fence is treated as prose (fence tracking off for that file):
* a stray ``` must never swallow every later section of a file gstack edits.
*/
function headingLines(lines: string[]): Array<{ index: number; heading: string }> {
const fences = lines.filter(l => /^```/.test(l)).length;
const trackFences = fences % 2 === 0;
const out: Array<{ index: number; heading: string }> = [];
let inFence = false;
for (let i = 0; i < lines.length; i++) {
if (trackFences && /^```/.test(lines[i])) { inFence = !inFence; continue; }
if (inFence) continue;
const h = lines[i].match(/^## (.+?)\s*$/);
if (h) out.push({ index: i, heading: h[1] });
}
return out;
}
/** Does a section heading name the requested section? By canonical name when the request has one, else by exact (case-insensitive) heading. */
function headingMatches(heading: string, wanted: string, canonical: CanonicalSection | null): boolean {
return canonical ? canonicalFor(heading) === canonical : heading.trim().toLowerCase() === wanted.trim().toLowerCase();
}
/** The file's dominant line ending; text-level editors restore it so a CRLF file stays CRLF. */
function eolOf(text: string): string {
return text.includes('\r\n') ? '\r\n' : '\n';
}
// ── 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; code: FormatCode; reason?: string } {
if (!doc) return { format: 'missing', code: 'missing' };
if (doc.frontmatterText !== null && doc.frontmatter === null) {
return { format: 'unknown', code: 'frontmatter-unparsable', reason: `front matter does not parse: ${doc.frontmatterError ?? 'unknown error'}` };
}
const spec = hasSpecFrontmatter(doc);
const identity = new Set<string>(LEGACY_IDENTITY_HEADINGS.map(h => h.toLowerCase()));
const legacyHeadings = doc.sections.some(s => identity.has(s.heading.trim().toLowerCase()));
if (spec && legacyHeadings) return { format: 'unknown', code: 'ambiguous', reason: 'ambiguous (legacy headings and front matter both present)' };
if (spec) return { format: 'spec', code: 'spec' };
if (isLegacyGstackFormat(doc)) return { format: 'legacy', code: 'legacy' };
if (doc.frontmatterText !== null) return { format: 'unknown', code: 'no-token-groups', reason: 'front matter carries none of the five token groups' };
return { format: 'unknown', code: 'no-shape', reason: 'no front matter and no gstack legacy headings' };
}
// ── YAML block emitter ───────────────────────────────────────────────────────
function needsQuotes(s: string): boolean {
// Control characters (an LLM-extracted font family with an embedded newline) must
// go through the double-quoted form: a bare multi-line scalar does not parse back.
return s === '' || /[\x00-\x1f\x7f]/.test(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(YAML_MARKER_LINE_RE, '');
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);
}
// Spec order is a spec-file property. A legacy or unknown file keeps its own
// order (Typography and Layout are canonical names, but re-sorting a file the
// user chose to keep legacy would rewrite it behind their back).
const specShaped = fm !== null;
const ordered = specShaped
? [
...CANONICAL_SECTIONS.map(c => doc.sections.find(s => s.canonical === c)).filter((s): s is Section => Boolean(s)),
...doc.sections.filter(s => !s.canonical),
]
: doc.sections;
for (const s of ordered) {
parts.push('', `## ${specShaped ? (s.canonical ?? s.heading) : s.heading}`);
if (s.body.trim()) parts.push('', s.body.trim());
}
return parts.join('\n').replace(/^\n+/, '') + '\n';
}
/**
* Text-level section splice: replace the body of `## <heading>` (matched by
* canonical name or exact heading) or append the section at the end. Every other
* byte of the file, front matter included, is untouched. This is what a tool
* that edits a file the user owns should use; renderDesignMd is for files gstack
* writes from scratch (convert, skeletons).
*/
export function spliceSection(text: string, heading: string, body: string): string {
const eol = eolOf(text);
const src = text.replace(/\r\n/g, '\n');
const canonical = canonicalFor(heading);
const lines = src.split('\n');
const heads = headingLines(lines);
const k = heads.findIndex(h => headingMatches(h.heading, heading, canonical));
const block = `## ${canonical ?? heading}\n\n${body.replace(/\s+$/, '')}\n`;
let out: string;
if (k === -1) {
out = src.replace(/\s*$/, '') + '\n\n' + block;
} else {
const start = heads[k].index;
const end = heads[k + 1]?.index ?? lines.length;
const before = lines.slice(0, start).join('\n');
const after = lines.slice(end).join('\n');
out = before + (before ? '\n' : '') + block + (after.trim() ? '\n' + after.replace(/^\n+/, '') : '');
}
return eol === '\n' ? out : out.replace(/\n/g, eol);
}
/**
* Text-level marker insertion: a YAML comment on line 2 of a file that opens
* with front matter, an HTML comment on line 1 otherwise. Replaces an existing
* marker; every other byte is untouched.
*/
export function insertMarker(text: string, choice: FormatChoice): string {
const eol = eolOf(text);
const src = text.replace(/\r\n/g, '\n');
const stripped = src.replace(LEGACY_MARKER_RE, '');
let out: string;
if (stripped.startsWith('---\n')) {
const withoutOld = stripped.replace(FRONTMATTER_OPEN_WITH_MARKER_RE, '---\n');
out = withoutOld.replace(/^---\n/, `---\n# ${FORMAT_MARKER_PREFIX}${choice}\n`);
} else {
out = `<!-- ${FORMAT_MARKER_PREFIX}${choice} -->\n` + stripped;
}
return eol === '\n' ? out : out.replace(/\n/g, eol);
}
/** 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 => headingMatches(s.heading, heading, canonical));
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 };
}
// ── 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 < TOKEN_REF_MAX_HOPS) {
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 ────────────────────────────────────────────────────────
/** kebab-case token key from a human label */
export 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([...LEGACY_IDENTITY_HEADINGS.map(h => h.toLowerCase()), '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: doc.preamble, // the title line and any intro prose under it survive verbatim
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);
}