/**
* Build-time Google Fonts automation - the `next/font/google` equivalent. At **build time** (never on
* the request path) it: builds the Google Fonts CSS2 URL, downloads the stylesheet, parses the
* `@font-face` rules, downloads each `.woff2`, content-hashes it, writes it next to your assets, and
* hands back a **self-hosted** `@font-face` stylesheet + the matching ``s. The
* result is identical to dropping the files in yourself and calling {@link fontFace} - no runtime CDN
* hotlink, no layout shift, hashed filenames for immutable caching.
*
* // fonts.build.ts - run once at build time (e.g. a prebuild step)
* import { loadGoogleFont } from "@nifrajs/web/fonts"
* const inter = await loadGoogleFont(
* { family: "Inter", weights: [400, 700], subsets: ["latin"] },
* { outDir: "public/fonts" }, // → public/fonts/inter-latin-normal-400-.woff2
* )
* await Bun.write("app/fonts.css", inter.css) // import this stylesheet from your app
* // inter.preloads → spread into a root layout's `meta.link`
*
* Security: this fetches remote content and writes it to disk, so every input is validated and the
* font-file host is **allowlisted to `fonts.gstatic.com` over https** - a tampered/MITM'd stylesheet
* cannot make the build fetch an arbitrary URL (SSRF) or write an attacker-chosen blob. Downloads are
* size-capped. Filenames are derived only from validated tokens + a content hash (no path traversal).
*/
import { writeFile as fsWriteFile, mkdir } from "node:fs/promises"
import { dirname, join } from "node:path"
import { type FontDisplay, fontFace, fontPreload } from "./fonts.ts"
import type { LinkDescriptor } from "./manifest.ts"
/** Options describing the Google font to fetch + self-host. */
export interface GoogleFontOptions {
/** Family name exactly as Google lists it, e.g. `"Inter"`, `"Open Sans"`, `"Roboto Mono"`. */
readonly family: string
/** Weights to request - numbers (`400`), numeric strings, a variable range (`"100..900"`, the
* CSS-style `"100 900"` also accepted), or the keywords `"normal"`/`"bold"`. Defaults to `[400]`. */
readonly weights?: readonly (number | string)[]
/** Styles to request. Defaults to `["normal"]`. */
readonly styles?: readonly ("normal" | "italic")[]
/** Keep only these named subsets (`"latin"`, `"latin-ext"`, `"cyrillic"`, …). Google returns every
* subset it has as a separate `@font-face`; this filters to the ones you serve. Defaults to keeping
* all returned subsets. Ignored when {@link text} is set (glyph subsetting supersedes it). */
readonly subsets?: readonly string[]
/** `font-display` strategy for the generated faces. Defaults to `"swap"`. */
readonly display?: FontDisplay
/** Glyph subsetting: request only the glyphs needed to render exactly this text (Google's `&text=`).
* Ideal for a logo/heading font - produces one tiny file. */
readonly text?: string
/** CLS metric overrides forwarded to every generated `@font-face` (the layout-shift fix). */
readonly sizeAdjust?: string
readonly ascentOverride?: string
readonly descentOverride?: string
readonly lineGapOverride?: string
}
/** A single `@font-face` block parsed out of Google's stylesheet. */
export interface ParsedFontFace {
readonly family: string
readonly style: string
readonly weight: string
readonly subset: string
readonly unicodeRange?: string
readonly src: readonly { readonly url: string; readonly format?: string }[]
}
/** One downloaded + written font file. */
export interface FontAsset {
/** The hashed filename written under `outDir` (no directory part). */
readonly fileName: string
/** The public URL the generated `@font-face`/preload reference (`${publicPath}/${fileName}`). */
readonly href: string
/** The original `fonts.gstatic.com` URL the bytes came from. */
readonly sourceUrl: string
readonly bytes: Uint8Array
readonly subset: string
readonly weight: string
readonly style: string
}
export interface LoadGoogleFontResult {
readonly family: string
/** A self-hosted `@font-face` stylesheet (one rule per written file). Import it from your app. */
readonly css: string
/** Every file written to `outDir`. */
readonly assets: readonly FontAsset[]
/** `fontPreload()` link-attribute sets - spread the ones you want into a layout's `meta.link`.
* Preloading *every* weight/subset is wasteful; usually preload just the primary subset + weight. */
readonly preloads: readonly LinkDescriptor[]
}
export interface LoadGoogleFontIO {
/** Directory to write the hashed `.woff2` files into (created if missing). */
readonly outDir: string
/** URL prefix the files are served under. Defaults to `"/fonts"`. */
readonly publicPath?: string
/** Injectable `fetch` (defaults to the global). Tests pass a canned implementation. */
readonly fetch?: typeof fetch
/** Injectable writer (defaults to `node:fs`). Tests pass an in-memory sink. */
readonly writeFile?: (path: string, bytes: Uint8Array) => Promise
/** Per-file download cap in bytes. Defaults to 5 MB (real woff2 are well under 1 MB). */
readonly maxBytesPerFile?: number
}
const CSS2_ENDPOINT = "https://fonts.googleapis.com/css2"
/** Only this host, only over https, may supply a font file we download + write to disk. The SSRF gate:
* a tampered stylesheet listing any other `src: url(...)` is rejected, not fetched. */
const ALLOWED_FONT_HOSTS: ReadonlySet = new Set(["fonts.gstatic.com"])
/** A modern desktop-Chrome UA so Google serves `woff2` (it falls back to bulky `ttf` for unknown UAs). */
const WOFF2_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
const DEFAULT_MAX_FONT_BYTES = 5 * 1024 * 1024
const MAX_CSS_BYTES = 512 * 1024
const MAX_TEXT_LEN = 2048
const VALID_DISPLAY: ReadonlySet = new Set([
"auto",
"block",
"swap",
"fallback",
"optional",
])
/** `true` iff `raw` is an `https://fonts.gstatic.com/…` URL - the only host we'll download from. */
export function isAllowedFontUrl(raw: string): boolean {
let url: URL
try {
url = new URL(raw)
} catch {
return false
}
return url.protocol === "https:" && ALLOWED_FONT_HOSTS.has(url.hostname)
}
/** Validate a family name (letters/digits/spaces only). It goes into both a URL and a filename, so a
* strict allowlist closes injection + path-traversal at the source. */
function validateFamily(family: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9 ]{0,62}$/.test(family)) {
throw new Error(
`loadGoogleFont: invalid family ${JSON.stringify(family)} (letters, digits, and spaces only)`,
)
}
return family
}
/** Normalize a weight to the token Google expects: `400`, `"700"`, `normal`→`400`, `bold`→`700`, or a
* variable range as `"100..900"` (also accepted spelled `"100 900"`). Rejects anything else (these
* land in the request URL). */
function normalizeWeight(weight: number | string): string {
if (typeof weight === "number") {
if (!Number.isInteger(weight) || weight < 1 || weight > 1000) {
throw new Error(`loadGoogleFont: invalid weight ${weight} (1-1000)`)
}
return String(weight)
}
const w = weight.trim()
if (w === "normal") return "400"
if (w === "bold") return "700"
if (/^\d{1,4}$/.test(w)) return w
// A variable range - Google's own "100..900" axis syntax or the CSS-style "100 900" spelling.
// Either way the URL token must be the DOTTED form; a literal space 400s at the CSS2 endpoint.
const range = /^(\d{1,4})(?:\.\.| )(\d{1,4})$/.exec(w)
if (range !== null) {
if (Number(range[1]) >= Number(range[2])) {
throw new Error(`loadGoogleFont: invalid weight range ${JSON.stringify(weight)} (min < max)`)
}
return `${range[1]}..${range[2]}`
}
throw new Error(`loadGoogleFont: invalid weight ${JSON.stringify(weight)}`)
}
function validateSubset(subset: string): string {
if (!/^[a-z0-9-]{1,32}$/.test(subset)) {
throw new Error(`loadGoogleFont: invalid subset ${JSON.stringify(subset)}`)
}
return subset
}
/** Build the Google Fonts CSS2 request URL. Pure + fully validated, so it's safe to feed a dynamic
* family/weights/text. Exported for advanced callers who fetch + parse the stylesheet themselves. */
export function googleFontsCssUrl(options: GoogleFontOptions): string {
const family = validateFamily(options.family)
const styles = options.styles ?? ["normal"]
for (const s of styles) {
if (s !== "normal" && s !== "italic") {
throw new Error(`loadGoogleFont: invalid style ${JSON.stringify(s)}`)
}
}
const display = options.display ?? "swap"
if (!VALID_DISPLAY.has(display)) {
throw new Error(`loadGoogleFont: invalid display ${JSON.stringify(display)}`)
}
// De-dupe + sort weights numerically (variable ranges sort by their lower bound).
const weights = [...new Set((options.weights ?? [400]).map(normalizeWeight))].sort(
(a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10),
)
const hasItalic = styles.includes("italic")
const hasNormal = styles.includes("normal") || !hasItalic
// CSS2 wants spaces in the family as `+`; the `:`, `@`, `,`, `;` axis syntax stays literal.
const familyName = encodeURIComponent(family).replace(/%20/g, "+")
let axis: string
if (hasItalic) {
// `ital,wght@` with tuples; all normal (ital=0) tuples must precede italic (ital=1).
const tuples: string[] = []
if (hasNormal) for (const w of weights) tuples.push(`0,${w}`)
for (const w of weights) tuples.push(`1,${w}`)
axis = `ital,wght@${tuples.join(";")}`
} else {
axis = `wght@${weights.join(";")}`
}
const params: string[] = [`family=${familyName}:${axis}`, `display=${display}`]
if (options.text !== undefined) {
if (options.text.length > MAX_TEXT_LEN) {
throw new Error(`loadGoogleFont: text exceeds ${MAX_TEXT_LEN} chars`)
}
if (options.text.length > 0) params.push(`text=${encodeURIComponent(options.text)}`)
}
return `${CSS2_ENDPOINT}?${params.join("&")}`
}
// The stylesheet is REMOTE input (Google's CDN, but still an upstream), so the whole parse is
// forward index scans - no unanchored regex runs on it, and no shape can backtrack polynomially on
// an adversarial response.
/** The `/* subset */` label immediately preceding a `@font-face` block (only whitespace between),
* scanned by index in the gap since the previous block. */
function subsetLabelBefore(css: string, faceStart: number, prevEnd: number): string | undefined {
const between = css.slice(prevEnd, faceStart)
const open = between.lastIndexOf("/*")
if (open === -1) return undefined
const close = between.indexOf("*/", open + 2)
if (close === -1 || between.slice(close + 2).trim() !== "") return undefined
return between.slice(open + 2, close)
}
/** The value of `prop: value;` inside a declaration body, by case-insensitive index scan. */
function declOf(body: string, prop: string): string | undefined {
const lower = body.toLowerCase()
const needle = prop.toLowerCase()
for (let at = lower.indexOf(needle); at !== -1; at = lower.indexOf(needle, at + 1)) {
let i = at + needle.length
while (i < body.length && (body[i] === " " || body[i] === "\t" || body[i] === "\n")) i++
if (body[i] !== ":") continue
i++
const end = body.indexOf(";", i)
if (end === -1) continue // unterminated declaration - same as the semicolon-requiring regex
return body.slice(i, end).trim()
}
return undefined
}
/** Strip one level of matching quotes. */
function unquote(value: string): string {
const first = value[0]
return (first === '"' || first === "'") && value.endsWith(first) && value.length > 1
? value.slice(1, -1)
: value
}
/** Every `url(…)` (with optional trailing `format(…)`) in a declaration body, by index scan. */
function parseSrc(body: string): { url: string; format?: string }[] {
const src: { url: string; format?: string }[] = []
for (let at = body.indexOf("url("); at !== -1; at = body.indexOf("url(", at + 1)) {
const close = body.indexOf(")", at + 4)
if (close === -1) break
const url = unquote(body.slice(at + 4, close).trim())
if (url === "") continue
let cursor = close + 1
while (cursor < body.length && (body[cursor] === " " || body[cursor] === "\t")) cursor++
let format: string | undefined
if (body.startsWith("format(", cursor)) {
const formatClose = body.indexOf(")", cursor + 7)
if (formatClose !== -1) format = unquote(body.slice(cursor + 7, formatClose).trim())
}
src.push(format === undefined || format === "" ? { url } : { url, format })
}
return src
}
/** Parse Google's stylesheet into structured faces, capturing the `/* subset */` label that precedes
* each `@font-face`. Pure - exported so callers can run their own download/write pipeline. */
export function parseGoogleFontCss(css: string): ParsedFontFace[] {
const faces: ParsedFontFace[] = []
let prevEnd = 0
// The next-`{` position is memoized: without it, a crafted response of N at-rules ahead of one
// late brace re-scans for that brace N times - the quadratic shape this parser exists to avoid.
let openMemo = -1
for (let at = css.indexOf("@font-face"); at !== -1; at = css.indexOf("@font-face", at + 1)) {
if (openMemo < at + 10) openMemo = css.indexOf("{", at + 10)
const open = openMemo
if (open === -1) break
if (css.slice(at + 10, open).trim() !== "") continue // not this at-rule's block
const closeBrace = css.indexOf("}", open + 1)
if (closeBrace === -1) break
const subset = (subsetLabelBefore(css, at, prevEnd) ?? "").trim() || "default"
prevEnd = closeBrace + 1
const body = css.slice(open + 1, closeBrace)
const familyRaw = declOf(body, "font-family")
if (familyRaw === undefined) continue
const family = unquote(familyRaw)
const src = parseSrc(body)
if (src.length === 0) continue
const unicodeRange = declOf(body, "unicode-range")
faces.push({
family,
style: declOf(body, "font-style") ?? "normal",
weight: declOf(body, "font-weight") ?? "400",
subset,
...(unicodeRange === undefined ? {} : { unicodeRange }),
src,
})
}
return faces
}
/** First 16 hex chars of the SHA-256 of the bytes - a content hash for an immutable, cache-busting
* filename. Web Crypto, so it runs identically on Bun/Node/edge build hosts. `Uint8Array`
* (not the generic `ArrayBufferLike`) to satisfy WebCrypto's `BufferSource` under TS 5.7+ generics. */
async function contentHash(bytes: Uint8Array): Promise {
const digest = await crypto.subtle.digest("SHA-256", bytes)
let hex = ""
for (const b of new Uint8Array(digest)) hex += b.toString(16).padStart(2, "0")
return hex.slice(0, 16)
}
/** Lowercase a validated token into a filename-safe slug. */
const slug = (s: string): string =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
/** Host of a URL for error messages - we never echo the full untrusted URL+path. */
const hostOf = (raw: string): string => {
try {
return new URL(raw).host
} catch {
return ""
}
}
/** Fetch with a hard byte ceiling. Checks the advertised `Content-Length` first (cheap reject), then
* re-checks the materialized body (a lying header can't smuggle a larger payload past us). */
async function fetchBounded(
fetchImpl: typeof fetch,
url: string,
maxBytes: number,
headers?: Record,
): Promise> {
const res = await fetchImpl(url, headers ? { headers } : {})
if (!res.ok) {
throw new Error(`loadGoogleFont: fetch failed (${res.status}) from ${hostOf(url)}`)
}
const advertised = Number(res.headers.get("content-length"))
if (Number.isFinite(advertised) && advertised > maxBytes) {
throw new Error(`loadGoogleFont: response from ${hostOf(url)} exceeds ${maxBytes} bytes`)
}
const bytes = new Uint8Array(await res.arrayBuffer())
if (bytes.byteLength > maxBytes) {
throw new Error(`loadGoogleFont: response from ${hostOf(url)} exceeds ${maxBytes} bytes`)
}
return bytes
}
const defaultWriteFile = async (path: string, bytes: Uint8Array): Promise => {
await mkdir(dirname(path), { recursive: true })
await fsWriteFile(path, bytes)
}
/**
* Download a Google font, self-host it, and return a CLS-safe `@font-face` stylesheet + preloads.
* See the module header for the full flow and security model. I/O (`fetch`, `writeFile`) is injectable
* so this is unit-testable without the network.
*/
export async function loadGoogleFont(
options: GoogleFontOptions,
io: LoadGoogleFontIO,
): Promise {
const family = validateFamily(options.family)
const fetchImpl = io.fetch ?? fetch
const writeFile = io.writeFile ?? defaultWriteFile
let publicPath = io.publicPath ?? "/fonts"
while (publicPath.endsWith("/")) publicPath = publicPath.slice(0, -1)
const maxBytes = io.maxBytesPerFile ?? DEFAULT_MAX_FONT_BYTES
const wantedSubsets = options.subsets?.map(validateSubset)
const cssUrl = googleFontsCssUrl(options)
const cssBytes = await fetchBounded(fetchImpl, cssUrl, MAX_CSS_BYTES, { "user-agent": WOFF2_UA })
const css = new TextDecoder().decode(cssBytes)
let faces = parseGoogleFontCss(css)
if (faces.length === 0) {
throw new Error(
`loadGoogleFont: Google returned no @font-face rules for ${JSON.stringify(family)}`,
)
}
// Filter to requested named subsets - but only when not glyph-subsetting (text mode labels faces
// `[0]`, `[1]`, … which carry no subset name).
if (wantedSubsets && wantedSubsets.length > 0 && options.text === undefined) {
faces = faces.filter((f) => wantedSubsets.includes(f.subset))
if (faces.length === 0) {
throw new Error(`loadGoogleFont: no faces for subsets [${wantedSubsets.join(", ")}]`)
}
}
const assets: FontAsset[] = []
const rules: string[] = []
const preloads: LinkDescriptor[] = []
for (const face of faces) {
// Prefer woff2; the CSS2 endpoint with a Chrome UA only ever returns woff2, but be defensive.
const source = face.src.find((s) => s.format === "woff2") ?? face.src[0]
if (!source) continue
if (!isAllowedFontUrl(source.url)) {
throw new Error(
`loadGoogleFont: refusing to download a font from a non-Google host (${hostOf(source.url)}); ` +
"the stylesheet may be tampered with",
)
}
const bytes = await fetchBounded(fetchImpl, source.url, maxBytes)
const hash = await contentHash(bytes)
const style = face.style === "italic" ? "italic" : "normal"
const fileName = `${slug(family)}-${slug(face.subset)}-${style}-${face.weight.replace(/\s+/g, "_")}-${hash}.woff2`
// The injected writer is also a public/test seam. Forward slashes are accepted by Node/Bun on
// Windows and keep its observed path stable instead of leaking the host separator.
const path = join(io.outDir, fileName).replaceAll("\\", "/")
await writeFile(path, bytes)
const href = `${publicPath}/${fileName}`
assets.push({
fileName,
href,
sourceUrl: source.url,
bytes,
subset: face.subset,
weight: face.weight,
style,
})
rules.push(
fontFace({
family,
src: [{ url: href, format: "woff2" }],
weight: face.weight,
style,
display: options.display ?? "swap",
...(face.unicodeRange === undefined ? {} : { unicodeRange: face.unicodeRange }),
...(options.sizeAdjust === undefined ? {} : { sizeAdjust: options.sizeAdjust }),
...(options.ascentOverride === undefined ? {} : { ascentOverride: options.ascentOverride }),
...(options.descentOverride === undefined
? {}
: { descentOverride: options.descentOverride }),
...(options.lineGapOverride === undefined
? {}
: { lineGapOverride: options.lineGapOverride }),
}),
)
preloads.push(fontPreload({ href }))
}
if (assets.length === 0) {
throw new Error(`loadGoogleFont: no downloadable woff2 sources for ${JSON.stringify(family)}`)
}
return { family, css: rules.join("\n\n"), assets, preloads }
}