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