/** * DESIGN.md parser — the 9-section heading walker. * * Strategy: * 1. Optional H1 title at the top of the file. * 2. Scan H2 headings. Match each against the 9 canonical sections * via a fuzzy-but-bounded resolver (lowercase, strip `&` / `'`, * collapse whitespace). Unrecognized headings land in * `extraSections` so the file round-trips cleanly. * 3. For each matched section, parse the body into structured * tokens with a section-specific extractor. Extractors are * forgiving — a malformed row is skipped, never fatal. * * Parsing must NEVER throw on real-world DESIGN.md files. The 69 * brand entries in awesome-design-md vary wildly in formatting; the * parser is the contract that smooths them out for the rest of Mandu. * * @module core/design/parser */ import type { AgentPrompt, AnyDesignSection, ColorToken, ComponentToken, DesignSectionId, DesignSpec, DoDontRule, ResponsiveBreakpoint, ShadowToken, SpacingToken, TypographyToken, ValidationIssue, ValidationResult, } from "./types"; import { DESIGN_SECTION_IDS } from "./types"; // ──────────────────────────────────────────────────────────────────── // Heading → section-id resolution // ──────────────────────────────────────────────────────────────────── /** * Match heading text against the 9 canonical section ids. Returns the * id when the heading "looks like" the section by keyword, else null. * The keyword sets are intentionally redundant to absorb the wording * differences across the awesome-design-md catalog. */ function resolveSectionId(heading: string): DesignSectionId | null { const h = heading .toLowerCase() .replace(/[&'`*_~]/g, "") .replace(/\s+/g, " ") .trim(); if (/^visual|^theme|^philosophy|^vibe|^aesthetic/.test(h)) return "theme"; if (/color|palette/.test(h)) return "color-palette"; if (/typograph|typeface|font|type scale/.test(h)) return "typography"; if (/component|button|card|input/.test(h)) return "components"; if (/layout|spacing|grid|whitespace/.test(h)) return "layout"; if (/shadow|elevation|depth/.test(h)) return "shadows"; if (/do.{0,3}dont|guideline|principle|rule/.test(h)) return "dos-donts"; if (/responsive|breakpoint|mobile|tablet|desktop/.test(h)) return "responsive"; if (/agent|prompt|llm|ai/.test(h)) return "agent-prompts"; return null; } // ──────────────────────────────────────────────────────────────────── // H2 splitter // ──────────────────────────────────────────────────────────────────── interface RawSection { heading: string; /** `1` or `2` — H1 or H2. */ level: number; body: string; } /** * Strip HTML comments (``) before structural parsing. The * empty-skeleton template uses HTML comments to show example tokens * without having them counted as real tokens; the parser must respect * that convention. Multi-line comments are supported. */ function stripHtmlComments(source: string): string { return source.replace(//g, ""); } function splitByHeadings(source: string): { title?: string; sections: RawSection[]; } { const lines = stripHtmlComments(source).split(/\r?\n/); let title: string | undefined; const sections: RawSection[] = []; let current: RawSection | null = null; // H1 detection — first non-empty line is `# Foo` or first `# Foo` before // any H2. Only the first H1 is treated as title. let titleConsumed = false; for (const line of lines) { const h1 = /^#\s+(.+?)\s*$/.exec(line); const h2 = /^##\s+(.+?)\s*$/.exec(line); if (h1 && !titleConsumed && current === null) { title = h1[1].trim(); titleConsumed = true; continue; } if (h2) { if (current) sections.push(current); current = { heading: h2[1].trim(), level: 2, body: "" }; continue; } if (current) { current.body += (current.body ? "\n" : "") + line; } } if (current) sections.push(current); return { title, sections }; } // ──────────────────────────────────────────────────────────────────── // Section extractors // ──────────────────────────────────────────────────────────────────── const COLOR_VALUE_RX = /(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\))/; function extractColorTokens(body: string): ColorToken[] { const tokens: ColorToken[] = []; for (const line of body.split(/\r?\n/)) { const trimmed = line.trim().replace(/^[-*+]\s*/, "").replace(/^\|\s*/, "").replace(/\s*\|.*$/, ""); if (!trimmed) continue; const valueMatch = COLOR_VALUE_RX.exec(line); // Try patterns: // - `name — #hex — role` // - `name: #hex (role)` // - `**name** \`#hex\` — role` // - `| name | #hex | role |` (markdown table row) const stripped = line .replace(/[`*_]/g, "") .replace(/^[\s|>-]+/, "") .replace(/\s*\|.*$/, ""); const sepMatch = /^([^:—–\-]+?)[\s]*[:—–\-][\s]*(.+)$/.exec(stripped); let name: string | undefined; let role: string | undefined; if (sepMatch) { name = sepMatch[1].trim(); const rest = sepMatch[2]; // role is whatever isn't the colour value if (valueMatch) { role = rest.replace(valueMatch[0], "").replace(/[\s—–\-:|]+/g, " ").trim() || undefined; } else { role = rest.trim() || undefined; } } if (!name && valueMatch) { // Fallback — colour value present but no clear "name : value" split. name = stripped.replace(valueMatch[0], "").trim() || valueMatch[0]; } if (!name) continue; if (name.length > 100) continue; // garbage line // Filter out heading-like rows ("Color Palette", "Functional Roles", ...). if (/palette|role/i.test(name) && !valueMatch) continue; tokens.push({ name, value: valueMatch ? valueMatch[0] : undefined, role, }); } return tokens; } function extractTypographyTokens(body: string): TypographyToken[] { const tokens: TypographyToken[] = []; for (const line of body.split(/\r?\n/)) { const trimmed = line.trim().replace(/^[-*+]\s*/, ""); if (!trimmed) continue; if (trimmed.startsWith("#")) continue; // sub-headings const stripped = trimmed.replace(/[`*_]/g, ""); const nameMatch = /^([^:—–]+?)[\s]*[:—–]/.exec(stripped); const name = nameMatch ? nameMatch[1].trim() : stripped.split(/\s{2,}|—|–/)[0]?.trim(); if (!name) continue; const fontFamily = /font[\s-]*family[:\s]+([^,;]+)/i.exec(stripped)?.[1]?.trim(); const weight = /weight[:\s]+([0-9]{3}|bold|semi-?bold|medium|regular|light|thin)/i.exec(stripped)?.[1]?.trim(); const size = /(\d+(?:\.\d+)?(?:px|rem|em|pt))/.exec(stripped)?.[1]; const lineHeight = /line[\s-]*height[:\s]+([0-9.]+)/i.exec(stripped)?.[1]; tokens.push({ name, fontFamily, weight, size, lineHeight, usage: stripped !== name ? stripped : undefined, }); } return tokens; } function extractComponentTokens(body: string): ComponentToken[] { // Components are typically organised under H3 sub-headings ("### Button", // "### Card"). When present, treat each H3 as one component; otherwise // fall back to bullet rows. const tokens: ComponentToken[] = []; const h3Rx = /^###\s+(.+?)\s*$/; const lines = body.split(/\r?\n/); let current: ComponentToken | null = null; for (const line of lines) { const h3 = h3Rx.exec(line); if (h3) { if (current) tokens.push(current); current = { name: h3[1].trim(), variants: {}, notes: "" }; continue; } if (!current) continue; // Variant detection — `variant: primary | secondary | ghost` const variantMatch = /^[\s-]*(\w[\w\s-]*?)[\s]*:[\s]*(.+)$/.exec(line.trim()); if (variantMatch) { const key = variantMatch[1].trim(); const valuesRaw = variantMatch[2].trim(); if (/[|,]/.test(valuesRaw) && valuesRaw.length < 200) { const values = valuesRaw .split(/[|,]/) .map((v) => v.trim().replace(/[`*_"']/g, "")) .filter(Boolean); if (values.length >= 2) { current.variants[key] = values; continue; } } } if (line.trim()) { current.notes = (current.notes ?? "") + (current.notes ? "\n" : "") + line; } } if (current) tokens.push(current); if (tokens.length === 0) { // Fallback — flat bullet list of components. for (const line of lines) { const m = /^[\s-*+]+([A-Z][\w\s]+?)(?::|—|–|$)/.exec(line); if (m) tokens.push({ name: m[1].trim(), variants: {}, notes: undefined }); } } return tokens; } function extractSpacingTokens(body: string): SpacingToken[] { const tokens: SpacingToken[] = []; for (const line of body.split(/\r?\n/)) { const stripped = line.trim().replace(/^[-*+]\s*/, "").replace(/[`*_]/g, ""); if (!stripped) continue; const m = /^([\w-]+)[\s:—–-]+(\d+(?:\.\d+)?(?:px|rem|em|%)?)/.exec(stripped); if (m) tokens.push({ name: m[1], value: m[2] }); } return tokens; } function extractShadowTokens(body: string): ShadowToken[] { const tokens: ShadowToken[] = []; // Shadows are typically `name: ` rows. CSS values contain // commas / parens / `0 1px 2px rgba(...)` so we don't attempt to fully // parse them — capture the whole right-hand side verbatim. for (const line of body.split(/\r?\n/)) { const stripped = line.trim().replace(/^[-*+]\s*/, "").replace(/[`*_]/g, ""); if (!stripped) continue; const m = /^([\w-]+)\s*[:—–]\s*(.+)$/.exec(stripped); if (m && (m[2].includes("px") || m[2].includes("rgba"))) { tokens.push({ name: m[1], value: m[2].trim() }); } } return tokens; } function extractDoDontRules(body: string): DoDontRule[] { const rules: DoDontRule[] = []; let mode: "do" | "dont" | null = null; for (const line of body.split(/\r?\n/)) { const stripped = line.trim(); if (!stripped) continue; if (/^#{2,}\s+do\b|^\*\*do\*\*|^do['s]*[:\s]/i.test(stripped) && !/don.?t/i.test(stripped)) { mode = "do"; continue; } if (/^#{2,}\s+don.?t|^\*\*don.?t\*\*|^don.?t[s]?[:\s]/i.test(stripped)) { mode = "dont"; continue; } const bullet = /^[-*+]\s+(.+)$/.exec(stripped); if (bullet && mode) { // Preserve backticks — they mark identifier tokens // (`btn-hard`, `shadow-hard`) that downstream consumers // (Guard `autoFromDesignMd`) extract from the rule text. rules.push({ kind: mode, text: bullet[1].replace(/[*_]/g, "").trim() }); } else if (bullet) { // ✅ / ❌ inline markers. const text = bullet[1]; if (text.startsWith("✅") || /^do\b/i.test(text)) { rules.push({ kind: "do", text: text.replace(/^[✅do:\s]+/i, "").trim() }); } else if (text.startsWith("❌") || /^don.?t\b/i.test(text)) { rules.push({ kind: "dont", text: text.replace(/^[❌don'?t:\s]+/i, "").trim() }); } } } return rules; } function extractBreakpoints(body: string): ResponsiveBreakpoint[] { const bps: ResponsiveBreakpoint[] = []; for (const line of body.split(/\r?\n/)) { const stripped = line.trim().replace(/^[-*+]\s*/, "").replace(/[`*_]/g, ""); if (!stripped) continue; const m = /^([\w-]+)[\s:—–-]+(\d+(?:px|rem|em)?)\b(.*)$/.exec(stripped); if (m) { bps.push({ name: m[1], value: m[2], notes: m[3].replace(/^[\s—–-]+/, "").trim() || undefined, }); } } return bps; } function extractAgentPrompts(body: string): AgentPrompt[] { // Group by H3; everything else collapses into a single "default" prompt. const prompts: AgentPrompt[] = []; const lines = body.split(/\r?\n/); const h3Rx = /^###\s+(.+?)\s*$/; let current: AgentPrompt | null = null; let buf: string[] = []; for (const line of lines) { const h3 = h3Rx.exec(line); if (h3) { if (current) { current.body = buf.join("\n").trim(); prompts.push(current); } current = { title: h3[1].trim(), body: "" }; buf = []; continue; } buf.push(line); } if (current) { current.body = buf.join("\n").trim(); prompts.push(current); } else if (buf.join("").trim()) { prompts.push({ title: "default", body: buf.join("\n").trim() }); } return prompts; } // ──────────────────────────────────────────────────────────────────── // Public API // ──────────────────────────────────────────────────────────────────── function emptySections(): DesignSpec["sections"] { return { theme: { id: "theme", present: false, rawBody: "" }, "color-palette": { id: "color-palette", present: false, rawBody: "", tokens: [] }, typography: { id: "typography", present: false, rawBody: "", tokens: [] }, components: { id: "components", present: false, rawBody: "", tokens: [] }, layout: { id: "layout", present: false, rawBody: "", tokens: [] }, shadows: { id: "shadows", present: false, rawBody: "", tokens: [] }, "dos-donts": { id: "dos-donts", present: false, rawBody: "", rules: [] }, responsive: { id: "responsive", present: false, rawBody: "", breakpoints: [] }, "agent-prompts": { id: "agent-prompts", present: false, rawBody: "", prompts: [] }, }; } /** Parse a DESIGN.md source string into the structured spec. Never throws. */ export function parseDesignMd(source: string): DesignSpec { const { title, sections: rawSections } = splitByHeadings(source); const result: DesignSpec = { source, title, sections: emptySections(), extraSections: [], }; for (const raw of rawSections) { const id = resolveSectionId(raw.heading); if (!id) { result.extraSections.push({ heading: raw.heading, body: raw.body }); continue; } const body = raw.body.trim(); switch (id) { case "theme": { const summary = body.split(/\n\s*\n/)[0]?.trim() || undefined; result.sections.theme = { id, present: true, headingText: raw.heading, rawBody: body, summary, }; break; } case "color-palette": result.sections["color-palette"] = { id, present: true, headingText: raw.heading, rawBody: body, tokens: extractColorTokens(body), }; break; case "typography": result.sections.typography = { id, present: true, headingText: raw.heading, rawBody: body, tokens: extractTypographyTokens(body), }; break; case "components": result.sections.components = { id, present: true, headingText: raw.heading, rawBody: body, tokens: extractComponentTokens(body), }; break; case "layout": result.sections.layout = { id, present: true, headingText: raw.heading, rawBody: body, tokens: extractSpacingTokens(body), }; break; case "shadows": result.sections.shadows = { id, present: true, headingText: raw.heading, rawBody: body, tokens: extractShadowTokens(body), }; break; case "dos-donts": result.sections["dos-donts"] = { id, present: true, headingText: raw.heading, rawBody: body, rules: extractDoDontRules(body), }; break; case "responsive": result.sections.responsive = { id, present: true, headingText: raw.heading, rawBody: body, breakpoints: extractBreakpoints(body), }; break; case "agent-prompts": result.sections["agent-prompts"] = { id, present: true, headingText: raw.heading, rawBody: body, prompts: extractAgentPrompts(body), }; break; } } return result; } // ──────────────────────────────────────────────────────────────────── // Validation // ──────────────────────────────────────────────────────────────────── /** * Surface gaps in a parsed DESIGN.md. The validator never returns * errors that block builds — Mandu's enforcement layer (Guard) is the * gate. This function is the diagnostic that tells the user "your * DESIGN.md is missing colour tokens" so they can fill it in. */ export function validateDesignSpec(spec: DesignSpec): ValidationResult { const issues: ValidationIssue[] = []; for (const id of DESIGN_SECTION_IDS) { const section = spec.sections[id] as AnyDesignSection; if (!section.present) { issues.push({ kind: "missing", section: id, message: `Section "${id}" not found. Add a "## ${humanizeSectionId(id)}" heading.`, }); continue; } const empty = isSectionEmpty(section); if (empty) { issues.push({ kind: "empty", section: id, message: `Section "${id}" is present but has no structured tokens.`, }); } } return { ok: issues.length === 0, issues }; } function isSectionEmpty(section: AnyDesignSection): boolean { switch (section.id) { case "theme": return !section.summary || section.summary.length === 0; case "color-palette": case "typography": case "components": case "layout": case "shadows": return section.tokens.length === 0; case "dos-donts": return section.rules.length === 0; case "responsive": return section.breakpoints.length === 0; case "agent-prompts": return section.prompts.length === 0; } } function humanizeSectionId(id: DesignSectionId): string { switch (id) { case "theme": return "Visual Theme & Philosophy"; case "color-palette": return "Color Palette"; case "typography": return "Typography"; case "components": return "Components"; case "layout": return "Layout"; case "shadows": return "Depth & Elevation"; case "dos-donts": return "Do's & Don'ts"; case "responsive": return "Responsive"; case "agent-prompts": return "Agent Prompts"; } } export { humanizeSectionId };