#!/usr/bin/env bun import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { join } from 'node:path'; import { mapPalette } from './themes/map'; import { deriveOmarchyPalette } from './themes/from-ansi'; import { parseAlacrittyToml, parseGhosttyConf } from './themes/ansi-source'; import { contrast, ensureContrast, TEXT, UI } from './themes/contrast'; // Port community Omarchy themes from omarchythemes.com into the same 84-token shape the official // pipeline produces — see `generate-themes.ts` for that one. This is its sibling for themes that have no // `colors.toml`: a single-theme repo instead ships per-app configs, so `from-ansi.ts` reconstructs the // same `Palette` from `alacritty.toml` or `ghostty.conf`, and everything downstream (mapping, contrast // checking) is identical. What's NOT identical: these are unofficial, one author's repo can vanish or // change, and the reconstruction is a heuristic, not Omarchy's own curated palette — every generated file // says so in its header. // // bun scripts/port-community-themes.ts [--limit=N] [--slug=] [--redo] // // State lives in `scripts/themes/community-manifest.json` — one entry per theme successfully ported, so a // re-run only fetches what's missing (or everything, with --redo) and the registry/index blocks are // always rewritten fresh from the whole manifest, never hand-edited. const CSS_THEME_DIR = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'css', 'themes'); const INDEX_CSS = join(CSS_THEME_DIR, '..', 'index.css'); const REGISTRY = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'src', 'themes.ts'); const MANIFEST_PATH = join(import.meta.dir, 'themes', 'community-manifest.json'); const SITE = 'https://omarchythemes.com'; const CONCURRENCY = 8; const REDO = process.argv.includes('--redo'); const ONLY_SLUG = process.argv.find((a) => a.startsWith('--slug='))?.split('=')[1]; const LIMIT = Number(process.argv.find((a) => a.startsWith('--limit='))?.split('=')[1] ?? Infinity); type ManifestEntry = { id: string; name: string; repo: string; kind: 'alacritty.toml' | 'ghostty.conf'; dark: boolean; }; // Same pairs `generate-themes.ts` checks, kept in sync by hand since this is the only other place that // needs them and duplicating a 20-line array beats a shared module for one caller each. const PAIRS: [string, string, number][] = [ ['--foreground', '--background', TEXT], ['--card-foreground', '--card', TEXT], ['--popover-foreground', '--popover', TEXT], ['--muted-foreground', '--muted', TEXT], ['--accent-foreground', '--accent', TEXT], ['--primary-foreground', '--primary', TEXT], ['--secondary-foreground', '--secondary', TEXT], ['--cta-foreground', '--cta', TEXT], ['--success-foreground', '--success', TEXT], ['--warning-foreground', '--warning', TEXT], ['--destructive-foreground', '--destructive', TEXT], ['--info-foreground', '--info', TEXT], ['--busy-foreground', '--busy', TEXT], ['--busy-ink', '--background', TEXT], ['--input', '--background', 1.35], ['--ring', '--background', UI], ]; const title = (id: string): string => id .split('-') .map((w) => (w.length <= 2 ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1))) .join(' '); function loadManifest(): ManifestEntry[] { if (!existsSync(MANIFEST_PATH)) return []; return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); } function saveManifest(entries: ManifestEntry[]): void { writeFileSync(MANIFEST_PATH, `${JSON.stringify(entries, null, 2)}\n`); } function ghApiDefaultBranch(repo: string): string { try { const out = execSync(`gh api repos/${repo} --jq .default_branch`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); return out.trim() || 'main'; } catch { return 'main'; } } async function fetchText(url: string): Promise { try { const res = await fetch(url); if (!res.ok) return undefined; return await res.text(); } catch { return undefined; } } async function discoverRepo(slug: string): Promise { const html = await fetchText(`${SITE}/themes/${slug}`); if (!html) return undefined; const m = html.match(/https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)/); return m ? `${m[1]}/${m[2]}` : undefined; } async function sourceFor( repo: string, ): Promise<{ ansi: NonNullable>; kind: ManifestEntry['kind'] } | undefined> { const branch = ghApiDefaultBranch(repo); const raw = (p: string) => `https://raw.githubusercontent.com/${repo}/${branch}/${p}`; for (const path of [ 'alacritty.toml', '.config/alacritty.toml', 'alacritty/alacritty.toml', 'config/alacritty.toml', ]) { const text = await fetchText(raw(path)); if (!text) continue; const ansi = parseAlacrittyToml(text); if (ansi) return { ansi, kind: 'alacritty.toml' }; } for (const path of ['ghostty.conf', 'ghostty-theme', '.config/ghostty/config']) { const text = await fetchText(raw(path)); if (!text) continue; const ansi = parseGhosttyConf(text); if (ansi) return { ansi, kind: 'ghostty.conf' }; } return undefined; } async function portOne(slug: string): Promise<{ entry: ManifestEntry; css: string } | { skip: string }> { const repo = await discoverRepo(slug); if (!repo) return { skip: 'no github repo link found on its omarchythemes.com page' }; const source = await sourceFor(repo); if (!source) return { skip: `no parseable alacritty.toml/ghostty.conf in ${repo}` }; const palette = deriveOmarchyPalette(source.ansi); const tokens = mapPalette(palette); // `--input` carries a real accessibility requirement (a field boundary you can't see) where `--border` // is decoration and isn't held to one — see the README. `map.ts` derives both from the same mix for // every theme, official ones included, and it normally clears 1.35:1 on its own; a few derived palettes // land just under it, so nudge only this one rather than loosening the check. tokens['--input'] = ensureContrast(tokens['--input']!, tokens['--background']!, 1.35); const failures: string[] = []; for (const [fgKey, bgKey, min] of PAIRS) { const fg = tokens[fgKey]!; const bg = tokens[bgKey]!; if (!fg.startsWith('#') || !bg.startsWith('#')) continue; const ratio = contrast(fg, bg); if (ratio < min) failures.push(`${fgKey} on ${bgKey} is ${ratio.toFixed(2)}:1, needs ${min}:1`); } if (failures.length > 0) return { skip: `contrast failed after derivation: ${failures.join('; ')}` }; const name = title(slug); const body = Object.entries(tokens) .map(([k, v]) => ` ${k}: ${v};`) .join('\n'); const css = `/* ${name} — ported from a community Omarchy theme, not from basecamp/omarchy's own set. ${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}), by luminance of its own background — the source repo carries no mode flag of its own to read. Source: https://github.com/${repo} (${source.kind}), read by scripts/port-community-themes.ts. Values went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because a single-theme repo ships per-app configs, not Omarchy 4's own colors.toml shape — some tokens here are reconstructed, not authored. Every text/surface pair is contrast-checked the same way the official themes are. DO NOT EDIT BY HAND. Re-run the script to refresh from the source repo. */ [data-theme='${slug}'] { ${body} } `; return { entry: { id: slug, name, repo, kind: source.kind, dark: palette.mode === 'dark' }, css }; } function rewriteIndexCss(entries: ManifestEntry[]): void { const index = readFileSync(INDEX_CSS, 'utf8'); const marker = '/* Community themes'; const startIdx = index.indexOf(marker); const head = startIdx >= 0 ? index.slice(0, startIdx) : index.replace(/\n@import '\.\/chrome\.css';\n$/, '\n'); const imports = entries.map((e) => `@import './themes/${e.id}.css';`).join('\n'); writeFileSync( INDEX_CSS, `${head}/* Community themes — ported from omarchythemes.com by scripts/port-community-themes.ts. Do not edit this block. */\n${imports}\n\n@import './chrome.css';\n`, ); } function rewriteRegistry(entries: ManifestEntry[]): void { const registry = readFileSync(REGISTRY, 'utf8'); const START = ' /* COMMUNITY THEMES — start */'; const END = ' /* COMMUNITY THEMES — end */'; const rows = entries .map((e) => ` { id: '${e.id}', name: '${e.name}', family: '${e.name}', applyDarkClass: ${e.dark} },`) .join('\n'); const block = `${START}\n${rows}\n${END}`; if (registry.includes(START) && registry.includes(END)) { // Snap to the start of the marker's own line — `indexOf` on a string with leading spaces can match // partway into stray indentation left by an earlier write, which would otherwise accumulate. const startLineBegin = registry.lastIndexOf('\n', registry.indexOf(START)) + 1; const before = registry.slice(0, startLineBegin); const after = registry.slice(registry.indexOf(END) + END.length); writeFileSync(REGISTRY, `${before}${block}${after}`); } else { const anchor = '] as const;'; const idx = registry.indexOf(anchor); writeFileSync(REGISTRY, `${registry.slice(0, idx)}${block}\n${registry.slice(idx)}`); } } async function main() { const slugsFile = process.argv.find((a) => a.startsWith('--slugs='))?.split('=')[1]; const allSlugs: string[] = slugsFile ? readFileSync(slugsFile, 'utf8').trim().split('\n').filter(Boolean) : []; const manifest = loadManifest(); const known = new Set(manifest.map((e) => e.id)); // Never take a slug the OFFICIAL/hand-authored part of the registry already owns — that's everything // before this script's own block, which it is free to redo. const registryHead = readFileSync(REGISTRY, 'utf8').split('/* COMMUNITY THEMES — start */')[0]!; const registryIds = new Set(Array.from(registryHead.matchAll(/id: '([\w-]+)'/g)).map((m) => m[1]!)); let todo = ONLY_SLUG ? [ONLY_SLUG] : allSlugs.filter((s) => !registryIds.has(s) && (REDO || !known.has(s))); todo = todo.slice(0, LIMIT); console.log(`community theme port: ${todo.length} to try, ${manifest.length} already in the manifest`); const results: ManifestEntry[] = [...manifest.filter((e) => !todo.includes(e.id))]; let done = 0; let ok = 0; let skipped = 0; async function worker() { while (todo.length > 0) { const slug = todo.shift(); if (!slug) break; try { const result = await portOne(slug); done++; if ('skip' in result) { skipped++; console.log(`[${done}] SKIP ${slug} — ${result.skip}`); } else { writeFileSync(join(CSS_THEME_DIR, `${slug}.css`), result.css); results.push(result.entry); ok++; console.log(`[${done}] OK ${slug} — from ${result.entry.repo} (${result.entry.kind})`); } } catch (err) { done++; skipped++; console.log(`[${done}] ERROR ${slug} — ${(err as Error).message}`); } } } await Promise.all(Array.from({ length: CONCURRENCY }, worker)); results.sort((a, b) => a.id.localeCompare(b.id)); saveManifest(results); rewriteIndexCss(results); rewriteRegistry(results); console.log(`\ndone: ${ok} ported, ${skipped} skipped, ${results.length} total in manifest`); } main();