a tool for shared writing and social publishing
at feature/fonts 252 lines 7.4 kB view raw
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}