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