a tool for shared writing and social publishing
1// Font configuration for Google Fonts and the default system font
2// Allows dynamic font 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 // Google Fonts loaded via CDN
13 type: "google";
14 googleFontsFamily: string; // e.g., "Open+Sans:ital,wght@0,400;0,700;1,400;1,700"
15 }
16 | {
17 // System fonts or fonts loaded elsewhere (e.g. next/font/local)
18 type: "system";
19 }
20);
21
22export const fonts: Record<string, FontConfig> = {
23 quattro: {
24 id: "quattro",
25 displayName: "iA Writer Quattro",
26 fontFamily: "iA Writer Quattro V",
27 baseSize: 16,
28 type: "system", // Loaded via next/font/local in layout.tsx
29 fallback: ["system-ui", "sans-serif"],
30 },
31 lora: {
32 id: "lora",
33 displayName: "Lora",
34 fontFamily: "Lora",
35 baseSize: 17,
36 type: "google",
37 googleFontsFamily: "Lora:ital,wght@0,400..700;1,400..700",
38 fallback: ["Georgia", "serif"],
39 },
40 "atkinson-hyperlegible": {
41 id: "atkinson-hyperlegible",
42 displayName: "Atkinson Hyperlegible",
43 fontFamily: "Atkinson Hyperlegible Next",
44 baseSize: 18,
45 type: "google",
46 googleFontsFamily:
47 "Atkinson+Hyperlegible+Next:ital,wght@0,200..800;1,200..800",
48 fallback: ["system-ui", "sans-serif"],
49 },
50 // Additional Google Fonts - Mono
51 "sometype-mono": {
52 id: "sometype-mono",
53 displayName: "Sometype Mono",
54 fontFamily: "Sometype Mono",
55 baseSize: 17,
56 type: "google",
57 googleFontsFamily: "Sometype+Mono:ital,wght@0,400;0,700;1,400;1,700",
58 fallback: ["monospace"],
59 },
60
61 // Additional Google Fonts - Sans
62 montserrat: {
63 id: "montserrat",
64 displayName: "Montserrat",
65 fontFamily: "Montserrat",
66 baseSize: 17,
67 type: "google",
68 googleFontsFamily: "Montserrat:ital,wght@0,400;0,700;1,400;1,700",
69 fallback: ["system-ui", "sans-serif"],
70 },
71 "source-sans": {
72 id: "source-sans",
73 displayName: "Source Sans 3",
74 fontFamily: "Source Sans 3",
75 baseSize: 18,
76 type: "google",
77 googleFontsFamily: "Source+Sans+3:ital,wght@0,400;0,700;1,400;1,700",
78 fallback: ["system-ui", "sans-serif"],
79 },
80};
81
82export const defaultFontId = "quattro";
83export const defaultBaseSize = 16;
84
85// Parse a Google Fonts URL or string to extract the font name and family parameter
86// Supports various formats:
87// - Full URL: https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap
88// - Family param: Open+Sans:ital,wght@0,400;0,700
89// - Just font name: Open Sans
90export function parseGoogleFontInput(input: string): {
91 fontName: string;
92 googleFontsFamily: string;
93} | null {
94 const trimmed = input.trim();
95 if (!trimmed) return null;
96
97 // Try to parse as full URL
98 try {
99 const url = new URL(trimmed);
100 const family = url.searchParams.get("family");
101 if (family) {
102 // Extract font name from family param (before the colon if present)
103 const fontName = family.split(":")[0].replace(/\+/g, " ");
104 return { fontName, googleFontsFamily: family };
105 }
106 } catch {
107 // Not a valid URL, continue with other parsing
108 }
109
110 // Check if it's a family parameter with weight/style specifiers (contains : or @)
111 if (trimmed.includes(":") || trimmed.includes("@")) {
112 const fontName = trimmed.split(":")[0].replace(/\+/g, " ");
113 // Ensure plus signs are used for spaces in the family param
114 const googleFontsFamily = trimmed.includes("+")
115 ? trimmed
116 : trimmed.replace(/ /g, "+");
117 return { fontName, googleFontsFamily };
118 }
119
120 // Treat as just a font name - construct a basic family param with common weights
121 const fontName = trimmed.replace(/\+/g, " ");
122 const googleFontsFamily = `${trimmed.replace(/ /g, "+")}:ital,wght@0,400;0,700;1,400;1,700`;
123 return { fontName, googleFontsFamily };
124}
125
126// Custom font ID format: "custom:FontName:googleFontsFamily"
127export function createCustomFontId(
128 fontName: string,
129 googleFontsFamily: string,
130): string {
131 return `custom:${fontName}:${googleFontsFamily}`;
132}
133
134export function isCustomFontId(fontId: string): boolean {
135 return fontId.startsWith("custom:");
136}
137
138export function parseCustomFontId(fontId: string): {
139 fontName: string;
140 googleFontsFamily: string;
141} | null {
142 if (!isCustomFontId(fontId)) return null;
143 const parts = fontId.slice("custom:".length).split(":");
144 if (parts.length < 2) return null;
145 const fontName = parts[0];
146 const googleFontsFamily = parts.slice(1).join(":");
147 return { fontName, googleFontsFamily };
148}
149
150// Ensure a Google Fonts family string includes italic variants and standard weights.
151// Handles already-stored custom font IDs that may only specify upright weights.
152function ensureItalicWeights(googleFontsFamily: string): string {
153 const [name, spec] = googleFontsFamily.split(/:(.+)/);
154 if (!name) return googleFontsFamily;
155
156 // Already includes italic axis — leave as-is
157 if (spec && spec.includes("ital")) return googleFontsFamily;
158
159 // Has wght@ with values — add italic variants for each weight
160 if (spec) {
161 const wghtMatch = spec.match(/wght@(.+)/);
162 if (wghtMatch) {
163 const weights = wghtMatch[1];
164 // Variable range like 400..700
165 if (weights.includes("..")) {
166 return `${name}:ital,wght@0,${weights};1,${weights}`;
167 }
168 // Discrete weights like 400;700
169 const uprightWeights = weights.split(";").map((w) => `0,${w}`);
170 const italicWeights = weights.split(";").map((w) => `1,${w}`);
171 return `${name}:ital,wght@${[...uprightWeights, ...italicWeights].join(";")}`;
172 }
173 }
174
175 // No weight spec at all — add standard weights with italics
176 return `${name}:ital,wght@0,400;0,700;1,400;1,700`;
177}
178
179export function getFontConfig(fontId: string | undefined): FontConfig {
180 if (!fontId) return fonts[defaultFontId];
181
182 // Check for custom font
183 if (isCustomFontId(fontId)) {
184 const parsed = parseCustomFontId(fontId);
185 if (parsed) {
186 return {
187 id: fontId,
188 displayName: parsed.fontName,
189 fontFamily: parsed.fontName,
190 type: "google",
191 googleFontsFamily: ensureItalicWeights(parsed.googleFontsFamily),
192 fallback: ["system-ui", "sans-serif"],
193 };
194 }
195 }
196
197 return fonts[fontId] || fonts[defaultFontId];
198}
199
200// Get Google Fonts URL for a font
201// Using display=swap per Google's recommendation: shows fallback immediately, swaps when ready
202// This is better UX than blocking text rendering (display=block)
203export function getGoogleFontsUrl(font: FontConfig): string | null {
204 if (font.type !== "google") return null;
205 return `https://fonts.googleapis.com/css2?family=${font.googleFontsFamily}&display=swap`;
206}
207
208// Get the base font size for a font config
209export function getFontBaseSize(font: FontConfig): number {
210 return font.baseSize ?? defaultBaseSize;
211}
212
213// Get the CSS font-family value with fallbacks
214export function getFontFamilyValue(font: FontConfig): string {
215 const family = font.fontFamily.includes(" ")
216 ? `'${font.fontFamily}'`
217 : font.fontFamily;
218 return [family, ...font.fallback].join(", ");
219}