|
| 1 | +// DESIGN.md — agent-native single-file output. Compatible with the |
| 2 | +// 8-canonical-section convention (Overview · Colors · Typography · Layout |
| 3 | +// · Elevation and Depth · Shapes · Components · Do's and Don'ts), plus |
| 4 | +// YAML front matter holding the machine-readable token snapshot. |
| 5 | +// |
| 6 | +// Designed to be a drop-in replacement for design-extractor.com's |
| 7 | +// DESIGN.md output, but driven by the v10/v11 semantic layer (intent, |
| 8 | +// material, voice, anatomy, library detection) rather than just colors. |
| 9 | + |
| 10 | +import { readFileSync } from 'fs'; |
| 11 | + |
| 12 | +function yamlString(v) { |
| 13 | + if (v == null) return '~'; |
| 14 | + if (typeof v === 'number' || typeof v === 'boolean') return String(v); |
| 15 | + const s = String(v); |
| 16 | + if (s === '' || /[:#&*!?[\]{},|>%@`'"\n]/.test(s) || /^\s|\s$/.test(s)) { |
| 17 | + return JSON.stringify(s); |
| 18 | + } |
| 19 | + return s; |
| 20 | +} |
| 21 | + |
| 22 | +function yamlList(arr, indent = ' ') { |
| 23 | + if (!arr || arr.length === 0) return '[]'; |
| 24 | + return '\n' + arr.map(v => `${indent}- ${yamlString(v)}`).join('\n'); |
| 25 | +} |
| 26 | + |
| 27 | +function yamlMap(obj, indent = ' ') { |
| 28 | + const entries = Object.entries(obj || {}).filter(([, v]) => v != null); |
| 29 | + if (entries.length === 0) return '{}'; |
| 30 | + return '\n' + entries.map(([k, v]) => `${indent}${k}: ${yamlString(v)}`).join('\n'); |
| 31 | +} |
| 32 | + |
| 33 | +function topColor(colors, role) { |
| 34 | + return colors?.[role]?.hex || null; |
| 35 | +} |
| 36 | + |
| 37 | +function fmtPx(v) { |
| 38 | + if (v == null) return null; |
| 39 | + const n = typeof v === 'number' ? v : parseFloat(String(v)); |
| 40 | + return Number.isFinite(n) ? `${Math.round(n)}px` : String(v); |
| 41 | +} |
| 42 | + |
| 43 | +function uniq(arr) { return [...new Set(arr.filter(Boolean))]; } |
| 44 | + |
| 45 | +function pickHeading(voice, fallback) { |
| 46 | + const s = (voice?.sampleHeadings || []).find(h => h && h.length > 4 && h.length < 80); |
| 47 | + return s || fallback; |
| 48 | +} |
| 49 | + |
| 50 | +function ratioLine(v, n) { return `${v} (${n})`; } |
| 51 | + |
| 52 | +// ─── Section renderers ───────────────────────────────────────── |
| 53 | + |
| 54 | +function sectionOverview(design) { |
| 55 | + const intent = design.pageIntent?.type || 'landing'; |
| 56 | + const intentConf = design.pageIntent?.confidence; |
| 57 | + const material = design.materialLanguage?.label || 'flat'; |
| 58 | + const matConf = design.materialLanguage?.confidence; |
| 59 | + const library = design.componentLibrary?.library; |
| 60 | + const libConf = design.componentLibrary?.confidence; |
| 61 | + const order = (design.sectionRoles?.readingOrder || []).join(' → ') || '—'; |
| 62 | + const voice = design.voice || {}; |
| 63 | + const tone = voice.tone || 'neutral'; |
| 64 | + const lede = pickHeading(voice, design.meta?.title || ''); |
| 65 | + const url = design.meta?.url || ''; |
| 66 | + |
| 67 | + const lines = []; |
| 68 | + lines.push(`A **${intent}** page${intentConf ? ` (heuristic confidence ${intentConf})` : ''}, dressed in **${material}** material${matConf ? ` (${matConf})` : ''}.`); |
| 69 | + if (library && library !== 'unknown') { |
| 70 | + lines.push(''); |
| 71 | + lines.push(`Component library appears to be **${library}**${libConf ? ` (${libConf})` : ''}.`); |
| 72 | + } |
| 73 | + if (lede) { |
| 74 | + lines.push(''); |
| 75 | + lines.push(`> "${lede}"`); |
| 76 | + } |
| 77 | + lines.push(''); |
| 78 | + lines.push(`The author writes in a **${tone}** voice; headings tend to be **${voice.headingStyle || 'sentence'}** case and **${voice.headingLengthClass || 'balanced'}**.`); |
| 79 | + lines.push(''); |
| 80 | + lines.push(`Reading order detected on the source: \`${order}\`.`); |
| 81 | + if (url) { |
| 82 | + lines.push(''); |
| 83 | + lines.push(`Source: <${url}>.`); |
| 84 | + } |
| 85 | + return lines.join('\n'); |
| 86 | +} |
| 87 | + |
| 88 | +function sectionColors(design) { |
| 89 | + const c = design.colors || {}; |
| 90 | + const lines = ['| role | hex | usage |', '|---|---|---|']; |
| 91 | + const rows = [ |
| 92 | + ['primary', c.primary?.hex, c.primary?.count], |
| 93 | + ['secondary', c.secondary?.hex, c.secondary?.count], |
| 94 | + ['accent', c.accent?.hex, c.accent?.count], |
| 95 | + ['background', c.backgrounds?.[0], '—'], |
| 96 | + ['foreground', c.text?.[0], '—'], |
| 97 | + ]; |
| 98 | + for (const [role, hex, count] of rows) { |
| 99 | + if (!hex) continue; |
| 100 | + lines.push(`| ${role} | \`${hex}\` | ${count ?? '—'} |`); |
| 101 | + } |
| 102 | + const neutrals = (c.neutrals || []).slice(0, 5); |
| 103 | + if (neutrals.length) { |
| 104 | + lines.push(''); |
| 105 | + lines.push('**Neutrals:** ' + neutrals.map(n => `\`${n.hex}\``).join(' · ')); |
| 106 | + } |
| 107 | + if (c.all?.length) { |
| 108 | + lines.push(''); |
| 109 | + lines.push(`**Total unique colors detected:** ${c.all.length}.`); |
| 110 | + } |
| 111 | + return lines.join('\n'); |
| 112 | +} |
| 113 | + |
| 114 | +function sectionTypography(design) { |
| 115 | + const t = design.typography || {}; |
| 116 | + const lines = []; |
| 117 | + if (t.families?.length) { |
| 118 | + lines.push('**Families**'); |
| 119 | + for (const f of t.families.slice(0, 4)) { |
| 120 | + lines.push(`- \`${f.name}\`${f.weights ? ` — weights ${[...new Set(f.weights)].join(', ')}` : ''}${f.count ? ` · ${f.count} uses` : ''}`); |
| 121 | + } |
| 122 | + } |
| 123 | + if (t.body?.size) { |
| 124 | + lines.push(''); |
| 125 | + lines.push(`**Body size:** \`${t.body.size}px\` / line-height \`${t.body.lineHeight ?? '1.5'}\`.`); |
| 126 | + } |
| 127 | + if (t.headings?.length) { |
| 128 | + lines.push(''); |
| 129 | + lines.push('**Heading scale**'); |
| 130 | + lines.push('| level | size | weight | line-height |'); |
| 131 | + lines.push('|---|---|---|---|'); |
| 132 | + for (const [i, h] of t.headings.slice(0, 4).entries()) { |
| 133 | + lines.push(`| h${i + 1} | \`${h.size}px\` | \`${h.weight}\` | \`${h.lineHeight}\` |`); |
| 134 | + } |
| 135 | + } |
| 136 | + return lines.join('\n'); |
| 137 | +} |
| 138 | + |
| 139 | +function sectionLayout(design) { |
| 140 | + const sp = design.spacing || {}; |
| 141 | + const bp = design.breakpoints || []; |
| 142 | + const layout = design.layout || {}; |
| 143 | + const lines = []; |
| 144 | + if (sp.base) lines.push(`**Spacing base:** \`${sp.base}px\` increments.`); |
| 145 | + if (sp.scale?.length) lines.push(`**Scale:** ${sp.scale.slice(0, 10).map(s => `\`${(s.value ?? s)}px\``).join(' · ')}`); |
| 146 | + if (layout.gridCount != null || layout.flexCount != null) { |
| 147 | + lines.push(''); |
| 148 | + lines.push(`**Layout primitives:** ${layout.gridCount ?? 0} grid containers · ${layout.flexCount ?? 0} flex containers.`); |
| 149 | + } |
| 150 | + if (bp.length) { |
| 151 | + lines.push(''); |
| 152 | + lines.push(`**Breakpoints:** ${bp.map(b => `\`${b}px\``).join(' · ')}`); |
| 153 | + } |
| 154 | + return lines.join('\n') || '_No layout signals captured._'; |
| 155 | +} |
| 156 | + |
| 157 | +function sectionElevation(design) { |
| 158 | + const sh = design.shadows?.values || []; |
| 159 | + const z = design.zIndex || {}; |
| 160 | + const lines = []; |
| 161 | + if (sh.length) { |
| 162 | + lines.push('**Shadow scale**'); |
| 163 | + for (const s of sh.slice(0, 6)) { |
| 164 | + lines.push(`- \`${s.label || '?'}\` — \`${s.raw || s.value}\``); |
| 165 | + } |
| 166 | + } else { |
| 167 | + lines.push('_No discrete shadow tokens detected — flat material._'); |
| 168 | + } |
| 169 | + if (z.allValues?.length) { |
| 170 | + lines.push(''); |
| 171 | + lines.push(`**Z-index layers:** ${z.allValues.length}${z.issues?.length ? ` · ⚠ ${z.issues.length} issue(s)` : ''}`); |
| 172 | + } |
| 173 | + return lines.join('\n'); |
| 174 | +} |
| 175 | + |
| 176 | +function sectionShapes(design) { |
| 177 | + const r = design.borders?.radii || []; |
| 178 | + const lines = []; |
| 179 | + if (r.length) { |
| 180 | + lines.push('**Radius scale**'); |
| 181 | + for (const x of r.slice(0, 6)) lines.push(`- \`${x.label || '?'}\` — \`${x.value}px\``); |
| 182 | + } else { |
| 183 | + lines.push('_No discrete radius tokens detected — sharp/brutalist shapes._'); |
| 184 | + } |
| 185 | + return lines.join('\n'); |
| 186 | +} |
| 187 | + |
| 188 | +function sectionComponents(design) { |
| 189 | + const lines = []; |
| 190 | + const detected = Object.keys(design.components || {}); |
| 191 | + if (detected.length) { |
| 192 | + lines.push(`**Detected patterns:** ${detected.map(c => `\`${c}\``).join(' · ')}`); |
| 193 | + lines.push(''); |
| 194 | + } |
| 195 | + const anatomies = design.componentAnatomy || []; |
| 196 | + if (anatomies.length) { |
| 197 | + lines.push('**Anatomy**'); |
| 198 | + lines.push('| kind | variants | sizes | instances |'); |
| 199 | + lines.push('|---|---|---|---|'); |
| 200 | + for (const a of anatomies.slice(0, 8)) { |
| 201 | + const variants = (a.props?.variant || []).join(', ') || '—'; |
| 202 | + const sizes = (a.props?.size || []).join(', ') || '—'; |
| 203 | + lines.push(`| ${a.kind} | ${variants} | ${sizes} | ${a.totalInstances ?? '—'} |`); |
| 204 | + } |
| 205 | + } |
| 206 | + if (!lines.length) lines.push('_No component anatomy extracted._'); |
| 207 | + return lines.join('\n'); |
| 208 | +} |
| 209 | + |
| 210 | +function sectionDosDonts(design) { |
| 211 | + const voice = design.voice || {}; |
| 212 | + const score = design.score || {}; |
| 213 | + const a11y = design.accessibility || {}; |
| 214 | + const lines = []; |
| 215 | + |
| 216 | + lines.push("**Do's**"); |
| 217 | + const dos = []; |
| 218 | + const ctas = (voice.ctaVerbs || []).slice(0, 3).map(v => v.value).filter(Boolean); |
| 219 | + if (ctas.length) dos.push(`Use \`${ctas.join('\`, \`')}\` as the primary verbs in CTAs — these dominate the source.`); |
| 220 | + if (voice.headingStyle) dos.push(`Write headings in **${voice.headingStyle}** case, **${voice.headingLengthClass || 'balanced'}** length.`); |
| 221 | + if (voice.pronoun && voice.pronoun !== 'neutral') dos.push(`Address the reader with the pronoun posture **${voice.pronoun}**.`); |
| 222 | + if (design.materialLanguage?.label) dos.push(`Stay inside the **${design.materialLanguage.label}** material — match shadow and radius habits.`); |
| 223 | + if (!dos.length) dos.push('_No strong directional signals captured._'); |
| 224 | + for (const d of dos) lines.push(`- ${d}`); |
| 225 | + |
| 226 | + lines.push(''); |
| 227 | + lines.push("**Don'ts**"); |
| 228 | + const donts = []; |
| 229 | + if (a11y.failCount > 0) donts.push(`Don't ship copy on the colors flagged in accessibility — ${a11y.failCount} contrast pair(s) fail WCAG AA on the source itself.`); |
| 230 | + if (score.issues?.length) for (const i of score.issues.slice(0, 4)) donts.push(`Don't ${i.toLowerCase().replace(/^./, c => c.toLowerCase())}.`); |
| 231 | + if (!donts.length) donts.push("_No anti-patterns surfaced. Don't invent new tokens — reuse the scale above._"); |
| 232 | + for (const d of donts) lines.push(`- ${d}`); |
| 233 | + |
| 234 | + return lines.join('\n'); |
| 235 | +} |
| 236 | + |
| 237 | +// ─── YAML front matter ───────────────────────────────────────── |
| 238 | + |
| 239 | +function frontMatter(design, version) { |
| 240 | + const url = design.meta?.url || ''; |
| 241 | + const title = design.meta?.title || ''; |
| 242 | + const c = design.colors || {}; |
| 243 | + const t = design.typography || {}; |
| 244 | + const sp = design.spacing || {}; |
| 245 | + const r = design.borders?.radii || []; |
| 246 | + const sh = design.shadows?.values || []; |
| 247 | + |
| 248 | + const lines = ['---']; |
| 249 | + lines.push(`site: ${yamlString(title || url)}`); |
| 250 | + if (url) lines.push(`url: ${yamlString(url)}`); |
| 251 | + lines.push(`generated_at: ${yamlString(new Date().toISOString())}`); |
| 252 | + lines.push(`generator: ${yamlString(`designlang@${version}`)}`); |
| 253 | + if (design.pageIntent?.type) lines.push(`intent: ${yamlString(design.pageIntent.type)}`); |
| 254 | + if (design.materialLanguage?.label) lines.push(`material: ${yamlString(design.materialLanguage.label)}`); |
| 255 | + if (design.componentLibrary?.library && design.componentLibrary.library !== 'unknown') { |
| 256 | + lines.push(`library: ${yamlString(design.componentLibrary.library)}`); |
| 257 | + } |
| 258 | + |
| 259 | + lines.push('tokens:'); |
| 260 | + lines.push(' colors:' + yamlMap({ |
| 261 | + primary: topColor(c, 'primary'), |
| 262 | + secondary: topColor(c, 'secondary'), |
| 263 | + accent: topColor(c, 'accent'), |
| 264 | + background: c.backgrounds?.[0], |
| 265 | + foreground: c.text?.[0], |
| 266 | + }, ' ')); |
| 267 | + lines.push(' typography:' + yamlMap({ |
| 268 | + sans: t.families?.[0]?.name, |
| 269 | + mono: t.families?.find(f => /mono/i.test(f.name))?.name, |
| 270 | + base: t.body?.size, |
| 271 | + }, ' ')); |
| 272 | + lines.push(' spacing:' + yamlMap({ |
| 273 | + base: sp.base, |
| 274 | + scale: sp.scale?.length ? '[' + sp.scale.slice(0, 10).map(s => (s.value ?? s)).join(', ') + ']' : null, |
| 275 | + }, ' ')); |
| 276 | + if (r.length) { |
| 277 | + lines.push(' radii:' + yamlMap(Object.fromEntries(r.slice(0, 6).map(x => [x.label || `r${x.value}`, x.value])), ' ')); |
| 278 | + } |
| 279 | + if (sh.length) { |
| 280 | + lines.push(' shadows:' + yamlMap(Object.fromEntries(sh.slice(0, 4).map(x => [x.label || 'shadow', x.raw || x.value])), ' ')); |
| 281 | + } |
| 282 | + lines.push('---'); |
| 283 | + return lines.join('\n'); |
| 284 | +} |
| 285 | + |
| 286 | +// ─── Main ────────────────────────────────────────────────────── |
| 287 | + |
| 288 | +let CACHED_VERSION = null; |
| 289 | +function pkgVersion() { |
| 290 | + if (CACHED_VERSION) return CACHED_VERSION; |
| 291 | + try { |
| 292 | + const url = new URL('../../package.json', import.meta.url); |
| 293 | + CACHED_VERSION = JSON.parse(readFileSync(url, 'utf-8')).version; |
| 294 | + } catch { CACHED_VERSION = '0.0.0'; } |
| 295 | + return CACHED_VERSION; |
| 296 | +} |
| 297 | + |
| 298 | +export function formatDesignMd(design) { |
| 299 | + const version = pkgVersion(); |
| 300 | + const fm = frontMatter(design, version); |
| 301 | + |
| 302 | + const sections = [ |
| 303 | + ['Overview', sectionOverview(design)], |
| 304 | + ['Colors', sectionColors(design)], |
| 305 | + ['Typography', sectionTypography(design)], |
| 306 | + ['Layout', sectionLayout(design)], |
| 307 | + ['Elevation and Depth', sectionElevation(design)], |
| 308 | + ['Shapes', sectionShapes(design)], |
| 309 | + ['Components', sectionComponents(design)], |
| 310 | + ["Do's and Don'ts", sectionDosDonts(design)], |
| 311 | + ]; |
| 312 | + |
| 313 | + const body = sections.map(([h, b]) => `# ${h}\n\n${b || '_—_'}\n`).join('\n'); |
| 314 | + |
| 315 | + const sourceUrl = design.meta?.url || ''; |
| 316 | + const footer = `\n---\n_Generated by [designlang](https://github.com/Manavarya09/design-extract) v${version} from <${sourceUrl}>._\n_Compatible with the DESIGN.md convention pioneered by [design-extractor.com](https://www.design-extractor.com) — extended with intent, material, voice, anatomy, and library detection._\n`; |
| 317 | + |
| 318 | + return `${fm}\n\n${body}${footer}`; |
| 319 | +} |
0 commit comments