249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
/**
|
|
* Google Fonts API — Search, load, and cache fonts.
|
|
*
|
|
* Provides:
|
|
* - fetchGoogleFonts(): Fetch & cache the full font catalog (top 200 by popularity)
|
|
* - searchFonts(): Fuzzy search by family name
|
|
* - loadGoogleFont(): Inject CSS link + wait for font to render
|
|
* - getRecentFonts() / addRecentFont(): Recent fonts list (localStorage)
|
|
*/
|
|
|
|
export interface FontMeta {
|
|
family: string;
|
|
category: string; // sans-serif, serif, display, handwriting, monospace
|
|
variants: string[];
|
|
subsets: string[];
|
|
}
|
|
|
|
// ─── Cache keys ───
|
|
const CACHE_KEY = 'remix-google-fonts-cache';
|
|
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
const RECENT_KEY = 'remix-recent-fonts';
|
|
const MAX_RECENT = 10;
|
|
|
|
// ─── In-memory state ───
|
|
const loadedFonts = new Set<string>();
|
|
let cachedFontList: FontMeta[] | null = null;
|
|
|
|
// ─── Static fallback (top 200 by popularity) ───
|
|
const FALLBACK_FONTS: FontMeta[] = [
|
|
// Sans Serif
|
|
{ family: 'Inter', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Roboto', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Open Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Montserrat', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Poppins', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Nunito', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Lato', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Raleway', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Work Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Nunito Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Rubik', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Outfit', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Space Grotesk', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'DM Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Figtree', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Manrope', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Plus Jakarta Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Ubuntu', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Karla', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Cabin', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Barlow', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Quicksand', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Josefin Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Mulish', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Comfortaa', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
// Serif
|
|
{ family: 'Playfair Display', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Merriweather', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Lora', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Crimson Text', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'PT Serif', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Bitter', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Source Serif 4', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Libre Baskerville', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'EB Garamond', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Cormorant Garamond', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Spectral', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'DM Serif Display', category: 'serif', variants: ['400'], subsets: ['latin'] },
|
|
// Display
|
|
{ family: 'Bebas Neue', category: 'display', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Oswald', category: 'display', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Anton', category: 'display', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Archivo Black', category: 'display', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Righteous', category: 'display', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Passion One', category: 'display', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Staatliches', category: 'display', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Bungee', category: 'display', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Bangers', category: 'display', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Teko', category: 'display', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Titan One', category: 'display', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Lilita One', category: 'display', variants: ['400'], subsets: ['latin'] },
|
|
// Handwriting
|
|
{ family: 'Dancing Script', category: 'handwriting', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Pacifico', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Caveat', category: 'handwriting', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Satisfy', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Great Vibes', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Sacramento', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Permanent Marker', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Indie Flower', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Kaushan Script', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
|
|
{ family: 'Yellowtail', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
|
|
// Monospace
|
|
{ family: 'JetBrains Mono', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Fira Code', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Source Code Pro', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Roboto Mono', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'Space Mono', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
|
|
{ family: 'IBM Plex Mono', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
|
|
];
|
|
|
|
/**
|
|
* Fetch Google Fonts catalog. Uses localStorage cache with 24h TTL.
|
|
* Falls back to built-in static list if API call fails or no key is provided.
|
|
*/
|
|
export async function fetchGoogleFonts(apiKey?: string): Promise<FontMeta[]> {
|
|
if (cachedFontList) return cachedFontList;
|
|
|
|
// Check localStorage cache
|
|
try {
|
|
const raw = localStorage.getItem(CACHE_KEY);
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed.ts && Date.now() - parsed.ts < CACHE_TTL && parsed.fonts?.length > 0) {
|
|
cachedFontList = parsed.fonts;
|
|
return parsed.fonts;
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// Fetch from API
|
|
if (apiKey) {
|
|
try {
|
|
const res = await fetch(
|
|
`https://www.googleapis.com/webfonts/v1/webfonts?key=${apiKey}&sort=popularity`
|
|
);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const fonts: FontMeta[] = data.items.slice(0, 200).map((item: any) => ({
|
|
family: item.family,
|
|
category: item.category,
|
|
variants: item.variants,
|
|
subsets: item.subsets,
|
|
}));
|
|
|
|
// Persist to localStorage
|
|
try {
|
|
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), fonts }));
|
|
} catch {}
|
|
|
|
cachedFontList = fonts;
|
|
return fonts;
|
|
}
|
|
} catch (err) {
|
|
console.warn('Google Fonts API fetch failed, using fallback:', err);
|
|
}
|
|
}
|
|
|
|
// Use fallback
|
|
cachedFontList = FALLBACK_FONTS;
|
|
return FALLBACK_FONTS;
|
|
}
|
|
|
|
/**
|
|
* Search fonts by family name (case-insensitive substring match).
|
|
*/
|
|
export function searchFonts(query: string, fonts: FontMeta[]): FontMeta[] {
|
|
if (!query.trim()) return fonts;
|
|
const q = query.toLowerCase();
|
|
return fonts.filter(f => f.family.toLowerCase().includes(q));
|
|
}
|
|
|
|
/**
|
|
* Load a Google Font by injecting a <link> into <head>.
|
|
* Waits for the font to become available via document.fonts.
|
|
* Safe to call multiple times — tracks loaded fonts internally.
|
|
*/
|
|
export async function loadGoogleFont(fontFamily: string): Promise<void> {
|
|
if (loadedFonts.has(fontFamily)) return;
|
|
|
|
// Check if already available (system font or previously loaded)
|
|
try {
|
|
const alreadyLoaded = document.fonts.check(`16px "${fontFamily}"`);
|
|
if (alreadyLoaded) {
|
|
loadedFonts.add(fontFamily);
|
|
return;
|
|
}
|
|
} catch {}
|
|
|
|
// Inject <link> to Google Fonts CSS
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontFamily)}:wght@400;700&display=swap`;
|
|
|
|
document.head.appendChild(link);
|
|
loadedFonts.add(fontFamily);
|
|
|
|
// Wait for the font to load (with timeout)
|
|
try {
|
|
await Promise.race([
|
|
document.fonts.ready,
|
|
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
]);
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Check if a font is already loaded.
|
|
*/
|
|
export function isFontLoaded(fontFamily: string): boolean {
|
|
return loadedFonts.has(fontFamily);
|
|
}
|
|
|
|
/**
|
|
* Get recently used fonts from localStorage.
|
|
*/
|
|
export function getRecentFonts(): string[] {
|
|
try {
|
|
const raw = localStorage.getItem(RECENT_KEY);
|
|
return raw ? JSON.parse(raw) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a font to the recent list. Most recent first, max 10.
|
|
*/
|
|
export function addRecentFont(family: string): void {
|
|
try {
|
|
let recent = getRecentFonts().filter(f => f !== family);
|
|
recent.unshift(family);
|
|
if (recent.length > MAX_RECENT) recent = recent.slice(0, MAX_RECENT);
|
|
localStorage.setItem(RECENT_KEY, JSON.stringify(recent));
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Group fonts by category for display.
|
|
*/
|
|
export function groupFontsByCategory(fonts: FontMeta[]): Record<string, FontMeta[]> {
|
|
const groups: Record<string, FontMeta[]> = {};
|
|
const categoryLabels: Record<string, string> = {
|
|
'sans-serif': 'Sans Serif',
|
|
'serif': 'Serif',
|
|
'display': 'Display',
|
|
'handwriting': 'Handwriting',
|
|
'monospace': 'Monospace',
|
|
};
|
|
|
|
for (const font of fonts) {
|
|
const label = categoryLabels[font.category] || font.category;
|
|
if (!groups[label]) groups[label] = [];
|
|
groups[label].push(font);
|
|
}
|
|
|
|
return groups;
|
|
}
|