design-parser.mjs

  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 };