mirror of
https://github.com/Xevion/dotfiles.git
synced 2026-01-31 02:24:11 -06:00
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:
@@ -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>
|
||||||
@@ -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 -}}
|
||||||
Reference in New Issue
Block a user