/** * Font configuration. * * Declare fonts in `wrnexus.config.ts` under `fonts` and the framework emits * optimized `` markup for you: * - Google Fonts: `preconnect` hints + a single subsetted stylesheet request * (only the weights you list) with `font-display`. The CSP is auto-extended * so the fonts load under the default security policy (see loadAppConfig). * - Self-hosted fonts: generated `@font-face` rules + optional `` * for above-the-fold text (the fastest, no-third-party option). * - Family stacks: `sans`/`mono`/`serif` become `--wrn-font-*` CSS variables, * and `sans` is applied to `body`. */ export type FontDisplay = "auto" | "block" | "swap" | "fallback" | "optional"; export interface GoogleFont { /** Family name as it appears on fonts.google.com, e.g. "Inter". */ family: string; /** Weights to load — ONLY these are fetched. Default: [400]. */ weights?: (number | string)[]; /** Also load italic styles for each weight. */ italic?: boolean; /** Per-font `font-display` override (else the config default). */ display?: FontDisplay; } export interface LocalFontFace { /** `font-family` name this face defines. */ family: string; /** URL to the font file, typically served from `public/` (e.g. "/fonts/inter.woff2"). */ src: string; /** e.g. 400, "700", or "100 900" for a variable font. Default: 400. */ weight?: number | string; style?: "normal" | "italic"; /** CSS `src` format; inferred from the file extension when omitted. */ format?: string; display?: FontDisplay; /** Emit `` — use for the primary above-the-fold face. */ preload?: boolean; /** Optional `unicode-range` subset. */ unicodeRange?: string; } export interface FontConfig { /** Google Fonts, loaded with preconnect + weight subsetting + `font-display`. */ google?: GoogleFont[]; /** Self-hosted `@font-face` definitions (files served from `public/`). */ local?: LocalFontFace[]; /** Default `font-display` for faces that don't set their own. Default: "swap". */ display?: FontDisplay; /** Body / default family stack → `--wrn-font-sans` + `body { font-family }`. */ sans?: string; /** Monospace family stack → `--wrn-font-mono`. */ mono?: string; /** Serif family stack → `--wrn-font-serif`. */ serif?: string; } const GOOGLE_CSS = "https://fonts.googleapis.com"; const GOOGLE_STATIC = "https://fonts.gstatic.com"; function escAttr(s: string): string { return s .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } function cssString(s: string): string { return s .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/[\r\n\f]/g, " "); } function safeStyle(css: string): string { return css.replace(/<\/style/gi, "<\\/style"); } function extOf(src: string): string { return (src.split(/[?#]/)[0].split(".").pop() ?? "").toLowerCase(); } function cssFormat(src: string, override?: string): string { if (override) return override; const e = extOf(src); return e === "woff2" ? "woff2" : e === "woff" ? "woff" : e === "ttf" ? "truetype" : e === "otf" ? "opentype" : "woff2"; } function preloadType(src: string): string { const e = extOf(src); return e === "woff" ? "font/woff" : e === "ttf" ? "font/ttf" : e === "otf" ? "font/otf" : "font/woff2"; } /** Build the Google Fonts `css2` URL for the given families (weights subsetted). */ function googleFontsUrl(fonts: GoogleFont[], defDisplay: FontDisplay): string { const families = fonts.map((f) => { const name = encodeURIComponent(f.family).replace(/%20/g, "+"); const weights = (f.weights?.length ? f.weights : [400]).map(String); if (f.italic) { const pairs = weights.flatMap((w) => [`0,${w}`, `1,${w}`]).sort((a, b) => a.localeCompare(b)); return `family=${name}:ital,wght@${pairs.join(";")}`; } const sorted = [...weights].sort((a, b) => Number(a) - Number(b)); return `family=${name}:wght@${sorted.join(";")}`; }); return `${GOOGLE_CSS}/css2?${families.join("&")}&display=${defDisplay}`; } /** * Render all `` markup for a font config. Returns "" when nothing is * configured. The output is trusted, framework-controlled HTML. */ export function renderFontHead(fonts?: FontConfig): string { if (!fonts) return ""; const display = fonts.display ?? "swap"; const out: string[] = []; // Google Fonts — preconnect (perf) then one subsetted stylesheet. if (fonts.google?.length) { out.push(``); out.push(``); out.push(``); } // Self-hosted @font-face + optional preload. if (fonts.local?.length) { const faces = fonts.local.map((f) => { const lines = [ ` font-family: "${cssString(f.family)}";`, ` src: url("${cssString(f.src)}") format("${cssString(cssFormat(f.src, f.format))}");`, ` font-weight: ${f.weight ?? 400};`, ` font-style: ${f.style ?? "normal"};`, ` font-display: ${f.display ?? display};`, ]; if (f.unicodeRange) lines.push(` unicode-range: ${f.unicodeRange};`); return `@font-face {\n${lines.join("\n")}\n}`; }); out.push(``); for (const f of fonts.local) { if (f.preload) { out.push( ``, ); } } } // Family stacks → CSS variables + body default. const vars: string[] = []; if (fonts.sans) vars.push(` --wrn-font-sans: ${fonts.sans};`); if (fonts.mono) vars.push(` --wrn-font-mono: ${fonts.mono};`); if (fonts.serif) vars.push(` --wrn-font-serif: ${fonts.serif};`); if (vars.length) { const body = fonts.sans ? `\nbody { font-family: var(--wrn-font-sans); }` : ""; out.push(``); } return out.join("\n "); } /** * Production variant that inlines the small Google Fonts stylesheet at build * time. This removes a render-blocking CSS round trip while retaining the same * font files, `font-display`, CSP sources, and offline-safe fallback markup. */ export async function renderProductionFontHead( fonts?: FontConfig, fetcher: (input: string, init?: RequestInit) => Promise = fetch, ): Promise { const fallback = renderFontHead(fonts); if (!fonts?.google?.length) return fallback; const url = googleFontsUrl(fonts.google, fonts.display ?? "swap"); try { const response = await fetcher(url, { headers: { // Google returns compact WOFF2 rules to modern browser user agents. "user-agent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36", }, }); if (!response.ok) return fallback; const css = (await response.text()).replace(/<\/style/gi, "<\\/style"); const stylesheet = ``; return fallback .replace(`\n `, "") .replace(stylesheet, ``); } catch { return fallback; } } /** * CSP source hosts required by the configured fonts, so the policy can be * auto-extended (Google Fonts need their CSS + static hosts allow-listed). */ export function fontCspSources(fonts?: FontConfig): { style: string[]; font: string[] } { if (fonts?.google?.length) return { style: [GOOGLE_CSS], font: [GOOGLE_STATIC] }; return { style: [], font: [] }; }