1// Parse a DESIGN.md (Stitch-spec format) into a structured JSON model that
2// the live-mode design-system panel can render. Deterministic, dependency-free.
3//
4// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
5// (prose with six canonical H2 sections). When frontmatter is present, it's
6// exposed on `model.frontmatter` alongside the prose-scraped sections;
7// consumers can prefer frontmatter values and fall back to prose.
8
9const CANONICAL_SECTIONS = [
10 'Overview',
11 'Colors',
12 'Typography',
13 'Elevation',
14 'Components',
15 "Do's and Don'ts",
16];
17
18// ---------- Frontmatter (Stitch YAML subset) ----------
19
20function parseFrontmatter(md) {
21 const lines = md.split(/\r?\n/);
22 if (lines[0]?.trim() !== '---') return { frontmatter: null, body: md };
23
24 let end = -1;
25 for (let i = 1; i < lines.length; i++) {
26 if (lines[i].trim() === '---') { end = i; break; }
27 }
28 if (end === -1) return { frontmatter: null, body: md };
29
30 const yaml = lines.slice(1, end).join('\n');
31 const body = lines.slice(end + 1).join('\n');
32 try {
33 return { frontmatter: parseYamlSubset(yaml), body };
34 } catch {
35 return { frontmatter: null, body: md };
36 }
37}
38
39// Minimal YAML reader for the Stitch frontmatter subset: scalar maps with
40// one level of nested objects (typography roles, components). Indent-based,
41// 2-space convention. No arrays, no anchors, no multi-line scalars — Stitch's
42// schema doesn't need them and accepting them would require a real YAML
43// dependency we don't want to vendor.
44function parseYamlSubset(yaml) {
45 const lines = yaml.split(/\r?\n/);
46 const root = {};
47 const stack = [{ indent: -1, obj: root }];
48
49 for (const raw of lines) {
50 // Skip blanks and line-only comments. Don't strip inline comments:
51 // unquoted hex values start with `#` and can't be safely distinguished
52 // from a comment after whitespace.
53 if (!raw.trim() || /^\s*#/.test(raw)) continue;
54
55 const indent = raw.match(/^\s*/)[0].length;
56 const content = raw.slice(indent);
57
58 const colonIdx = findTopLevelColon(content);
59 if (colonIdx === -1) continue;
60
61 while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
62 stack.pop();
63 }
64
65 const key = content.slice(0, colonIdx).trim();
66 const rest = content.slice(colonIdx + 1).trim();
67 const parent = stack[stack.length - 1].obj;
68
69 if (rest === '') {
70 const obj = {};
71 parent[key] = obj;
72 stack.push({ indent, obj });
73 } else {
74 parent[key] = parseScalar(rest);
75 }
76 }
77
78 return root;
79}
80
81function findTopLevelColon(s) {
82 let inQuote = null;
83 for (let i = 0; i < s.length; i++) {
84 const ch = s[i];
85 if (inQuote) {
86 if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
87 } else if (ch === '"' || ch === "'") {
88 inQuote = ch;
89 } else if (ch === ':') {
90 return i;
91 }
92 }
93 return -1;
94}
95
96function parseScalar(raw) {
97 const s = raw.trim();
98 if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
99 return s.slice(1, -1);
100 }
101 if (s === 'true') return true;
102 if (s === 'false') return false;
103 if (s === 'null' || s === '~') return null;
104 if (/^-?\d+$/.test(s)) return Number(s);
105 if (/^-?\d*\.\d+$/.test(s)) return Number(s);
106 return s;
107}
108
109const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
110const OKLCH_RE = /oklch\([^)]+\)/gi;
111const RGBA_RE = /rgba?\([^)]+\)/gi;
112const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
113const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
114
115// ---------- Section splitting ----------
116
117function splitSections(md) {
118 const lines = md.split(/\r?\n/);
119 let title = null;
120 const sections = {};
121 let current = null;
122
123 for (const raw of lines) {
124 const line = raw.trimEnd();
125
126 if (!title && line.startsWith('# ') && !line.startsWith('## ')) {
127 title = line.replace(/^#\s+/, '').trim();
128 continue;
129 }
130
131 const h2 = line.match(/^##\s+(?:\d+\.\s*)?([^:\n]+?)(?::\s*(.+))?$/);
132 if (h2) {
133 const rawName = normalizeApostrophes(h2[1].trim());
134 const subtitle = h2[2] ? h2[2].trim() : null;
135 const canonical = matchCanonicalSection(rawName);
136 if (canonical) {
137 current = { name: canonical, subtitle, lines: [] };
138 sections[canonical] = current;
139 continue;
140 }
141 // non-canonical H2 — ignore but stop feeding into current
142 current = null;
143 continue;
144 }
145
146 if (current) current.lines.push(raw);
147 }
148
149 return { title, sections };
150}
151
152function normalizeApostrophes(s) {
153 return s.replace(/[\u2018\u2019]/g, "'");
154}
155
156function matchCanonicalSection(name) {
157 const normalized = normalizeApostrophes(name).toLowerCase();
158 // Exact match first
159 for (const c of CANONICAL_SECTIONS) {
160 if (normalizeApostrophes(c).toLowerCase() === normalized) return c;
161 }
162 // Keyword-contained match: "Overview & Creative North Star" -> "Overview",
163 // "Elevation & Depth" -> "Elevation", etc.
164 for (const c of CANONICAL_SECTIONS) {
165 const key = normalizeApostrophes(c).toLowerCase();
166 const pattern = new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
167 if (pattern.test(normalized)) return c;
168 }
169 return null;
170}
171
172// ---------- Subsection splitting (inside a canonical section) ----------
173
174function splitSubsections(lines) {
175 const subs = [];
176 let current = { name: null, lines: [] };
177 subs.push(current);
178
179 for (const raw of lines) {
180 const h3 = raw.match(/^###\s+(.+?)\s*$/);
181 if (h3) {
182 current = { name: h3[1].trim(), lines: [] };
183 subs.push(current);
184 continue;
185 }
186 current.lines.push(raw);
187 }
188
189 return subs;
190}
191
192// ---------- Generic helpers ----------
193
194function collectParagraphs(lines) {
195 const paragraphs = [];
196 let buf = [];
197 const flush = () => {
198 if (buf.length) {
199 paragraphs.push(buf.join(' ').trim());
200 buf = [];
201 }
202 };
203 for (const raw of lines) {
204 const trimmed = raw.trim();
205 if (trimmed === '') { flush(); continue; }
206 // Horizontal rules (---, ***) and headings/bullets end a paragraph.
207 if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flush(); continue; }
208 if (raw.startsWith('#') || raw.match(/^[-*]\s/)) { flush(); continue; }
209 buf.push(trimmed);
210 }
211 flush();
212 return paragraphs.filter(Boolean);
213}
214
215function collectBullets(lines) {
216 const bullets = [];
217 let current = null;
218 for (const raw of lines) {
219 const m = raw.match(/^\s*[-*]\s+(.+)$/);
220 if (m) {
221 if (current) bullets.push(current);
222 current = m[1];
223 continue;
224 }
225 // continuation of a bullet (indented line)
226 if (current && raw.match(/^\s{2,}\S/)) {
227 current += ' ' + raw.trim();
228 continue;
229 }
230 // blank line ends a bullet
231 if (raw.trim() === '' && current) {
232 bullets.push(current);
233 current = null;
234 }
235 }
236 if (current) bullets.push(current);
237 return bullets;
238}
239
240function stripBold(s) {
241 return s.replace(/\*\*(.+?)\*\*/g, '$1');
242}
243
244function extractNamedRules(lines) {
245 const rules = [];
246 const seen = new Set();
247
248 // Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
249 const joined = lines.join('\n');
250 const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
251 const inlineMatches = [];
252 let m;
253 while ((m = inlineStart.exec(joined)) !== null) {
254 inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
255 }
256 for (let i = 0; i < inlineMatches.length; i++) {
257 const mm = inlineMatches[i];
258 const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
259 const body = joined
260 .slice(mm.end, bodyEnd)
261 .replace(/\n##[^\n]*$/s, '')
262 .replace(/\n###[^\n]*$/s, '')
263 .trim();
264 const name = stripBold(mm.name).trim();
265 seen.add(name.toLowerCase());
266 rules.push({ name, body: stripBold(body) });
267 }
268
269 // Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
270 // bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
271 for (let i = 0; i < lines.length; i++) {
272 const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
273 if (!h3) continue;
274 const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
275 if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
276 if (seen.has(headerName.toLowerCase())) continue;
277
278 const bodyLines = [];
279 for (let j = i + 1; j < lines.length; j++) {
280 if (/^##\s|^###\s/.test(lines[j])) break;
281 bodyLines.push(lines[j]);
282 }
283 const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
284 if (body) {
285 seen.add(headerName.toLowerCase());
286 rules.push({ name: headerName, body });
287 }
288 }
289
290 // Style C (Stitch bullet form): "* **The Layering Principle:** body"
291 // Colon/period lives inside the bold, so match "**...**" then inspect.
292 for (const b of collectBullets(lines)) {
293 const mm = b.match(/^\*\*([^*]+?)\*\*\s*(.+)$/);
294 if (!mm) continue;
295 const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
296 if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
297 if (seen.has(nameRaw.toLowerCase())) continue;
298 seen.add(nameRaw.toLowerCase());
299 rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
300 }
301
302 return rules;
303}
304
305// ---------- Per-section extractors ----------
306
307function extractOverview(section) {
308 if (!section) return null;
309 const text = section.lines.join('\n');
310 const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
311 const keyChars = [];
312 const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
313 if (keyCharMatch) {
314 for (const line of keyCharMatch[1].split('\n')) {
315 const m = line.match(/^\s*[-*]\s+(.+)$/);
316 if (m) keyChars.push(stripBold(m[1].trim()));
317 }
318 }
319
320 // Philosophy paragraphs: everything that isn't a rule header or key-char block
321 const paragraphs = collectParagraphs(section.lines).filter(
322 (p) =>
323 !p.startsWith('**Creative North Star') &&
324 !p.startsWith('**Key Characteristics')
325 );
326
327 return {
328 subtitle: section.subtitle,
329 creativeNorthStar: northStar ? northStar[1] : null,
330 philosophy: paragraphs,
331 keyCharacteristics: keyChars,
332 };
333}
334
335function extractColors(section) {
336 if (!section) return null;
337 const subs = splitSubsections(section.lines);
338
339 const description = collectParagraphs(subs[0].lines).join(' ');
340 const groups = [];
341 const ROLE_KEYWORDS = /^(primary|secondary|tertiary|neutral|accent)\b/i;
342
343 for (const sub of subs.slice(1)) {
344 if (!sub.name || /Named Rules?/i.test(sub.name) || /^The\s/i.test(sub.name)) continue;
345
346 const bullets = collectBullets(sub.lines);
347 const parsed = bullets.map((b) => parseColorBullet(b)).filter(Boolean);
348 if (parsed.length === 0) continue;
349
350 // If every bullet starts with a role keyword (Primary/Secondary/...), promote
351 // each bullet to its own group. Otherwise keep the subsection as the group.
352 const allRoleBullets =
353 parsed.length > 0 && parsed.every((p) => p.name && ROLE_KEYWORDS.test(p.name));
354
355 if (allRoleBullets) {
356 for (const p of parsed) {
357 groups.push({ role: p.name, colors: [p] });
358 }
359 } else {
360 groups.push({ role: sub.name, colors: parsed });
361 }
362 }
363
364 // If the Colors section has no subsections at all (unlikely), fall back to
365 // scanning the whole section as a flat bullet list.
366 if (groups.length === 0) {
367 const flat = collectBullets(section.lines)
368 .map((b) => parseColorBullet(b))
369 .filter(Boolean);
370 if (flat.length) {
371 for (const p of flat) {
372 if (p.name && ROLE_KEYWORDS.test(p.name)) {
373 groups.push({ role: p.name, colors: [p] });
374 } else {
375 const fallback = groups.find((g) => g.role === 'Palette');
376 if (fallback) fallback.colors.push(p);
377 else groups.push({ role: 'Palette', colors: [p] });
378 }
379 }
380 }
381 }
382
383 return {
384 subtitle: section.subtitle,
385 description: description || null,
386 groups,
387 rules: extractNamedRules(section.lines),
388 };
389}
390
391function parseColorBullet(bullet) {
392 const text = bullet.trim();
393
394 // Case 1 (Impeccable): **Name** (value-with-maybe-nested-parens): description
395 const bold = text.match(/^\*\*(.+?)\*\*\s*(.*)$/);
396 if (bold && bold[2].startsWith('(')) {
397 const value = extractParenGroup(bold[2]);
398 if (value !== null) {
399 const after = bold[2].slice(value.length + 2).trimStart();
400 if (after.startsWith(':')) {
401 return buildColor(bold[1], value, after.slice(1).trim());
402 }
403 }
404 }
405
406 // Case 2 (Stitch): **Name (values):** description — value embedded in bold.
407 const stitch = text.match(/^\*\*([^*]+?)\s*\(([^)]+)\):\*\*\s*(.*)$/);
408 if (stitch) {
409 return buildColor(stitch[1].trim(), stitch[2], stitch[3]);
410 }
411
412 // Case 3: bullet without bold, just hex/oklch inside.
413 const values = collectColorValues(text);
414 if (values.length) {
415 return buildColor(null, values.join(' to '), text);
416 }
417 return null;
418}
419
420function extractParenGroup(s) {
421 if (s[0] !== '(') return null;
422 let depth = 0;
423 for (let i = 0; i < s.length; i++) {
424 if (s[i] === '(') depth++;
425 else if (s[i] === ')') {
426 depth--;
427 if (depth === 0) return s.slice(1, i);
428 }
429 }
430 return null;
431}
432
433function buildColor(name, rawValue, description) {
434 const values = collectColorValues(rawValue);
435 const primary = values[0] ?? rawValue.trim();
436 return {
437 name: name ? stripBold(name).trim() : null,
438 value: primary,
439 valueRange: values.length > 1 ? values : null,
440 format: detectFormat(primary),
441 description: stripBold(description || '').trim() || null,
442 };
443}
444
445function collectColorValues(s) {
446 const out = [];
447 s.replace(HEX_RE, (v) => {
448 out.push(v);
449 return v;
450 });
451 s.replace(OKLCH_RE, (v) => {
452 out.push(v);
453 return v;
454 });
455 return out;
456}
457
458function detectFormat(v) {
459 if (!v) return 'unknown';
460 if (v.startsWith('#')) return 'hex';
461 if (/^oklch/i.test(v)) return 'oklch';
462 if (/^rgb/i.test(v)) return 'rgb';
463 return 'unknown';
464}
465
466function scanInlineColors(lines) {
467 const out = [];
468 for (const line of lines) {
469 if (!/^\s*[-*]\s/.test(line)) continue;
470 const trimmed = line.replace(/^\s*[-*]\s+/, '');
471 const color = parseColorBullet(trimmed);
472 if (color) out.push(color);
473 }
474 return out;
475}
476
477function parseStitchInlineGroups(lines) {
478 // Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
479 // Each bullet IS its own role. Group them under the spoken role name.
480 const out = [];
481 for (const line of lines) {
482 if (!/^\s*[-*]\s/.test(line)) continue;
483 const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
484 const m = trimmed.match(
485 /^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
486 );
487 if (m) {
488 const role = m[1];
489 const color = buildColor(role, m[2], m[3]);
490 out.push({ role, colors: [color] });
491 }
492 }
493 return out;
494}
495
496function extractTypography(section) {
497 if (!section) return null;
498 const text = section.lines.join('\n');
499
500 const fonts = {};
501 // Pattern A: **Display Font:** Family (with fallback)
502 const fontLineRe = /\*\*([\w\s/]+?)Font:\*\*\s*([^\n(]+?)(?:\s*\(with\s+([^)]+)\))?\s*$/gm;
503 let fm;
504 while ((fm = fontLineRe.exec(text)) !== null) {
505 const rawRole = fm[1].trim().toLowerCase().replace(/\s+/g, '-');
506 const role = normalizeFontRole(rawRole) || 'display';
507 fonts[role] = {
508 family: fm[2].trim(),
509 fallback: fm[3] ? fm[3].trim() : null,
510 };
511 }
512
513 // Pattern B (Stitch): * **Display & Headlines (Noto Serif):** description
514 if (Object.keys(fonts).length === 0) {
515 const stitchRe = /\*\*([\w\s&/]+?)\s*\(([^)]+)\):\*\*\s*(.+)/g;
516 let sm;
517 while ((sm = stitchRe.exec(text)) !== null) {
518 const rawRole = sm[1]
519 .trim()
520 .toLowerCase()
521 .replace(/\s*&\s*/g, '-')
522 .replace(/\s+/g, '-');
523 const role = normalizeFontRole(rawRole) || rawRole;
524 fonts[role] = { family: sm[2].trim(), fallback: null, purpose: sm[3].trim() };
525 }
526 }
527
528 // Character paragraph — either a **Character:** label, or fall back to the
529 // first free paragraph under the section header (Stitch style).
530 const characterMatch = text.match(/\*\*Character:\*\*\s*([^\n]+(?:\n[^\n]+)*?)(?=\n\n|\n###|\n##|$)/);
531 let character = characterMatch ? characterMatch[1].replace(/\n/g, ' ').trim() : null;
532 if (!character) {
533 const paragraphs = collectParagraphs(section.lines).filter(
534 (p) => !/^\*\*[\w\s/&]+Font/i.test(p) && !/^\*\*[\w\s/&]+\([^)]+\)/.test(p)
535 );
536 if (paragraphs.length) character = paragraphs[0];
537 }
538
539 // Hierarchy bullets under ### Hierarchy
540 const subs = splitSubsections(section.lines);
541 let hierarchy = [];
542 const hierSub = subs.find((s) => s.name && /hierarch/i.test(s.name));
543 if (hierSub) {
544 const bullets = collectBullets(hierSub.lines);
545 hierarchy = bullets.map(parseTypeBullet).filter(Boolean);
546 }
547
548 return {
549 subtitle: section.subtitle,
550 fonts,
551 character,
552 hierarchy,
553 rules: extractNamedRules(section.lines),
554 };
555}
556
557function normalizeFontRole(raw) {
558 // Canonical roles the panel cares about: display, body, label, mono.
559 // Stitch often writes compound roles like "display-&-headlines" or "ui-&-body"
560 // — collapse them to the first canonical role present.
561 const tokens = raw.split(/[-/&\s]+/).filter(Boolean);
562 const priority = ['display', 'headline', 'body', 'ui', 'label', 'mono'];
563 const canonical = { headline: 'display', ui: 'body' };
564 for (const p of priority) {
565 if (tokens.includes(p)) return canonical[p] || p;
566 }
567 return null;
568}
569
570function parseTypeBullet(bullet) {
571 // - **Display** (family, weight 300, italic, clamp(...), line-height 1): purpose
572 const m = bullet.match(/^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*(.*)$/);
573 if (!m) return null;
574 const name = m[1].trim();
575 const specs = m[2].split(',').map((s) => s.trim());
576 return {
577 name,
578 specs,
579 purpose: stripBold(m[3] || '').trim() || null,
580 };
581}
582
583function extractElevation(section) {
584 if (!section) return null;
585 const subs = splitSubsections(section.lines);
586
587 const description = collectParagraphs(subs[0].lines).join(' ') || null;
588
589 const shadows = [];
590 const seen = new Set();
591 const dedupe = (entry) => {
592 const key = (entry.name || '') + '::' + entry.value;
593 if (seen.has(key)) return;
594 seen.add(key);
595 shadows.push(entry);
596 };
597
598 for (const b of collectBullets(section.lines)) {
599 const parsed = parseShadowBullet(b);
600 if (parsed) dedupe(parsed);
601 }
602
603 // Fallback: extract shadows written inline in prose. Stitch style is
604 // "...use an extra-diffused shadow: `box-shadow: 0 12px 40px rgba(...)`."
605 for (const p of collectParagraphs(section.lines)) {
606 for (const inline of extractInlineShadows(p)) dedupe(inline);
607 }
608 for (const b of collectBullets(section.lines)) {
609 for (const inline of extractInlineShadows(b)) dedupe(inline);
610 }
611
612 return {
613 subtitle: section.subtitle,
614 description,
615 shadows,
616 rules: extractNamedRules(section.lines),
617 };
618}
619
620function extractInlineShadows(text) {
621 // Find `box-shadow: ...` anywhere in prose and capture the value. Work on the
622 // raw string so it handles both backtick-fenced and unfenced variants.
623 const out = [];
624 const re = /box-shadow\s*:\s*([^`;\n]+)/gi;
625 let m;
626 while ((m = re.exec(text)) !== null) {
627 const value = m[1].replace(/[`.)]+$/, '').trim();
628 if (!value) continue;
629 // Name heuristic: the noun immediately before the shadow phrase.
630 // e.g. "an extra-diffused shadow: ..." -> "extra-diffused shadow"
631 const before = text.slice(0, m.index);
632 const nameMatch = before.match(/\b([A-Za-z][A-Za-z\- ]{2,40})\s+shadow\b[^A-Za-z0-9]*$/i);
633 let name = null;
634 if (nameMatch) {
635 const stripped = nameMatch[1]
636 .replace(/^(?:use|using|apply|applying|is|are|looks? like)\s+/i, '')
637 .replace(/^(?:a|an|the)\s+/i, '')
638 .trim();
639 if (stripped) {
640 name =
641 stripped.charAt(0).toUpperCase() + stripped.slice(1) + ' shadow';
642 }
643 }
644 out.push({
645 name,
646 value,
647 purpose: null,
648 });
649 }
650 return out;
651}
652
653function parseShadowBullet(bullet) {
654 // - **Name** (`box-shadow: value`): purpose
655 // - **Name** (`value`): purpose
656 // Only accept if the paren content looks like a shadow value (contains px,
657 // rem, rgba, or box-shadow). This filters out `**Rule Name:**` bullets.
658 const m = bullet.match(/^\*\*(.+?)\*\*\s*\(`?([^`]+?)`?\):\s*(.*)$/);
659 if (!m) return null;
660 const rawValue = m[2].replace(/^box-shadow:\s*/i, '').trim();
661 const looksLikeShadow =
662 /box-shadow|rgba?\(|\bpx\b|\brem\b|^-?\d+\s/i.test(rawValue) &&
663 /\d/.test(rawValue);
664 if (!looksLikeShadow) return null;
665 const name = stripBold(m[1]).trim();
666 return {
667 name,
668 value: rawValue,
669 purpose: stripBold(m[3] || '').trim() || null,
670 };
671}
672
673function extractComponents(section) {
674 if (!section) return null;
675 const subs = splitSubsections(section.lines);
676 const components = [];
677
678 for (const sub of subs.slice(1)) {
679 if (!sub.name) continue;
680
681 const bullets = collectBullets(sub.lines);
682 const paragraphs = collectParagraphs(sub.lines);
683
684 const variants = [];
685 const properties = {};
686
687 for (const b of bullets) {
688 // - **Key:** value
689 const m = b.match(/^\*\*(.+?):?\*\*:?\s*(.+)$/);
690 if (m) {
691 const key = stripBold(m[1]).trim();
692 const value = stripBold(m[2]).trim();
693 // Heuristic: "Primary", "Secondary", "Hover", "Focus" etc are variants;
694 // "Shape", "Background", "Padding" are properties.
695 if (/^(primary|secondary|tertiary|ghost|hover|focus|active|disabled|default|error|selected|unselected|state)$/i.test(key.split(/[\s/]/)[0])) {
696 variants.push({ name: key, description: value });
697 } else {
698 properties[key.toLowerCase()] = value;
699 }
700 }
701 }
702
703 components.push({
704 name: sub.name,
705 description: paragraphs.join(' ') || null,
706 properties,
707 variants,
708 });
709 }
710
711 return {
712 subtitle: section.subtitle,
713 components,
714 };
715}
716
717function extractDosDonts(section) {
718 if (!section) return null;
719 const subs = splitSubsections(section.lines);
720 const dos = [];
721 const donts = [];
722
723 for (const sub of subs.slice(1)) {
724 if (!sub.name) continue;
725 const subName = normalizeApostrophes(sub.name);
726 const bullets = collectBullets(sub.lines).map((b) => stripBold(b).trim());
727 if (/^do'?t?:?$/i.test(subName) || /^do:?$/i.test(subName)) {
728 dos.push(...bullets);
729 } else if (/^don'?t:?$/i.test(subName)) {
730 donts.push(...bullets);
731 }
732 }
733
734 // Classify by bullet prefix as a backup (catches loose bullets outside H3 wrappers)
735 for (const b of collectBullets(section.lines)) {
736 const stripped = normalizeApostrophes(stripBold(b).trim());
737 if (/^don'?t\b/i.test(stripped)) {
738 if (!donts.some((d) => normalizeApostrophes(d) === stripped)) donts.push(stripped);
739 } else if (/^do\b/i.test(stripped)) {
740 if (!dos.some((d) => normalizeApostrophes(d) === stripped)) dos.push(stripped);
741 }
742 }
743
744 return { dos, donts };
745}
746
747// ---------- Coverage assessment ----------
748
749function assessCoverage(model) {
750 const report = {};
751
752 report.overview = model.overview
753 ? {
754 northStar: Boolean(model.overview.creativeNorthStar),
755 philosophy: model.overview.philosophy.length > 0,
756 keyCharacteristics: model.overview.keyCharacteristics.length,
757 }
758 : 'missing';
759
760 report.colors = model.colors
761 ? {
762 groups: model.colors.groups.length,
763 totalColors: model.colors.groups.reduce((n, g) => n + g.colors.length, 0),
764 rules: model.colors.rules.length,
765 }
766 : 'missing';
767
768 report.typography = model.typography
769 ? {
770 fonts: Object.keys(model.typography.fonts).length,
771 hierarchyEntries: model.typography.hierarchy.length,
772 character: Boolean(model.typography.character),
773 rules: model.typography.rules.length,
774 }
775 : 'missing';
776
777 report.elevation = model.elevation
778 ? {
779 shadows: model.elevation.shadows.length,
780 rules: model.elevation.rules.length,
781 description: Boolean(model.elevation.description),
782 }
783 : 'missing';
784
785 report.components = model.components
786 ? {
787 count: model.components.components.length,
788 variantTotal: model.components.components.reduce((n, c) => n + c.variants.length, 0),
789 }
790 : 'missing';
791
792 report.dosDonts = model.dosDonts
793 ? {
794 dos: model.dosDonts.dos.length,
795 donts: model.dosDonts.donts.length,
796 }
797 : 'missing';
798
799 return report;
800}
801
802// ---------- Main ----------
803
804export function parseDesignMd(md) {
805 const { frontmatter, body } = parseFrontmatter(md);
806 const { title, sections } = splitSections(body);
807 return {
808 schemaVersion: 2,
809 title,
810 frontmatter,
811 overview: extractOverview(sections['Overview']),
812 colors: extractColors(sections['Colors']),
813 typography: extractTypography(sections['Typography']),
814 elevation: extractElevation(sections['Elevation']),
815 components: extractComponents(sections['Components']),
816 dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
817 };
818}
819
820export { assessCoverage };