mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(daemon): parse DESIGN.md YAML frontmatter for presets (#2110)
* feat(daemon): parse DESIGN.md YAML frontmatter for presets Falls back to frontmatter name/description/category/surface behind existing Markdown heuristics; frontmatter `colors` wins over Markdown swatches. Closes #1857 Contract: runs/2026-05-18T08-09-54_open-design_issue-feat/contract.md * test(daemon): cover DESIGN.md frontmatter fallback chain * fix(daemon): keep Markdown swatch row when frontmatter colors miss slots Material Design 3 token names (`on-surface`, `outline`) in totality- festival's frontmatter left fg/support unmatched, regressing its picker swatches; fall back to Markdown when the frontmatter row would use hard- coded defaults. Refs: https://github.com/nexu-io/open-design/pull/2110#discussion_r3258871692
This commit is contained in:
parent
226bc57471
commit
477dd627b1
2 changed files with 323 additions and 18 deletions
|
|
@ -3,6 +3,11 @@
|
|||
// with only DESIGN.md remain valid. Without a manifest, title comes from the
|
||||
// first H1, category from a `> Category: <name>` blockquote line beneath the
|
||||
// H1, and summary from the first paragraph between the H1 and next heading.
|
||||
//
|
||||
// YAML frontmatter (Google spec, issue #1857): frontmatter `colors` wins
|
||||
// over Markdown swatches only when its row fills every semantic slot;
|
||||
// otherwise Markdown wins. Other fields (`name`/`description`/`category`/
|
||||
// `surface`) fall back to frontmatter when the body has none.
|
||||
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
|
@ -12,6 +17,9 @@ import {
|
|||
summarizeComponentsManifestForPrompt,
|
||||
} from '@open-design/contracts';
|
||||
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
import type { FrontmatterObject, FrontmatterValue } from './frontmatter.js';
|
||||
|
||||
export type DesignSystemSurface = 'web' | 'image' | 'video' | 'audio';
|
||||
|
||||
export type DesignSystemSummary = {
|
||||
|
|
@ -25,6 +33,7 @@ export type DesignSystemSummary = {
|
|||
};
|
||||
|
||||
type ColorToken = { name: string; value: string };
|
||||
type SwatchRow = { values: string[]; filledAllSlots: boolean };
|
||||
type DesignSystemProjectManifest = {
|
||||
schemaVersion: 'od-design-system-project/v1';
|
||||
id: string;
|
||||
|
|
@ -55,15 +64,34 @@ export async function listDesignSystems(root: string): Promise<DesignSystemSumma
|
|||
const stats = await stat(designPath);
|
||||
if (!stats.isFile()) continue;
|
||||
const raw = await readFile(designPath, 'utf8');
|
||||
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
|
||||
const title = manifest?.name ?? cleanTitle(titleMatch?.[1] ?? entry.name);
|
||||
const { data: frontmatter, body } = parseFrontmatter(raw);
|
||||
const titleMatch = /^#\s+(.+?)\s*$/m.exec(body);
|
||||
const markdownTitle =
|
||||
titleMatch?.[1] !== undefined ? cleanTitle(titleMatch[1]) : '';
|
||||
const localTitle =
|
||||
markdownTitle || stringField(frontmatter, 'name') || entry.name;
|
||||
const title = manifest?.name ?? localTitle;
|
||||
const markdownSummary = summarize(body);
|
||||
const markdownSwatches = extractSwatches(body);
|
||||
const frontmatterSwatchRow = swatchesFromFrontmatter(frontmatter);
|
||||
const swatches = pickFinalSwatchRow(frontmatterSwatchRow, markdownSwatches);
|
||||
out.push({
|
||||
id: entry.name,
|
||||
title,
|
||||
category: manifest?.category ?? extractCategory(raw) ?? 'Uncategorized',
|
||||
summary: manifest?.description?.trim() || summarize(raw),
|
||||
swatches: extractSwatches(raw),
|
||||
surface: extractSurface(raw),
|
||||
category:
|
||||
manifest?.category
|
||||
?? extractCategory(body)
|
||||
?? stringField(frontmatter, 'category')
|
||||
?? 'Uncategorized',
|
||||
summary:
|
||||
(manifest?.description?.trim() || markdownSummary)
|
||||
|| stringField(frontmatter, 'description')
|
||||
|| '',
|
||||
swatches,
|
||||
surface:
|
||||
extractSurface(body)
|
||||
?? frontmatterSurface(frontmatter)
|
||||
?? 'web',
|
||||
body: raw,
|
||||
});
|
||||
} catch {
|
||||
|
|
@ -73,6 +101,44 @@ export async function listDesignSystems(root: string): Promise<DesignSystemSumma
|
|||
return out;
|
||||
}
|
||||
|
||||
function stringField(data: FrontmatterObject, key: string): string {
|
||||
const v: FrontmatterValue | undefined = data[key];
|
||||
return typeof v === 'string' ? v.trim() : '';
|
||||
}
|
||||
|
||||
function frontmatterSurface(data: FrontmatterObject): DesignSystemSurface | undefined {
|
||||
const v = stringField(data, 'surface').toLowerCase();
|
||||
return isDesignSystemSurface(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function swatchesFromFrontmatter(data: FrontmatterObject): SwatchRow | null {
|
||||
const raw = data['colors'];
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
|
||||
const colors: ColorToken[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [name, value] of Object.entries(raw)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const hex = normalizeHex(value);
|
||||
if (!hex) continue;
|
||||
const cleanName = name.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
const key = `${cleanName}|${hex}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
colors.push({ name: cleanName, value: hex });
|
||||
}
|
||||
if (colors.length === 0) return null;
|
||||
return pickSwatchRow(colors);
|
||||
}
|
||||
|
||||
function pickFinalSwatchRow(
|
||||
frontmatter: SwatchRow | null,
|
||||
markdownSwatches: string[],
|
||||
): string[] {
|
||||
if (frontmatter !== null && frontmatter.filledAllSlots) return frontmatter.values;
|
||||
if (markdownSwatches.length > 0) return markdownSwatches;
|
||||
return frontmatter?.values ?? [];
|
||||
}
|
||||
|
||||
export async function readDesignSystem(root: string, id: string): Promise<string | null> {
|
||||
const brandRoot = path.join(root, id);
|
||||
const manifest = await readProjectManifest(brandRoot, id);
|
||||
|
|
@ -309,11 +375,11 @@ function extractCategory(raw: string): string | undefined {
|
|||
}
|
||||
|
||||
const KNOWN_SURFACES = new Set<DesignSystemSurface>(['web', 'image', 'video', 'audio']);
|
||||
function extractSurface(raw: string): DesignSystemSurface {
|
||||
function extractSurface(raw: string): DesignSystemSurface | undefined {
|
||||
const m = /^>\s*Surface:\s*(.+?)\s*$/im.exec(raw);
|
||||
if (!m) return 'web';
|
||||
if (!m) return undefined;
|
||||
const v = m[1]?.trim().toLowerCase();
|
||||
return isDesignSystemSurface(v) ? v : 'web';
|
||||
return isDesignSystemSurface(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function isDesignSystemSurface(value: string | undefined): value is DesignSystemSurface {
|
||||
|
|
@ -365,7 +431,10 @@ function extractSwatches(raw: string): string[] {
|
|||
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
|
||||
while ((m = reB.exec(raw)) !== null) push(m[1] ?? '', m[2] ?? '');
|
||||
if (colors.length === 0) return [];
|
||||
return pickSwatchRow(colors).values;
|
||||
}
|
||||
|
||||
function pickSwatchRow(colors: ColorToken[]): SwatchRow {
|
||||
function pick(hints: string[]): string | null {
|
||||
for (const h of hints) {
|
||||
const found = colors.find((c) => c.name.includes(h));
|
||||
|
|
@ -381,25 +450,28 @@ function extractSwatches(raw: string): string[] {
|
|||
return Math.max(r, g, b) - Math.min(r, g, b) < 10;
|
||||
}
|
||||
|
||||
const bg =
|
||||
pick(['page background', 'background', 'canvas', 'paper', 'surface'])
|
||||
?? '#ffffff';
|
||||
const fg =
|
||||
pick(['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite'])
|
||||
?? '#111111';
|
||||
const bgHit = pick(['page background', 'background', 'canvas', 'paper', 'surface']);
|
||||
const fgHit = pick(['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite']);
|
||||
const accentHit = pick(['primary brand', 'brand primary', 'accent', 'brand', 'primary']);
|
||||
const supportHit = pick(['border', 'divider', 'rule', 'muted', 'secondary', 'subtle']);
|
||||
|
||||
const bg = bgHit ?? '#ffffff';
|
||||
const fg = fgHit ?? '#111111';
|
||||
const accent =
|
||||
pick(['primary brand', 'brand primary', 'accent', 'brand', 'primary'])
|
||||
accentHit
|
||||
?? colors.find((c) => !isNeutral(c.value))?.value
|
||||
?? colors[0]?.value
|
||||
?? '#888888';
|
||||
const support =
|
||||
pick(['border', 'divider', 'rule', 'muted', 'secondary', 'subtle'])
|
||||
supportHit
|
||||
?? colors.find(
|
||||
(c) => isNeutral(c.value) && c.value !== bg && c.value !== fg,
|
||||
)?.value
|
||||
?? '#cccccc';
|
||||
|
||||
return [bg, support, fg, accent];
|
||||
const filledAllSlots =
|
||||
bgHit !== null && fgHit !== null && accentHit !== null && supportHit !== null;
|
||||
return { values: [bg, support, fg, accent], filledAllSlots };
|
||||
}
|
||||
|
||||
function normalizeHex(raw: string): string | null {
|
||||
|
|
|
|||
233
apps/daemon/tests/design-systems-frontmatter.test.ts
Normal file
233
apps/daemon/tests/design-systems-frontmatter.test.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { listDesignSystems } from '../src/design-systems.js';
|
||||
|
||||
function fresh(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), 'od-design-systems-frontmatter-'));
|
||||
}
|
||||
|
||||
function brandDir(root: string, id: string): string {
|
||||
const dir = path.join(root, id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeDesignMd(root: string, id: string, body: string): void {
|
||||
const dir = brandDir(root, id);
|
||||
writeFileSync(path.join(dir, 'DESIGN.md'), body);
|
||||
}
|
||||
|
||||
describe('listDesignSystems frontmatter parsing (issue #1857)', () => {
|
||||
it('uses frontmatter name/description/category/surface when no Markdown equivalents are present', async () => {
|
||||
const root = fresh();
|
||||
writeDesignMd(
|
||||
root,
|
||||
'google-only',
|
||||
[
|
||||
'---',
|
||||
'name: Google Material',
|
||||
'description: A clean material-inspired system.',
|
||||
'category: Productivity',
|
||||
'surface: web',
|
||||
'---',
|
||||
'',
|
||||
'Some rationale text without an H1 or Category blockquote.',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const out = await listDesignSystems(root);
|
||||
expect(out).toHaveLength(1);
|
||||
const ds = out[0]!;
|
||||
expect(ds.id).toBe('google-only');
|
||||
expect(ds.title).toBe('Google Material');
|
||||
expect(ds.category).toBe('Productivity');
|
||||
expect(ds.summary).toBe('A clean material-inspired system.');
|
||||
expect(ds.surface).toBe('web');
|
||||
});
|
||||
|
||||
it('extracts swatches from frontmatter colors when the Markdown body has no hex swatches', async () => {
|
||||
const root = fresh();
|
||||
writeDesignMd(
|
||||
root,
|
||||
'colors-only',
|
||||
[
|
||||
'---',
|
||||
'name: Colors Only',
|
||||
'description: Frontmatter-driven palette.',
|
||||
'colors:',
|
||||
' background: "#fafafa"',
|
||||
' text: "#111111"',
|
||||
' accent: "#ff3366"',
|
||||
' border: "#dddddd"',
|
||||
'---',
|
||||
'',
|
||||
'Body prose with no hex codes at all.',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const [ds] = await listDesignSystems(root);
|
||||
expect(ds?.swatches).toEqual(['#fafafa', '#dddddd', '#111111', '#ff3366']);
|
||||
});
|
||||
|
||||
it('returns identical summary shape for legacy Markdown-only DESIGN.md (no frontmatter, regression guard)', async () => {
|
||||
const root = fresh();
|
||||
writeDesignMd(
|
||||
root,
|
||||
'legacy',
|
||||
[
|
||||
'# Design System Inspired by Legacy',
|
||||
'',
|
||||
'> Category: Productivity',
|
||||
'',
|
||||
'A productivity-oriented system used by the picker today.',
|
||||
'',
|
||||
'- **Background:** `#ffffff`',
|
||||
'- **Text:** `#222222`',
|
||||
'- **Accent:** `#ff3366`',
|
||||
'- **Border:** `#dddddd`',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const [ds] = await listDesignSystems(root);
|
||||
expect(ds?.title).toBe('Legacy');
|
||||
expect(ds?.category).toBe('Productivity');
|
||||
expect(ds?.summary).toBe('A productivity-oriented system used by the picker today.');
|
||||
expect(ds?.swatches).toEqual(['#ffffff', '#dddddd', '#222222', '#ff3366']);
|
||||
expect(ds?.surface).toBe('web');
|
||||
});
|
||||
|
||||
it('prefers frontmatter colors over Markdown swatches when both are present', async () => {
|
||||
const root = fresh();
|
||||
writeDesignMd(
|
||||
root,
|
||||
'hybrid-colors',
|
||||
[
|
||||
'---',
|
||||
'colors:',
|
||||
' background: "#fafafa"',
|
||||
' text: "#111111"',
|
||||
' accent: "#ff3366"',
|
||||
' border: "#dddddd"',
|
||||
'---',
|
||||
'',
|
||||
'# Hybrid Colors',
|
||||
'',
|
||||
'> Category: Productivity',
|
||||
'',
|
||||
'Body with explicit swatches.',
|
||||
'',
|
||||
'- **Background:** `#000000`',
|
||||
'- **Text:** `#ffffff`',
|
||||
'- **Accent:** `#abcdef`',
|
||||
'- **Border:** `#999999`',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const [ds] = await listDesignSystems(root);
|
||||
expect(ds?.swatches).toEqual(['#fafafa', '#dddddd', '#111111', '#ff3366']);
|
||||
});
|
||||
|
||||
it('falls back to Markdown swatches when frontmatter colors use unrecognized token names (totality-festival regression)', async () => {
|
||||
const root = fresh();
|
||||
writeDesignMd(
|
||||
root,
|
||||
'totality-style',
|
||||
[
|
||||
'---',
|
||||
'colors:',
|
||||
' surface: "#121318"',
|
||||
' on-surface: "#e3e1e9"',
|
||||
' on-surface-variant: "#d0c6ab"',
|
||||
' outline: "#999077"',
|
||||
' primary: "#fff6df"',
|
||||
' secondary: "#bdf4ff"',
|
||||
' background: "#121318"',
|
||||
'---',
|
||||
'',
|
||||
'# Design System Inspired by Totality',
|
||||
'',
|
||||
'> Category: Themed & Unique',
|
||||
'',
|
||||
'Cosmic-premium dark system.',
|
||||
'',
|
||||
'- **Surface:** `#121318`',
|
||||
'- **Text:** `#e3e1e9`',
|
||||
'- **Text Muted:** `#d0c6ab`',
|
||||
'- **Primary:** `#fff6df`',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const [ds] = await listDesignSystems(root);
|
||||
expect(ds?.swatches).toEqual(['#121318', '#d0c6ab', '#e3e1e9', '#fff6df']);
|
||||
});
|
||||
|
||||
it('prefers Markdown H1 and Markdown Category over frontmatter when a hybrid file has both', async () => {
|
||||
const root = fresh();
|
||||
writeDesignMd(
|
||||
root,
|
||||
'hybrid',
|
||||
[
|
||||
'---',
|
||||
'name: Frontmatter Title',
|
||||
'description: Frontmatter summary that loses to Markdown.',
|
||||
'category: Frontmatter Category',
|
||||
'---',
|
||||
'',
|
||||
'# Markdown H1 Title',
|
||||
'',
|
||||
'> Category: Markdown Category',
|
||||
'',
|
||||
'Markdown summary paragraph wins.',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const [ds] = await listDesignSystems(root);
|
||||
expect(ds?.title).toBe('Markdown H1 Title');
|
||||
expect(ds?.category).toBe('Markdown Category');
|
||||
expect(ds?.summary).toBe('Markdown summary paragraph wins.');
|
||||
});
|
||||
|
||||
it('does not throw on malformed or empty frontmatter and still surfaces the brand from body heuristics', async () => {
|
||||
const root = fresh();
|
||||
writeDesignMd(
|
||||
root,
|
||||
'malformed',
|
||||
[
|
||||
'---',
|
||||
'---',
|
||||
'',
|
||||
'# Body Only',
|
||||
'',
|
||||
'> Category: From Body',
|
||||
'',
|
||||
'Body summary line.',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const [ds] = await listDesignSystems(root);
|
||||
expect(ds?.title).toBe('Body Only');
|
||||
expect(ds?.category).toBe('From Body');
|
||||
expect(ds?.summary).toBe('Body summary line.');
|
||||
});
|
||||
|
||||
it('keeps the body field as the verbatim file content including the frontmatter delimiters', async () => {
|
||||
const root = fresh();
|
||||
const raw = [
|
||||
'---',
|
||||
'name: Verbatim Body',
|
||||
'description: Body must include the frontmatter.',
|
||||
'---',
|
||||
'',
|
||||
'Trailing prose.',
|
||||
'',
|
||||
].join('\n');
|
||||
writeDesignMd(root, 'verbatim', raw);
|
||||
|
||||
const [ds] = await listDesignSystems(root);
|
||||
expect(ds?.body).toBe(raw);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue