#!/usr/bin/env bun /** * Font Installer for Chezmoi * * Downloads and installs fonts from Google Fonts based on ~/.config/fontconfig/fonts.toml * Uses google-webfonts-helper API (no auth required). * Also supports GitHub-sourced fonts (like Iosevka) via special handlers. * * Usage: * install-fonts.ts # Install fonts from config * install-fonts.ts --list # List all available fonts * install-fonts.ts --search # Search for fonts */ import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join, basename } from "node:path"; import { parseArgs } from "node:util"; import { $ } from "bun"; interface GoogleFont { id: string; family: string; variants: string[]; subsets: string[]; category: string; version: string; lastModified: string; popularity: number; defSubset: string; defVariant: string; } interface FontVariant { id: string; fontFamily: string; fontStyle: string; fontWeight: string; woff: string; woff2: string; ttf: string; } interface FontDetails extends GoogleFont { variants: FontVariant[]; } interface FontCategoryConfig { primary: string; fallback?: string; } interface FontConfig { categories: Record; extras: string[]; } interface FuseResult { item: T; refIndex: number; score?: number; } const FONTS_DIR = join(homedir(), ".local", "share", "fonts"); // Meta-config location: chezmoi source dir, not deployed to filesystem const CONFIG_PATH = join( homedir(), ".local", "share", "chezmoi", "meta", "fonts.toml", ); const API_BASE = "https://gwfh.mranftl.com/api"; const CACHE_FILE = join(homedir(), ".cache", "font-catalog.json"); const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days // GitHub-sourced fonts not available on Google Fonts // Maps font name to { repo, assetPattern, variant? } interface GitHubFontConfig { repo: string; assetPattern: RegExp; variant?: string; // For Iosevka variants } const GITHUB_FONTS: Record = { "ZedMono NF": { repo: "ryanoasis/nerd-fonts", assetPattern: /^ZedMono\.zip$/, }, Iosevka: { repo: "be5invis/Iosevka", assetPattern: /^PkgTTF-Iosevka-[\d.]+\.zip$/, }, "Iosevka Fixed": { repo: "be5invis/Iosevka", assetPattern: /^PkgTTF-IosevkaFixed-[\d.]+\.zip$/, }, "Iosevka Slab": { repo: "be5invis/Iosevka", assetPattern: /^PkgTTF-IosevkaSlab-[\d.]+\.zip$/, }, "Iosevka Curly": { repo: "be5invis/Iosevka", assetPattern: /^PkgTTF-IosevkaCurly-[\d.]+\.zip$/, }, "Iosevka Aile": { repo: "be5invis/Iosevka", assetPattern: /^PkgTTF-IosevkaAile-[\d.]+\.zip$/, }, "Iosevka Etoile": { repo: "be5invis/Iosevka", assetPattern: /^PkgTTF-IosevkaEtoile-[\d.]+\.zip$/, }, }; // ANSI colors const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; const YELLOW = "\x1b[33m"; const BLUE = "\x1b[34m"; const CYAN = "\x1b[36m"; const RESET = "\x1b[0m"; const BOLD = "\x1b[1m"; const log = { info: (msg: string) => console.log(`${BLUE}[info]${RESET} ${msg}`), success: (msg: string) => console.log(`${GREEN}[ok]${RESET} ${msg}`), warn: (msg: string) => console.log(`${YELLOW}[warn]${RESET} ${msg}`), error: (msg: string) => console.log(`${RED}[error]${RESET} ${msg}`), step: (msg: string) => console.log(`${CYAN}>>>${RESET} ${msg}`), }; function fuzzyScore(needle: string, haystack: string): number { const n = needle.toLowerCase(); const h = haystack.toLowerCase(); // Exact match if (n === h) return 1.0; // Starts with if (h.startsWith(n)) return 0.9; // Contains if (h.includes(n)) return 0.7; // Subsequence match let ni = 0; let consecutiveBonus = 0; let lastMatchIdx = -2; for (let hi = 0; hi < h.length && ni < n.length; hi++) { if (h[hi] === n[ni]) { if (hi === lastMatchIdx + 1) { consecutiveBonus += 0.1; } lastMatchIdx = hi; ni++; } } if (ni === n.length) { const baseScore = 0.3 + (n.length / h.length) * 0.3; return Math.min(baseScore + consecutiveBonus, 0.65); } return 0; } function fuzzySearch( items: T[], query: string, getKey: (item: T) => string, threshold = 0.3, limit = 5, ): FuseResult[] { const results: FuseResult[] = []; for (let i = 0; i < items.length; i++) { const score = fuzzyScore(query, getKey(items[i])); if (score >= threshold) { results.push({ item: items[i], refIndex: i, score }); } } return results .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) .slice(0, limit); } async function fetchWithRetry( url: string, retries = 3, ): Promise { for (let i = 0; i < retries; i++) { try { const response = await fetch(url); if (response.ok) return response; if (response.status === 404) return null; log.warn(`HTTP ${response.status} for ${url}, retrying...`); } catch (e) { log.warn(`Network error for ${url}, retrying... (${i + 1}/${retries})`); } await Bun.sleep(1000 * (i + 1)); } return null; } async function fetchFontCatalog(): Promise { // Check cache if (existsSync(CACHE_FILE)) { const stat = Bun.file(CACHE_FILE); const mtime = (await stat.stat()).mtime; if (Date.now() - mtime.getTime() < CACHE_MAX_AGE_MS) { try { const cached = await Bun.file(CACHE_FILE).json(); log.info(`Using cached font catalog (${cached.length} fonts)`); return cached; } catch { // Cache corrupt, fetch fresh } } } log.step("Fetching font catalog from google-webfonts-helper..."); const response = await fetchWithRetry(`${API_BASE}/fonts`); if (!response) { throw new Error("Failed to fetch font catalog"); } const fonts: GoogleFont[] = await response.json(); log.success(`Fetched ${fonts.length} fonts`); // Cache the catalog const cacheDir = join(homedir(), ".cache"); if (!existsSync(cacheDir)) { mkdirSync(cacheDir, { recursive: true }); } await Bun.write(CACHE_FILE, JSON.stringify(fonts)); return fonts; } async function fetchFontDetails(fontId: string): Promise { // Emoji fonts don't have latin subset, so we handle them specially const isEmoji = fontId.toLowerCase().includes("emoji"); const url = isEmoji ? `${API_BASE}/fonts/${fontId}` : `${API_BASE}/fonts/${fontId}?subsets=latin,latin-ext`; const response = await fetchWithRetry(url); if (!response) return null; return response.json(); } async function downloadFont(url: string, destPath: string): Promise { try { const response = await fetchWithRetry(url); if (!response) return false; const buffer = await response.arrayBuffer(); await Bun.write(destPath, buffer); return true; } catch (e) { log.error(`Failed to download: ${url}`); return false; } } interface GitHubRelease { tag_name: string; assets: Array<{ name: string; browser_download_url: string; }>; } async function installGitHubFont(fontName: string): Promise { const config = GITHUB_FONTS[fontName]; if (!config) return false; const fontDir = join(FONTS_DIR, fontName.replace(/\s+/g, "")); // Check if already installed if (existsSync(fontDir)) { const files = readdirSync(fontDir).filter((f) => f.endsWith(".ttf")); if (files.length > 0) { log.success(`${fontName} already installed (${files.length} files)`); return true; } } log.step(`Installing ${fontName} from GitHub...`); // Fetch latest release const releaseUrl = `https://api.github.com/repos/${config.repo}/releases/latest`; const response = await fetchWithRetry(releaseUrl); if (!response) { log.error(`Failed to fetch release info for ${fontName}`); return false; } const release: GitHubRelease = await response.json(); const asset = release.assets.find((a) => config.assetPattern.test(a.name)); if (!asset) { log.error(`No matching asset found for ${fontName} in ${release.tag_name}`); log.info( `Available assets: ${release.assets.map((a) => a.name).join(", ")}`, ); return false; } // Download ZIP log.info(`Downloading ${asset.name}...`); const zipResponse = await fetchWithRetry(asset.browser_download_url); if (!zipResponse) { log.error(`Failed to download ${asset.name}`); return false; } const zipPath = join(tmpdir(), asset.name); const zipBuffer = await zipResponse.arrayBuffer(); await Bun.write(zipPath, zipBuffer); // Create font directory if (!existsSync(fontDir)) { mkdirSync(fontDir, { recursive: true }); } // Extract ZIP using unzip command try { await $`unzip -o -j ${zipPath} "*.ttf" -d ${fontDir}`.quiet(); } catch (e) { log.error(`Failed to extract ${asset.name}`); rmSync(zipPath, { force: true }); return false; } // Clean up ZIP rmSync(zipPath, { force: true }); const files = readdirSync(fontDir).filter((f) => f.endsWith(".ttf")); log.success( `Installed ${fontName}: ${files.length} files (${release.tag_name})`, ); return true; } function isGitHubFont(fontName: string): boolean { return fontName in GITHUB_FONTS; } function variantToFilename(family: string, variant: FontVariant): string { const weight = variant.fontWeight; const style = variant.fontStyle === "italic" ? "Italic" : ""; const weightName = weightToName(parseInt(weight)); return `${family.replace(/\s+/g, "")}-${weightName}${style}.ttf`; } function weightToName(weight: number): string { const weights: Record = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black", }; return weights[weight] ?? `W${weight}`; } async function installFont( fontName: string, catalog: GoogleFont[], ): Promise { // Fuzzy search for the font const results = fuzzySearch(catalog, fontName, (f) => f.family, 0.3, 5); if (results.length === 0) { log.error(`Font "${fontName}" not found in Google Fonts.`); // Check if there's a GitHub font match const githubFontNames = Object.keys(GITHUB_FONTS); const githubMatches = fuzzySearch( githubFontNames, fontName, (name) => name, 0.3, 3, ); if (githubMatches.length > 0) { const suggestions = githubMatches.map((r) => r.item).join(", "); log.info(`GitHub fonts available: ${suggestions}`); log.info(`Use the exact name in fonts.toml to install.`); return false; } const looseSuggestions = fuzzySearch( catalog, fontName, (f) => f.family, 0.2, 5, ); if (looseSuggestions.length > 0) { const suggestions = looseSuggestions.map((r) => r.item.family).join(", "); log.info(`Did you mean: ${suggestions}?`); } return false; } const bestMatch = results[0]; const font = bestMatch.item; // Warn if not an exact match if (font.family.toLowerCase() !== fontName.toLowerCase()) { log.warn( `"${fontName}" matched to "${font.family}" (score: ${(bestMatch.score ?? 0).toFixed(2)})`, ); } const fontDir = join(FONTS_DIR, font.family.replace(/\s+/g, "")); // Check if already installed (with at least some TTF files) if (existsSync(fontDir)) { const files = readdirSync(fontDir).filter((f) => f.endsWith(".ttf")); if (files.length > 0) { log.success(`${font.family} already installed (${files.length} files)`); return true; } } log.step(`Installing ${font.family}...`); // Fetch font details const details = await fetchFontDetails(font.id); if (!details) { log.error(`Failed to fetch details for ${font.family}`); return false; } // Create font directory if (!existsSync(fontDir)) { mkdirSync(fontDir, { recursive: true }); } // Download all variants let successCount = 0; let failCount = 0; for (const variant of details.variants) { if (typeof variant === "string") continue; // Skip if just variant name const filename = variantToFilename(font.family, variant); const destPath = join(fontDir, filename); // Prefer TTF, fall back to WOFF2 const url = variant.ttf || variant.woff2; if (!url) { log.warn(`No download URL for ${filename}`); failCount++; continue; } const success = await downloadFont(url, destPath); if (success) { successCount++; } else { failCount++; } } if (successCount > 0) { log.success( `Installed ${font.family}: ${successCount} files` + (failCount > 0 ? ` (${failCount} failed)` : ""), ); return true; } else { log.error(`Failed to install any files for ${font.family}`); // Clean up empty directory if (existsSync(fontDir) && readdirSync(fontDir).length === 0) { rmSync(fontDir); } return false; } } async function loadConfig(): Promise { if (!existsSync(CONFIG_PATH)) { throw new Error(`Config not found: ${CONFIG_PATH}`); } const content = await Bun.file(CONFIG_PATH).text(); // Simple TOML parser for our specific format const config: FontConfig = { categories: {}, extras: [] }; let currentSection = ""; for (const line of content.split("\n")) { const trimmed = line.trim(); // Skip comments and empty lines if (!trimmed || trimmed.startsWith("#")) continue; // Section header const sectionMatch = trimmed.match(/^\[(\w+)\]$/); if (sectionMatch) { currentSection = sectionMatch[1]; if (currentSection !== "extras") { config.categories[currentSection] = { primary: "" }; } continue; } // Handle [extras] section - array of fonts if (currentSection === "extras") { // Match: fonts = ["Font1", "Font2"] const arrayMatch = trimmed.match(/^fonts\s*=\s*\[(.*)\]$/); if (arrayMatch) { const arrayContent = arrayMatch[1]; // Parse quoted strings from array const fontMatches = arrayContent.matchAll(/"([^"]+)"/g); for (const match of fontMatches) { config.extras.push(match[1]); } } continue; } // Key-value pair for category sections const kvMatch = trimmed.match(/^(\w+)\s*=\s*"([^"]+)"$/); if (kvMatch && currentSection && currentSection !== "extras") { const [, key, value] = kvMatch; if (key === "primary" || key === "fallback") { config.categories[currentSection][key] = value; } } } return config; } async function listFonts(): Promise { const catalog = await fetchFontCatalog(); console.log(`\n${BOLD}Available Fonts (${catalog.length} total)${RESET}\n`); // Group by category const byCategory: Record = {}; for (const font of catalog) { const cat = font.category || "other"; if (!byCategory[cat]) byCategory[cat] = []; byCategory[cat].push(font); } for (const [category, fonts] of Object.entries(byCategory).sort()) { console.log(`${CYAN}${category}${RESET} (${fonts.length}):`); const names = fonts .sort((a, b) => a.family.localeCompare(b.family)) .slice(0, 10) .map((f) => f.family) .join(", "); console.log(` ${names}${fonts.length > 10 ? ", ..." : ""}`); console.log(); } } async function searchFonts(query: string): Promise { const catalog = await fetchFontCatalog(); const results = fuzzySearch(catalog, query, (f) => f.family, 0.2, 20); // Also search GitHub fonts const githubFontNames = Object.keys(GITHUB_FONTS); const githubResults = fuzzySearch( githubFontNames, query, (name) => name, 0.2, 10, ); if (results.length === 0 && githubResults.length === 0) { log.warn(`No fonts matching "${query}"`); return; } console.log(`\n${BOLD}Search results for "${query}"${RESET}\n`); // Show GitHub fonts first (marked as such) for (const result of githubResults) { const fontName = result.item; const score = ((result.score ?? 0) * 100).toFixed(0); console.log( ` ${GREEN}${fontName}${RESET} (monospace) - via GitHub [${score}% match]`, ); } // Then Google Fonts for (const result of results) { const font = result.item; const score = ((result.score ?? 0) * 100).toFixed(0); const variants = Array.isArray(font.variants) ? font.variants.length : "?"; console.log( ` ${GREEN}${font.family}${RESET} (${font.category}) - ${variants} variants [${score}% match]`, ); } console.log(); } async function installFromConfig(): Promise { log.step("Loading font configuration..."); const config = await loadConfig(); log.step("Fetching font catalog..."); const catalog = await fetchFontCatalog(); // Ensure fonts directory exists if (!existsSync(FONTS_DIR)) { mkdirSync(FONTS_DIR, { recursive: true }); } // Collect all fonts to install from categories const fontsToInstall: string[] = []; for (const category of Object.values(config.categories)) { if (category.primary) fontsToInstall.push(category.primary); if (category.fallback) fontsToInstall.push(category.fallback); } // Add extras fontsToInstall.push(...config.extras); // Remove duplicates const uniqueFonts = [...new Set(fontsToInstall)]; log.info(`Installing ${uniqueFonts.length} font families...`); console.log(); let successCount = 0; let failCount = 0; for (const fontName of uniqueFonts) { let success: boolean; // Check if this is a GitHub-sourced font if (isGitHubFont(fontName)) { success = await installGitHubFont(fontName); } else { success = await installFont(fontName, catalog); } if (success) { successCount++; } else { failCount++; } } console.log(); // Rebuild font cache log.step("Rebuilding font cache..."); try { await $`fc-cache -f`.quiet(); log.success("Font cache updated"); } catch (e) { log.warn("Failed to update font cache (fc-cache not available?)"); } // Summary console.log(); console.log(`${BOLD}Summary${RESET}`); console.log(` ${GREEN}Installed:${RESET} ${successCount}`); if (failCount > 0) { console.log(` ${RED}Failed:${RESET} ${failCount}`); } } async function main(): Promise { const { values, positionals } = parseArgs({ args: Bun.argv.slice(2), options: { list: { type: "boolean", short: "l" }, search: { type: "string", short: "s" }, help: { type: "boolean", short: "h" }, }, allowPositionals: true, }); if (values.help) { console.log(` ${BOLD}Font Installer for Chezmoi${RESET} ${CYAN}Usage:${RESET} install-fonts.ts Install fonts from config install-fonts.ts --list List all available fonts install-fonts.ts --search Search for fonts install-fonts.ts --help Show this help ${CYAN}Config:${RESET} ${CONFIG_PATH} ${CYAN}Fonts:${RESET} ${FONTS_DIR} `); return; } if (values.list) { await listFonts(); return; } if (values.search) { await searchFonts(values.search); return; } // Handle positional argument as search if (positionals.length > 0) { await searchFonts(positionals.join(" ")); return; } // Default: install from config await installFromConfig(); } main().catch((e) => { log.error(e.message); process.exit(1); });