a tool for shared writing and social publishing
1// Server-side font loading component
2// Following Google's best practices: https://web.dev/articles/font-best-practices
3// - Preconnect to font origins for early connection
4// - Use font-display: swap (shows fallback immediately, swaps when ready)
5// - Don't block rendering - some FOUT is acceptable and better UX than invisible text
6
7import {
8 getFontConfig,
9 getGoogleFontsUrl,
10 getFontFamilyValue,
11 getFontBaseSize,
12 defaultFontId,
13} from "src/fonts";
14
15type FontLoaderProps = {
16 headingFontId: string | undefined;
17 bodyFontId: string | undefined;
18};
19
20export function FontLoader({ headingFontId, bodyFontId }: FontLoaderProps) {
21 const headingFont = getFontConfig(headingFontId);
22 const bodyFont = getFontConfig(bodyFontId);
23
24 // Don't load the default font (Quattro) here — it's already loaded via
25 // next/font/local in layout.tsx under --font-quattro. Loading it again with
26 // a different family name ('iA Writer Quattro V') causes issues when the
27 // @font-face from this component isn't available (e.g., client-side navigation).
28 const isDefaultHeading = headingFont.id === defaultFontId;
29 const isDefaultBody = bodyFont.id === defaultFontId;
30
31 // Collect Google Fonts URLs (deduplicated)
32 const fontsToLoad = [headingFont, bodyFont].filter(
33 (f, i, arr) => f.id !== defaultFontId && arr.findIndex((o) => o.id === f.id) === i
34 );
35 const googleFontsUrls = [...new Set(
36 fontsToLoad
37 .map((font) => getGoogleFontsUrl(font))
38 .filter((url): url is string => url !== null)
39 )];
40
41 const headingFontValue = isDefaultHeading
42 ? (isDefaultBody ? null : "var(--font-quattro)")
43 : getFontFamilyValue(headingFont);
44 const bodyFontValue = isDefaultBody
45 ? (isDefaultHeading ? null : "var(--font-quattro)")
46 : getFontFamilyValue(bodyFont);
47 const bodyFontBaseSize = isDefaultBody ? null : getFontBaseSize(bodyFont);
48
49 // Set font CSS variables scoped to .leafletWrapper so they don't affect app UI
50 // Don't set variables for the default font — let CSS fallback to var(--font-quattro)
51 const fontVariableLines = [
52 headingFontValue && ` --theme-heading-font: ${headingFontValue};`,
53 bodyFontValue && ` --theme-font: ${bodyFontValue};`,
54 bodyFontBaseSize && ` --theme-font-base-size: ${bodyFontBaseSize}px;`,
55 ].filter(Boolean);
56
57 const fontVariableCSS = fontVariableLines.length > 0
58 ? `.leafletWrapper {\n${fontVariableLines.join("\n")}\n}`
59 : "";
60
61 return (
62 <>
63 {/*
64 Google Fonts best practice: preconnect to both origins
65 - fonts.googleapis.com serves the CSS
66 - fonts.gstatic.com serves the font files (needs crossorigin for CORS)
67 Place these as early as possible in <head>
68 */}
69 {googleFontsUrls.length > 0 && (
70 <>
71 <link rel="preconnect" href="https://fonts.googleapis.com" />
72 <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
73 {googleFontsUrls.map((url) => (
74 <link key={url} rel="stylesheet" href={url} />
75 ))}
76 </>
77 )}
78 {/* CSS variables scoped to .leafletWrapper for SSR (before client hydration) */}
79 {fontVariableCSS && (
80 <style
81 dangerouslySetInnerHTML={{
82 __html: fontVariableCSS,
83 }}
84 />
85 )}
86 </>
87 );
88}
89
90// Helper to extract fonts from facts array (for server-side use)
91export function extractFontsFromFacts(
92 facts: Array<{ entity: string; attribute: string; data: { value: string } }>,
93 rootEntity: string
94): { headingFontId: string | undefined; bodyFontId: string | undefined } {
95 const headingFontFact = facts.find(
96 (f) => f.entity === rootEntity && f.attribute === "theme/heading-font"
97 );
98 const bodyFontFact = facts.find(
99 (f) => f.entity === rootEntity && f.attribute === "theme/body-font"
100 );
101 return {
102 headingFontId: headingFontFact?.data?.value,
103 bodyFontId: bodyFontFact?.data?.value,
104 };
105}