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/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 = content.slice(0, colonIdx).trim();66const rest = 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 parseScalar(raw) {97const s = raw.trim();98if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {99return s.slice(1, -1);100}101if (s === 'true') return true;102if (s === 'false') return false;103if (s === 'null' || s === '~') return null;104if (/^-?\d+$/.test(s)) return Number(s);105if (/^-?\d*\.\d+$/.test(s)) return Number(s);106return s;107}108109const 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*(.+)/;114115// ---------- Section splitting ----------116117function splitSections(md) {118const lines = md.split(/\r?\n/);119let title = null;120const sections = {};121let current = null;122123for (const raw of lines) {124const line = raw.trimEnd();125126if (!title && line.startsWith('# ') && !line.startsWith('## ')) {127title = line.replace(/^#\s+/, '').trim();128continue;129}130131const h2 = line.match(/^##\s+(?:\d+\.\s*)?([^:\n]+?)(?::\s*(.+))?$/);132if (h2) {133const rawName = normalizeApostrophes(h2[1].trim());134const subtitle = h2[2] ? h2[2].trim() : null;135const canonical = matchCanonicalSection(rawName);136if (canonical) {137current = { name: canonical, subtitle, lines: [] };138sections[canonical] = current;139continue;140}141// non-canonical H2 — ignore but stop feeding into current142current = null;143continue;144}145146if (current) current.lines.push(raw);147}148149return { title, sections };150}151152function normalizeApostrophes(s) {153return s.replace(/[\u2018\u2019]/g, "'");154}155156function matchCanonicalSection(name) {157const normalized = normalizeApostrophes(name).toLowerCase();158// Exact match first159for (const c of CANONICAL_SECTIONS) {160if (normalizeApostrophes(c).toLowerCase() === normalized) return c;161}162// Keyword-contained match: "Overview & Creative North Star" -> "Overview",163// "Elevation & Depth" -> "Elevation", etc.164for (const c of CANONICAL_SECTIONS) {165const key = normalizeApostrophes(c).toLowerCase();166const pattern = new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);167if (pattern.test(normalized)) return c;168}169return null;170}171172// ---------- Subsection splitting (inside a canonical section) ----------173174function splitSubsections(lines) {175const subs = [];176let current = { name: null, lines: [] };177subs.push(current);178179for (const raw of lines) {180const h3 = raw.match(/^###\s+(.+?)\s*$/);181if (h3) {182current = { name: h3[1].trim(), lines: [] };183subs.push(current);184continue;185}186current.lines.push(raw);187}188189return subs;190}191192// ---------- Generic helpers ----------193194function collectParagraphs(lines) {195const paragraphs = [];196let buf = [];197const flush = () => {198if (buf.length) {199paragraphs.push(buf.join(' ').trim());200buf = [];201}202};203for (const raw of lines) {204const trimmed = raw.trim();205if (trimmed === '') { flush(); continue; }206// Horizontal rules (---, ***) and headings/bullets end a paragraph.207if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flush(); continue; }208if (raw.startsWith('#') || raw.match(/^[-*]\s/)) { flush(); continue; }209buf.push(trimmed);210}211flush();212return paragraphs.filter(Boolean);213}214215function collectBullets(lines) {216const bullets = [];217let current = null;218for (const raw of lines) {219const m = raw.match(/^\s*[-*]\s+(.+)$/);220if (m) {221if (current) bullets.push(current);222current = m[1];223continue;224}225// continuation of a bullet (indented line)226if (current && raw.match(/^\s{2,}\S/)) {227current += ' ' + raw.trim();228continue;229}230// blank line ends a bullet231if (raw.trim() === '' && current) {232bullets.push(current);233current = null;234}235}236if (current) bullets.push(current);237return bullets;238}239240function stripBold(s) {241return s.replace(/\*\*(.+?)\*\*/g, '$1');242}243244function extractNamedRules(lines) {245const rules = [];246const seen = new Set();247248// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.249const joined = lines.join('\n');250const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;251const inlineMatches = [];252let m;253while ((m = inlineStart.exec(joined)) !== null) {254inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });255}256for (let i = 0; i < inlineMatches.length; i++) {257const mm = inlineMatches[i];258const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;259const body = joined260.slice(mm.end, bodyEnd)261.replace(/\n##[^\n]*$/s, '')262.replace(/\n###[^\n]*$/s, '')263.trim();264const name = stripBold(mm.name).trim();265seen.add(name.toLowerCase());266rules.push({ name, body: stripBold(body) });267}268269// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the270// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.271for (let i = 0; i < lines.length; i++) {272const h3 = lines[i].match(/^###\s+(.+?)\s*$/);273if (!h3) continue;274const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();275if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;276if (seen.has(headerName.toLowerCase())) continue;277278const bodyLines = [];279for (let j = i + 1; j < lines.length; j++) {280if (/^##\s|^###\s/.test(lines[j])) break;281bodyLines.push(lines[j]);282}283const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();284if (body) {285seen.add(headerName.toLowerCase());286rules.push({ name: headerName, body });287}288}289290// Style C (Stitch bullet form): "* **The Layering Principle:** body"291// Colon/period lives inside the bold, so match "**...**" then inspect.292for (const b of collectBullets(lines)) {293const mm = b.match(/^\*\*([^*]+?)\*\*\s*(.+)$/);294if (!mm) continue;295const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();296if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;297if (seen.has(nameRaw.toLowerCase())) continue;298seen.add(nameRaw.toLowerCase());299rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });300}301302return rules;303}304305// ---------- Per-section extractors ----------306307function extractOverview(section) {308if (!section) return null;309const text = section.lines.join('\n');310const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);311const keyChars = [];312const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);313if (keyCharMatch) {314for (const line of keyCharMatch[1].split('\n')) {315const m = line.match(/^\s*[-*]\s+(.+)$/);316if (m) keyChars.push(stripBold(m[1].trim()));317}318}319320// Philosophy paragraphs: everything that isn't a rule header or key-char block321const paragraphs = collectParagraphs(section.lines).filter(322(p) =>323!p.startsWith('**Creative North Star') &&324!p.startsWith('**Key Characteristics')325);326327return {328subtitle: section.subtitle,329creativeNorthStar: northStar ? northStar[1] : null,330philosophy: paragraphs,331keyCharacteristics: keyChars,332};333}334335function extractColors(section) {336if (!section) return null;337const subs = splitSubsections(section.lines);338339const description = collectParagraphs(subs[0].lines).join(' ');340const groups = [];341const ROLE_KEYWORDS = /^(primary|secondary|tertiary|neutral|accent)\b/i;342343for (const sub of subs.slice(1)) {344if (!sub.name || /Named Rules?/i.test(sub.name) || /^The\s/i.test(sub.name)) continue;345346const bullets = collectBullets(sub.lines);347const parsed = bullets.map((b) => parseColorBullet(b)).filter(Boolean);348if (parsed.length === 0) continue;349350// If every bullet starts with a role keyword (Primary/Secondary/...), promote351// each bullet to its own group. Otherwise keep the subsection as the group.352const allRoleBullets =353parsed.length > 0 && parsed.every((p) => p.name && ROLE_KEYWORDS.test(p.name));354355if (allRoleBullets) {356for (const p of parsed) {357groups.push({ role: p.name, colors: [p] });358}359} else {360groups.push({ role: sub.name, colors: parsed });361}362}363364// If the Colors section has no subsections at all (unlikely), fall back to365// scanning the whole section as a flat bullet list.366if (groups.length === 0) {367const flat = collectBullets(section.lines)368.map((b) => parseColorBullet(b))369.filter(Boolean);370if (flat.length) {371for (const p of flat) {372if (p.name && ROLE_KEYWORDS.test(p.name)) {373groups.push({ role: p.name, colors: [p] });374} else {375const fallback = groups.find((g) => g.role === 'Palette');376if (fallback) fallback.colors.push(p);377else groups.push({ role: 'Palette', colors: [p] });378}379}380}381}382383return {384subtitle: section.subtitle,385description: description || null,386groups,387rules: extractNamedRules(section.lines),388};389}390391function parseColorBullet(bullet) {392const text = bullet.trim();393394// Case 1 (Impeccable): **Name** (value-with-maybe-nested-parens): description395const bold = text.match(/^\*\*(.+?)\*\*\s*(.*)$/);396if (bold && bold[2].startsWith('(')) {397const value = extractParenGroup(bold[2]);398if (value !== null) {399const after = bold[2].slice(value.length + 2).trimStart();400if (after.startsWith(':')) {401return buildColor(bold[1], value, after.slice(1).trim());402}403}404}405406// Case 2 (Stitch): **Name (values):** description — value embedded in bold.407const stitch = text.match(/^\*\*([^*]+?)\s*\(([^)]+)\):\*\*\s*(.*)$/);408if (stitch) {409return buildColor(stitch[1].trim(), stitch[2], stitch[3]);410}411412// Case 3: bullet without bold, just hex/oklch inside.413const values = collectColorValues(text);414if (values.length) {415return buildColor(null, values.join(' to '), text);416}417return null;418}419420function extractParenGroup(s) {421if (s[0] !== '(') return null;422let depth = 0;423for (let i = 0; i < s.length; i++) {424if (s[i] === '(') depth++;425else if (s[i] === ')') {426depth--;427if (depth === 0) return s.slice(1, i);428}429}430return null;431}432433function buildColor(name, rawValue, description) {434const values = collectColorValues(rawValue);435const primary = values[0] ?? rawValue.trim();436return {437name: name ? stripBold(name).trim() : null,438value: primary,439valueRange: values.length > 1 ? values : null,440format: detectFormat(primary),441description: stripBold(description || '').trim() || null,442};443}444445function collectColorValues(s) {446const out = [];447s.replace(HEX_RE, (v) => {448out.push(v);449return v;450});451s.replace(OKLCH_RE, (v) => {452out.push(v);453return v;454});455return out;456}457458function detectFormat(v) {459if (!v) return 'unknown';460if (v.startsWith('#')) return 'hex';461if (/^oklch/i.test(v)) return 'oklch';462if (/^rgb/i.test(v)) return 'rgb';463return 'unknown';464}465466function scanInlineColors(lines) {467const out = [];468for (const line of lines) {469if (!/^\s*[-*]\s/.test(line)) continue;470const trimmed = line.replace(/^\s*[-*]\s+/, '');471const color = parseColorBullet(trimmed);472if (color) out.push(color);473}474return out;475}476477function 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.480const out = [];481for (const line of lines) {482if (!/^\s*[-*]\s/.test(line)) continue;483const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();484const m = trimmed.match(485/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/486);487if (m) {488const role = m[1];489const color = buildColor(role, m[2], m[3]);490out.push({ role, colors: [color] });491}492}493return out;494}495496function extractTypography(section) {497if (!section) return null;498const text = section.lines.join('\n');499500const fonts = {};501// Pattern A: **Display Font:** Family (with fallback)502const fontLineRe = /\*\*([\w\s/]+?)Font:\*\*\s*([^\n(]+?)(?:\s*\(with\s+([^)]+)\))?\s*$/gm;503let fm;504while ((fm = fontLineRe.exec(text)) !== null) {505const rawRole = fm[1].trim().toLowerCase().replace(/\s+/g, '-');506const role = normalizeFontRole(rawRole) || 'display';507fonts[role] = {508family: fm[2].trim(),509fallback: fm[3] ? fm[3].trim() : null,510};511}512513// Pattern B (Stitch): * **Display & Headlines (Noto Serif):** description514if (Object.keys(fonts).length === 0) {515const stitchRe = /\*\*([\w\s&/]+?)\s*\(([^)]+)\):\*\*\s*(.+)/g;516let sm;517while ((sm = stitchRe.exec(text)) !== null) {518const rawRole = sm[1]519.trim()520.toLowerCase()521.replace(/\s*&\s*/g, '-')522.replace(/\s+/g, '-');523const role = normalizeFontRole(rawRole) || rawRole;524fonts[role] = { family: sm[2].trim(), fallback: null, purpose: sm[3].trim() };525}526}527528// Character paragraph — either a **Character:** label, or fall back to the529// first free paragraph under the section header (Stitch style).530const characterMatch = text.match(/\*\*Character:\*\*\s*([^\n]+(?:\n[^\n]+)*?)(?=\n\n|\n###|\n##|$)/);531let character = characterMatch ? characterMatch[1].replace(/\n/g, ' ').trim() : null;532if (!character) {533const paragraphs = collectParagraphs(section.lines).filter(534(p) => !/^\*\*[\w\s/&]+Font/i.test(p) && !/^\*\*[\w\s/&]+\([^)]+\)/.test(p)535);536if (paragraphs.length) character = paragraphs[0];537}538539// Hierarchy bullets under ### Hierarchy540const subs = splitSubsections(section.lines);541let hierarchy = [];542const hierSub = subs.find((s) => s.name && /hierarch/i.test(s.name));543if (hierSub) {544const bullets = collectBullets(hierSub.lines);545hierarchy = bullets.map(parseTypeBullet).filter(Boolean);546}547548return {549subtitle: section.subtitle,550fonts,551character,552hierarchy,553rules: extractNamedRules(section.lines),554};555}556557function 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.561const tokens = raw.split(/[-/&\s]+/).filter(Boolean);562const priority = ['display', 'headline', 'body', 'ui', 'label', 'mono'];563const canonical = { headline: 'display', ui: 'body' };564for (const p of priority) {565if (tokens.includes(p)) return canonical[p] || p;566}567return null;568}569570function parseTypeBullet(bullet) {571// - **Display** (family, weight 300, italic, clamp(...), line-height 1): purpose572const m = bullet.match(/^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*(.*)$/);573if (!m) return null;574const name = m[1].trim();575const specs = m[2].split(',').map((s) => s.trim());576return {577name,578specs,579purpose: stripBold(m[3] || '').trim() || null,580};581}582583function extractElevation(section) {584if (!section) return null;585const subs = splitSubsections(section.lines);586587const description = collectParagraphs(subs[0].lines).join(' ') || null;588589const shadows = [];590const seen = new Set();591const dedupe = (entry) => {592const key = (entry.name || '') + '::' + entry.value;593if (seen.has(key)) return;594seen.add(key);595shadows.push(entry);596};597598for (const b of collectBullets(section.lines)) {599const parsed = parseShadowBullet(b);600if (parsed) dedupe(parsed);601}602603// Fallback: extract shadows written inline in prose. Stitch style is604// "...use an extra-diffused shadow: `box-shadow: 0 12px 40px rgba(...)`."605for (const p of collectParagraphs(section.lines)) {606for (const inline of extractInlineShadows(p)) dedupe(inline);607}608for (const b of collectBullets(section.lines)) {609for (const inline of extractInlineShadows(b)) dedupe(inline);610}611612return {613subtitle: section.subtitle,614description,615shadows,616rules: extractNamedRules(section.lines),617};618}619620function extractInlineShadows(text) {621// Find `box-shadow: ...` anywhere in prose and capture the value. Work on the622// raw string so it handles both backtick-fenced and unfenced variants.623const out = [];624const re = /box-shadow\s*:\s*([^`;\n]+)/gi;625let m;626while ((m = re.exec(text)) !== null) {627const value = m[1].replace(/[`.)]+$/, '').trim();628if (!value) continue;629// Name heuristic: the noun immediately before the shadow phrase.630// e.g. "an extra-diffused shadow: ..." -> "extra-diffused shadow"631const before = text.slice(0, m.index);632const nameMatch = before.match(/\b([A-Za-z][A-Za-z\- ]{2,40})\s+shadow\b[^A-Za-z0-9]*$/i);633let name = null;634if (nameMatch) {635const stripped = nameMatch[1]636.replace(/^(?:use|using|apply|applying|is|are|looks? like)\s+/i, '')637.replace(/^(?:a|an|the)\s+/i, '')638.trim();639if (stripped) {640name =641stripped.charAt(0).toUpperCase() + stripped.slice(1) + ' shadow';642}643}644out.push({645name,646value,647purpose: null,648});649}650return out;651}652653function parseShadowBullet(bullet) {654// - **Name** (`box-shadow: value`): purpose655// - **Name** (`value`): purpose656// 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.658const m = bullet.match(/^\*\*(.+?)\*\*\s*\(`?([^`]+?)`?\):\s*(.*)$/);659if (!m) return null;660const rawValue = m[2].replace(/^box-shadow:\s*/i, '').trim();661const looksLikeShadow =662/box-shadow|rgba?\(|\bpx\b|\brem\b|^-?\d+\s/i.test(rawValue) &&663/\d/.test(rawValue);664if (!looksLikeShadow) return null;665const name = stripBold(m[1]).trim();666return {667name,668value: rawValue,669purpose: stripBold(m[3] || '').trim() || null,670};671}672673function extractComponents(section) {674if (!section) return null;675const subs = splitSubsections(section.lines);676const components = [];677678for (const sub of subs.slice(1)) {679if (!sub.name) continue;680681const bullets = collectBullets(sub.lines);682const paragraphs = collectParagraphs(sub.lines);683684const variants = [];685const properties = {};686687for (const b of bullets) {688// - **Key:** value689const m = b.match(/^\*\*(.+?):?\*\*:?\s*(.+)$/);690if (m) {691const key = stripBold(m[1]).trim();692const value = stripBold(m[2]).trim();693// Heuristic: "Primary", "Secondary", "Hover", "Focus" etc are variants;694// "Shape", "Background", "Padding" are properties.695if (/^(primary|secondary|tertiary|ghost|hover|focus|active|disabled|default|error|selected|unselected|state)$/i.test(key.split(/[\s/]/)[0])) {696variants.push({ name: key, description: value });697} else {698properties[key.toLowerCase()] = value;699}700}701}702703components.push({704name: sub.name,705description: paragraphs.join(' ') || null,706properties,707variants,708});709}710711return {712subtitle: section.subtitle,713components,714};715}716717function extractDosDonts(section) {718if (!section) return null;719const subs = splitSubsections(section.lines);720const dos = [];721const donts = [];722723for (const sub of subs.slice(1)) {724if (!sub.name) continue;725const subName = normalizeApostrophes(sub.name);726const bullets = collectBullets(sub.lines).map((b) => stripBold(b).trim());727if (/^do'?t?:?$/i.test(subName) || /^do:?$/i.test(subName)) {728dos.push(...bullets);729} else if (/^don'?t:?$/i.test(subName)) {730donts.push(...bullets);731}732}733734// Classify by bullet prefix as a backup (catches loose bullets outside H3 wrappers)735for (const b of collectBullets(section.lines)) {736const stripped = normalizeApostrophes(stripBold(b).trim());737if (/^don'?t\b/i.test(stripped)) {738if (!donts.some((d) => normalizeApostrophes(d) === stripped)) donts.push(stripped);739} else if (/^do\b/i.test(stripped)) {740if (!dos.some((d) => normalizeApostrophes(d) === stripped)) dos.push(stripped);741}742}743744return { dos, donts };745}746747// ---------- Coverage assessment ----------748749function assessCoverage(model) {750const report = {};751752report.overview = model.overview753? {754northStar: Boolean(model.overview.creativeNorthStar),755philosophy: model.overview.philosophy.length > 0,756keyCharacteristics: model.overview.keyCharacteristics.length,757}758: 'missing';759760report.colors = model.colors761? {762groups: model.colors.groups.length,763totalColors: model.colors.groups.reduce((n, g) => n + g.colors.length, 0),764rules: model.colors.rules.length,765}766: 'missing';767768report.typography = model.typography769? {770fonts: Object.keys(model.typography.fonts).length,771hierarchyEntries: model.typography.hierarchy.length,772character: Boolean(model.typography.character),773rules: model.typography.rules.length,774}775: 'missing';776777report.elevation = model.elevation778? {779shadows: model.elevation.shadows.length,780rules: model.elevation.rules.length,781description: Boolean(model.elevation.description),782}783: 'missing';784785report.components = model.components786? {787count: model.components.components.length,788variantTotal: model.components.components.reduce((n, c) => n + c.variants.length, 0),789}790: 'missing';791792report.dosDonts = model.dosDonts793? {794dos: model.dosDonts.dos.length,795donts: model.dosDonts.donts.length,796}797: 'missing';798799return report;800}801802// ---------- Main ----------803804export function parseDesignMd(md) {805const { frontmatter, body } = parseFrontmatter(md);806const { title, sections } = splitSections(body);807return {808schemaVersion: 2,809title,810frontmatter,811overview: extractOverview(sections['Overview']),812colors: extractColors(sections['Colors']),813typography: extractTypography(sections['Typography']),814elevation: extractElevation(sections['Elevation']),815components: extractComponents(sections['Components']),816dosDonts: extractDosDonts(sections["Do's and Don'ts"]),817};818}819820export { assessCoverage };821