From 62c575aa9245d29711d514b3ee0c47d9df785a6e Mon Sep 17 00:00:00 2001 From: Xevion Date: Sun, 28 Dec 2025 15:20:04 -0600 Subject: [PATCH] feat: add automated font installation system with fontconfig management - Install fonts from Google Fonts via TOML config - Generate fontconfig XML with optimized rendering settings - Auto-run on config changes via chezmoi hook - Support GitHub-sourced fonts (Iosevka) alongside Google Fonts --- home/dot_config/fontconfig/fonts.conf | 237 ++++++ home/dot_config/fontconfig/fonts.toml | 31 + .../dot_local/bin/executable_install-fonts.ts | 740 ++++++++++++++++++ home/run_onchange_after_install-fonts.sh.tmpl | 30 + 4 files changed, 1038 insertions(+) create mode 100644 home/dot_config/fontconfig/fonts.conf create mode 100644 home/dot_config/fontconfig/fonts.toml create mode 100644 home/dot_local/bin/executable_install-fonts.ts create mode 100644 home/run_onchange_after_install-fonts.sh.tmpl diff --git a/home/dot_config/fontconfig/fonts.conf b/home/dot_config/fontconfig/fonts.conf new file mode 100644 index 0000000..6772e4c --- /dev/null +++ b/home/dot_config/fontconfig/fonts.conf @@ -0,0 +1,237 @@ + + + + + + + + + + + true + + + + + + + true + + + + + + + hintslight + + + + + + + rgb + + + + + + + lcddefault + + + + + + + false + + + + + + + false + + + + + + + Noto Color Emoji + + + true + + + + + + + + sans-serif + + Inter + Noto Sans + DejaVu Sans + + + + + system-ui + + Inter + Noto Sans + + + + + + serif + + Source Serif 4 + Noto Serif + DejaVu Serif + + + + + + monospace + + Iosevka + JetBrains Mono + DejaVu Sans Mono + + + + + ui-monospace + + Iosevka + JetBrains Mono + + + + + + Arial + + Inter + Noto Sans + + + + + Helvetica + + Inter + Noto Sans + + + + + Times New Roman + + Source Serif 4 + Noto Serif + + + + + Times + + Source Serif 4 + Noto Serif + + + + + Courier New + + Iosevka + JetBrains Mono + + + + + Courier + + Iosevka + JetBrains Mono + + + + + + emoji + + Noto Color Emoji + + + + + + + + + Iosevka + + + hintmedium + + + + + + + Iosevka + + + hintmedium + + + + + + + Inter + + + hintslight + + + + diff --git a/home/dot_config/fontconfig/fonts.toml b/home/dot_config/fontconfig/fonts.toml new file mode 100644 index 0000000..da3f801 --- /dev/null +++ b/home/dot_config/fontconfig/fonts.toml @@ -0,0 +1,31 @@ +# Font Configuration for Chezmoi +# This file defines which fonts to install and configure. +# Fonts are sourced from Google Fonts automatically. +# +# To add a font: Just type its name - fuzzy matching will help if you misspell. +# To swap fonts: Change the primary, run `chezmoi apply`, done! +# +# Run `install-fonts.ts` manually to see available fonts or troubleshoot. + +[ui] +# Sans-serif fonts for user interface elements +primary = "Inter" +fallback = "Noto Sans" + +[serif] +# Serif fonts for documents and reading +primary = "Source Serif 4" +fallback = "Noto Serif" + +[mono] +# Monospace fonts for code and terminals +primary = "Iosevka" +fallback = "JetBrains Mono" + +[emoji] +# Emoji font for unicode emoji support +primary = "Noto Color Emoji" + +# Optional: Uncomment to install accessibility-focused fonts +# [accessibility] +# primary = "Atkinson Hyperlegible" diff --git a/home/dot_local/bin/executable_install-fonts.ts b/home/dot_local/bin/executable_install-fonts.ts new file mode 100644 index 0000000..3e0097f --- /dev/null +++ b/home/dot_local/bin/executable_install-fonts.ts @@ -0,0 +1,740 @@ +#!/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"; + +// ============================================================================ +// Types +// ============================================================================ + +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 FontConfig { + [category: string]: { + primary: string; + fallback?: string; + }; +} + +interface FuseResult { + item: T; + refIndex: number; + score?: number; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const FONTS_DIR = join(homedir(), ".local", "share", "fonts"); +const CONFIG_PATH = join(homedir(), ".config", "fontconfig", "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 = { + 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"; + +// ============================================================================ +// Logging +// ============================================================================ + +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}`), +}; + +// ============================================================================ +// Simple Fuzzy Matching (no external deps) +// ============================================================================ + +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); +} + +// ============================================================================ +// API Functions +// ============================================================================ + +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(); +} + +// ============================================================================ +// Font Installation +// ============================================================================ + +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; + } +} + +// ============================================================================ +// GitHub Font Installation (Iosevka, etc.) +// ============================================================================ + +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; + } +} + +// ============================================================================ +// Configuration +// ============================================================================ + +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 = {}; + 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]; + config[currentSection] = { primary: "" }; + continue; + } + + // Key-value pair + const kvMatch = trimmed.match(/^(\w+)\s*=\s*"([^"]+)"$/); + if (kvMatch && currentSection) { + const [, key, value] = kvMatch; + if (key === "primary" || key === "fallback") { + config[currentSection][key] = value; + } + } + } + + return config; +} + +// ============================================================================ +// Main Commands +// ============================================================================ + +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 + const fontsToInstall: string[] = []; + for (const category of Object.values(config)) { + if (category.primary) fontsToInstall.push(category.primary); + if (category.fallback) fontsToInstall.push(category.fallback); + } + + // 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}`); + } +} + +// ============================================================================ +// Entry Point +// ============================================================================ + +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); +}); diff --git a/home/run_onchange_after_install-fonts.sh.tmpl b/home/run_onchange_after_install-fonts.sh.tmpl new file mode 100644 index 0000000..d2a685b --- /dev/null +++ b/home/run_onchange_after_install-fonts.sh.tmpl @@ -0,0 +1,30 @@ +{{ if eq .chezmoi.os "linux" -}} +#!/bin/bash +# Font Installer Hook +# Runs automatically when fonts.toml changes +# +# fonts.toml hash: {{ include "dot_config/fontconfig/fonts.toml" | sha256sum }} + +set -eu + +echo "chezmoi: Font configuration changed, installing fonts..." + +SCRIPT="$HOME/.local/bin/install-fonts.ts" + +if [ ! -f "$SCRIPT" ]; then + echo "chezmoi: Font installer not found at $SCRIPT" + echo "chezmoi: Run 'chezmoi apply' again after the script is installed" + exit 0 +fi + +if ! command -v bun &> /dev/null; then + echo "chezmoi: bun not found, skipping font installation" + echo "chezmoi: Install bun and run 'chezmoi apply' again" + exit 0 +fi + +# Run the font installer +bun "$SCRIPT" + +echo "chezmoi: Font installation complete" +{{ end -}}