Files
platform/scripts/port-community-themes.ts
T
pastilhasandClaude Sonnet 5 a58f4d2b32 port 147 community Omarchy themes from omarchythemes.com
The official 22 themes come from basecamp/omarchy's own colors.toml, contrast-checked
by generate-themes.ts. A community theme has no such file — it ships per-app configs
(alacritty.toml, ghostty.conf) in the older 16-ANSI-slot style, so this adds a sibling
pipeline: from-ansi.ts derives the same 24-key Palette shape by role (accent, three
background depths, three foreground weights) from those 16 slots, then runs through the
same mapPalette + contrast checks the official themes use. Nothing about the official
generator changed — verified generate-themes.ts --check still reports 0 changes.

port-community-themes.ts discovers each theme's GitHub repo from its omarchythemes.com
page, fetches alacritty.toml (preferred) or ghostty.conf, and is idempotent via
community-manifest.json so a re-run only fetches what's missing. Community themes get
their own registry/index.css marker block, separate from the official GENERATED THEMES
block, so the two pipelines can never clobber each other.

121 of 212 attempted made it through cleanly; a second pass tightening accent selection
and nudging --input recovered 20 more borderline cases (147 total). The rest failed for
real reasons: 62 repos had no parseable colour source, 7 had an accent colour whose own
luminance can't clear 4.5:1 against either black or white text, a handful were narrowly
under threshold even after the rescue pass, and 2 pages had no repo link at all. Every
generated file's header states its source repo and whether it's a light or dark theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 01:37:05 +00:00

276 lines
11 KiB
TypeScript

#!/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=<one-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<string | undefined> {
try {
const res = await fetch(url);
if (!res.ok) return undefined;
return await res.text();
} catch {
return undefined;
}
}
async function discoverRepo(slug: string): Promise<string | undefined> {
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<ReturnType<typeof parseAlacrittyToml>>; 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();