mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d3c3ea27b9
commit
e49d1eea49
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* gstack-design-md — inspect, convert, and read DESIGN.md in the open format.
|
||||
*
|
||||
* bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts check [DESIGN.md]
|
||||
* bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts convert [DESIGN.md] [--write]
|
||||
* bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts tokens [DESIGN.md]
|
||||
* bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts mark <spec|legacy-keep> [DESIGN.md]
|
||||
*
|
||||
* check DESIGN_MD_FORMAT: spec | legacy | unknown | missing (+ DESIGN_MD_REASON for unknown),
|
||||
* DESIGN_MD_MARKER: spec | legacy-keep | none. Exit 0.
|
||||
* convert Legacy → spec (lib/design-md.ts convertLegacy). Prints the result; with --write,
|
||||
* backs the original up to DESIGN.md.legacy.bak and writes temp+rename. Refuses an
|
||||
* ambiguous file (DESIGN_MD_CONVERT_REFUSED, exit 2) and a non-legacy one (exit 1).
|
||||
* tokens Flat token map as JSON ({"colors.primary": "#F59E0B", ...}); {path} refs resolved;
|
||||
* invalid refs listed under "errors" (DESIGN_MD_TOKEN_REF_INVALID). Exit 0.
|
||||
* mark Persist the user's one-time format choice inside the file: spec files get a YAML
|
||||
* comment on line 2, legacy files an HTML comment on line 1. Body bytes untouched.
|
||||
*
|
||||
* Exit 3 + DESIGN_MD_INTERNAL_ERROR is a gstack bug. YAML errors never propagate: a file whose
|
||||
* front matter does not parse is `unknown` with a reason.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { SENTINEL } from '../lib/design-detect-contract';
|
||||
import {
|
||||
parseDesignMd, detectFormat, convertLegacy, renderDesignMd, tokensFlat, setMarker,
|
||||
type DesignMdDoc, type FormatChoice,
|
||||
} from '../lib/design-md';
|
||||
|
||||
function resolveFile(arg?: string): string {
|
||||
return path.resolve(arg && !arg.startsWith('--') ? arg : 'DESIGN.md');
|
||||
}
|
||||
|
||||
function load(file: string): { text: string; doc: DesignMdDoc } | null {
|
||||
try { const text = fs.readFileSync(file, 'utf-8'); return { text, doc: parseDesignMd(text) }; } catch { return null; }
|
||||
}
|
||||
|
||||
function writeAtomic(file: string, content: string) {
|
||||
const tmp = `${file}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tmp, content);
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)): number {
|
||||
const verb = argv[0] ?? '';
|
||||
const flags = new Set(argv.filter(a => a.startsWith('--')));
|
||||
const positional = argv.slice(1).filter(a => !a.startsWith('--'));
|
||||
|
||||
switch (verb) {
|
||||
case 'check': {
|
||||
const file = resolveFile(positional[0]);
|
||||
const loaded = load(file);
|
||||
const { format, reason } = detectFormat(loaded?.doc ?? null);
|
||||
process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: ${format}\n`);
|
||||
if (reason) process.stdout.write(`DESIGN_MD_REASON: ${reason}\n`);
|
||||
process.stdout.write(`DESIGN_MD_MARKER: ${loaded?.doc.marker ?? 'none'}\n`);
|
||||
return 0;
|
||||
}
|
||||
case 'convert': {
|
||||
const file = resolveFile(positional[0]);
|
||||
const loaded = load(file);
|
||||
const { format, reason } = detectFormat(loaded?.doc ?? null);
|
||||
if (format === 'unknown' && reason?.startsWith('ambiguous')) {
|
||||
process.stderr.write(`${SENTINEL.DESIGN_MD_CONVERT_REFUSED}: ${reason}\n`);
|
||||
return 2;
|
||||
}
|
||||
if (format !== 'legacy' || !loaded) {
|
||||
process.stderr.write(`${SENTINEL.DESIGN_MD_FORMAT}: ${format}${reason ? ` (${reason})` : ''}; convert only accepts a legacy gstack DESIGN.md\n`);
|
||||
return 1;
|
||||
}
|
||||
const out = renderDesignMd(convertLegacy(loaded.doc), { emitFrontmatter: true });
|
||||
if (flags.has('--write')) {
|
||||
fs.writeFileSync(`${file}.legacy.bak`, loaded.text);
|
||||
writeAtomic(file, out);
|
||||
process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: spec\nDESIGN_MD_WRITTEN: ${file}\nDESIGN_MD_BACKUP: ${file}.legacy.bak\n`);
|
||||
} else {
|
||||
process.stdout.write(out);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case 'tokens': {
|
||||
const file = resolveFile(positional[0]);
|
||||
const loaded = load(file);
|
||||
const flat = tokensFlat(loaded?.doc.frontmatter ?? null);
|
||||
process.stdout.write(JSON.stringify({ file, format: detectFormat(loaded?.doc ?? null).format, ...flat }, null, 2) + '\n');
|
||||
for (const e of flat.errors) process.stderr.write(e + '\n');
|
||||
return 0;
|
||||
}
|
||||
case 'mark': {
|
||||
const choice = positional[0] as FormatChoice | undefined;
|
||||
if (choice !== 'spec' && choice !== 'legacy-keep') {
|
||||
process.stderr.write('usage: gstack-design-md.ts mark <spec|legacy-keep> [DESIGN.md]\n');
|
||||
return 2;
|
||||
}
|
||||
const file = resolveFile(positional[1]);
|
||||
const loaded = load(file);
|
||||
if (!loaded) { process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: missing\n`); return 1; }
|
||||
writeAtomic(file, renderDesignMd(setMarker(loaded.doc, choice)));
|
||||
process.stdout.write(`DESIGN_MD_MARKER: ${choice}\n`);
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
process.stderr.write('usage: gstack-design-md.ts check [file] | convert [file] [--write] | tokens [file] | mark <spec|legacy-keep> [file]\n');
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
process.stderr.write(`${SENTINEL.DESIGN_MD_INTERNAL_ERROR}: ${e?.name ?? 'Error'}: ${String(e?.message ?? e).slice(0, 300)}\n`);
|
||||
process.exitCode = 3;
|
||||
}
|
||||
}
|
||||
+52
-21
@@ -15,6 +15,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { requireApiKey } from "./auth";
|
||||
import { receiptedFetch } from "./receipted-fetch";
|
||||
import { parseDesignMd, detectFormat, renderDesignMd, upsertSection, specSkeleton, tokensFlat } from "../../lib/design-md";
|
||||
|
||||
export interface ExtractedDesign {
|
||||
colors: { name: string; hex: string; usage: string }[];
|
||||
@@ -100,8 +101,13 @@ function defaultDesign(): ExtractedDesign {
|
||||
|
||||
/**
|
||||
* Write or update DESIGN.md with extracted design patterns.
|
||||
* If DESIGN.md exists, appends an "Extracted from mockup" section.
|
||||
* If not, creates a new one.
|
||||
*
|
||||
* Existing file (spec, legacy, or anything with `##` sections): the
|
||||
* "## Extracted Design Language" section is upserted through lib/design-md.ts,
|
||||
* which splices the body only — front matter bytes are never re-emitted, and a
|
||||
* spec file keeps its canonical section order (the extracted section is an extra
|
||||
* after them). New file: a spec-format skeleton whose tokens come from the
|
||||
* extraction (colors by name, typography by role) plus the extracted section.
|
||||
*/
|
||||
export function updateDesignMd(
|
||||
repoRoot: string,
|
||||
@@ -110,30 +116,46 @@ export function updateDesignMd(
|
||||
): void {
|
||||
const designPath = path.join(repoRoot, "DESIGN.md");
|
||||
const timestamp = new Date().toISOString().split("T")[0];
|
||||
|
||||
const section = formatExtractedSection(extracted, sourceMockup, timestamp);
|
||||
const heading = "Extracted Design Language";
|
||||
const body = section.split("\n").slice(1).join("\n"); // drop the "## Extracted Design Language" line
|
||||
|
||||
const write = (content: string) => {
|
||||
const tmp = `${designPath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tmp, content);
|
||||
fs.renameSync(tmp, designPath);
|
||||
};
|
||||
|
||||
if (fs.existsSync(designPath)) {
|
||||
// Append to existing DESIGN.md
|
||||
const existing = fs.readFileSync(designPath, "utf-8");
|
||||
|
||||
// Check if there's already an extracted section, replace it
|
||||
const marker = "## Extracted Design Language";
|
||||
if (existing.includes(marker)) {
|
||||
const before = existing.split(marker)[0];
|
||||
fs.writeFileSync(designPath, before.trimEnd() + "\n\n" + section);
|
||||
} else {
|
||||
fs.writeFileSync(designPath, existing.trimEnd() + "\n\n" + section);
|
||||
}
|
||||
const doc = parseDesignMd(fs.readFileSync(designPath, "utf-8"));
|
||||
write(renderDesignMd(upsertSection(doc, heading, body)));
|
||||
console.error(`Updated DESIGN.md with extracted design language`);
|
||||
} else {
|
||||
// Create new DESIGN.md
|
||||
const content = `# Design System
|
||||
|
||||
${section}`;
|
||||
fs.writeFileSync(designPath, content);
|
||||
console.error(`Created DESIGN.md with extracted design language`);
|
||||
return;
|
||||
}
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
for (const c of extracted.colors) {
|
||||
const key = c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
if (key && /^#[0-9a-fA-F]{3,8}$/.test(c.hex) && !(key in colors)) colors[key] = c.hex;
|
||||
}
|
||||
const typography: Record<string, Record<string, string>> = {};
|
||||
for (const t of extracted.typography) {
|
||||
const role = t.role.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
if (!role || typography[role]) continue;
|
||||
const entry: Record<string, string> = { fontFamily: t.family };
|
||||
if (t.size) entry.fontSize = t.size;
|
||||
if (t.weight) entry.fontWeight = t.weight;
|
||||
typography[role] = entry;
|
||||
}
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
if (Object.keys(colors).length) frontmatter.colors = colors;
|
||||
if (Object.keys(typography).length) frontmatter.typography = typography;
|
||||
const doc = specSkeleton("Design System", frontmatter, [
|
||||
{ heading: "Overview", body: `${extracted.mood}\n\nCreated by the gstack designer from an approved mockup (${path.basename(sourceMockup)}) on ${timestamp}.` },
|
||||
{ heading, body },
|
||||
]);
|
||||
write(renderDesignMd(doc));
|
||||
console.error(`Created DESIGN.md with extracted design language`);
|
||||
}
|
||||
|
||||
function formatExtractedSection(
|
||||
@@ -198,6 +220,15 @@ export function readDesignConstraints(repoRoot: string): string | null {
|
||||
if (!fs.existsSync(designPath)) return null;
|
||||
|
||||
const content = fs.readFileSync(designPath, "utf-8");
|
||||
const doc = parseDesignMd(content);
|
||||
if (detectFormat(doc).format === "spec") {
|
||||
// Spec file: the normative tokens first, then the Overview prose. Both fit
|
||||
// the brief far better than the first 2000 bytes of YAML would.
|
||||
const { tokens } = tokensFlat(doc.frontmatter);
|
||||
const tokenLines = Object.entries(tokens).map(([k, v]) => `${k}: ${v}`).join("; ");
|
||||
const overview = doc.sections.find((s) => s.canonical === "Overview")?.body ?? "";
|
||||
return `Tokens: ${tokenLines}. ${overview}`.slice(0, 2000);
|
||||
}
|
||||
// Truncate to first 2000 chars to keep brief reasonable
|
||||
return content.slice(0, 2000);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
// 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",
|
||||
"do’s and don’ts": "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);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* lib/design-md.ts + bin/gstack-design-md.ts + design/src/memory.ts (DESIGN.md writer).
|
||||
*
|
||||
* Pins the open DESIGN.md format rules gstack depends on: eight canonical
|
||||
* sections in order, only the five token groups in front matter, `{path}`
|
||||
* references resolving to primitives, extras surviving a round trip, the
|
||||
* format marker's placement (YAML comment on line 2 for spec files, HTML
|
||||
* comment on line 1 for legacy), body-only upserts that never re-emit front
|
||||
* matter bytes, and a legacy → spec conversion of gstack's own DESIGN.md.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
parseDesignMd, detectFormat, renderDesignMd, upsertSection, convertLegacy, tokensFlat,
|
||||
emitYamlBlock, setMarker, specSkeleton, CANONICAL_SECTIONS, TOKEN_GROUPS, isLegacyGstackFormat,
|
||||
} from '../lib/design-md';
|
||||
import { updateDesignMd, readDesignConstraints } from '../design/src/memory';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-design-md.ts');
|
||||
const LEGACY = fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf-8');
|
||||
|
||||
const SPEC = `---
|
||||
# gstack: design-md-format=spec
|
||||
name: Heritage
|
||||
colors:
|
||||
primary: "#1A1C1E"
|
||||
accent: "#B8422E"
|
||||
cta: "{colors.accent}"
|
||||
typography:
|
||||
display:
|
||||
fontFamily: Public Sans
|
||||
fontSize: 3rem
|
||||
rounded:
|
||||
md: 8px
|
||||
spacing:
|
||||
md: 16px
|
||||
components:
|
||||
button-primary:
|
||||
backgroundColor: "{colors.cta}"
|
||||
textColor: "{colors.primary}"
|
||||
---
|
||||
|
||||
# Heritage
|
||||
|
||||
## Overview
|
||||
|
||||
Architectural minimalism.
|
||||
|
||||
## Colors
|
||||
|
||||
Ink and clay.
|
||||
|
||||
## Typography
|
||||
|
||||
Public Sans everywhere.
|
||||
|
||||
## Motion
|
||||
|
||||
One authored moment.
|
||||
|
||||
## Decisions Log
|
||||
|
||||
| Date | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 2026-09-08 | spec format | portable |
|
||||
`;
|
||||
|
||||
describe('parse + detect', () => {
|
||||
test('spec file: front matter bytes preserved, marker read from line 2, sections classified', () => {
|
||||
const doc = parseDesignMd(SPEC);
|
||||
expect(doc.marker).toBe('spec');
|
||||
expect(doc.frontmatter?.name).toBe('Heritage');
|
||||
expect(doc.frontmatterText).toContain('primary: "#1A1C1E"');
|
||||
expect(doc.preamble).toBe('# Heritage');
|
||||
expect(doc.sections.map(s => s.canonical ?? s.heading)).toEqual(['Overview', 'Colors', 'Typography', 'Motion', 'Decisions Log']);
|
||||
expect(detectFormat(doc)).toEqual({ format: 'spec' });
|
||||
});
|
||||
|
||||
test("gstack's own DESIGN.md is legacy; a fresh file is unknown; nothing is missing", () => {
|
||||
const doc = parseDesignMd(LEGACY);
|
||||
expect(isLegacyGstackFormat(doc)).toBe(true);
|
||||
expect(detectFormat(doc)).toEqual({ format: 'legacy' });
|
||||
expect(detectFormat(parseDesignMd('# Hello\n\nJust prose.\n')).format).toBe('unknown');
|
||||
expect(detectFormat(null)).toEqual({ format: 'missing' });
|
||||
});
|
||||
|
||||
test('malformed front matter is unknown with a reason, never a throw', () => {
|
||||
const doc = parseDesignMd('---\ncolors: [unclosed\n---\n\n## Overview\n\nx\n');
|
||||
expect(doc.frontmatter).toBeNull();
|
||||
const d = detectFormat(doc);
|
||||
expect(d.format).toBe('unknown');
|
||||
expect(d.reason).toMatch(/front matter does not parse/);
|
||||
});
|
||||
|
||||
test('legacy headings plus front matter is ambiguous', () => {
|
||||
const d = detectFormat(parseDesignMd('---\nname: x\ncolors:\n a: "#fff"\n---\n\n## Product Context\n\nx\n\n## Aesthetic Direction\n\ny\n'));
|
||||
expect(d.format).toBe('unknown');
|
||||
expect(d.reason).toMatch(/^ambiguous/);
|
||||
});
|
||||
|
||||
test('a ## inside a code fence is not a section', () => {
|
||||
const doc = parseDesignMd('## Overview\n\n```md\n## Not a section\n```\n\n## Colors\n\nx\n');
|
||||
expect(doc.sections.map(s => s.heading)).toEqual(['Overview', 'Colors']);
|
||||
});
|
||||
|
||||
test('legacy marker on line 1 is read and survives a render', () => {
|
||||
const doc = parseDesignMd('<!-- gstack: design-md-format=legacy-keep -->\n# Design System — X\n\n## Product Context\n\n- a\n\n## Color\n\n- **Primary:** #fff\n');
|
||||
expect(doc.marker).toBe('legacy-keep');
|
||||
const out = renderDesignMd(doc);
|
||||
expect(out.split('\n')[0]).toBe('<!-- gstack: design-md-format=legacy-keep -->');
|
||||
expect(out).toContain('# Design System — X');
|
||||
});
|
||||
});
|
||||
|
||||
describe('render + upsert', () => {
|
||||
test('round trip is stable and keeps canonical order with extras after', () => {
|
||||
const once = renderDesignMd(parseDesignMd(SPEC));
|
||||
expect(renderDesignMd(parseDesignMd(once))).toBe(once);
|
||||
const headings = [...once.matchAll(/^## (.+)$/gm)].map(m => m[1]);
|
||||
expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Motion', 'Decisions Log']);
|
||||
expect(once.split('\n')[0]).toBe('---');
|
||||
expect(once.split('\n')[1]).toBe('# gstack: design-md-format=spec');
|
||||
});
|
||||
|
||||
test('canonical sections re-sort into spec order when the file had them shuffled', () => {
|
||||
const shuffled = '---\nname: x\ncolors:\n a: "#fff"\n---\n\n## Typography\n\nt\n\n## Overview\n\no\n\n## Shapes\n\ns\n\n## Colors\n\nc\n\n## Custom\n\nz\n';
|
||||
const out = renderDesignMd(parseDesignMd(shuffled));
|
||||
const headings = [...out.matchAll(/^## (.+)$/gm)].map(m => m[1]);
|
||||
expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Shapes', 'Custom']);
|
||||
const order = headings.filter(h => (CANONICAL_SECTIONS as readonly string[]).includes(h)).map(h => CANONICAL_SECTIONS.indexOf(h as any));
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
});
|
||||
|
||||
test('aliases map to canonical names (Brand & Style → Overview, Elevation → Elevation & Depth)', () => {
|
||||
const doc = parseDesignMd('## Brand & Style\n\nx\n\n## Elevation\n\ny\n');
|
||||
expect(doc.sections.map(s => s.canonical)).toEqual(['Overview', 'Elevation & Depth']);
|
||||
expect(renderDesignMd(doc)).toContain('## Elevation & Depth');
|
||||
});
|
||||
|
||||
test('upsertSection splices the body only: front matter bytes are identical before and after', () => {
|
||||
const doc = parseDesignMd(SPEC);
|
||||
const next = upsertSection(upsertSection(doc, 'Colors', 'Ink, clay, and one more.'), 'Extracted Design Language', 'from a mockup');
|
||||
const out = renderDesignMd(next);
|
||||
const fmBefore = SPEC.slice(0, SPEC.indexOf('\n---\n', 4) + 5);
|
||||
expect(out.startsWith(fmBefore)).toBe(true);
|
||||
expect(out).toContain('## Colors\n\nInk, clay, and one more.');
|
||||
const headings = [...out.matchAll(/^## (.+)$/gm)].map(m => m[1]);
|
||||
expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Motion', 'Decisions Log', 'Extracted Design Language']);
|
||||
});
|
||||
|
||||
test('setMarker on a spec file writes the YAML comment on line 2 and nothing else moves', () => {
|
||||
const noMarker = SPEC.replace('# gstack: design-md-format=spec\n', '');
|
||||
const out = renderDesignMd(setMarker(parseDesignMd(noMarker), 'spec'));
|
||||
expect(out.split('\n').slice(0, 3)).toEqual(['---', '# gstack: design-md-format=spec', 'name: Heritage']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokens', () => {
|
||||
test('flattens the five groups and resolves {path} references to primitives', () => {
|
||||
const { tokens, errors } = tokensFlat(parseDesignMd(SPEC).frontmatter);
|
||||
expect(errors).toEqual([]);
|
||||
expect(tokens['colors.primary']).toBe('#1A1C1E');
|
||||
expect(tokens['colors.cta']).toBe('#B8422E');
|
||||
expect(tokens['components.button-primary.backgroundColor']).toBe('#B8422E');
|
||||
expect(tokens['components.button-primary.textColor']).toBe('#1A1C1E');
|
||||
expect(tokens['typography.display.fontSize']).toBe('3rem');
|
||||
expect(Object.keys(tokens).every(k => TOKEN_GROUPS.some(g => k.startsWith(g + '.')))).toBe(true);
|
||||
expect('name' in tokens).toBe(false);
|
||||
});
|
||||
|
||||
test('group refs, self refs, and dangling refs are DESIGN_MD_TOKEN_REF_INVALID', () => {
|
||||
const fm = { colors: { a: '#111', group: '{colors}', self: '{colors.self}', gone: '{colors.nope}' }, components: { btn: { bg: '{colors}' } } };
|
||||
const { tokens, errors } = tokensFlat(fm);
|
||||
expect(tokens['colors.a']).toBe('#111');
|
||||
expect(errors.filter(e => e.startsWith('DESIGN_MD_TOKEN_REF_INVALID: ')).length).toBe(4);
|
||||
expect(errors.join('\n')).toContain('{colors} (refers to a group');
|
||||
expect(errors.join('\n')).toContain('{colors.self} (self-reference)');
|
||||
expect(errors.join('\n')).toContain('{colors.nope} (no such token)');
|
||||
});
|
||||
|
||||
test('emitYamlBlock writes block style that Bun.YAML parses back identically', () => {
|
||||
const obj = { name: 'X: y', colors: { primary: '#fff', 'on-primary': '#000', weird: 'yes' }, spacing: { '2xs': '2px', md: 16 }, list: ['a', 'b'] };
|
||||
const yaml = emitYamlBlock(obj);
|
||||
expect(yaml).not.toContain('{');
|
||||
expect(yaml).toContain('colors:\n primary: "#fff"');
|
||||
expect((Bun as any).YAML.parse(yaml)).toEqual(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertLegacy on gstack's own DESIGN.md", () => {
|
||||
const converted = convertLegacy(parseDesignMd(LEGACY));
|
||||
const out = renderDesignMd(converted, { emitFrontmatter: true });
|
||||
|
||||
test('produces a spec file with the marker on line 2 and only the five token groups plus name', () => {
|
||||
const doc = parseDesignMd(out);
|
||||
expect(detectFormat(doc)).toEqual({ format: 'spec' });
|
||||
expect(out.split('\n')[1]).toBe('# gstack: design-md-format=spec');
|
||||
for (const k of Object.keys(doc.frontmatter!)) expect(['name', ...TOKEN_GROUPS]).toContain(k);
|
||||
expect(doc.frontmatter!.name).toBe('gstack');
|
||||
});
|
||||
|
||||
test('maps roles, colors, spacing, and radii into tokens', () => {
|
||||
const { tokens, errors } = tokensFlat(parseDesignMd(out).frontmatter);
|
||||
expect(errors).toEqual([]);
|
||||
expect(tokens['typography.display.fontFamily']).toBe('Satoshi');
|
||||
expect(tokens['typography.body.fontFamily']).toBe('DM Sans');
|
||||
expect(tokens['typography.label.fontFamily']).toBe('DM Sans');
|
||||
expect(tokens['typography.mono.fontFamily']).toBe('JetBrains Mono');
|
||||
expect(tokens['typography.mono.fontFeature']).toBe('tnum');
|
||||
expect(tokens['colors.primary-dark-mode']).toBe('#F59E0B');
|
||||
expect(tokens['colors.primary-light-mode']).toBe('#D97706');
|
||||
expect(tokens['colors.success']).toBe('#22C55E');
|
||||
expect(tokens['colors.semantic']).toBeUndefined();
|
||||
expect(tokens['spacing.md']).toBe('16px');
|
||||
expect(tokens['spacing.2xs']).toBe('2px');
|
||||
expect(tokens['rounded.lg']).toBe('12px');
|
||||
expect(tokens['rounded.full']).toBe('9999px');
|
||||
});
|
||||
|
||||
test('folds Product Context and Aesthetic Direction into Overview; Motion, Grain Texture, Decisions Log survive as extras in order', () => {
|
||||
const headings = [...out.matchAll(/^## (.+)$/gm)].map(m => m[1]);
|
||||
expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Layout', 'Motion', 'Grain Texture', 'Decisions Log']);
|
||||
expect(out).toContain('**What this is:**');
|
||||
expect(out).toContain('**Direction:** Industrial/Utilitarian');
|
||||
expect(out).toContain('| 2026-03-21 | Grain texture |');
|
||||
expect(out).toContain('### Spacing');
|
||||
});
|
||||
|
||||
test('re-rendering the converted file is stable (idempotent write)', () => {
|
||||
expect(renderDesignMd(parseDesignMd(out))).toBe(out);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bin/gstack-design-md.ts', () => {
|
||||
const run = (args: string[], cwd: string) => {
|
||||
const r = spawnSync(process.execPath, ['--no-env-file', 'run', BIN, ...args], { cwd, encoding: 'utf-8', timeout: 60_000 });
|
||||
return { code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' };
|
||||
};
|
||||
|
||||
test('check reports format + marker for spec, legacy, unknown, missing', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-'));
|
||||
try {
|
||||
expect(run(['check'], dir).out).toContain('DESIGN_MD_FORMAT: missing');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), SPEC);
|
||||
expect(run(['check'], dir).out).toBe('DESIGN_MD_FORMAT: spec\nDESIGN_MD_MARKER: spec\n');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), LEGACY);
|
||||
expect(run(['check'], dir).out).toBe('DESIGN_MD_FORMAT: legacy\nDESIGN_MD_MARKER: none\n');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), '---\n: bad: [\n---\n');
|
||||
const bad = run(['check'], dir);
|
||||
expect(bad.out).toContain('DESIGN_MD_FORMAT: unknown');
|
||||
expect(bad.out).toContain('DESIGN_MD_REASON: front matter does not parse');
|
||||
expect(bad.code).toBe(0);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('convert --write backs up, writes atomically, refuses ambiguous and non-legacy input', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), LEGACY);
|
||||
const dry = run(['convert'], dir);
|
||||
expect(dry.code).toBe(0);
|
||||
expect(dry.out.split('\n')[1]).toBe('# gstack: design-md-format=spec');
|
||||
expect(fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8')).toBe(LEGACY);
|
||||
const wr = run(['convert', '--write'], dir);
|
||||
expect(wr.code).toBe(0);
|
||||
expect(wr.out).toContain('DESIGN_MD_WRITTEN:');
|
||||
expect(fs.readFileSync(path.join(dir, 'DESIGN.md.legacy.bak'), 'utf-8')).toBe(LEGACY);
|
||||
expect(run(['check'], dir).out).toContain('DESIGN_MD_FORMAT: spec');
|
||||
expect(fs.readdirSync(dir).some(f => f.includes('.tmp-'))).toBe(false);
|
||||
// already spec → refused as non-legacy (exit 1), not clobbered
|
||||
const again = run(['convert', '--write'], dir);
|
||||
expect(again.code).toBe(1);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), '---\nname: x\ncolors:\n a: "#fff"\n---\n\n## Product Context\n\nx\n\n## Aesthetic Direction\n\ny\n');
|
||||
const amb = run(['convert', '--write'], dir);
|
||||
expect(amb.code).toBe(2);
|
||||
expect(amb.err).toContain('DESIGN_MD_CONVERT_REFUSED: ambiguous');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('tokens prints the flat map; mark persists the choice', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), SPEC);
|
||||
const t = JSON.parse(run(['tokens'], dir).out);
|
||||
expect(t.tokens['colors.cta']).toBe('#B8422E');
|
||||
expect(t.errors).toEqual([]);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), LEGACY);
|
||||
const m = run(['mark', 'legacy-keep'], dir);
|
||||
expect(m.code).toBe(0);
|
||||
const text = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8');
|
||||
expect(text.split('\n')[0]).toBe('<!-- gstack: design-md-format=legacy-keep -->');
|
||||
expect(run(['check'], dir).out).toBe('DESIGN_MD_FORMAT: legacy\nDESIGN_MD_MARKER: legacy-keep\n');
|
||||
expect(run(['mark', 'maybe'], dir).code).toBe(2);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('design binary: updateDesignMd is frontmatter-safe', () => {
|
||||
const extracted = {
|
||||
colors: [{ name: 'Primary', hex: '#F59E0B', usage: 'buttons' }, { name: 'Surface', hex: '#141414', usage: 'cards' }],
|
||||
typography: [{ role: 'heading', family: 'Satoshi', size: '48px', weight: '900' }],
|
||||
spacing: ['8px base unit'],
|
||||
layout: ['max-width 1200px'],
|
||||
mood: 'Serious tool built with care.',
|
||||
};
|
||||
|
||||
test('spec input: section appended after the canonical ones, front matter bytes untouched, replaces on rerun', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), SPEC);
|
||||
updateDesignMd(dir, extracted, '/tmp/mock.png');
|
||||
const once = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8');
|
||||
expect(once.startsWith(SPEC.slice(0, SPEC.indexOf('\n---\n', 4) + 5))).toBe(true);
|
||||
expect([...once.matchAll(/^## (.+)$/gm)].map(m => m[1]).at(-1)).toBe('Extracted Design Language');
|
||||
expect(once.split('## Extracted Design Language').length - 1).toBe(1);
|
||||
updateDesignMd(dir, { ...extracted, mood: 'second pass' }, '/tmp/mock2.png');
|
||||
const twice = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8');
|
||||
expect(twice.split('## Extracted Design Language').length - 1).toBe(1);
|
||||
expect(twice).toContain('second pass');
|
||||
expect(twice).not.toContain('Serious tool built with care.');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('legacy input: sections preserved, extracted section added at the end', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), LEGACY);
|
||||
updateDesignMd(dir, extracted, '/tmp/mock.png');
|
||||
const out = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8');
|
||||
expect(out.startsWith('# Design System — gstack')).toBe(true);
|
||||
expect(out).toContain('## Decisions Log');
|
||||
expect([...out.matchAll(/^## (.+)$/gm)].map(m => m[1]).at(-1)).toBe('Extracted Design Language');
|
||||
expect(detectFormat(parseDesignMd(out)).format).toBe('legacy');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('absent input: a spec skeleton with tokens from the extraction; readDesignConstraints leads with tokens', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-'));
|
||||
try {
|
||||
updateDesignMd(dir, extracted, '/tmp/mock.png');
|
||||
const out = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8');
|
||||
const doc = parseDesignMd(out);
|
||||
expect(detectFormat(doc).format).toBe('spec');
|
||||
expect(out.split('\n').slice(0, 2)).toEqual(['---', '# gstack: design-md-format=spec']);
|
||||
const { tokens } = tokensFlat(doc.frontmatter);
|
||||
expect(tokens['colors.primary']).toBe('#F59E0B');
|
||||
expect(tokens['typography.heading.fontFamily']).toBe('Satoshi');
|
||||
expect([...out.matchAll(/^## (.+)$/gm)].map(m => m[1])).toEqual(['Overview', 'Extracted Design Language']);
|
||||
const constraints = readDesignConstraints(dir)!;
|
||||
expect(constraints.startsWith('Tokens: colors.primary: #F59E0B')).toBe(true);
|
||||
expect(constraints).toContain('Serious tool built with care.');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user