[]previous and next page

Fonts

defineFonts in @shadow-js/dusk/vite builds the stylesheet for fonts.

For agentsUpdated

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.

fonts.google.tsts
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/css2 only, and every file from https://fonts.gstatic.com/ only: the face URL is parsed the way fetch reads 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 use redirect: "error", and an answer that arrives from another address anyway (a custom fetch) is refused.
  • Hashes. A file whose hash differs from the one in fonts.lock.json fails 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 pass updateLock: true once. A file already in public/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 under public/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-Length over 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; display is a font-display keyword; variable is --name; fallbackFace is false, Arial or Times 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.

fonts.subset.tsts
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 preload names 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>.woff2 under fonts/subset/ (directory changes it). A cache of the finished slices, keyed by the font's bytes and the slice, sits in node_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 as subsetFont to use another tool.

Variables, fallbacks and classes#

  • variableTarget sets 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 from SYSTEM_FALLBACKS (Arial, Times New Roman). Pass metrics.font and metrics.fallback to 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, or false for none.
  • classes names the classes of each family, by its key: className sets the family's stack, and variableClass sets its variable on the element it is applied to (<body className={classes.sans.variableClass}>).
  • display is per family. preload is 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.

fonts.routes.tsts
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.