// The Google Fonts catalog the font browser shows: an offline snapshot (googleFontsCatalog.json, // regenerated by packages/shell/scripts/google-fonts-catalog.mjs) of family names, categories and // weights. The list itself is never fetched by the app; only the font files the owner picks are // (natively). Pure helpers, shared with the tests. import { textLocale } from '../../frame/i18n.ts'; export type Category = 'sans' | 'serif' | 'display' | 'handwriting' | 'mono'; export type CatalogFont = { family: string; category: Category; axis: [number, number] | null; weights: number[]; rank: number }; export type Weights = { kind: 'range'; min: number; max: number } | { kind: 'list'; values: number[] }; /** Filter chips; labels are `fonts.category.` (short) and `fonts.categoryName.` (full) in the catalogs. */ export const CATEGORIES: Array = ['all', 'sans', 'serif', 'display', 'handwriting', 'mono']; type Raw = { families: Array<[string, string, [number, number] | null, number[], number]> }; const KNOWN = new Set(['sans', 'serif', 'display', 'handwriting', 'mono']); const NAME = /^[A-Za-z0-9]+( [A-Za-z0-9]+)*$/; export function parseCatalog(raw: unknown): CatalogFont[] { const families = (raw as Raw | null)?.families; if (!Array.isArray(families)) return []; return families .filter(([family, category, , weights]) => typeof family === 'string' && NAME.test(family) && family.length <= 48 && KNOWN.has(category as Category) && Array.isArray(weights)) .map(([family, category, axis, weights, rank]) => ({ family, category: category as Category, axis: Array.isArray(axis) && axis.length === 2 ? [axis[0], axis[1]] as [number, number] : null, weights: weights.filter(w => Number.isInteger(w) && w >= 100 && w <= 1000), rank: Number(rank) || 9999, })); } let loaded: Promise | null = null; /** The bundled catalog, loaded on first use (it is ~90 KB, so it stays out of the shell's first paint). */ export function loadCatalog(): Promise { loaded ??= import('./googleFontsCatalog.json').then(module => parseCatalog(module.default)); return loaded; } /** Search by name (prefix matches first, then popularity), within a category and optionally only installed ones. */ export function searchFonts(fonts: CatalogFont[], options: { query?: string; category?: Category | 'all'; only?: Set }): CatalogFont[] { const query = (options.query ?? '').trim().toLowerCase(); const list = fonts.filter(font => (!options.category || options.category === 'all' || font.category === options.category) && (!options.only || options.only.has(font.family)) && (!query || font.family.toLowerCase().includes(query))); if (!query) return list.sort((a, b) => a.rank - b.rank); const score = (font: CatalogFont) => { const name = font.family.toLowerCase(); return name === query ? 0 : name.startsWith(query) ? 1 : name.split(' ').some(word => word.startsWith(query)) ? 2 : 3; }; return list.sort((a, b) => score(a) - score(b) || a.rank - b.rank); } /** What to download by default: the whole weight axis of a variable font, else regular and bold (or the nearest). */ export function defaultWeights(font: CatalogFont): Weights { if (font.axis) return { kind: 'range', min: font.axis[0], max: font.axis[1] }; const pick = (target: number) => font.weights.reduce((best, w) => Math.abs(w - target) < Math.abs(best - target) ? w : best, font.weights[0] ?? 400); return { kind: 'list', values: [...new Set([pick(400), pick(700)])].sort((a, b) => a - b) }; } /** The weights a font can be previewed at (every 100 inside the axis, or the static weights). */ export const availableWeights = (font: CatalogFont) => font.axis ? Array.from({ length: 9 }, (_, i) => (i + 1) * 100).filter(w => w >= font.axis![0] && w <= font.axis![1]) : font.weights; /** A stack that falls back sensibly while the font loads, or where it lacks a glyph. */ export function stackFor(font: Pick): string[] { const fallback: Record = { sans: ['system-ui', 'sans-serif'], serif: ['Georgia', 'serif'], display: ['system-ui', 'sans-serif'], handwriting: ['cursive'], mono: ['ui-monospace', 'monospace'], }; return [font.family, ...fallback[font.category]]; } export const weightsLabel = (weights: Weights) => weights.kind === 'range' ? `${weights.min}–${weights.max}` : weights.values.join(', '); const numberLocale = () => textLocale().split('-x-')[0] || 'en'; export const bytesLabel = (bytes: number) => bytes >= 1_000_000 ? `${new Intl.NumberFormat(numberLocale(), { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(bytes / 1_000_000)} MB` : `${new Intl.NumberFormat(numberLocale()).format(Math.max(1, Math.round(bytes / 1000)))} KB`; /** Google Fonts' own page for a family (its license and designers), opened in the browser. */ export const specimenUrl = (family: string) => `https://fonts.google.com/specimen/${family.replace(/ /g, '+')}`;