216 lines
7.7 KiB
TypeScript
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, "&")
|
|
.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 `<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: [] };
|
|
}
|