a tool for shared writing and social publishing
1// Font configuration for self-hosted and Google Fonts
2// This replicates what next/font does but allows dynamic selection per-leaflet
3
4export type FontConfig = {
5 id: string;
6 displayName: string;
7 fontFamily: string;
8 fallback: string[];
9 baseSize?: number; // base font size in px for document content
10} & (
11 | {
12 // Self-hosted fonts with local files
13 type: "local";
14 files: {
15 path: string;
16 style: "normal" | "italic";
17 weight?: string;
18 }[];
19 }
20 | {
21 // Google Fonts loaded via CDN
22 type: "google";
23 googleFontsFamily: string; // e.g., "Open+Sans:ital,wght@0,400;0,700;1,400;1,700"
24 }
25 | {
26 // System fonts (no loading required)
27 type: "system";
28 }
29);
30
31export const fonts: Record<string, FontConfig> = {
32 // Self-hosted variable fonts (WOFF2)
33 quattro: {
34 id: "quattro",
35 displayName: "iA Writer Quattro",
36 fontFamily: "iA Writer Quattro V",
37 baseSize: 16,
38 type: "local",
39 files: [
40 {
41 path: "/fonts/iaw-quattro-vf.woff2",
42 style: "normal",
43 weight: "400 700",
44 },
45 {
46 path: "/fonts/iaw-quattro-vf-Italic.woff2",
47 style: "italic",
48 weight: "400 700",
49 },
50 ],
51 fallback: ["system-ui", "sans-serif"],
52 },
53 lora: {
54 id: "lora",
55 displayName: "Lora",
56 fontFamily: "Lora",
57 baseSize: 17,
58 type: "local",
59 files: [
60 {
61 path: "/fonts/Lora-Variable.woff2",
62 style: "normal",
63 weight: "400 700",
64 },
65 {
66 path: "/fonts/Lora-Italic-Variable.woff2",
67 style: "italic",
68 weight: "400 700",
69 },
70 ],
71 fallback: ["Georgia", "serif"],
72 },
73 "atkinson-hyperlegible": {
74 id: "atkinson-hyperlegible",
75 displayName: "Atkinson Hyperlegible",
76 fontFamily: "Atkinson Hyperlegible Next",
77 baseSize: 18,
78 type: "google",
79 googleFontsFamily:
80 "Atkinson+Hyperlegible+Next:ital,wght@0,200..800;1,200..800",
81 fallback: ["system-ui", "sans-serif"],
82 },
83 // Additional Google Fonts - Mono
84 "sometype-mono": {
85 id: "sometype-mono",
86 displayName: "Sometype Mono",
87 fontFamily: "Sometype Mono",
88 baseSize: 17,
89 type: "google",
90 googleFontsFamily: "Sometype+Mono:ital,wght@0,400;0,700;1,400;1,700",
91 fallback: ["monospace"],
92 },
93
94 // Additional Google Fonts - Sans
95 montserrat: {
96 id: "montserrat",
97 displayName: "Montserrat",
98 fontFamily: "Montserrat",
99 baseSize: 17,
100 type: "google",
101 googleFontsFamily: "Montserrat:ital,wght@0,400;0,700;1,400;1,700",
102 fallback: ["system-ui", "sans-serif"],
103 },
104 "source-sans": {
105 id: "source-sans",
106 displayName: "Source Sans 3",
107 fontFamily: "Source Sans 3",
108 baseSize: 18,
109 type: "google",
110 googleFontsFamily: "Source+Sans+3:ital,wght@0,400;0,700;1,400;1,700",
111 fallback: ["system-ui", "sans-serif"],
112 },
113};
114
115export const defaultFontId = "quattro";
116export const defaultBaseSize = 16;
117
118// Parse a Google Fonts URL or string to extract the font name and family parameter
119// Supports various formats:
120// - Full URL: https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap
121// - Family param: Open+Sans:ital,wght@0,400;0,700
122// - Just font name: Open Sans
123export function parseGoogleFontInput(input: string): {
124 fontName: string;
125 googleFontsFamily: string;
126} | null {
127 const trimmed = input.trim();
128 if (!trimmed) return null;
129
130 // Try to parse as full URL
131 try {
132 const url = new URL(trimmed);
133 const family = url.searchParams.get("family");
134 if (family) {
135 // Extract font name from family param (before the colon if present)
136 const fontName = family.split(":")[0].replace(/\+/g, " ");
137 return { fontName, googleFontsFamily: family };
138 }
139 } catch {
140 // Not a valid URL, continue with other parsing
141 }
142
143 // Check if it's a family parameter with weight/style specifiers (contains : or @)
144 if (trimmed.includes(":") || trimmed.includes("@")) {
145 const fontName = trimmed.split(":")[0].replace(/\+/g, " ");
146 // Ensure plus signs are used for spaces in the family param
147 const googleFontsFamily = trimmed.includes("+")
148 ? trimmed
149 : trimmed.replace(/ /g, "+");
150 return { fontName, googleFontsFamily };
151 }
152
153 // Treat as just a font name - construct a basic family param with common weights
154 const fontName = trimmed.replace(/\+/g, " ");
155 const googleFontsFamily = `${trimmed.replace(/ /g, "+")}:wght@400;700`;
156 return { fontName, googleFontsFamily };
157}
158
159// Custom font ID format: "custom:FontName:googleFontsFamily"
160export function createCustomFontId(
161 fontName: string,
162 googleFontsFamily: string,
163): string {
164 return `custom:${fontName}:${googleFontsFamily}`;
165}
166
167export function isCustomFontId(fontId: string): boolean {
168 return fontId.startsWith("custom:");
169}
170
171export function parseCustomFontId(fontId: string): {
172 fontName: string;
173 googleFontsFamily: string;
174} | null {
175 if (!isCustomFontId(fontId)) return null;
176 const parts = fontId.slice("custom:".length).split(":");
177 if (parts.length < 2) return null;
178 const fontName = parts[0];
179 const googleFontsFamily = parts.slice(1).join(":");
180 return { fontName, googleFontsFamily };
181}
182
183export function getFontConfig(fontId: string | undefined): FontConfig {
184 if (!fontId) return fonts[defaultFontId];
185
186 // Check for custom font
187 if (isCustomFontId(fontId)) {
188 const parsed = parseCustomFontId(fontId);
189 if (parsed) {
190 return {
191 id: fontId,
192 displayName: parsed.fontName,
193 fontFamily: parsed.fontName,
194 type: "google",
195 googleFontsFamily: parsed.googleFontsFamily,
196 fallback: ["system-ui", "sans-serif"],
197 };
198 }
199 }
200
201 return fonts[fontId] || fonts[defaultFontId];
202}
203
204// Generate @font-face CSS for a local font
205export function generateFontFaceCSS(font: FontConfig): string {
206 if (font.type !== "local") return "";
207 return font.files
208 .map((file) => {
209 const format = file.path.endsWith(".woff2") ? "woff2" : "truetype";
210 return `
211@font-face {
212 font-family: '${font.fontFamily}';
213 src: url('${file.path}') format('${format}');
214 font-style: ${file.style};
215 font-weight: ${file.weight || "normal"};
216 font-display: swap;
217}`.trim();
218 })
219 .join("\n\n");
220}
221
222// Generate preload link attributes for a local font
223export function getFontPreloadLinks(
224 font: FontConfig,
225): { href: string; type: string }[] {
226 if (font.type !== "local") return [];
227 return font.files.map((file) => ({
228 href: file.path,
229 type: file.path.endsWith(".woff2") ? "font/woff2" : "font/ttf",
230 }));
231}
232
233// Get Google Fonts URL for a font
234// Using display=swap per Google's recommendation: shows fallback immediately, swaps when ready
235// This is better UX than blocking text rendering (display=block)
236export function getGoogleFontsUrl(font: FontConfig): string | null {
237 if (font.type !== "google") return null;
238 return `https://fonts.googleapis.com/css2?family=${font.googleFontsFamily}&display=swap`;
239}
240
241// Get the base font size for a font config
242export function getFontBaseSize(font: FontConfig): number {
243 return font.baseSize ?? defaultBaseSize;
244}
245
246// Get the CSS font-family value with fallbacks
247export function getFontFamilyValue(font: FontConfig): string {
248 const family = font.fontFamily.includes(" ")
249 ? `'${font.fontFamily}'`
250 : font.fontFamily;
251 return [family, ...font.fallback].join(", ");
252}