Fonts
defineFonts in @shadow-js/dusk/vite builds the stylesheet for fonts.
defineFonts in @shadow-js/dusk/vite builds the stylesheet for fonts. It writes one @font-face per file (with
font-display: swap by default, the weight range and the width axis), a size-adjusted local fallback face, one :root
custom property per family (--font-sans), and the preload tags. With local files it reads them from public/ and never
downloads: a missing file fails the build and names it.
The defaults follow next/font, and each has an explicit way out:
| Default | What it does | Turn it off or change it |
|---|---|---|
| Preload | Every file is preloaded, except a file with a unicodeRange (a slice that is fetched only when its characters are used). A Google family preloads its first declared subset (latin when you declare none); only the subsets and weights you asked for are downloaded at all. |
preload: false on a file, a family or a Google family; a list of subsets on a Google family; preload: true on a file to preload a slice. |
| Fallback face | A size-adjusted fallback (size-adjust, ascent-override, descent-override, line-gap-override) is built from the file's own metrics whenever they can be read: Arial, or Times New Roman when the fallback stack has serif. A file that is not a readable font gives no fallback face and no error. |
metrics: false (local) or fallbackFace: false (Google); metrics: { local, font, fallback } to ask for it with your own values, which also makes an unreadable file an error. |
font-display |
swap. |
display. |
| Preconnect | Only with assetOrigin (a CDN): the files are then served from that origin, and preconnects has the link that opens the connection early. Without it the files are on the app's origin and there is nothing to preconnect to. |
Leave assetOrigin out. |
dusk()'s own assetOrigin does not move files from public/, so pass assetOrigin to defineFonts only when the font
files are on that origin. Put preconnects before preloads in the head.
With google, the families come from Google Fonts at build time. The CSS2 API is asked for the faces you name (the
subsets, the weights, the italics, the variable axes), each file is downloaded and vendored into public/fonts/google/
under a name from its hash, and the stylesheet points there. The page never talks to Google. Every download is recorded
with its SRI hash in fonts.lock.json at the project root, and the bytes are cached in node_modules/.cache/dusk/fonts.
When the network is down, the cache and the lock are used; a copy that does not match the lock is never used.
import { defineFonts } from "@shadow-js/dusk/vite";
export const fonts = await defineFonts(
{
google: [
{
family: "Inter",
variable: "--font-sans",
axes: { wght: "400..700" },
italic: true,
subsets: ["latin", "latin-ext"],
preload: ["latin"],
},
{ family: "JetBrains Mono", variable: "--font-mono", weights: [400, 700], subsets: ["latin"] },
],
},
{ publicDir: "public", offline: false },
);
The options: frozen also fails the build when the stylesheet differs from the lock (use it in CI, so a font cannot
change without a reviewed lock); updateLock accepts a file whose hash differs from the lock and records the new
hash (not with frozen); offline never touches the network; root moves the lock and the cache. The input may also
carry local families, which are written into the same stylesheet.
What the Google step refuses, before it writes anything:
- Hosts. The stylesheet comes from
https://fonts.googleapis.com/css2only, and every file fromhttps://fonts.gstatic.com/only: the face URL is parsed the wayfetchreads it, and anything with another host, a port, credentials, a fragment, a space, a backslash or a non-ASCII character is refused. A stylesheet that names another host fails the build and no file is fetched from it. Requests useredirect: "error", and an answer that arrives from another address anyway (a customfetch) is refused. - Hashes. A file whose hash differs from the one in
fonts.lock.jsonfails the build in every mode, and nothing is written: not the vendored file, not the cache, not the lock. The URLs Google serves are versioned, so the bytes behind one should never change. Review the change, then passupdateLock: trueonce. A file already inpublic/fonts/google/is checked by content, not by name: a copy that was changed on disk is replaced by the verified bytes. A lock file that is not valid (a missing field, a hash that is not a string) is an error that names it. The names underpublic/fonts/google/are a hash and an extension, never part of a URL or a family name. - Sizes. A stylesheet is read up to 1 MiB and a font up to 8 MiB; a declared
Content-Lengthover the limit ends the request before the body is read, and the body is cut off at the limit. - Names and values. The family is letters, digits and spaces (at most 100); axes are four letters with a number or a
range;
displayis afont-displaykeyword;variableis--name;fallbackFaceisfalse,ArialorTimes New Roman; subsets are lower-case names. All of this is checked before the first request.
For any family (Google or local), a value that reaches the stylesheet must not carry CSS. A family name has no quote,
backslash, brace, semicolon, angle bracket or control character. A weight is a number from 1 to 1000, a range such as
"400 700", or normal or bold; a style is normal or italic. Each fallback entry is a family name (words,
or one quoted name) or a generic keyword (system-ui, -apple-system, "Segoe UI"); a comma, a brace, a semicolon, a
parenthesis or an unbalanced quote is a TypeError. A font file path stays under public/: no .., no backslash, no
control character, and no %2e, %2f or %5c, which a browser would read as dots and slashes in the URL the stylesheet names.
The metrics reader inflates only the tables it reads: head, hhea and OS/2, each at most 64 KiB, and cmap and
hmtx for the a-z average, each at most 2 MiB (a larger or damaged one only costs the average, not the other metrics). It
decodes a WOFF2 stream to at most 64 MiB, so a font that is a decompression bomb is a RangeError, not an out-of-memory build.
Subsetting a font by script#
A family file that holds every script it supports makes each visitor download all of it. subsetFonts cuts a local file into
slices by Unicode range, writes each slice as a WOFF2 file under public/, and returns the family with one @font-face per
slice and its unicode-range: a browser then fetches only the slices whose characters the page uses (a page in English never
loads the Arabic slice). The result goes to defineFonts like any local family, so the fallback face, the variables and the
preloads work as before. FONT_SUBSETS names the usual slices (latin, latin-ext, vietnamese, cyrillic, cyrillic-ext,
greek, arabic, hebrew, devanagari: the same ranges Google Fonts serves); { name, unicodeRange } makes your own.
import { defineFonts, subsetFonts } from "@shadow-js/dusk/vite";
const families = await subsetFonts(
{
sans: {
family: "Inter",
variable: "--font-sans",
files: [{ file: "fonts/Inter.ttf", weight: "100 900" }],
},
},
{ publicDir: "public", subsets: ["latin", "latin-ext", "arabic"], preload: ["latin"] },
);
export const fonts = defineFonts(families, { publicDir: "public" });
- The first slice is preloaded unless
preloadnames others; a slice is fetched when its characters are used. - A slice the font has no glyph for (an Arabic slice of a Latin font) is left out; a family none of whose slices has a glyph is an error that names it. Some ranges share punctuation (a hyphen, a zero width joiner), so a slice can hold a few glyphs of another script's range.
- The slices are named
<file>-<slice>-<hash>.woff2underfonts/subset/(directorychanges it). A cache of the finished slices, keyed by the font's bytes and the slice, sits innode_modules/.cache/dusk/font-subsets, so an unchanged font is not cut again. - A variable font keeps its axes. The cutting is done by
subset-font(HarfBuzz), an optional peer:npm install --save-dev subset-font. Pass your own function assubsetFontto use another tool.
Variables, fallbacks and classes#
variableTargetsets where the CSS variables are written:":root"(the default),"html","body", or a class (".font-vars"). Next.js writes them on<body>through its variable classes;"body"is the same.- The fallback face is on by default (see the table above).
metrics: { local: "Arial" }asks for it explicitly: the web font's metrics (units per em, the hhea ascender, descender and line gap, and the a-z average advance) are read from the family's files, preferring an upright file nearest weight 400 that has the Latin letters. The system font's measured values come fromSYSTEM_FALLBACKS(Arial, Times New Roman). Passmetrics.fontandmetrics.fallbackto use other values, for example Next's capsize table. A font with no glyph for the Latin letters is set at the system font's width (size-adjust: 100%), as Next does. - A Google family takes
fallbackFace: "Arial"or"Times New Roman"to choose the system font, orfalsefor none. classesnames the classes of each family, by its key:classNamesets the family's stack, andvariableClasssets its variable on the element it is applied to (<body className={classes.sans.variableClass}>).displayis per family.preloadis per file, per family, or per Google subset (see the table above).
Preloads per route and the report#
preloads is for every page. A font that only some pages show above the fold is better preloaded on those pages: mark the
family preload: false, and name it under routes (a route glob to the keys of the families it preloads).
fontPreloadsFor(fonts.routePreloads, pathname, fonts.preloads) returns the tags for a path, without any tag that every
page already has and without repeats. A slice of a family (a file with a unicodeRange) is left out unless its own
preload is true.
report lists every file with its size and says which ones every page preloads. It is always in the result;
report: true also prints it, and report: (text) => ... hands the text to your logger.
import { defineFonts, fontPreloadsFor, formatFontReport } from "@shadow-js/dusk/vite";
export const fonts = defineFonts(
{
body: { family: "Inter", variable: "--font-sans", files: [{ file: "fonts/Inter.woff2", weight: "100 900" }] },
display: {
family: "Fraunces",
preload: false,
files: [{ file: "fonts/Fraunces.woff2", weight: "400 700" }],
},
},
{ publicDir: "public", routes: { "/": ["display"], "/blog/**": ["display"] } },
);
export function headFontsFor(pathname: string) {
return [...fonts.preloads, ...fontPreloadsFor(fonts.routePreloads, pathname, fonts.preloads)];
}
export const summary: string = formatFontReport(fonts.report);
The width average is the one Next.js uses: the mean advance of aaabcdeeeefghiijklmnnoopqrrssttuvwxyz and six spaces,
read from cmap and hmtx. It is not the OS/2 xAvgCharWidth, which means different things in different OS/2
versions (version 3 and later average every glyph) and is far off for some fonts: on Noto Sans it is 571 of 1000 units
against 485.8 for the a-z average, which would size the fallback at 129% of Arial instead of 106.5%. Both measures
agree on Arial (904 against 934.5 of 2048 units) only roughly. A fallback built from a file matches Next's when the
font has the same glyphs; the capsize table Next ships for Google fonts can differ in the last digits, and
metrics.font and metrics.fallback match it exactly. readFontMetrics returns the average as azAvgWidth
(undefined when a letter is missing). The measure reads only the latin letters, so a font for another script is set at
100% width.