Files
WRNexusJS/packages/styles/src/fonts.ts
T

216 lines
7.7 KiB
TypeScript

/**
* Font configuration.
*
* Declare fonts in `wrnexus.config.ts` under `fonts` and the framework emits
* optimized `<head>` 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 `<link rel=preload>`
* 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 `<link rel="preload" as="font">` — 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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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 `<head>` 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(`<link rel="preconnect" href="${GOOGLE_CSS}">`);
out.push(`<link rel="preconnect" href="${GOOGLE_STATIC}" crossorigin>`);
out.push(`<link rel="stylesheet" href="${escAttr(googleFontsUrl(fonts.google, display))}">`);
}
// 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(`<style>\n${safeStyle(faces.join("\n"))}\n</style>`);
for (const f of fonts.local) {
if (f.preload) {
out.push(
`<link rel="preload" href="${escAttr(f.src)}" as="font" type="${preloadType(f.src)}" crossorigin>`,
);
}
}
}
// 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(`<style>\n${safeStyle(`:root {\n${vars.join("\n")}\n}${body}`)}\n</style>`);
}
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<Response> = fetch,
): Promise<string> {
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 = `<link rel="stylesheet" href="${escAttr(url)}">`;
return fallback
.replace(`<link rel="preconnect" href="${GOOGLE_CSS}">\n `, "")
.replace(stylesheet, `<style data-wrnexus-google-fonts>\n${css}\n</style>`);
} 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: [] };
}