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
This commit is contained in:
2025-12-28 15:20:04 -06:00
parent ffcb41380c
commit 62c575aa92
4 changed files with 1038 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<!--
Fontconfig Configuration
Managed by Chezmoi - edit fonts.toml for font selection
Optimized for:
- LCD displays (1080p)
- Both light and dark modes
- General readability and crisp text
-->
<fontconfig>
<!-- ============================================================
Global Rendering Settings
============================================================ -->
<!-- Enable antialiasing for smooth edges -->
<match target="font">
<edit name="antialias" mode="assign">
<bool>true</bool>
</edit>
</match>
<!-- Enable hinting for sharper text -->
<match target="font">
<edit name="hinting" mode="assign">
<bool>true</bool>
</edit>
</match>
<!--
Hintstyle: slight
- Best balance between shape preservation and sharpness
- Works well for both light and dark modes
- Recommended for modern LCDs
-->
<match target="font">
<edit name="hintstyle" mode="assign">
<const>hintslight</const>
</edit>
</match>
<!--
Subpixel rendering: RGB
- Standard for most LCD monitors (horizontal RGB stripe)
- Effectively triples horizontal resolution
-->
<match target="font">
<edit name="rgba" mode="assign">
<const>rgb</const>
</edit>
</match>
<!--
LCD filter: default
- Reduces color fringing from subpixel rendering
- Good balance for most displays
-->
<match target="font">
<edit name="lcdfilter" mode="assign">
<const>lcddefault</const>
</edit>
</match>
<!-- Disable autohinting (use font's built-in hints) -->
<match target="font">
<edit name="autohint" mode="assign">
<bool>false</bool>
</edit>
</match>
<!-- Disable embedded bitmap fonts (use vector glyphs) -->
<match target="font">
<edit name="embeddedbitmap" mode="assign">
<bool>false</bool>
</edit>
</match>
<!-- Re-enable embedded bitmaps for emoji fonts -->
<match target="font">
<test name="family" qual="any">
<string>Noto Color Emoji</string>
</test>
<edit name="embeddedbitmap" mode="assign">
<bool>true</bool>
</edit>
</match>
<!-- ============================================================
Default Font Aliases
============================================================ -->
<!-- Sans-serif: Inter with Noto Sans fallback -->
<alias>
<family>sans-serif</family>
<prefer>
<family>Inter</family>
<family>Noto Sans</family>
<family>DejaVu Sans</family>
</prefer>
</alias>
<alias>
<family>system-ui</family>
<prefer>
<family>Inter</family>
<family>Noto Sans</family>
</prefer>
</alias>
<!-- Serif: Source Serif 4 with Noto Serif fallback -->
<alias>
<family>serif</family>
<prefer>
<family>Source Serif 4</family>
<family>Noto Serif</family>
<family>DejaVu Serif</family>
</prefer>
</alias>
<!-- Monospace: Iosevka with JetBrains Mono fallback -->
<alias>
<family>monospace</family>
<prefer>
<family>Iosevka</family>
<family>JetBrains Mono</family>
<family>DejaVu Sans Mono</family>
</prefer>
</alias>
<alias>
<family>ui-monospace</family>
<prefer>
<family>Iosevka</family>
<family>JetBrains Mono</family>
</prefer>
</alias>
<!-- Common font substitutions -->
<alias>
<family>Arial</family>
<prefer>
<family>Inter</family>
<family>Noto Sans</family>
</prefer>
</alias>
<alias>
<family>Helvetica</family>
<prefer>
<family>Inter</family>
<family>Noto Sans</family>
</prefer>
</alias>
<alias>
<family>Times New Roman</family>
<prefer>
<family>Source Serif 4</family>
<family>Noto Serif</family>
</prefer>
</alias>
<alias>
<family>Times</family>
<prefer>
<family>Source Serif 4</family>
<family>Noto Serif</family>
</prefer>
</alias>
<alias>
<family>Courier New</family>
<prefer>
<family>Iosevka</family>
<family>JetBrains Mono</family>
</prefer>
</alias>
<alias>
<family>Courier</family>
<prefer>
<family>Iosevka</family>
<family>JetBrains Mono</family>
</prefer>
</alias>
<!-- Emoji support -->
<alias>
<family>emoji</family>
<prefer>
<family>Noto Color Emoji</family>
</prefer>
</alias>
<!-- ============================================================
Font-Specific Tweaks
============================================================ -->
<!--
Iosevka: Use medium hinting for better terminal rendering
Iosevka is quite thin, so medium hinting helps legibility
-->
<match target="font">
<test name="family" qual="any">
<string>Iosevka</string>
</test>
<edit name="hintstyle" mode="assign">
<const>hintmedium</const>
</edit>
</match>
<!-- Same for Iosevka variants -->
<match target="font">
<test name="family" qual="any" compare="contains">
<string>Iosevka</string>
</test>
<edit name="hintstyle" mode="assign">
<const>hintmedium</const>
</edit>
</match>
<!--
Inter: Slight hinting works great for this font
Already using global defaults, but explicit for clarity
-->
<match target="font">
<test name="family" qual="any">
<string>Inter</string>
</test>
<edit name="hintstyle" mode="assign">
<const>hintslight</const>
</edit>
</match>
</fontconfig>
+31
View File
@@ -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"
@@ -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 <query> # 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<T> {
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<string, GitHubFontConfig> = {
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<T>(
items: T[],
query: string,
getKey: (item: T) => string,
threshold = 0.3,
limit = 5
): FuseResult<T>[] {
const results: FuseResult<T>[] = [];
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<Response | null> {
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<GoogleFont[]> {
// 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<FontDetails | null> {
// 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<boolean> {
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<boolean> {
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<number, string> = {
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<boolean> {
// 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<FontConfig> {
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<void> {
const catalog = await fetchFontCatalog();
console.log(`\n${BOLD}Available Fonts (${catalog.length} total)${RESET}\n`);
// Group by category
const byCategory: Record<string, GoogleFont[]> = {};
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<void> {
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<void> {
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<void> {
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 <q> 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);
});
@@ -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 -}}