import type { DesignMdSpec, DesignMdColor, DesignMdTypography } from '@zseven-w/pen-types'; import type { PenDocument } from '@zseven-w/pen-types'; import type { VariableDefinition } from '@zseven-w/pen-types'; // --------------------------------------------------------------------------- // Section header patterns (fuzzy matching) // --------------------------------------------------------------------------- const SECTION_PATTERNS: Record = { visualTheme: /visual\s*theme|atmosphere|mood/i, colorPalette: /colou?r\s*(palette|roles?|system)?/i, typography: /typography|type\s*rules?|fonts?/i, componentStyles: /component\s*(styl|pattern)|button|card|input/i, layoutPrinciples: /layout\s*(principle|rule|system)?|grid|whitespace/i, generationNotes: /generation\s*notes?|design\s*system\s*notes?|stitch|prompting/i, }; interface ParsedSection { key: string; title: string; content: string; } // --------------------------------------------------------------------------- // Parse markdown into sections // --------------------------------------------------------------------------- function splitSections(markdown: string): { projectName?: string; sections: ParsedSection[] } { const lines = markdown.split('\n'); let projectName: string | undefined; // Extract project name from H1 const h1Match = markdown.match(/^#\s+(?:Design\s+System:\s*)?(.+)$/m); if (h1Match) projectName = h1Match[1].trim(); const sections: ParsedSection[] = []; let currentTitle = ''; let currentLines: string[] = []; let currentKey = ''; for (const line of lines) { const h2Match = line.match(/^##\s+(?:\d+\.\s*)?(.+)$/); if (h2Match) { // Flush previous section if (currentKey) { sections.push({ key: currentKey, title: currentTitle, content: currentLines.join('\n').trim(), }); } currentTitle = h2Match[1].trim(); currentLines = []; currentKey = matchSectionKey(currentTitle); } else { currentLines.push(line); } } // Flush last section if (currentKey) { sections.push({ key: currentKey, title: currentTitle, content: currentLines.join('\n').trim(), }); } return { projectName, sections }; } function matchSectionKey(title: string): string { for (const [key, pattern] of Object.entries(SECTION_PATTERNS)) { if (pattern.test(title)) return key; } // Fallback: try to match common section names loosely const lower = title.toLowerCase(); if (lower.includes('theme') || lower.includes('vibe') || lower.includes('aesthetic')) return 'visualTheme'; if (lower.includes('color') || lower.includes('palette') || lower.includes('colour')) return 'colorPalette'; if (lower.includes('type') || lower.includes('font') || lower.includes('typo')) return 'typography'; if (lower.includes('component') || lower.includes('style') || lower.includes('element')) return 'componentStyles'; if (lower.includes('layout') || lower.includes('spacing') || lower.includes('grid')) return 'layoutPrinciples'; if (lower.includes('note') || lower.includes('generation') || lower.includes('usage')) return 'generationNotes'; return 'unknown'; } // --------------------------------------------------------------------------- // Color extraction // --------------------------------------------------------------------------- const HEX_COLOR_RE = /#([0-9A-Fa-f]{6})\b/; function parseColors(content: string): DesignMdColor[] { const colors: DesignMdColor[] = []; const lines = content.split('\n'); for (const line of lines) { // Skip JSON/code artifacts if (line.trimStart().startsWith('{') || line.trimStart().startsWith('`')) continue; const hexMatch = line.match(HEX_COLOR_RE); if (!hexMatch) continue; const hex = `#${hexMatch[1].toUpperCase()}`; // Try pattern: **Name** (hex) — role OR - **Name** (#hex) – role const namedMatch = line.match(/\*\*([^*]+)\*\*\s*\(?#?[0-9A-Fa-f]{6}\)?\s*[–—-]\s*(.+)/); if (namedMatch) { colors.push({ name: namedMatch[1].trim(), hex, role: namedMatch[2].trim() }); continue; } // Try pattern: - Name (#hex) — role OR Name (#hex): role const dashMatch = line.match(/[-*]\s*(.+?)\s*\(#?[0-9A-Fa-f]{6}\)\s*[–—:-]\s*(.+)/); if (dashMatch) { colors.push({ name: dashMatch[1].trim(), hex, role: dashMatch[2].trim() }); continue; } // Try pattern: - name: #hex (role) OR - name: #hex — role const colonHexMatch = line.match(/[-*]\s*([^:#(]+?)\s*:\s*#?[0-9A-Fa-f]{6}\s*\(?([^)]*)\)?/); if (colonHexMatch) { colors.push({ name: colonHexMatch[1].trim(), hex, role: colonHexMatch[2].trim(), }); continue; } // Fallback: just grab the hex and surrounding text const before = line .substring(0, hexMatch.index) .replace(/[-*#\s]+$/, '') .trim(); const after = line .substring((hexMatch.index ?? 0) + hexMatch[0].length) .replace(/^[)\s–—:-]+/, '') .trim(); colors.push({ name: before || hex, hex, role: after || '', }); } return colors; } // --------------------------------------------------------------------------- // Typography extraction // --------------------------------------------------------------------------- function parseTypography(content: string): DesignMdTypography { const typo: DesignMdTypography = {}; // Font family const fontMatch = content.match(/(?:font\s*family|primary\s*font)[:\s]*\*?\*?([^*\n]+)/i); if (fontMatch) typo.fontFamily = fontMatch[1].trim(); // Try to extract heading/body descriptions const headingMatch = content.match(/(?:heading|display|h1)[^:]*:\s*([^\n]+)/i); if (headingMatch) typo.headings = headingMatch[1].trim(); const bodyMatch = content.match(/(?:body\s*text|paragraph)[^:]*:\s*([^\n]+)/i); if (bodyMatch) typo.body = bodyMatch[1].trim(); // Keep full content as scale description typo.scale = content; return typo; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** Parse a design.md markdown string into a structured DesignMdSpec. */ export function parseDesignMd(markdown: string): DesignMdSpec { const { projectName, sections } = splitSections(markdown); const spec: DesignMdSpec = { raw: markdown, projectName, colorPalette: [] }; for (const section of sections) { switch (section.key) { case 'visualTheme': spec.visualTheme = section.content; break; case 'colorPalette': spec.colorPalette = parseColors(section.content); break; case 'typography': spec.typography = parseTypography(section.content); break; case 'componentStyles': spec.componentStyles = section.content; break; case 'layoutPrinciples': spec.layoutPrinciples = section.content; break; case 'generationNotes': spec.generationNotes = section.content; break; case 'unknown': // Append to componentStyles as catch-all spec.componentStyles = spec.componentStyles ? spec.componentStyles + '\n\n' + section.content : section.content; break; } } // Fallback: if no sections were parsed at all, try to extract colors // and typography from the entire markdown if (!spec.colorPalette || spec.colorPalette.length === 0) { const colors = parseColors(markdown); if (colors.length > 0) spec.colorPalette = colors; } if (!spec.typography) { const typo = parseTypography(markdown); if (typo.fontFamily) spec.typography = typo; } // If still nothing parsed, store the whole text as visual theme if (!spec.visualTheme && !spec.colorPalette && !spec.componentStyles && sections.length === 0) { spec.visualTheme = markdown.trim(); } return spec; } /** Generate a design.md markdown string from a DesignMdSpec. */ export function generateDesignMd(spec: DesignMdSpec): string { // If raw was set and nothing was structurally changed, return as-is if (spec.raw) return spec.raw; const lines: string[] = []; lines.push(`# Design System: ${spec.projectName ?? 'Untitled'}`); lines.push(''); if (spec.visualTheme) { lines.push('## 1. Visual Theme & Atmosphere'); lines.push(spec.visualTheme); lines.push(''); } if (spec.colorPalette?.length) { lines.push('## 2. Color Palette & Roles'); lines.push(''); for (const c of spec.colorPalette) { lines.push(`- **${c.name}** (${c.hex}) — ${c.role}`); } lines.push(''); } if (spec.typography) { lines.push('## 3. Typography Rules'); if (spec.typography.fontFamily) { lines.push(`**Primary Font Family:** ${spec.typography.fontFamily}`); } if (spec.typography.scale) { lines.push(spec.typography.scale); } lines.push(''); } if (spec.componentStyles) { lines.push('## 4. Component Stylings'); lines.push(spec.componentStyles); lines.push(''); } if (spec.layoutPrinciples) { lines.push('## 5. Layout Principles'); lines.push(spec.layoutPrinciples); lines.push(''); } if (spec.generationNotes) { lines.push('## 6. Design System Notes'); lines.push(spec.generationNotes); lines.push(''); } return lines.join('\n'); } /** Auto-extract a DesignMdSpec from an existing PenDocument. */ export function extractDesignMdFromDocument(doc: PenDocument): DesignMdSpec { const colors: DesignMdColor[] = []; // Extract colors from document variables if (doc.variables) { for (const [name, def] of Object.entries(doc.variables)) { if (def.type !== 'color') continue; const value = typeof def.value === 'string' ? def.value : Array.isArray(def.value) ? String(def.value[0]?.value ?? '') : String(def.value); if (/^#[0-9A-Fa-f]{6,8}$/.test(value)) { colors.push({ name: name.replace(/^[$]/, ''), hex: value.substring(0, 7).toUpperCase(), role: `Design variable $${name}`, }); } } } // Collect font families from text nodes const fonts = new Set(); const collectFonts = (nodes: { fontFamily?: string; children?: unknown[] }[]) => { for (const n of nodes) { if ('fontFamily' in n && typeof n.fontFamily === 'string') fonts.add(n.fontFamily); if ('children' in n && Array.isArray(n.children)) collectFonts(n.children as { fontFamily?: string; children?: unknown[] }[]); } }; collectFonts(doc.children as { fontFamily?: string; children?: unknown[] }[]); if (doc.pages) { for (const page of doc.pages) { collectFonts(page.children as { fontFamily?: string; children?: unknown[] }[]); } } const typography: DesignMdTypography = {}; if (fonts.size > 0) typography.fontFamily = [...fonts].join(', '); const spec: DesignMdSpec = { raw: '', projectName: doc.name ?? 'Untitled', colorPalette: colors.length > 0 ? colors : undefined, typography: fonts.size > 0 ? typography : undefined, }; // Generate the markdown and set as raw spec.raw = generateDesignMd(spec); return spec; } /** Convert design.md colors to document variables. */ export function designMdColorsToVariables( colors: DesignMdColor[], ): Record { const vars: Record = {}; for (const color of colors) { const key = color.name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); vars[key] = { type: 'color', value: color.hex }; } return vars; }