mirror of
https://github.com/hanzoai/font.git
synced 2026-11-02 23:42:52 +00:00
The Arabic table now holds a third companion, and every companion must draw
every letter the Arabic-script catalogs in @hanzo/i18n write ("needs":
Arabic, Persian, Dari, Urdu, Pashto, Sindhi, Sorani Kurdish, Uyghur,
Shahmukhi Punjabi). arabic.py refuses a face that does not. Measured
against those catalogs, Cairo, Readex Pro and Rubik each miss letters
Pashto, Sindhi, Kurdish or Uyghur write; Noto Kufi, Noto Sans and IBM Plex
Sans Arabic miss none.
Plex ships as IBM published it, one woff2 per weight, byte for byte: the
OFL reserves "Plex", and a subset is a Modified Version that may not carry
it. So a face is either cut (the Noto pair, as before) or whole, and a
face lists its files. The default must still be one variable file over
Zen's 100-900, since a static face under 'Zen' wins the weight match for
Latin; Plex is offered under its own name only.
The publish check reads the files to expect from the table.
Claude-Session: https://claude.ai/code/session_015XYKLjZtDTJEnMo4eK3AFH
276 lines
15 KiB
JavaScript
276 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.
|
|
So the default is one variable file over Zen's 100-900, and a family that
|
|
ships one file per weight (IBM Plex Sans Arabic) is offered under its own
|
|
name only, one @font-face per weight.
|
|
|
|
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 face of ARABIC.faces)
|
|
for (const f of face.files)
|
|
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.files.length !== 1 || fallback.files[0].weight !== ZEN_WEIGHT)
|
|
throw new Error(`${fallback.family} is not one file over Zen's ${ZEN_WEIGHT}, so it may not be declared under 'Zen'`)
|
|
const arabicFace = (family, face) =>
|
|
face.files.map((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.flatMap((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')
|