Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/lib/design-parser.mjs
1// Parse a DESIGN.md (Stitch-spec format) into a structured JSON model that2// the live-mode design-system panel can render. Deterministic, dependency-free.3//4// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body5// (prose with six canonical H2 sections). When frontmatter is present, it's6// exposed on `model.frontmatter` alongside the prose-scraped sections;7// consumers can prefer frontmatter values and fall back to prose.89const CANONICAL_SECTIONS = [10'Overview',11'Colors',12'Typography',13'Elevation',14'Components',15"Do's and Don'ts",16];1718// ---------- Frontmatter (Stitch YAML subset) ----------1920function parseFrontmatter(md) {21const lines = md.split(/\r?\n/);22if (lines[0]?.trim() !== '---') return { frontmatter: null, body: md };2324let end = -1;25for (let i = 1; i < lines.length; i++) {26if (lines[i].trim() === '---') { end = i; break; }27}28if (end === -1) return { frontmatter: null, body: md };2930const yaml = lines.slice(1, end).join('\n');31const body = lines.slice(end + 1).join('\n');32try {33return { frontmatter: parseYamlSubset(yaml), body };34} catch {35return { frontmatter: null, body: md };36}37}3839// Minimal YAML reader for the Stitch frontmatter subset: scalar maps with40// one level of nested objects (typography roles, components). Indent-based,41// 2-space convention. No arrays, no anchors, no multi-line scalars — Stitch's42// schema doesn't need them and accepting them would require a real YAML43// dependency we don't want to vendor.44function parseYamlSubset(yaml) {45const lines = yaml.split(/\r?\n/);46const root = {};47const stack = [{ indent: -1, obj: root }];4849for (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 distinguished52// from a comment after whitespace.53if (!raw.trim() || /^\s*#/.test(raw)) continue;5455const indent = raw.match(/^\s*/)[0].length;56const content = raw.slice(indent);5758const colonIdx = findTopLevelColon(content);59if (colonIdx === -1) continue;6061while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {62stack.pop();63}6465const key = unquoteYamlKey(content.slice(0, colonIdx).trim());66const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());67const parent = stack[stack.length - 1].obj;6869if (rest === '') {70const obj = {};71parent[key] = obj;72stack.push({ indent, obj });73} else {74parent[key] = parseScalar(rest);75}76}7778return root;79}8081function findTopLevelColon(s) {82let inQuote = null;83for (let i = 0; i < s.length; i++) {84const ch = s[i];85if (inQuote) {86if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;87} else if (ch === '"' || ch === "'") {88inQuote = ch;89} else if (ch === ':') {90return i;91}92}93return -1;94}9596function unquoteYamlKey(key) {97if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {98return key.slice(1, -1);99}100return key;101}102103function stripInlineYamlComment(s) {104let inQuote = null;105for (let i = 0; i < s.length; i++) {106const ch = s[i];107if (inQuote) {108if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;109} else if (ch === '"' || ch === "'") {110inQuote = ch;111} else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {112return s.slice(0, i).trimEnd();113}114}115return s;116}117118function parseScalar(raw) {119const s = raw.trim();120if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {121return s.slice(1, -1);122}123if (s === 'true') return true;124if (s === 'false') return false;125if (s === 'null' || s === '~') return null;126if (/^-?\d+$/.test(s)) return Number(s);127if (/^-?\d*\.\d+$/.test(s)) return Number(s);128return s;129}130131const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;132const OKLCH_RE = /oklch\([^)]+\)/gi;133const RGBA_RE = /rgba?\([^)]+\)/gi;134const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;135const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;136137// ---------- Section splitting ----------138139function splitSections(md) {140const lines = md.split(/\r?\n/);141let title = null;142const sections = {};143let current = null;144145for (const raw of lines) {146const line = raw.trimEnd();147148if (!title && line.startsWith('# ') && !line.startsWith('## ')) {149title = line.replace(/^#\s+/, '').trim();150continue;151}152153const h2 = line.match(/^##\s+(?:\d+\.\s*)?([^:\n]+?)(?::\s*(.+))?$/);154if (h2) {155const rawName = normalizeApostrophes(h2[1].trim());156const subtitle = h2[2] ? h2[2].trim() : null;157const canonical = matchCanonicalSection(rawName);158if (canonical) {159current = { name: canonical, subtitle, lines: [] };160sections[canonical] = current;161continue;162}163// non-canonical H2 — ignore but stop feeding into current164current = null;165continue;166}167168if (current) current.lines.push(raw);169}170171return { title, sections };172}173174function normalizeApostrophes(s) {175return s.replace(/[\u2018\u2019]/g, "'");176}177178function matchCanonicalSection(name) {179const normalized = normalizeApostrophes(name).toLowerCase();180// Exact match first181for (const c of CANONICAL_SECTIONS) {182if (normalizeApostrophes(c).toLowerCase() === normalized) return c;183}184// Keyword-contained match: "Overview & Creative North Star" -> "Overview",185// "Elevation & Depth" -> "Elevation", etc.186for (const c of CANONICAL_SECTIONS) {187const key = normalizeApostrophes(c).toLowerCase();188const pattern = new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);189if (pattern.test(normalized)) return c;190}191return null;192}193194// ---------- Subsection splitting (inside a canonical section) ----------195196function splitSubsections(lines) {197const subs = [];198let current = { name: null, lines: [] };199subs.push(current);200201for (const raw of lines) {202const h3 = raw.match(/^###\s+(.+?)\s*$/);203if (h3) {204current = { name: h3[1].trim(), lines: [] };205subs.push(current);206continue;207}208current.lines.push(raw);209}210211return subs;212}213214// ---------- Generic helpers ----------215216function collectParagraphs(lines) {217const paragraphs = [];218let buf = [];219const flush = () => {220if (buf.length) {221paragraphs.push(buf.join(' ').trim());222buf = [];223}224};225for (const raw of lines) {226const trimmed = raw.trim();227if (trimmed === '') { flush(); continue; }228// Horizontal rules (---, ***) and headings/bullets end a paragraph.229if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flush(); continue; }230if (raw.startsWith('#') || raw.match(/^[-*]\s/)) { flush(); continue; }231buf.push(trimmed);232}233flush();234return paragraphs.filter(Boolean);235}236237function collectBullets(lines) {238const bullets = [];239let current = null;240for (const raw of lines) {241const m = raw.match(/^\s*[-*]\s+(.+)$/);242if (m) {243if (current) bullets.push(current);244current = m[1];245continue;246}247// continuation of a bullet (indented line)248if (current && raw.match(/^\s{2,}\S/)) {249current += ' ' + raw.trim();250continue;251}252// blank line ends a bullet253if (raw.trim() === '' && current) {254bullets.push(current);255current = null;256}257}258if (current) bullets.push(current);259return bullets;260}261262function stripBold(s) {263return s.replace(/\*\*(.+?)\*\*/g, '$1');264}265266function extractNamedRules(lines) {267const rules = [];268const seen = new Set();269270// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.271const joined = lines.join('\n');272const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;273const inlineMatches = [];274let m;275while ((m = inlineStart.exec(joined)) !== null) {276inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });277}278for (let i = 0; i < inlineMatches.length; i++) {279const mm = inlineMatches[i];280const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;281const body = joined282.slice(mm.end, bodyEnd)283.replace(/\n##[^\n]*$/s, '')284.replace(/\n###[^\n]*$/s, '')285.trim();286const name = stripBold(mm.name).trim();287seen.add(name.toLowerCase());288rules.push({ name, body: stripBold(body) });289}290291// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the292// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.293for (let i = 0; i < lines.length; i++) {294const h3 = lines[i].match(/^###\s+(.+?)\s*$/);295if (!h3) continue;296const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();297if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;298if (seen.has(headerName.toLowerCase())) continue;299300const bodyLines = [];301for (let j = i + 1; j < lines.length; j++) {302if (/^##\s|^###\s/.test(lines[j])) break;303bodyLines.push(lines[j]);304}305const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();306if (body) {307seen.add(headerName.toLowerCase());308rules.push({ name: headerName, body });309}310}311312// Style C (Stitch bullet form): "* **The Layering Principle:** body"313// Colon/period lives inside the bold, so match "**...**" then inspect.314for (const b of collectBullets(lines)) {315const mm = b.match(/^\*\*([^*]+?)\*\*\s*(.+)$/);316if (!mm) continue;317const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();318if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;319if (seen.has(nameRaw.toLowerCase())) continue;320seen.add(nameRaw.toLowerCase());321rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });322}323324return rules;325}326327// ---------- Per-section extractors ----------328329function extractOverview(section) {330if (!section) return null;331const text = section.lines.join('\n');332const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);333const keyChars = [];334const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);335if (keyCharMatch) {336for (const line of keyCharMatch[1].split('\n')) {337const m = line.match(/^\s*[-*]\s+(.+)$/);338if (m) keyChars.push(stripBold(m[1].trim()));339}340}341342// Philosophy paragraphs: everything that isn't a rule header or key-char block343const paragraphs = collectParagraphs(section.lines).filter(344(p) =>345!p.startsWith('**Creative North Star') &&346!p.startsWith('**Key Characteristics')347);348349return {350subtitle: section.subtitle,351creativeNorthStar: northStar ? northStar[1] : null,352philosophy: paragraphs,353keyCharacteristics: keyChars,354};355}356357function extractColors(section) {358if (!section) return null;359const subs = splitSubsections(section.lines);360361const description = collectParagraphs(subs[0].lines).join(' ');362const groups = [];363const ROLE_KEYWORDS = /^(primary|secondary|tertiary|neutral|accent)\b/i;364365for (const sub of subs.slice(1)) {366if (!sub.name || /Named Rules?/i.test(sub.name) || /^The\s/i.test(sub.name)) continue;367368const bullets = collectBullets(sub.lines);369const parsed = bullets.map((b) => parseColorBullet(b)).filter(Boolean);370if (parsed.length === 0) continue;371372// If every bullet starts with a role keyword (Primary/Secondary/...), promote373// each bullet to its own group. Otherwise keep the subsection as the group.374const allRoleBullets =375parsed.length > 0 && parsed.every((p) => p.name && ROLE_KEYWORDS.test(p.name));376377if (allRoleBullets) {378for (const p of parsed) {379groups.push({ role: p.name, colors: [p] });380}381} else {382groups.push({ role: sub.name, colors: parsed });383}384}385386// If the Colors section has no subsections at all (unlikely), fall back to387// scanning the whole section as a flat bullet list.388if (groups.length === 0) {389const flat = collectBullets(section.lines)390.map((b) => parseColorBullet(b))391.filter(Boolean);392if (flat.length) {393for (const p of flat) {394if (p.name && ROLE_KEYWORDS.test(p.name)) {395groups.push({ role: p.name, colors: [p] });396} else {397const fallback = groups.find((g) => g.role === 'Palette');398if (fallback) fallback.colors.push(p);399else groups.push({ role: 'Palette', colors: [p] });400}401}402}403}404405return {406subtitle: section.subtitle,407description: description || null,408groups,409rules: extractNamedRules(section.lines),410};411}412413function parseColorBullet(bullet) {414const text = bullet.trim();415416// Case 1 (Impeccable): **Name** (value-with-maybe-nested-parens): description417const bold = text.match(/^\*\*(.+?)\*\*\s*(.*)$/);418if (bold && bold[2].startsWith('(')) {419const value = extractParenGroup(bold[2]);420if (value !== null) {421const after = bold[2].slice(value.length + 2).trimStart();422if (after.startsWith(':')) {423return buildColor(bold[1], value, after.slice(1).trim());424}425}426}427428// Case 2 (Stitch): **Name (values):** description — value embedded in bold.429const stitch = text.match(/^\*\*([^*]+?)\s*\(([^)]+)\):\*\*\s*(.*)$/);430if (stitch) {431return buildColor(stitch[1].trim(), stitch[2], stitch[3]);432}433434// Case 3: bullet without bold, just hex/oklch inside.435const values = collectColorValues(text);436if (values.length) {437return buildColor(null, values.join(' to '), text);438}439return null;440}441442function extractParenGroup(s) {443if (s[0] !== '(') return null;444let depth = 0;445for (let i = 0; i < s.length; i++) {446if (s[i] === '(') depth++;447else if (s[i] === ')') {448depth--;449if (depth === 0) return s.slice(1, i);450}451}452return null;453}454455function buildColor(name, rawValue, description) {456const values = collectColorValues(rawValue);457const primary = values[0] ?? rawValue.trim();458return {459name: name ? stripBold(name).trim() : null,460value: primary,461valueRange: values.length > 1 ? values : null,462format: detectFormat(primary),463description: stripBold(description || '').trim() || null,464};465}466467function collectColorValues(s) {468const out = [];469s.replace(HEX_RE, (v) => {470out.push(v);471return v;472});473s.replace(OKLCH_RE, (v) => {474out.push(v);475return v;476});477return out;478}479480function detectFormat(v) {481if (!v) return 'unknown';482if (v.startsWith('#')) return 'hex';483if (/^oklch/i.test(v)) return 'oklch';484if (/^rgb/i.test(v)) return 'rgb';485return 'unknown';486}487488function scanInlineColors(lines) {489const out = [];490for (const line of lines) {491if (!/^\s*[-*]\s/.test(line)) continue;492const trimmed = line.replace(/^\s*[-*]\s+/, '');493const color = parseColorBullet(trimmed);494if (color) out.push(color);495}496return out;497}498499function parseStitchInlineGroups(lines) {500// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`501// Each bullet IS its own role. Group them under the spoken role name.502const out = [];503for (const line of lines) {504if (!/^\s*[-*]\s/.test(line)) continue;505const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();506const m = trimmed.match(507/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/508);509if (m) {510const role = m[1];511const color = buildColor(role, m[2], m[3]);512out.push({ role, colors: [color] });513}514}515return out;516}517518function extractTypography(section) {519if (!section) return null;520const text = section.lines.join('\n');521522const fonts = {};523// Pattern A: **Display Font:** Family (with fallback)524const fontLineRe = /\*\*([\w\s/]+?)Font:\*\*\s*([^\n(]+?)(?:\s*\(with\s+([^)]+)\))?\s*$/gm;525let fm;526while ((fm = fontLineRe.exec(text)) !== null) {527const rawRole = fm[1].trim().toLowerCase().replace(/\s+/g, '-');528const role = normalizeFontRole(rawRole) || 'display';529fonts[role] = {530family: fm[2].trim(),531fallback: fm[3] ? fm[3].trim() : null,532};533}534535// Pattern B (Stitch): * **Display & Headlines (Noto Serif):** description536if (Object.keys(fonts).length === 0) {537const stitchRe = /\*\*([\w\s&/]+?)\s*\(([^)]+)\):\*\*\s*(.+)/g;538let sm;539while ((sm = stitchRe.exec(text)) !== null) {540const rawRole = sm[1]541.trim()542.toLowerCase()543.replace(/\s*&\s*/g, '-')544.replace(/\s+/g, '-');545const role = normalizeFontRole(rawRole) || rawRole;546fonts[role] = { family: sm[2].trim(), fallback: null, purpose: sm[3].trim() };547}548}549550// Character paragraph — either a **Character:** label, or fall back to the551// first free paragraph under the section header (Stitch style).552const characterMatch = text.match(/\*\*Character:\*\*\s*([^\n]+(?:\n[^\n]+)*?)(?=\n\n|\n###|\n##|$)/);553let character = characterMatch ? characterMatch[1].replace(/\n/g, ' ').trim() : null;554if (!character) {555const paragraphs = collectParagraphs(section.lines).filter(556(p) => !/^\*\*[\w\s/&]+Font/i.test(p) && !/^\*\*[\w\s/&]+\([^)]+\)/.test(p)557);558if (paragraphs.length) character = paragraphs[0];559}560561// Hierarchy bullets under ### Hierarchy562const subs = splitSubsections(section.lines);563let hierarchy = [];564const hierSub = subs.find((s) => s.name && /hierarch/i.test(s.name));565if (hierSub) {566const bullets = collectBullets(hierSub.lines);567hierarchy = bullets.map(parseTypeBullet).filter(Boolean);568}569570return {571subtitle: section.subtitle,572fonts,573character,574hierarchy,575rules: extractNamedRules(section.lines),576};577}578579function normalizeFontRole(raw) {580// Canonical roles the panel cares about: display, body, label, mono.581// Stitch often writes compound roles like "display-&-headlines" or "ui-&-body"582// — collapse them to the first canonical role present.583const tokens = raw.split(/[-/&\s]+/).filter(Boolean);584const priority = ['display', 'headline', 'body', 'ui', 'label', 'mono'];585const canonical = { headline: 'display', ui: 'body' };586for (const p of priority) {587if (tokens.includes(p)) return canonical[p] || p;588}589return null;590}591592function parseTypeBullet(bullet) {593// - **Display** (family, weight 300, italic, clamp(...), line-height 1): purpose594const m = bullet.match(/^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*(.*)$/);595if (!m) return null;596const name = m[1].trim();597const specs = m[2].split(',').map((s) => s.trim());598return {599name,600specs,601purpose: stripBold(m[3] || '').trim() || null,602};603}604605function extractElevation(section) {606if (!section) return null;607const subs = splitSubsections(section.lines);608609const description = collectParagraphs(subs[0].lines).join(' ') || null;610611const shadows = [];612const seen = new Set();613const dedupe = (entry) => {614const key = (entry.name || '') + '::' + entry.value;615if (seen.has(key)) return;616seen.add(key);617shadows.push(entry);618};619620for (const b of collectBullets(section.lines)) {621const parsed = parseShadowBullet(b);622if (parsed) dedupe(parsed);623}624625// Fallback: extract shadows written inline in prose. Stitch style is626// "...use an extra-diffused shadow: `box-shadow: 0 12px 40px rgba(...)`."627for (const p of collectParagraphs(section.lines)) {628for (const inline of extractInlineShadows(p)) dedupe(inline);629}630for (const b of collectBullets(section.lines)) {631for (const inline of extractInlineShadows(b)) dedupe(inline);632}633634return {635subtitle: section.subtitle,636description,637shadows,638rules: extractNamedRules(section.lines),639};640}641642function extractInlineShadows(text) {643// Find `box-shadow: ...` anywhere in prose and capture the value. Work on the644// raw string so it handles both backtick-fenced and unfenced variants.645const out = [];646const re = /box-shadow\s*:\s*([^`;\n]+)/gi;647let m;648while ((m = re.exec(text)) !== null) {649const value = m[1].replace(/[`.)]+$/, '').trim();650if (!value) continue;651// Name heuristic: the noun immediately before the shadow phrase.652// e.g. "an extra-diffused shadow: ..." -> "extra-diffused shadow"653const before = text.slice(0, m.index);654const nameMatch = before.match(/\b([A-Za-z][A-Za-z\- ]{2,40})\s+shadow\b[^A-Za-z0-9]*$/i);655let name = null;656if (nameMatch) {657const stripped = nameMatch[1]658.replace(/^(?:use|using|apply|applying|is|are|looks? like)\s+/i, '')659.replace(/^(?:a|an|the)\s+/i, '')660.trim();661if (stripped) {662name =663stripped.charAt(0).toUpperCase() + stripped.slice(1) + ' shadow';664}665}666out.push({667name,668value,669purpose: null,670});671}672return out;673}674675function parseShadowBullet(bullet) {676// - **Name** (`box-shadow: value`): purpose677// - **Name** (`value`): purpose678// Only accept if the paren content looks like a shadow value (contains px,679// rem, rgba, or box-shadow). This filters out `**Rule Name:**` bullets.680const m = bullet.match(/^\*\*(.+?)\*\*\s*\(`?([^`]+?)`?\):\s*(.*)$/);681if (!m) return null;682const rawValue = m[2].replace(/^box-shadow:\s*/i, '').trim();683const looksLikeShadow =684/box-shadow|rgba?\(|\bpx\b|\brem\b|^-?\d+\s/i.test(rawValue) &&685/\d/.test(rawValue);686if (!looksLikeShadow) return null;687const name = stripBold(m[1]).trim();688return {689name,690value: rawValue,691purpose: stripBold(m[3] || '').trim() || null,692};693}694695function extractComponents(section) {696if (!section) return null;697const subs = splitSubsections(section.lines);698const components = [];699700for (const sub of subs.slice(1)) {701if (!sub.name) continue;702703const bullets = collectBullets(sub.lines);704const paragraphs = collectParagraphs(sub.lines);705706const variants = [];707const properties = {};708709for (const b of bullets) {710// - **Key:** value711const m = b.match(/^\*\*(.+?):?\*\*:?\s*(.+)$/);712if (m) {713const key = stripBold(m[1]).trim();714const value = stripBold(m[2]).trim();715// Heuristic: "Primary", "Secondary", "Hover", "Focus" etc are variants;716// "Shape", "Background", "Padding" are properties.717if (/^(primary|secondary|tertiary|ghost|hover|focus|active|disabled|default|error|selected|unselected|state)$/i.test(key.split(/[\s/]/)[0])) {718variants.push({ name: key, description: value });719} else {720properties[key.toLowerCase()] = value;721}722}723}724725components.push({726name: sub.name,727description: paragraphs.join(' ') || null,728properties,729variants,730});731}732733return {734subtitle: section.subtitle,735components,736};737}738739function extractDosDonts(section) {740if (!section) return null;741const subs = splitSubsections(section.lines);742const dos = [];743const donts = [];744745for (const sub of subs.slice(1)) {746if (!sub.name) continue;747const subName = normalizeApostrophes(sub.name);748const bullets = collectBullets(sub.lines).map((b) => stripBold(b).trim());749if (/^do'?t?:?$/i.test(subName) || /^do:?$/i.test(subName)) {750dos.push(...bullets);751} else if (/^don'?t:?$/i.test(subName)) {752donts.push(...bullets);753}754}755756// Classify by bullet prefix as a backup (catches loose bullets outside H3 wrappers)757for (const b of collectBullets(section.lines)) {758const stripped = normalizeApostrophes(stripBold(b).trim());759if (/^don'?t\b/i.test(stripped)) {760if (!donts.some((d) => normalizeApostrophes(d) === stripped)) donts.push(stripped);761} else if (/^do\b/i.test(stripped)) {762if (!dos.some((d) => normalizeApostrophes(d) === stripped)) dos.push(stripped);763}764}765766return { dos, donts };767}768769// ---------- Coverage assessment ----------770771function assessCoverage(model) {772const report = {};773774report.overview = model.overview775? {776northStar: Boolean(model.overview.creativeNorthStar),777philosophy: model.overview.philosophy.length > 0,778keyCharacteristics: model.overview.keyCharacteristics.length,779}780: 'missing';781782report.colors = model.colors783? {784groups: model.colors.groups.length,785totalColors: model.colors.groups.reduce((n, g) => n + g.colors.length, 0),786rules: model.colors.rules.length,787}788: 'missing';789790report.typography = model.typography791? {792fonts: Object.keys(model.typography.fonts).length,793hierarchyEntries: model.typography.hierarchy.length,794character: Boolean(model.typography.character),795rules: model.typography.rules.length,796}797: 'missing';798799report.elevation = model.elevation800? {801shadows: model.elevation.shadows.length,802rules: model.elevation.rules.length,803description: Boolean(model.elevation.description),804}805: 'missing';806807report.components = model.components808? {809count: model.components.components.length,810variantTotal: model.components.components.reduce((n, c) => n + c.variants.length, 0),811}812: 'missing';813814report.dosDonts = model.dosDonts815? {816dos: model.dosDonts.dos.length,817donts: model.dosDonts.donts.length,818}819: 'missing';820821return report;822}823824// ---------- Main ----------825826export function parseDesignMd(md) {827const { frontmatter, body } = parseFrontmatter(md);828const { title, sections } = splitSections(body);829return {830schemaVersion: 2,831title,832frontmatter,833overview: extractOverview(sections['Overview']),834colors: extractColors(sections['Colors']),835typography: extractTypography(sections['Typography']),836elevation: extractElevation(sections['Elevation']),837components: extractComponents(sections['Components']),838dosDonts: extractDosDonts(sections["Do's and Don'ts"]),839};840}841842export { assessCoverage };843