/** * Font optimization primitives - generate a **CLS-safe `@font-face`** rule and the matching * **preload ``** for a self-hosted font. The two fixes that matter most (the ones `next/font` * automates) are right here: `font-display: swap` (text paints immediately, no invisible-text FOIT) * plus a preload so the file downloads with the document, and optional metric overrides * (`size-adjust`/`ascent-override`/…) so swapping from the fallback to the web font doesn't shift layout. * * Self-host the file (drop a `.woff2` in your assets - never hotlink Google's CDN), then: * * // fonts.css (imported by your app - the CSS pipeline bundles + links it) * import { fontFace } from "@nifrajs/web" * export default fontFace({ family: "Inter", src: [{ url: "/fonts/inter.woff2" }], weight: "100 900" }) * * // a root layout's meta - becomes a in * export const meta = { link: [fontPreload({ href: "/fonts/inter.woff2" })] } */ import type { LinkDescriptor } from "./manifest.ts" /** `font-display` strategy. `swap` (the default here) paints fallback text immediately, then swaps. */ export type FontDisplay = "auto" | "block" | "swap" | "fallback" | "optional" export interface FontSource { /** URL of the self-hosted font file. */ readonly url: string /** `format()` hint (`woff2`, `woff`, `truetype`, …). Inferred from the URL extension when omitted. */ readonly format?: string } export interface FontFace { readonly family: string /** One or more sources (best format first; the browser picks the first it supports). */ readonly src: readonly FontSource[] /** `400`, `"700"`, or a variable-font range like `"100 900"`. */ readonly weight?: number | string readonly style?: "normal" | "italic" | "oblique" /** Defaults to `"swap"` (no flash of invisible text). */ readonly display?: FontDisplay /** Restrict the glyphs this face covers, e.g. `"U+0000-00FF"` - lets the browser skip the download * for pages without those characters. */ readonly unicodeRange?: string /** Metric overrides that eliminate layout shift when the fallback swaps to the web font (the * `next/font` technique). e.g. `sizeAdjust: "105%"`, `ascentOverride: "90%"`. */ readonly sizeAdjust?: string readonly ascentOverride?: string readonly descentOverride?: string readonly lineGapOverride?: string } const FORMAT_BY_EXT: Readonly> = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype", eot: "embedded-opentype", svg: "svg", } const MIME_BY_EXT: Readonly> = { woff2: "font/woff2", woff: "font/woff", ttf: "font/ttf", otf: "font/otf", } /** Lowercased file extension of a URL, ignoring any `?query`/`#hash`. */ function extOf(url: string): string { const clean = url.split(/[?#]/, 1)[0] ?? url const dot = clean.lastIndexOf(".") return dot === -1 ? "" : clean.slice(dot + 1).toLowerCase() } /** Escape a value placed inside a double-quoted CSS string (`font-family`, `url("…")`, `format("…")`): * neutralize the quote, the escape char, and newlines so a value can't break out of the declaration. */ function cssQuoted(value: string): string { return value.replace(/[\\"]/g, "\\$&").replace(/[\r\n]+/g, " ") } /** Sanitize an **unquoted** CSS token value (`font-weight`, `unicode-range`, `size-adjust`, …): drop the * characters that could end the declaration or the rule and inject more CSS. */ function cssToken(value: string): string { return value .replace(/[;{}<>]/g, "") .replace(/[\r\n]+/g, " ") .trim() } /** * Build a single `@font-face` CSS rule. Defaults to `font-display: swap`; infers each source's * `format()` from its extension. All values are CSS-escaped, so a dynamic family/URL can't inject CSS. * Put the result in a stylesheet your app imports (nifra's CSS pipeline bundles + links it). */ export function fontFace(face: FontFace): string { if (face.src.length === 0) { throw new Error("fontFace: `src` needs at least one source") } const src = face.src .map((source) => { const format = source.format ?? FORMAT_BY_EXT[extOf(source.url)] const formatPart = format === undefined ? "" : ` format("${cssQuoted(format)}")` return `url("${cssQuoted(source.url)}")${formatPart}` }) .join(", ") const declarations = [ `font-family: "${cssQuoted(face.family)}"`, `src: ${src}`, `font-display: ${face.display ?? "swap"}`, ] if (face.weight !== undefined) declarations.push(`font-weight: ${cssToken(String(face.weight))}`) if (face.style !== undefined) declarations.push(`font-style: ${face.style}`) if (face.unicodeRange !== undefined) declarations.push(`unicode-range: ${cssToken(face.unicodeRange)}`) if (face.sizeAdjust !== undefined) declarations.push(`size-adjust: ${cssToken(face.sizeAdjust)}`) if (face.ascentOverride !== undefined) declarations.push(`ascent-override: ${cssToken(face.ascentOverride)}`) if (face.descentOverride !== undefined) declarations.push(`descent-override: ${cssToken(face.descentOverride)}`) if (face.lineGapOverride !== undefined) declarations.push(`line-gap-override: ${cssToken(face.lineGapOverride)}`) return `@font-face {\n ${declarations.join(";\n ")};\n}` } export interface FontPreloadInput { /** URL of the self-hosted font file to preload. */ readonly href: string /** MIME type (`font/woff2`, …). Inferred from the extension when omitted. */ readonly type?: string /** `crossorigin` mode - defaults to `"anonymous"`. Fonts are always fetched in CORS mode, and a * preload whose `crossorigin` doesn't match the actual fetch is **wasted** (downloaded twice), so * this is on by default. */ readonly crossOrigin?: "anonymous" | "use-credentials" } /** * Build a font preload as a `` attribute set for a route/layout's `meta.link` - nifra injects it * into `` (``). Values are * escaped at injection by the head renderer. Preloading the font file removes a render-blocking round * trip (the browser would otherwise discover the font only after parsing the CSS). */ export function fontPreload(input: FontPreloadInput): LinkDescriptor { const type = input.type ?? MIME_BY_EXT[extOf(input.href)] return { rel: "preload", as: "font", href: input.href, ...(type === undefined ? {} : { type }), crossorigin: input.crossOrigin ?? "anonymous", } }