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 generateFontFaceCSS,
10 getFontPreloadLinks,
11 getGoogleFontsUrl,
12 getFontFamilyValue,
13 getFontBaseSize,
14} from "src/fonts";
15
16type FontLoaderProps = {
17 headingFontId: string | undefined;
18 bodyFontId: string | undefined;
19};
20
21export function FontLoader({ headingFontId, bodyFontId }: FontLoaderProps) {
22 const headingFont = getFontConfig(headingFontId);
23 const bodyFont = getFontConfig(bodyFontId);
24
25 // Collect all unique fonts to load
26 const fontsToLoad = headingFont.id === bodyFont.id
27 ? [headingFont]
28 : [headingFont, bodyFont];
29
30 // Collect preload links (deduplicated)
31 const preloadLinksSet = new Set<string>();
32 const preloadLinks: { href: string; type: string }[] = [];
33 for (const font of fontsToLoad) {
34 for (const link of getFontPreloadLinks(font)) {
35 if (!preloadLinksSet.has(link.href)) {
36 preloadLinksSet.add(link.href);
37 preloadLinks.push(link);
38 }
39 }
40 }
41
42 // Collect font-face CSS
43 const fontFaceCSS = fontsToLoad
44 .map((font) => generateFontFaceCSS(font))
45 .filter(Boolean)
46 .join("\n\n");
47
48 // Collect Google Fonts URLs (deduplicated)
49 const googleFontsUrls = [...new Set(
50 fontsToLoad
51 .map((font) => getGoogleFontsUrl(font))
52 .filter((url): url is string => url !== null)
53 )];
54
55 const headingFontValue = getFontFamilyValue(headingFont);
56 const bodyFontValue = getFontFamilyValue(bodyFont);
57 const bodyFontBaseSize = getFontBaseSize(bodyFont);
58
59 // Set font CSS variables scoped to .leafletWrapper so they don't affect app UI
60 const fontVariableCSS = `
61.leafletWrapper {
62 --theme-heading-font: ${headingFontValue};
63 --theme-font: ${bodyFontValue};
64 --theme-font-base-size: ${bodyFontBaseSize}px;
65}
66`.trim();
67
68 return (
69 <>
70 {/*
71 Google Fonts best practice: preconnect to both origins
72 - fonts.googleapis.com serves the CSS
73 - fonts.gstatic.com serves the font files (needs crossorigin for CORS)
74 Place these as early as possible in <head>
75 */}
76 {googleFontsUrls.length > 0 && (
77 <>
78 <link rel="preconnect" href="https://fonts.googleapis.com" />
79 <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
80 {googleFontsUrls.map((url) => (
81 <link key={url} rel="stylesheet" href={url} />
82 ))}
83 </>
84 )}
85 {/* Preload local font files for early discovery */}
86 {preloadLinks.map((link) => (
87 <link
88 key={link.href}
89 rel="preload"
90 href={link.href}
91 as="font"
92 type={link.type}
93 crossOrigin="anonymous"
94 />
95 ))}
96 {/* @font-face declarations (for local fonts) and CSS variable */}
97 <style
98 dangerouslySetInnerHTML={{
99 __html: `${fontFaceCSS}\n\n${fontVariableCSS}`,
100 }}
101 />
102 </>
103 );
104}
105
106// Helper to extract fonts from facts array (for server-side use)
107export function extractFontsFromFacts(
108 facts: Array<{ entity: string; attribute: string; data: { value: string } }>,
109 rootEntity: string
110): { headingFontId: string | undefined; bodyFontId: string | undefined } {
111 const headingFontFact = facts.find(
112 (f) => f.entity === rootEntity && f.attribute === "theme/heading-font"
113 );
114 const bodyFontFact = facts.find(
115 (f) => f.entity === rootEntity && f.attribute === "theme/body-font"
116 );
117 return {
118 headingFontId: headingFontFact?.data?.value,
119 bodyFontId: bodyFontFact?.data?.value,
120 };
121}