Files
zeekay b9b47efcda Arabic companions: Noto Kufi Arabic under Zen, Noto Sans Arabic beside it; 1.9.4
Zen draws no Arabic, so an Arabic page fell to whatever face the browser
picked. Two variable companions over Zen's own weight axis, cut to the
Arabic blocks, now ship in fonts/arabic. Each is declared under its own
family with an Arabic unicode-range, and the default under Zen and Zen
Mono too, so font-family: Zen sets Arabic and a page with no Arabic on it
requests nothing new.

They are not ours and keep their names, notices and vendor; identity.py
checks them for exactly that, from the table arabic.py builds them from,
pinned by commit and checksum. next.mjs refuses a default whose weight
range is not Zen's: faces composite only when their descriptors match,
and a companion that wins the weight match sends Latin to the system sans.
2026-10-22 19:22:13 -07:00

265 lines
14 KiB
JavaScript

/* The next/font wrappers, from the weight ladder rather than by hand.
*
* These list every static cut with the weight it answers to, and the list was
* maintained by hand against filenames that had been renamed on the way into
* the package — so `Zen-Black.woff2` was declared at 800 and the file renamed
* from ExtraBold at 900, and a page asking for 900 got the 800 face. A ladder
* cannot be off by one against itself.
*
* It writes the WHOLE published surface — every entry point, its types, and
* the plain-CSS face declarations — from one table, because the parts that were
* hand written had already drifted from the parts that were not: dist/sans.js
* carried no fallback stack while dist/mono.js did, and the root font.d.ts
* described a family called "Zen" that this package has never shipped.
*
* Run: node scripts/next.mjs
*/
import { writeFileSync, existsSync, readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist')
/* The CSS weight ladder. The name is the file's, and the number is what a page
asks for — one row, so they cannot disagree. */
const LADDER = [
[100, 'Thin'], [200, 'ExtraLight'], [300, 'Light'], [400, 'Regular'], [500, 'Medium'],
[600, 'SemiBold'], [700, 'Bold'], [800, 'ExtraBold'], [900, 'Black'],
]
/* The pixel cuts are five drawings of one alphabet, not five weights of one
drawing, so each is its own family and none of them has a ladder. */
const PIXEL = ['Circle', 'Grid', 'Line', 'Square', 'Triangle']
const FALLBACK = {
sans: ['ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'Roboto',
'Helvetica Neue', 'Arial', 'sans-serif'],
mono: ['ui-monospace', 'SFMono-Regular', 'Roboto Mono', 'Menlo', 'Monaco',
'Liberation Mono', 'DejaVu Sans Mono', 'Courier New', 'monospace'],
}
/* The export is the FAMILY NAME. The sans family is called Zen — that is what
the name table says and what a stylesheet asks for — so the binding is `Zen`
and not `ZenSans`, which named a family nothing ships. */
const FAMILY = {
/* `stem` is the filename, `css` is the family the face declares — ZenMono on
disk, 'Zen Mono' in a stylesheet. Naming both here is what keeps the CSS
and the bindings from drifting into two different spellings. */
sans: { dir: 'zen-sans', stem: 'Zen', css: 'Zen', export: 'Zen', variable: '--font-zen-sans' },
mono: { dir: 'zen-mono', stem: 'ZenMono', css: 'Zen Mono', export: 'ZenMono', variable: '--font-zen-mono' },
}
/* Italic is a separate file, so a non-variable page that leaves it out gets a
slanted copy of the roman. Every cut that exists is listed. */
const faces = ({ dir, stem }) =>
LADDER.flatMap(([weight, name]) =>
[['normal', name], ['italic', name === 'Regular' ? 'Italic' : `${name}Italic`]]
.map(([style, tail]) => ({ file: `${stem}-${tail}.woff2`, weight, style }))
.filter((f) => existsSync(join(DIST, 'fonts', dir, f.file))))
const src = (family) => faces(FAMILY[family]).map((f) =>
` {\n path: "./fonts/${FAMILY[family].dir}/${f.file}",\n` +
` weight: "${f.weight}",\n style: "${f.style}",\n },`).join('\n')
const block = (family, extra = '') =>
`export const ${FAMILY[family].export} = localFont({\n src: [\n${src(family)}\n ],\n` +
` variable: "${FAMILY[family].variable}",\n${extra}` +
` fallback: [\n${FALLBACK[family].map((f) => ` "${f}",`).join('\n')}\n ],\n});\n`
/* The ladder must cover the build, not just agree with itself. A cut that
exists and is not named here is a weight a page cannot ask for, and the
generator has no way to notice — so it looks. */
for (const [family, { dir }] of Object.entries(FAMILY)) {
const listed = new Set(faces(FAMILY[family]).map((f) => f.file))
const missing = readdirSync(join(DIST, 'fonts', dir))
.filter((f) => f.endsWith('.woff2') && !f.includes('Variable') && !listed.has(f))
if (missing.length) throw new Error(`${dir}: the ladder does not name ${missing.join(', ')}`)
}
const head = '// Generated by scripts/next.mjs. Do not edit.\nimport localFont from "next/font/local";\n\n'
const mono = ' adjustFontFallback: false,\n'
/* The variable entry: ONE file spanning 100-900, which is what a page should
reach for. It carries the same fallback stack as the ladder form — hand
written, sans.js carried none at all, so a page waiting on the download fell
through to the browser's default instead of the declared one. */
const cuts = ({ dir, stem }) =>
[['normal', `${stem}-Variable.woff2`], ['italic', `${stem}-Italic-Variable.woff2`]]
.filter(([, file]) => existsSync(join(DIST, 'fonts', dir, file)))
const variable = (family, extra = '') =>
`export const ${FAMILY[family].export} = localFont({\n src: [\n` +
cuts(FAMILY[family]).map(([style, file]) =>
` {\n path: "./fonts/${FAMILY[family].dir}/${file}",\n` +
` weight: "100 900",\n style: "${style}",\n },`).join('\n') +
`\n ],\n variable: "${FAMILY[family].variable}",\n${extra}` +
` fallback: [\n${FALLBACK[family].map((f) => ` "${f}",`).join('\n')}\n ],\n});\n`
/* Pixel: one family per cut, falling back to the mono ladder because that is
the shape a pixel face stands in for. */
const pixelBlock = (cut) =>
`export const ZenPixel${cut} = localFont({\n` +
` src: "./fonts/zen-pixel/ZenPixel-${cut}.woff2",\n` +
` variable: "--font-zen-pixel-${cut.toLowerCase()}",\n` +
` weight: "500",\n` +
` fallback: [\n "Zen Mono",\n${FALLBACK.mono.map((f) => ` "${f}",`).join('\n')}\n ],\n` +
` adjustFontFallback: false,\n});\n`
/* The types. Every entry point re-exports the root, so the shape is stated once
and a renamed export cannot leave a .d.ts describing the old name.
Each binding is documented with the family it actually declares and the
custom property it sets, because those two are what a caller has to get right
and neither is inferable from the identifier. The doc that was here before
was inherited: it described "Zen" and a deprecation ladder belonging to
another package. */
const DOC = {
Zen: ['Zen', 'The text face. Variable over wght 100-900, so any weight the design reaches for already exists'],
ZenMono: ['Zen Mono', 'The companion monospace, for code, data and anything that has to line up. Variable over wght 100-900'],
...Object.fromEntries(PIXEL.map((c) => [`ZenPixel${c}`,
[`Zen Pixel ${c}`, `A display face drawn in ${c.toLowerCase()} pixels. One weight — the five Pixel cuts are five drawings of one alphabet, not five weights of one drawing`]])),
}
const doc = (n) => {
const [family, what] = DOC[n]
return `/**\n * ${what}.\n *\n` +
` * Declares \`font-family: '${family}'\` and sets` +
` \`${VARIABLE[n]}\`.\n *\n` +
` * Attach \`.variable\` to a wrapper to publish the custom property, or\n` +
` * \`.className\` to set the family directly on an element.\n *\n` +
` * @see {@link https://font.hanzo.ai Specimen and downloads}\n */\n`
}
const dts = (names) =>
'// Generated by scripts/next.mjs. Do not edit.\n' +
'import { NextFontWithVariable } from "next/dist/compiled/@next/font";\n\n' +
names.map((n) => doc(n) + `export declare const ${n}: NextFontWithVariable;\n`).join('\n')
/* The Arabic companions (scripts/arabic.json, built by scripts/arabic.py). Zen
draws no Arabic, so each companion is declared twice:
- under its OWN family, Arabic code points only, so a stack can name the one
a person chose ahead of Zen (@hanzo/design's --font-arabic);
- the default ALSO under 'Zen' and 'Zen Mono', so anything that names either
gets Arabic with no other change.
The second only works when the companion's descriptors are Zen's exactly. A
face is matched by weight before unicode-range is consulted, and faces in one
family composite only when every other descriptor is equal: a companion
declared at 400, or 200-1000, wins the weight match for Latin text too, its
range then refuses every Latin letter, and the page falls to the system sans
— with no font request to show for it. Measured in Chromium with a static
and a narrower variable face; a refused default is the guard against it.
The joiners (U+200C-200F) are in the files and not in the range, so an
English page with an emoji ZWJ sequence never fetches an Arabic face. */
const ARABIC = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'arabic.json'), 'utf8'))
const ZEN_WEIGHT = '100 900'
for (const f of ARABIC.faces)
if (!existsSync(join(DIST, 'fonts', 'arabic', f.file))) throw new Error(`fonts/arabic/${f.file} is missing; run scripts/arabic.py`)
const fallback = ARABIC.faces.find((f) => f.family === ARABIC.default)
if (!fallback) throw new Error(`arabic.json names ${ARABIC.default} as the default and lists no such face`)
if (fallback.weight !== ZEN_WEIGHT)
throw new Error(`${fallback.family} spans ${fallback.weight}; only a face over Zen's ${ZEN_WEIGHT} may be declared under 'Zen'`)
const arabicFace = (family, f) =>
`@font-face{font-family:'${family}';src:url('./fonts/arabic/${f.file}') format('woff2');` +
`font-weight:${f.weight};font-style:normal;font-display:swap;unicode-range:${ARABIC.range}}`
/* The faces as plain CSS, for anything that is not a Next app. Same table, so a
family cannot be declared here under a name the bindings do not use. */
const face = (family, src, weights, style = 'normal') =>
`@font-face{font-family:'${family}';src:url('${src}') format('woff2');` +
(weights ? `font-weight:${weights};` : '') +
`font-style:${style};font-display:swap}`
/* Both cuts of a text family, upright and italic. A family that declares only
its upright still RENDERS italic — the browser slants the roman — so the
real drawing ships in the package and never reaches the page, and nothing
anywhere reports it. */
const textFaces = (family) => cuts(FAMILY[family]).map(([style, file]) =>
face(FAMILY[family].css, `./fonts/${FAMILY[family].dir}/${file}`, '100 900', style))
const css = [
`/* Zen — the Hanzo type family, as plain CSS. Generated by scripts/next.mjs.`,
` Do not edit.`,
``,
` Every other entry in this package is a next/font binding, so the faces were`,
` reachable only from a Next app. A font is not a framework feature: this file`,
` is @font-face and nothing else, so Vite, Tauri, Astro, a plain <link> and any`,
` bundler load the same faces from the same package.`,
``,
` Import it once at your root: import '@hanzo/font/css'`,
``,
` The two text families are variable over wght 100-900, so one file covers`,
` every weight. Pixel is five separate shapes rather than a weight axis.`,
``,
` Arabic comes from companions that are not ours, in fonts/arabic:`,
` ${ARABIC.faces.map((f) => f.family).join(', ')}. Each is declared under its own`,
` family, and the default under 'Zen' and 'Zen Mono' too, for the Arabic blocks`,
` only: a page with no Arabic on it never requests one. */`,
``,
...textFaces('sans'),
// After Zen's own faces: a family checks its faces last-declared first, so
// an Arabic letter meets the companion before the Latin face it lacks.
arabicFace('Zen', fallback),
...textFaces('mono'),
arabicFace('Zen Mono', fallback),
...ARABIC.faces.map((f) => arabicFace(f.family, f)),
...PIXEL.map((c) => face(`Zen Pixel ${c}`, `./fonts/zen-pixel/ZenPixel-${c}.woff2`)),
``,
`/* Bound as custom properties too, so a consumer can hand a family to a design`,
` system without spelling its name — and so a CSS-only consumer reads the same`,
` two properties the next/font bindings generate. Dropping this block is what`,
` 1.9.0 did, and it turned every \`var(--font-zen-sans)\` in a non-Next app into`,
` an invalid value, i.e. the OS default, silently. */`,
`:root{` + [
`--${FAMILY.sans.variable.slice(2)}:'Zen'`,
`--${FAMILY.mono.variable.slice(2)}:'Zen Mono'`,
`--font-zen-arabic:'${ARABIC.default}'`,
...PIXEL.map((c) => `--font-zen-pixel-${c.toLowerCase()}:'Zen Pixel ${c}'`),
].join(';') + `}`,
``,
].join('\n')
/* The root binds the VARIABLE pair, which is what a page should reach for: one
file per family spanning 100-900 instead of eighteen. It used to emit the
ladder, which made it a second spelling of sans-non-variable + mono-non-
variable — the same binding under two names, with the heavier of the two
sitting on the path everybody imports by default. */
writeFileSync(join(DIST, 'font.js'), head + variable('sans') + '\n' + variable('mono', mono))
writeFileSync(join(DIST, 'sans-non-variable.js'), head + block('sans'))
writeFileSync(join(DIST, 'mono-non-variable.js'), head + block('mono', mono))
writeFileSync(join(DIST, 'sans.js'), head + variable('sans'))
writeFileSync(join(DIST, 'mono.js'), head + variable('mono', mono))
writeFileSync(join(DIST, 'pixel.js'), head + PIXEL.map(pixelBlock).join('\n'))
/* The CSS carries two things a consumer depends on: the faces, and the custom
properties that name them. Losing either is silent at the consumer — an
undeclared family falls to the OS stack, an undefined var() computes to an
invalid value — so a file missing one is not written. */
for (const need of ['@font-face', ':root{', '--font-zen-sans', '--font-zen-mono', '--font-zen-arabic', 'unicode-range'])
if (!css.includes(need)) throw new Error(`zen.css would ship without ${need}`)
writeFileSync(join(DIST, 'zen.css'), css)
/* One place the custom property of every binding is known, so the doc cannot
name a variable the binding does not set. */
const VARIABLE = {
[FAMILY.sans.export]: FAMILY.sans.variable,
[FAMILY.mono.export]: FAMILY.mono.variable,
...Object.fromEntries(PIXEL.map((c) => [`ZenPixel${c}`, `--font-zen-pixel-${c.toLowerCase()}`])),
}
const SANS = FAMILY.sans.export
const MONO = FAMILY.mono.export
const ROOT = [SANS, MONO, ...PIXEL.map((c) => `ZenPixel${c}`)]
writeFileSync(join(DIST, '..', 'font.d.ts'), dts(ROOT))
for (const [file, names] of [
['font', ROOT], ['sans', [SANS]], ['mono', [MONO]],
['sans-non-variable', [SANS]], ['mono-non-variable', [MONO]],
['pixel', PIXEL.map((c) => `ZenPixel${c}`)],
]) writeFileSync(join(DIST, `${file}.d.ts`), 'export * from "../font";\n')
console.log('wrote dist/{font,sans,mono,sans-non-variable,mono-non-variable,pixel}.js,',
'their .d.ts, font.d.ts and zen.css —',
faces(FAMILY.sans).length, 'sans cuts,', faces(FAMILY.mono).length, 'mono cuts,',
PIXEL.length, 'pixel cuts')