Files
zeekay 868f59fe92 Arabic companions hold rather than swap; 1.9.5
A swap paints the platform's Arabic and then the companion, and Kufi is
wider and taller than Geeza Pro, Segoe UI or DejaVu Sans, so every Arabic
line re-wraps when the face lands. font-display: block holds the Arabic
a moment and paints it once. Zen's own faces keep swap.
2026-10-22 19:57:36 -07:00

271 lines
15 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.
`block`, where Zen's own faces `swap`. A swap paints the platform's Arabic
first and then the companion, and the two are not one shape at different
weights: Kufi is wider and taller than Geeza Pro, Segoe UI or DejaVu Sans, so
every Arabic line re-wraps under the reader's eye when the face lands. A
block holds the Arabic a moment and paints it once, in the face it keeps. */
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:block;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')