Load the full free Google Fonts gallery in Zona de marca typography.

Browse ~1950 OFL fonts with search, category filters and live preview; one click adds a font to the brand kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-10 02:00:09 +02:00
parent 1715b0ac67
commit 3cb3296518
4 changed files with 433 additions and 3 deletions

View File

@@ -0,0 +1,112 @@
/**
* Full free Google Fonts catalog (entire Google Fonts library is free/OSS).
* Fetched server-side from fonts.google.com metadata; cached in memory.
*/
export type GoogleFontEntry = {
family: string;
category: string;
variants: string[];
subsets: string[];
popularity: number;
};
type Cache = {
at: number;
fonts: GoogleFontEntry[];
};
const TTL_MS = 24 * 60 * 60 * 1000;
let cache: Cache | null = null;
function specimenUrl(family: string): string {
return `https://fonts.google.com/specimen/${encodeURIComponent(
family.replace(/ /g, '+')
)}`;
}
export function googleFontSpecimenUrl(family: string): string {
return specimenUrl(family);
}
export function googleFontCssUrl(
family: string,
weights = '400;500;600;700'
): string {
const fam = family.replace(/ /g, '+');
return `https://fonts.googleapis.com/css2?family=${fam}:wght@${weights}&display=swap`;
}
async function fetchCatalog(): Promise<GoogleFontEntry[]> {
if (cache && Date.now() - cache.at < TTL_MS) return cache.fonts;
// Official metadata (all free fonts on Google Fonts)
const r = await fetch('https://fonts.google.com/metadata/fonts', {
headers: {
Accept: 'application/json',
'User-Agent': '4Ventures-CentroDeMando/1.0',
},
next: { revalidate: 86400 },
});
if (!r.ok) {
throw new Error(`Google Fonts metadata ${r.status}`);
}
let text = await r.text();
// Response is prefixed with )]}'
if (text.startsWith(")]}'")) {
text = text.replace(/^\)\]\}'\n?/, '');
}
const json = JSON.parse(text) as {
familyMetadataList?: Array<{
family?: string;
category?: string;
fonts?: Record<string, unknown>;
subsets?: string[];
popularity?: number;
}>;
};
const list = json.familyMetadataList || [];
const fonts: GoogleFontEntry[] = list
.filter((f) => f.family)
.map((f, i) => ({
family: String(f.family),
category: String(f.category || 'sans-serif'),
variants: f.fonts ? Object.keys(f.fonts) : ['400', '700'],
subsets: Array.isArray(f.subsets) ? f.subsets.map(String) : ['latin'],
popularity: typeof f.popularity === 'number' ? f.popularity : i,
}))
.sort((a, b) => a.popularity - b.popularity || a.family.localeCompare(b.family));
cache = { at: Date.now(), fonts };
return fonts;
}
export async function listGoogleFonts(opts?: {
q?: string;
category?: string;
limit?: number;
offset?: number;
}): Promise<{
total: number;
fonts: GoogleFontEntry[];
categories: string[];
}> {
const all = await fetchCatalog();
const categories = [...new Set(all.map((f) => f.category))].sort();
const q = (opts?.q || '').trim().toLowerCase();
const cat = (opts?.category || '').trim().toLowerCase();
let filtered = all;
if (cat && cat !== 'all') {
filtered = filtered.filter((f) => f.category.toLowerCase() === cat);
}
if (q) {
filtered = filtered.filter((f) => f.family.toLowerCase().includes(q));
}
const offset = Math.max(0, opts?.offset || 0);
const limit = Math.min(200, Math.max(1, opts?.limit || 60));
return {
total: filtered.length,
fonts: filtered.slice(offset, offset + limit),
categories,
};
}