import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Cell, DesignDoc, Row } from '@/design/parse'
import { parseDesignDoc } from '@/design/parse'
export interface RenderResult {
htmlPath: string
cssPath: string
}
export function renderDesignDoc(
sourcePath: string,
outDir: string,
): RenderResult {
const doc = parseDesignDoc(sourcePath)
mkdirSync(outDir, { recursive: true })
const cssPath = join(outDir, 'design.css')
const htmlPath = join(outDir, 'index.html')
writeFileSync(cssPath, buildCss(doc))
writeFileSync(htmlPath, buildHtml(doc))
return { htmlPath, cssPath }
}
function slug(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
function cell(row: Row, key: string): Cell {
return row[key] ?? { value: '', tagged: false }
}
/** The value alone. Every swatch, sample, and custom property is built from it. */
function val(row: Row, key: string): string {
return cell(row, key).value
}
/** The marker, rendered beside a value rather than inside it. */
function mark(row: Row, key: string): string {
return cell(row, key).tagged
? ' ? verify'
: ''
}
/** A displayed cell: its escaped text, then its marker when it carries one. */
function cellText(row: Row, key: string): string {
return escape(val(row, key)) + mark(row, key)
}
interface Confidence {
tagged: number
total: number
}
/**
* The columns the confidence ratio reads, fixed by `standards/design.md`. The
* first column of each table names its row, and `Multiplier` and `When used`
* restate what the row already carries, so none of them is something a source
* could anchor and none belongs in the denominator.
*/
const ANCHORABLE = {
borders: ['Radius', 'Width'],
color: ['Intent', 'Value'],
spacing: ['Value'],
typography: ['Family', 'Weight', 'Size', 'Line height'],
} as const
/**
* A cell counts when it carries a tag, or when it holds a value in a column a
* source could anchor. A blank the record left unfilled is neither, and so is a
* row name. Counting a tagged cell whichever column it sits in is what keeps a
* marker the preview draws from sitting outside the ratio printed beside it.
*/
function confidence(doc: DesignDoc): Confidence {
const tables: ReadonlyArray = [
[doc.color, ANCHORABLE.color],
[doc.typography, ANCHORABLE.typography],
[doc.spacing, ANCHORABLE.spacing],
[doc.borders, ANCHORABLE.borders],
]
let tagged = 0
let total = 0
for (const [rows, columns] of tables) {
for (const row of rows) {
for (const [key, c] of Object.entries(row)) {
if (!c.tagged && (!c.value || !columns.includes(key))) continue
total += 1
if (c.tagged) tagged += 1
}
}
}
return { tagged, total }
}
function buildCss(doc: DesignDoc): string {
const lines: string[] = [':root {']
for (const row of doc.color) {
if (val(row, 'Value')) {
lines.push(` --color-${slug(val(row, 'Role'))}: ${val(row, 'Value')};`)
}
}
for (const row of doc.spacing) {
if (val(row, 'Value')) {
lines.push(` --space-${slug(val(row, 'Step'))}: ${val(row, 'Value')};`)
}
}
for (const row of doc.typography) {
if (val(row, 'Size')) {
lines.push(
` --type-${slug(val(row, 'Role'))}-size: ${val(row, 'Size')};`,
)
}
if (val(row, 'Line height')) {
lines.push(
` --type-${slug(val(row, 'Role'))}-lh: ${val(row, 'Line height')};`,
)
}
}
for (const row of doc.borders) {
if (val(row, 'Radius')) {
lines.push(` --radius-${slug(val(row, 'Role'))}: ${val(row, 'Radius')};`)
}
}
lines.push('}')
return lines.join('\n') + '\n'
}
function escape(s: string): string {
return s
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}
function buildHtml(doc: DesignDoc): string {
const sections = [
sectionPersonality(doc.personality),
sectionColor(doc.color),
sectionTypography(doc.typography),
sectionSpacing(doc.spacing),
sectionBorders(doc.borders),
sectionLine('Motion', doc.motion),
sectionLine('Iconography', doc.iconography),
]
const { tagged, total } = confidence(doc)
const verifyStyle = tagged
? '\n .verify { color: #a4471c; font-size: 12px; font-weight: 600; margin-left: 0.35rem; white-space: nowrap; }'
: ''
const verb = tagged === 1 ? 'carries' : 'carry'
const summary = tagged
? `\n
${total - tagged} of ${total} cells are anchored to a source. The other ${tagged} ${verb} ? verify, so nothing anchors them yet.
`
: ''
return `
Design tokens
Design tokens
Generated from .claude/DESIGN.md by aitk design render. Token preview only, not a screen mock.
${summary}
${sections.join('\n')}
`
}
function sectionPersonality(text: string): string {
if (!text) return ''
return `
Personality
\n
${escape(text)}
`
}
function sectionColor(rows: Row[]): string {
if (!rows.length) return ''
const body = rows
.map((r) => {
const value = val(r, 'Value')
const swatch = value
? ``
: ''
const shown = value ? escape(value) : 'unset'
return `