sync: auto-sync from DESKTOP-0O8A1RL at 2026-05-22 11:07:55
Author: Mike Swanson Machine: DESKTOP-0O8A1RL Timestamp: 2026-05-22 11:07:55
This commit is contained in:
820
.agents/skills/impeccable/scripts/design-parser.mjs
Normal file
820
.agents/skills/impeccable/scripts/design-parser.mjs
Normal file
@@ -0,0 +1,820 @@
|
||||
// Parse a DESIGN.md (Stitch-spec format) into a structured JSON model that
|
||||
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
||||
//
|
||||
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
||||
// (prose with six canonical H2 sections). When frontmatter is present, it's
|
||||
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
||||
// consumers can prefer frontmatter values and fall back to prose.
|
||||
|
||||
const CANONICAL_SECTIONS = [
|
||||
'Overview',
|
||||
'Colors',
|
||||
'Typography',
|
||||
'Elevation',
|
||||
'Components',
|
||||
"Do's and Don'ts",
|
||||
];
|
||||
|
||||
// ---------- Frontmatter (Stitch YAML subset) ----------
|
||||
|
||||
function parseFrontmatter(md) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
if (lines[0]?.trim() !== '---') return { frontmatter: null, body: md };
|
||||
|
||||
let end = -1;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '---') { end = i; break; }
|
||||
}
|
||||
if (end === -1) return { frontmatter: null, body: md };
|
||||
|
||||
const yaml = lines.slice(1, end).join('\n');
|
||||
const body = lines.slice(end + 1).join('\n');
|
||||
try {
|
||||
return { frontmatter: parseYamlSubset(yaml), body };
|
||||
} catch {
|
||||
return { frontmatter: null, body: md };
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal YAML reader for the Stitch frontmatter subset: scalar maps with
|
||||
// one level of nested objects (typography roles, components). Indent-based,
|
||||
// 2-space convention. No arrays, no anchors, no multi-line scalars — Stitch's
|
||||
// schema doesn't need them and accepting them would require a real YAML
|
||||
// dependency we don't want to vendor.
|
||||
function parseYamlSubset(yaml) {
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
const root = {};
|
||||
const stack = [{ indent: -1, obj: root }];
|
||||
|
||||
for (const raw of lines) {
|
||||
// Skip blanks and line-only comments. Don't strip inline comments:
|
||||
// unquoted hex values start with `#` and can't be safely distinguished
|
||||
// from a comment after whitespace.
|
||||
if (!raw.trim() || /^\s*#/.test(raw)) continue;
|
||||
|
||||
const indent = raw.match(/^\s*/)[0].length;
|
||||
const content = raw.slice(indent);
|
||||
|
||||
const colonIdx = findTopLevelColon(content);
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
const key = content.slice(0, colonIdx).trim();
|
||||
const rest = content.slice(colonIdx + 1).trim();
|
||||
const parent = stack[stack.length - 1].obj;
|
||||
|
||||
if (rest === '') {
|
||||
const obj = {};
|
||||
parent[key] = obj;
|
||||
stack.push({ indent, obj });
|
||||
} else {
|
||||
parent[key] = parseScalar(rest);
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function findTopLevelColon(s) {
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === ':') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
if (s === 'null' || s === '~') return null;
|
||||
if (/^-?\d+$/.test(s)) return Number(s);
|
||||
if (/^-?\d*\.\d+$/.test(s)) return Number(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
|
||||
const OKLCH_RE = /oklch\([^)]+\)/gi;
|
||||
const RGBA_RE = /rgba?\([^)]+\)/gi;
|
||||
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
|
||||
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
|
||||
|
||||
// ---------- Section splitting ----------
|
||||
|
||||
function splitSections(md) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
let title = null;
|
||||
const sections = {};
|
||||
let current = null;
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
|
||||
if (!title && line.startsWith('# ') && !line.startsWith('## ')) {
|
||||
title = line.replace(/^#\s+/, '').trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
const h2 = line.match(/^##\s+(?:\d+\.\s*)?([^:\n]+?)(?::\s*(.+))?$/);
|
||||
if (h2) {
|
||||
const rawName = normalizeApostrophes(h2[1].trim());
|
||||
const subtitle = h2[2] ? h2[2].trim() : null;
|
||||
const canonical = matchCanonicalSection(rawName);
|
||||
if (canonical) {
|
||||
current = { name: canonical, subtitle, lines: [] };
|
||||
sections[canonical] = current;
|
||||
continue;
|
||||
}
|
||||
// non-canonical H2 — ignore but stop feeding into current
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current) current.lines.push(raw);
|
||||
}
|
||||
|
||||
return { title, sections };
|
||||
}
|
||||
|
||||
function normalizeApostrophes(s) {
|
||||
return s.replace(/[\u2018\u2019]/g, "'");
|
||||
}
|
||||
|
||||
function matchCanonicalSection(name) {
|
||||
const normalized = normalizeApostrophes(name).toLowerCase();
|
||||
// Exact match first
|
||||
for (const c of CANONICAL_SECTIONS) {
|
||||
if (normalizeApostrophes(c).toLowerCase() === normalized) return c;
|
||||
}
|
||||
// Keyword-contained match: "Overview & Creative North Star" -> "Overview",
|
||||
// "Elevation & Depth" -> "Elevation", etc.
|
||||
for (const c of CANONICAL_SECTIONS) {
|
||||
const key = normalizeApostrophes(c).toLowerCase();
|
||||
const pattern = new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
||||
if (pattern.test(normalized)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Subsection splitting (inside a canonical section) ----------
|
||||
|
||||
function splitSubsections(lines) {
|
||||
const subs = [];
|
||||
let current = { name: null, lines: [] };
|
||||
subs.push(current);
|
||||
|
||||
for (const raw of lines) {
|
||||
const h3 = raw.match(/^###\s+(.+?)\s*$/);
|
||||
if (h3) {
|
||||
current = { name: h3[1].trim(), lines: [] };
|
||||
subs.push(current);
|
||||
continue;
|
||||
}
|
||||
current.lines.push(raw);
|
||||
}
|
||||
|
||||
return subs;
|
||||
}
|
||||
|
||||
// ---------- Generic helpers ----------
|
||||
|
||||
function collectParagraphs(lines) {
|
||||
const paragraphs = [];
|
||||
let buf = [];
|
||||
const flush = () => {
|
||||
if (buf.length) {
|
||||
paragraphs.push(buf.join(' ').trim());
|
||||
buf = [];
|
||||
}
|
||||
};
|
||||
for (const raw of lines) {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') { flush(); continue; }
|
||||
// Horizontal rules (---, ***) and headings/bullets end a paragraph.
|
||||
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flush(); continue; }
|
||||
if (raw.startsWith('#') || raw.match(/^[-*]\s/)) { flush(); continue; }
|
||||
buf.push(trimmed);
|
||||
}
|
||||
flush();
|
||||
return paragraphs.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectBullets(lines) {
|
||||
const bullets = [];
|
||||
let current = null;
|
||||
for (const raw of lines) {
|
||||
const m = raw.match(/^\s*[-*]\s+(.+)$/);
|
||||
if (m) {
|
||||
if (current) bullets.push(current);
|
||||
current = m[1];
|
||||
continue;
|
||||
}
|
||||
// continuation of a bullet (indented line)
|
||||
if (current && raw.match(/^\s{2,}\S/)) {
|
||||
current += ' ' + raw.trim();
|
||||
continue;
|
||||
}
|
||||
// blank line ends a bullet
|
||||
if (raw.trim() === '' && current) {
|
||||
bullets.push(current);
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
if (current) bullets.push(current);
|
||||
return bullets;
|
||||
}
|
||||
|
||||
function stripBold(s) {
|
||||
return s.replace(/\*\*(.+?)\*\*/g, '$1');
|
||||
}
|
||||
|
||||
function extractNamedRules(lines) {
|
||||
const rules = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
|
||||
const joined = lines.join('\n');
|
||||
const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
|
||||
const inlineMatches = [];
|
||||
let m;
|
||||
while ((m = inlineStart.exec(joined)) !== null) {
|
||||
inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
|
||||
}
|
||||
for (let i = 0; i < inlineMatches.length; i++) {
|
||||
const mm = inlineMatches[i];
|
||||
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
|
||||
const body = joined
|
||||
.slice(mm.end, bodyEnd)
|
||||
.replace(/\n##[^\n]*$/s, '')
|
||||
.replace(/\n###[^\n]*$/s, '')
|
||||
.trim();
|
||||
const name = stripBold(mm.name).trim();
|
||||
seen.add(name.toLowerCase());
|
||||
rules.push({ name, body: stripBold(body) });
|
||||
}
|
||||
|
||||
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
|
||||
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
|
||||
if (!h3) continue;
|
||||
const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
|
||||
if (seen.has(headerName.toLowerCase())) continue;
|
||||
|
||||
const bodyLines = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
if (/^##\s|^###\s/.test(lines[j])) break;
|
||||
bodyLines.push(lines[j]);
|
||||
}
|
||||
const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) {
|
||||
seen.add(headerName.toLowerCase());
|
||||
rules.push({ name: headerName, body });
|
||||
}
|
||||
}
|
||||
|
||||
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
|
||||
// Colon/period lives inside the bold, so match "**...**" then inspect.
|
||||
for (const b of collectBullets(lines)) {
|
||||
const mm = b.match(/^\*\*([^*]+?)\*\*\s*(.+)$/);
|
||||
if (!mm) continue;
|
||||
const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
|
||||
if (seen.has(nameRaw.toLowerCase())) continue;
|
||||
seen.add(nameRaw.toLowerCase());
|
||||
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
// ---------- Per-section extractors ----------
|
||||
|
||||
function extractOverview(section) {
|
||||
if (!section) return null;
|
||||
const text = section.lines.join('\n');
|
||||
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
||||
const keyChars = [];
|
||||
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
||||
if (keyCharMatch) {
|
||||
for (const line of keyCharMatch[1].split('\n')) {
|
||||
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
||||
if (m) keyChars.push(stripBold(m[1].trim()));
|
||||
}
|
||||
}
|
||||
|
||||
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
||||
const paragraphs = collectParagraphs(section.lines).filter(
|
||||
(p) =>
|
||||
!p.startsWith('**Creative North Star') &&
|
||||
!p.startsWith('**Key Characteristics')
|
||||
);
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
creativeNorthStar: northStar ? northStar[1] : null,
|
||||
philosophy: paragraphs,
|
||||
keyCharacteristics: keyChars,
|
||||
};
|
||||
}
|
||||
|
||||
function extractColors(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
|
||||
const description = collectParagraphs(subs[0].lines).join(' ');
|
||||
const groups = [];
|
||||
const ROLE_KEYWORDS = /^(primary|secondary|tertiary|neutral|accent)\b/i;
|
||||
|
||||
for (const sub of subs.slice(1)) {
|
||||
if (!sub.name || /Named Rules?/i.test(sub.name) || /^The\s/i.test(sub.name)) continue;
|
||||
|
||||
const bullets = collectBullets(sub.lines);
|
||||
const parsed = bullets.map((b) => parseColorBullet(b)).filter(Boolean);
|
||||
if (parsed.length === 0) continue;
|
||||
|
||||
// If every bullet starts with a role keyword (Primary/Secondary/...), promote
|
||||
// each bullet to its own group. Otherwise keep the subsection as the group.
|
||||
const allRoleBullets =
|
||||
parsed.length > 0 && parsed.every((p) => p.name && ROLE_KEYWORDS.test(p.name));
|
||||
|
||||
if (allRoleBullets) {
|
||||
for (const p of parsed) {
|
||||
groups.push({ role: p.name, colors: [p] });
|
||||
}
|
||||
} else {
|
||||
groups.push({ role: sub.name, colors: parsed });
|
||||
}
|
||||
}
|
||||
|
||||
// If the Colors section has no subsections at all (unlikely), fall back to
|
||||
// scanning the whole section as a flat bullet list.
|
||||
if (groups.length === 0) {
|
||||
const flat = collectBullets(section.lines)
|
||||
.map((b) => parseColorBullet(b))
|
||||
.filter(Boolean);
|
||||
if (flat.length) {
|
||||
for (const p of flat) {
|
||||
if (p.name && ROLE_KEYWORDS.test(p.name)) {
|
||||
groups.push({ role: p.name, colors: [p] });
|
||||
} else {
|
||||
const fallback = groups.find((g) => g.role === 'Palette');
|
||||
if (fallback) fallback.colors.push(p);
|
||||
else groups.push({ role: 'Palette', colors: [p] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
description: description || null,
|
||||
groups,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
}
|
||||
|
||||
function parseColorBullet(bullet) {
|
||||
const text = bullet.trim();
|
||||
|
||||
// Case 1 (Impeccable): **Name** (value-with-maybe-nested-parens): description
|
||||
const bold = text.match(/^\*\*(.+?)\*\*\s*(.*)$/);
|
||||
if (bold && bold[2].startsWith('(')) {
|
||||
const value = extractParenGroup(bold[2]);
|
||||
if (value !== null) {
|
||||
const after = bold[2].slice(value.length + 2).trimStart();
|
||||
if (after.startsWith(':')) {
|
||||
return buildColor(bold[1], value, after.slice(1).trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2 (Stitch): **Name (values):** description — value embedded in bold.
|
||||
const stitch = text.match(/^\*\*([^*]+?)\s*\(([^)]+)\):\*\*\s*(.*)$/);
|
||||
if (stitch) {
|
||||
return buildColor(stitch[1].trim(), stitch[2], stitch[3]);
|
||||
}
|
||||
|
||||
// Case 3: bullet without bold, just hex/oklch inside.
|
||||
const values = collectColorValues(text);
|
||||
if (values.length) {
|
||||
return buildColor(null, values.join(' to '), text);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractParenGroup(s) {
|
||||
if (s[0] !== '(') return null;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === '(') depth++;
|
||||
else if (s[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return s.slice(1, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildColor(name, rawValue, description) {
|
||||
const values = collectColorValues(rawValue);
|
||||
const primary = values[0] ?? rawValue.trim();
|
||||
return {
|
||||
name: name ? stripBold(name).trim() : null,
|
||||
value: primary,
|
||||
valueRange: values.length > 1 ? values : null,
|
||||
format: detectFormat(primary),
|
||||
description: stripBold(description || '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function collectColorValues(s) {
|
||||
const out = [];
|
||||
s.replace(HEX_RE, (v) => {
|
||||
out.push(v);
|
||||
return v;
|
||||
});
|
||||
s.replace(OKLCH_RE, (v) => {
|
||||
out.push(v);
|
||||
return v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectFormat(v) {
|
||||
if (!v) return 'unknown';
|
||||
if (v.startsWith('#')) return 'hex';
|
||||
if (/^oklch/i.test(v)) return 'oklch';
|
||||
if (/^rgb/i.test(v)) return 'rgb';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function scanInlineColors(lines) {
|
||||
const out = [];
|
||||
for (const line of lines) {
|
||||
if (!/^\s*[-*]\s/.test(line)) continue;
|
||||
const trimmed = line.replace(/^\s*[-*]\s+/, '');
|
||||
const color = parseColorBullet(trimmed);
|
||||
if (color) out.push(color);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseStitchInlineGroups(lines) {
|
||||
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
|
||||
// Each bullet IS its own role. Group them under the spoken role name.
|
||||
const out = [];
|
||||
for (const line of lines) {
|
||||
if (!/^\s*[-*]\s/.test(line)) continue;
|
||||
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
|
||||
const m = trimmed.match(
|
||||
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
|
||||
);
|
||||
if (m) {
|
||||
const role = m[1];
|
||||
const color = buildColor(role, m[2], m[3]);
|
||||
out.push({ role, colors: [color] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractTypography(section) {
|
||||
if (!section) return null;
|
||||
const text = section.lines.join('\n');
|
||||
|
||||
const fonts = {};
|
||||
// Pattern A: **Display Font:** Family (with fallback)
|
||||
const fontLineRe = /\*\*([\w\s/]+?)Font:\*\*\s*([^\n(]+?)(?:\s*\(with\s+([^)]+)\))?\s*$/gm;
|
||||
let fm;
|
||||
while ((fm = fontLineRe.exec(text)) !== null) {
|
||||
const rawRole = fm[1].trim().toLowerCase().replace(/\s+/g, '-');
|
||||
const role = normalizeFontRole(rawRole) || 'display';
|
||||
fonts[role] = {
|
||||
family: fm[2].trim(),
|
||||
fallback: fm[3] ? fm[3].trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern B (Stitch): * **Display & Headlines (Noto Serif):** description
|
||||
if (Object.keys(fonts).length === 0) {
|
||||
const stitchRe = /\*\*([\w\s&/]+?)\s*\(([^)]+)\):\*\*\s*(.+)/g;
|
||||
let sm;
|
||||
while ((sm = stitchRe.exec(text)) !== null) {
|
||||
const rawRole = sm[1]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s*&\s*/g, '-')
|
||||
.replace(/\s+/g, '-');
|
||||
const role = normalizeFontRole(rawRole) || rawRole;
|
||||
fonts[role] = { family: sm[2].trim(), fallback: null, purpose: sm[3].trim() };
|
||||
}
|
||||
}
|
||||
|
||||
// Character paragraph — either a **Character:** label, or fall back to the
|
||||
// first free paragraph under the section header (Stitch style).
|
||||
const characterMatch = text.match(/\*\*Character:\*\*\s*([^\n]+(?:\n[^\n]+)*?)(?=\n\n|\n###|\n##|$)/);
|
||||
let character = characterMatch ? characterMatch[1].replace(/\n/g, ' ').trim() : null;
|
||||
if (!character) {
|
||||
const paragraphs = collectParagraphs(section.lines).filter(
|
||||
(p) => !/^\*\*[\w\s/&]+Font/i.test(p) && !/^\*\*[\w\s/&]+\([^)]+\)/.test(p)
|
||||
);
|
||||
if (paragraphs.length) character = paragraphs[0];
|
||||
}
|
||||
|
||||
// Hierarchy bullets under ### Hierarchy
|
||||
const subs = splitSubsections(section.lines);
|
||||
let hierarchy = [];
|
||||
const hierSub = subs.find((s) => s.name && /hierarch/i.test(s.name));
|
||||
if (hierSub) {
|
||||
const bullets = collectBullets(hierSub.lines);
|
||||
hierarchy = bullets.map(parseTypeBullet).filter(Boolean);
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
fonts,
|
||||
character,
|
||||
hierarchy,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFontRole(raw) {
|
||||
// Canonical roles the panel cares about: display, body, label, mono.
|
||||
// Stitch often writes compound roles like "display-&-headlines" or "ui-&-body"
|
||||
// — collapse them to the first canonical role present.
|
||||
const tokens = raw.split(/[-/&\s]+/).filter(Boolean);
|
||||
const priority = ['display', 'headline', 'body', 'ui', 'label', 'mono'];
|
||||
const canonical = { headline: 'display', ui: 'body' };
|
||||
for (const p of priority) {
|
||||
if (tokens.includes(p)) return canonical[p] || p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTypeBullet(bullet) {
|
||||
// - **Display** (family, weight 300, italic, clamp(...), line-height 1): purpose
|
||||
const m = bullet.match(/^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*(.*)$/);
|
||||
if (!m) return null;
|
||||
const name = m[1].trim();
|
||||
const specs = m[2].split(',').map((s) => s.trim());
|
||||
return {
|
||||
name,
|
||||
specs,
|
||||
purpose: stripBold(m[3] || '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractElevation(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
|
||||
const description = collectParagraphs(subs[0].lines).join(' ') || null;
|
||||
|
||||
const shadows = [];
|
||||
const seen = new Set();
|
||||
const dedupe = (entry) => {
|
||||
const key = (entry.name || '') + '::' + entry.value;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
shadows.push(entry);
|
||||
};
|
||||
|
||||
for (const b of collectBullets(section.lines)) {
|
||||
const parsed = parseShadowBullet(b);
|
||||
if (parsed) dedupe(parsed);
|
||||
}
|
||||
|
||||
// Fallback: extract shadows written inline in prose. Stitch style is
|
||||
// "...use an extra-diffused shadow: `box-shadow: 0 12px 40px rgba(...)`."
|
||||
for (const p of collectParagraphs(section.lines)) {
|
||||
for (const inline of extractInlineShadows(p)) dedupe(inline);
|
||||
}
|
||||
for (const b of collectBullets(section.lines)) {
|
||||
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
description,
|
||||
shadows,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
}
|
||||
|
||||
function extractInlineShadows(text) {
|
||||
// Find `box-shadow: ...` anywhere in prose and capture the value. Work on the
|
||||
// raw string so it handles both backtick-fenced and unfenced variants.
|
||||
const out = [];
|
||||
const re = /box-shadow\s*:\s*([^`;\n]+)/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const value = m[1].replace(/[`.)]+$/, '').trim();
|
||||
if (!value) continue;
|
||||
// Name heuristic: the noun immediately before the shadow phrase.
|
||||
// e.g. "an extra-diffused shadow: ..." -> "extra-diffused shadow"
|
||||
const before = text.slice(0, m.index);
|
||||
const nameMatch = before.match(/\b([A-Za-z][A-Za-z\- ]{2,40})\s+shadow\b[^A-Za-z0-9]*$/i);
|
||||
let name = null;
|
||||
if (nameMatch) {
|
||||
const stripped = nameMatch[1]
|
||||
.replace(/^(?:use|using|apply|applying|is|are|looks? like)\s+/i, '')
|
||||
.replace(/^(?:a|an|the)\s+/i, '')
|
||||
.trim();
|
||||
if (stripped) {
|
||||
name =
|
||||
stripped.charAt(0).toUpperCase() + stripped.slice(1) + ' shadow';
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
name,
|
||||
value,
|
||||
purpose: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseShadowBullet(bullet) {
|
||||
// - **Name** (`box-shadow: value`): purpose
|
||||
// - **Name** (`value`): purpose
|
||||
// Only accept if the paren content looks like a shadow value (contains px,
|
||||
// rem, rgba, or box-shadow). This filters out `**Rule Name:**` bullets.
|
||||
const m = bullet.match(/^\*\*(.+?)\*\*\s*\(`?([^`]+?)`?\):\s*(.*)$/);
|
||||
if (!m) return null;
|
||||
const rawValue = m[2].replace(/^box-shadow:\s*/i, '').trim();
|
||||
const looksLikeShadow =
|
||||
/box-shadow|rgba?\(|\bpx\b|\brem\b|^-?\d+\s/i.test(rawValue) &&
|
||||
/\d/.test(rawValue);
|
||||
if (!looksLikeShadow) return null;
|
||||
const name = stripBold(m[1]).trim();
|
||||
return {
|
||||
name,
|
||||
value: rawValue,
|
||||
purpose: stripBold(m[3] || '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractComponents(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
const components = [];
|
||||
|
||||
for (const sub of subs.slice(1)) {
|
||||
if (!sub.name) continue;
|
||||
|
||||
const bullets = collectBullets(sub.lines);
|
||||
const paragraphs = collectParagraphs(sub.lines);
|
||||
|
||||
const variants = [];
|
||||
const properties = {};
|
||||
|
||||
for (const b of bullets) {
|
||||
// - **Key:** value
|
||||
const m = b.match(/^\*\*(.+?):?\*\*:?\s*(.+)$/);
|
||||
if (m) {
|
||||
const key = stripBold(m[1]).trim();
|
||||
const value = stripBold(m[2]).trim();
|
||||
// Heuristic: "Primary", "Secondary", "Hover", "Focus" etc are variants;
|
||||
// "Shape", "Background", "Padding" are properties.
|
||||
if (/^(primary|secondary|tertiary|ghost|hover|focus|active|disabled|default|error|selected|unselected|state)$/i.test(key.split(/[\s/]/)[0])) {
|
||||
variants.push({ name: key, description: value });
|
||||
} else {
|
||||
properties[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
components.push({
|
||||
name: sub.name,
|
||||
description: paragraphs.join(' ') || null,
|
||||
properties,
|
||||
variants,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
function extractDosDonts(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
const dos = [];
|
||||
const donts = [];
|
||||
|
||||
for (const sub of subs.slice(1)) {
|
||||
if (!sub.name) continue;
|
||||
const subName = normalizeApostrophes(sub.name);
|
||||
const bullets = collectBullets(sub.lines).map((b) => stripBold(b).trim());
|
||||
if (/^do'?t?:?$/i.test(subName) || /^do:?$/i.test(subName)) {
|
||||
dos.push(...bullets);
|
||||
} else if (/^don'?t:?$/i.test(subName)) {
|
||||
donts.push(...bullets);
|
||||
}
|
||||
}
|
||||
|
||||
// Classify by bullet prefix as a backup (catches loose bullets outside H3 wrappers)
|
||||
for (const b of collectBullets(section.lines)) {
|
||||
const stripped = normalizeApostrophes(stripBold(b).trim());
|
||||
if (/^don'?t\b/i.test(stripped)) {
|
||||
if (!donts.some((d) => normalizeApostrophes(d) === stripped)) donts.push(stripped);
|
||||
} else if (/^do\b/i.test(stripped)) {
|
||||
if (!dos.some((d) => normalizeApostrophes(d) === stripped)) dos.push(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
return { dos, donts };
|
||||
}
|
||||
|
||||
// ---------- Coverage assessment ----------
|
||||
|
||||
function assessCoverage(model) {
|
||||
const report = {};
|
||||
|
||||
report.overview = model.overview
|
||||
? {
|
||||
northStar: Boolean(model.overview.creativeNorthStar),
|
||||
philosophy: model.overview.philosophy.length > 0,
|
||||
keyCharacteristics: model.overview.keyCharacteristics.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.colors = model.colors
|
||||
? {
|
||||
groups: model.colors.groups.length,
|
||||
totalColors: model.colors.groups.reduce((n, g) => n + g.colors.length, 0),
|
||||
rules: model.colors.rules.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.typography = model.typography
|
||||
? {
|
||||
fonts: Object.keys(model.typography.fonts).length,
|
||||
hierarchyEntries: model.typography.hierarchy.length,
|
||||
character: Boolean(model.typography.character),
|
||||
rules: model.typography.rules.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.elevation = model.elevation
|
||||
? {
|
||||
shadows: model.elevation.shadows.length,
|
||||
rules: model.elevation.rules.length,
|
||||
description: Boolean(model.elevation.description),
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.components = model.components
|
||||
? {
|
||||
count: model.components.components.length,
|
||||
variantTotal: model.components.components.reduce((n, c) => n + c.variants.length, 0),
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.dosDonts = model.dosDonts
|
||||
? {
|
||||
dos: model.dosDonts.dos.length,
|
||||
donts: model.dosDonts.donts.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ---------- Main ----------
|
||||
|
||||
export function parseDesignMd(md) {
|
||||
const { frontmatter, body } = parseFrontmatter(md);
|
||||
const { title, sections } = splitSections(body);
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
title,
|
||||
frontmatter,
|
||||
overview: extractOverview(sections['Overview']),
|
||||
colors: extractColors(sections['Colors']),
|
||||
typography: extractTypography(sections['Typography']),
|
||||
elevation: extractElevation(sections['Elevation']),
|
||||
components: extractComponents(sections['Components']),
|
||||
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
||||
};
|
||||
}
|
||||
|
||||
export { assessCoverage };
|
||||
Reference in New Issue
Block a user