mirror of
https://github.com/hanzoai/design.git
synced 2026-10-30 14:58:37 +00:00
Zen draws no Arabic. tokens/fonts.css now declares Noto Kufi Arabic (the default) and Noto Sans Arabic, each under its own family with an Arabic unicode-range, and names --font-arabic first in the sans, mono and serif stacks. Latin passes the companion over without a download, so an English page fetches what it did before, and the first available font a line box is measured from is still Zen. The preference gains an arabic axis (kufi | sans), set by reference to --font-arabic-kufi and --font-arabic-sans. It is its own axis because a face and an Arabic companion are two questions: serif with Kufi is two names, not a pairing enum. check-preference holds the axis and that every stack names the slot first.
250 lines
13 KiB
JavaScript
250 lines
13 KiB
JavaScript
// gen-tokens.mjs — the ONE source→TS bridge for @hanzo/design.
|
||
// The hand-authored token CSS under tokens/*.css is the source of truth; this
|
||
// script parses every `--name: value;` custom property out of it and emits a
|
||
// typed TypeScript module (src/tokens.gen.ts). CSS and code therefore can never
|
||
// drift — you edit a token in ONE place (the CSS) and both the stylesheet and
|
||
// the programmatic API update. Run via `npm run gen` (part of `build`).
|
||
import { readFileSync, writeFileSync, readdirSync, copyFileSync } from 'node:fs'
|
||
import { createRequire } from 'node:module'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { dirname, join } from 'node:path'
|
||
|
||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||
const tokensDir = join(root, 'tokens')
|
||
|
||
// THE FACES ARE COPIED, NOT PLACED BY HAND. tokens/fonts.css declares Zen with a
|
||
// relative url() into assets/fonts, because an @import cannot survive being
|
||
// flattened into the middle of a larger sheet (see the note in that file). So
|
||
// this package carries the binaries — but it must not AUTHOR them. `@hanzo/font`
|
||
// does, and this copies its output on every `npm run gen`.
|
||
//
|
||
// It is here because the alternative was measured and it failed: the two files
|
||
// under assets/fonts were once dropped in by hand and only renamed on disk. Their
|
||
// internal `name` table still read the family they were derived FROM, so anything
|
||
// that reads a font's own name rather than the @font-face rule — a font picker, a
|
||
// PDF embed, a design tool opening the woff2 — reported the wrong typeface on
|
||
// every Hanzo surface, while the browser rendered correctly and hid it. A copy
|
||
// with no author cannot be checked; a copy WITH one is checked every build.
|
||
const require = createRequire(import.meta.url)
|
||
const fontDir = dirname(require.resolve('@hanzo/font/css'))
|
||
|
||
// A copy is only as good as what it copied FROM, and that had already gone
|
||
// wrong once: the floor here sat at ^1.8.8, whose faces carry Vercel's vendor
|
||
// tag under our filenames, so every `npm run gen` quietly overwrote the good
|
||
// font package's own build refuses to publish a face that is not ours, so a
|
||
// version at or above it is the thing worth asserting — the bytes are its
|
||
// concern, and this is only the copy.
|
||
// Read beside the faces rather than through the specifier: the package's
|
||
// exports map does not publish its own package.json, and it has no reason to.
|
||
// 1.9.4 is the first to carry the Arabic companions tokens/fonts.css names.
|
||
const FLOOR = [1, 9, 4]
|
||
const { version } = JSON.parse(readFileSync(join(fontDir, '..', 'package.json'), 'utf8'))
|
||
const got = version.split('.').map(Number)
|
||
// Order is decided by the first component that differs, so 1.10.0 and 2.0.0
|
||
// both clear a 1.9.2 floor.
|
||
const differs = FLOOR.findIndex((n, i) => got[i] !== n)
|
||
if (differs !== -1 && got[differs] < FLOOR[differs])
|
||
throw new Error(
|
||
`@hanzo/font ${version} predates the faces verifying themselves; ` +
|
||
`raise the floor to ${FLOOR.join('.')} rather than copying from it`,
|
||
)
|
||
|
||
const FACES = [
|
||
['fonts/zen-sans/Zen-Variable.woff2', 'Zen-Variable.woff2'],
|
||
['fonts/zen-mono/ZenMono-Variable.woff2', 'ZenMono-Variable.woff2'],
|
||
// The Arabic companions are not ours, so their licence travels with them.
|
||
['fonts/arabic/NotoKufiArabic-Variable.woff2', 'NotoKufiArabic-Variable.woff2'],
|
||
['fonts/arabic/NotoSansArabic-Variable.woff2', 'NotoSansArabic-Variable.woff2'],
|
||
['fonts/arabic/OFL.txt', 'LICENSE-Arabic.txt'],
|
||
]
|
||
for (const [from, to] of FACES) copyFileSync(join(fontDir, from), join(root, 'assets/fonts', to))
|
||
console.log(`gen-tokens: copied ${FACES.length - 1} variable faces and the Arabic licence from @hanzo/font ${version}`)
|
||
|
||
// THE COLOURS ARE COPIED TOO, for the reason the faces are. @hanzo/tokens owns
|
||
// the colour system (src/theme.ts) and emits it as `css/colors`; this package
|
||
// republishes that sheet. It used to be placed by hand, and twice a token was
|
||
// added straight into the copy — the art hues, then `--edge` — under a header
|
||
// that says DO NOT EDIT, so the author no longer described what shipped. A copy
|
||
// made on every gen cannot drift; a token added here is overwritten, which is
|
||
// the point: it goes in theme.ts.
|
||
const colors = require.resolve('@hanzo/tokens/css/colors')
|
||
const tokensVersion = JSON.parse(readFileSync(join(dirname(colors), '..', 'package.json'), 'utf8')).version
|
||
copyFileSync(colors, join(tokensDir, 'colors.css'))
|
||
console.log(`gen-tokens: copied colors.css from @hanzo/tokens ${tokensVersion}`)
|
||
|
||
|
||
// The token files, in the same order styles.css imports them. base.css is the
|
||
// semantic-alias layer (references other vars) — parsed too, so `--background`
|
||
// etc. are available programmatically.
|
||
const FILES = ['colors', 'typography', 'spacing', 'grid', 'radius', 'elevation', 'motion', 'z', 'fonts', 'base']
|
||
|
||
// group name for each file (the export const); a couple read better renamed.
|
||
const GROUP = { z: 'zIndex' }
|
||
|
||
/** Strip /* */ comments, then pull every `--name: value;` pair (multi-per-line ok). */
|
||
function parse(css) {
|
||
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, '')
|
||
const out = []
|
||
const re = /--([A-Za-z0-9-]+)\s*:\s*([^;]+);/g
|
||
let m
|
||
while ((m = re.exec(noComments))) out.push([m[1].trim(), m[2].trim()])
|
||
return out
|
||
}
|
||
|
||
// A token may be authored more than once — the dark `:root{}` default and a
|
||
// light `@media`/`[data-theme]` override carry the SAME name. Keep the FIRST
|
||
// occurrence (dark, the default theme): the static map is the authored default;
|
||
// live theme switching happens through the CSS cascade via cssVar()/var(--x).
|
||
/**
|
||
* A knob is CSS's, so the TS table publishes the VALUE and leaves the knob behind.
|
||
*
|
||
* `--text-xs` is authored as `calc(0.75rem * var(--type-scale, 1))` and `--space-1` likewise on `--density`,
|
||
* so one property retunes the whole ramp at runtime. That is right in a stylesheet
|
||
* and unusable in JavaScript twice over: nothing in JS can resolve a `var()`, so a
|
||
* consumer computing with the value gets NaN, and a consumer writing it into an
|
||
* inline style outranks every stylesheet — which is the one thing this package
|
||
* spends its whole cascade avoiding.
|
||
*
|
||
* So the two facts braided into that one string are separated here. The value is
|
||
* the ramp; the knob is the cascade's, and `styles.css` is generated straight from
|
||
* the CSS, so it keeps the calc and the runtime knob still works. `cssVar()`'s
|
||
* fallback gets the literal too, which is what a host with no token layer mounted
|
||
* actually wants — there is no `--type-scale` there either.
|
||
*
|
||
* The shape is exact (`calc(<value> * var(--<knob>, 1))`) and only that shape is
|
||
* unwrapped; a genuinely composite calc (`calc(var(--fold) - 1px)`) is untouched.
|
||
* check-tokens.mjs holds it: no emitted value may name a knob.
|
||
*
|
||
* The type ramp adds a second knob INSIDE the first — a rung is
|
||
* `calc((<anchor> ± <delta> * var(--type-ratio, 1)) * var(--type-scale, 1))`, and
|
||
* the small rungs wrap that in a `max()` floor. Both are peeled here, in the
|
||
* order they nest, and the arithmetic is then done at the published defaults
|
||
* (both knobs 1) so the table carries exactly the rung the sheet renders — which
|
||
* is the same number it carried before the ratio existed. The floor is DROPPED
|
||
* rather than evaluated: it exists to catch a person's settings compounding, and
|
||
* at the defaults it never binds, so keeping it would put a guard in the table
|
||
* against a knob the table does not have.
|
||
*/
|
||
const KNOB = /^calc\(\s*([^*]+?)\s*\*\s*var\(--(?:type-scale|density),\s*1\)\s*\)$/
|
||
// The BOUNDS peel first. Every rung is `clamp(floor, value, ceiling)` — one
|
||
// rule at both ends — and the table carries the VALUE: a bound is a rendering
|
||
// decision the browser makes against a live viewport, so a JS table cannot hold
|
||
// one and must not pretend to. `max(floor, value)` is the older one-ended form,
|
||
// kept so a token file mid-migration still generates.
|
||
const BOUNDS = [
|
||
/^clamp\(\s*[^,]+,\s*(calc\(.*\))\s*,\s*[^,]+\)$/,
|
||
/^max\(\s*[\d.]+[a-z%]*\s*,\s*(calc\(.+\))\s*\)$/,
|
||
]
|
||
const RUNG =
|
||
/^calc\(\(\s*([\d.]+)(r?em|px)\s*([+-])\s*([\d.]+)(?:r?em|px)\s*\*\s*var\(--type-ratio,\s*1\)\s*\)\s*\*\s*var\(--type-scale,\s*1\)\s*\)$/
|
||
|
||
const scale = (v) => {
|
||
let inner = v
|
||
for (const b of BOUNDS) {
|
||
const m = inner.match(b)
|
||
if (m) { inner = m[1]; break }
|
||
}
|
||
const r = inner.match(RUNG)
|
||
if (r) {
|
||
const [, anchor, unit, sign, delta] = r
|
||
const n = sign === '-' ? Number(anchor) - Number(delta) : Number(anchor) + Number(delta)
|
||
// 4dp, then trim — 0.875 + 0.75 must read `1.625rem`, not `1.6250000000000002rem`.
|
||
return `${Number(n.toFixed(4))}${unit}`
|
||
}
|
||
return inner.match(KNOB)?.[1] ?? inner
|
||
}
|
||
|
||
const groupMaps = {} // groupName -> Map(name->value), first-wins
|
||
const flatMap = new Map() // '--name' -> value, first-wins
|
||
for (const f of FILES) {
|
||
let css
|
||
try {
|
||
css = readFileSync(join(tokensDir, `${f}.css`), 'utf8')
|
||
} catch {
|
||
continue
|
||
}
|
||
const g = GROUP[f] ?? f
|
||
const gm = (groupMaps[g] ??= new Map())
|
||
for (const [n, v0] of parse(css)) {
|
||
const v = scale(v0)
|
||
if (!gm.has(n)) gm.set(n, v)
|
||
if (!flatMap.has(`--${n}`)) flatMap.set(`--${n}`, v)
|
||
}
|
||
}
|
||
const groups = Object.fromEntries(Object.entries(groupMaps).map(([g, m]) => [g, [...m]]))
|
||
const flat = [...flatMap]
|
||
|
||
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
||
const objLit = (pairs) =>
|
||
'{\n' + pairs.map(([n, v]) => ` '${esc(n)}': '${esc(v)}',`).join('\n') + '\n} as const'
|
||
|
||
let ts = `// AUTO-GENERATED by scripts/gen-tokens.mjs from tokens/*.css — DO NOT EDIT.
|
||
// Edit the token in tokens/<group>.css and re-run \`npm run gen\`.
|
||
/* eslint-disable */
|
||
|
||
`
|
||
|
||
// Per-group typed maps (keyed by the var name WITHOUT the leading '--').
|
||
for (const [g, pairs] of Object.entries(groups)) {
|
||
ts += `/** ${g} tokens (from tokens/${Object.keys(GROUP).find((k) => GROUP[k] === g) ?? g}.css). Values are raw CSS. */\n`
|
||
ts += `export const ${g} = ${objLit(pairs)}\n\n`
|
||
}
|
||
|
||
// The complete flat map, keyed by the literal CSS custom-property name.
|
||
ts += `/** Every token, keyed by its literal CSS custom-property name ('--background', …). */\n`
|
||
ts += `export const cssVars = ${objLit(flat)}\n\n`
|
||
|
||
ts += `export type CssVarName = keyof typeof cssVars\n`
|
||
|
||
writeFileSync(join(root, 'src', 'tokens.gen.ts'), ts)
|
||
console.log(`gen-tokens: wrote src/tokens.gen.ts — ${flat.length} tokens across ${Object.keys(groups).length} groups`)
|
||
|
||
// ── styles.css, FLATTENED ────────────────────────────────────────────────
|
||
// The entry point says "import THIS one file", and for a long time no bundler
|
||
// could. It was a list of `@import url("tokens/*.css")`, and a relative url
|
||
// resolves against the file doing the importing — so a consumer's
|
||
// `@import "@hanzo/font/css"` sent webpack looking for
|
||
// `<their app>/tokens/fonts.css` and the build died on a module it never had.
|
||
// Nested imports also have to precede every other rule, which the spec then
|
||
// invalidates the moment the consumer imports anything before them; browsers
|
||
// drop them silently, every token resolves to nothing, and `border-border`
|
||
// falls back to currentColor — a stark white hairline on black.
|
||
//
|
||
// Both failures are the same fact: an @import list is not a stylesheet a
|
||
// consumer can use. So the entry point is now the CONTENT, concatenated in the
|
||
// same order, with no imports to resolve and no order to get wrong. That is why
|
||
// nothing consumed this package and every app hand-rolled its own palette.
|
||
//
|
||
// Authoring does not change: tokens/*.css stays the source of truth and this is
|
||
// its build product, exactly like tokens.gen.ts.
|
||
const banner = `/* Hanzo Design System — the entry point. Import THIS one file.
|
||
*
|
||
* AUTO-GENERATED by scripts/gen-tokens.mjs from tokens/*.css — DO NOT EDIT.
|
||
* Edit the token in tokens/<group>.css and re-run "npm run gen".
|
||
*
|
||
* Flattened deliberately: a consumer's bundler resolves a nested url() against
|
||
* ITS OWN directory, not ours, so an @import list here is a build error in
|
||
* every app that follows the instruction above. Nested imports must also
|
||
* precede all other rules, which the spec invalidates the moment the consumer
|
||
* imports anything first — browsers then drop them silently and every token
|
||
* resolves to nothing. The content is inlined in the order the groups were
|
||
* always imported in.
|
||
*/
|
||
`
|
||
// Hoisting a file from tokens/ to the package root moves what its relative
|
||
// url()s point at. tokens/fonts.css says `../assets/fonts/Zen-Variable.woff2`
|
||
// — correct from tokens/, one directory too high from the root, where it lands
|
||
// outside the package entirely and every consumer's build fails on a missing
|
||
// module. Rebasing is therefore part of flattening, not an afterthought: the
|
||
// bundle is one level shallower, so `../` becomes `./`.
|
||
const rebase = (css) =>
|
||
css.replace(/url\((\s*['"]?)\.\.\/(?!\.)/g, 'url($1./')
|
||
|
||
const bundle = banner + FILES.map((f) => {
|
||
const css = rebase(readFileSync(join(tokensDir, `${f}.css`), 'utf8').trim())
|
||
return `\n/* ── tokens/${f}.css ─────────────────────────────────────── */\n${css}\n`
|
||
}).join('')
|
||
writeFileSync(join(root, 'styles.css'), bundle)
|
||
console.log(`gen-tokens: wrote styles.css — ${FILES.length} token groups, flattened`)
|
||
|