first commit
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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 ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: [] };
|
||||
}
|
||||
Reference in New Issue
Block a user