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>
276 lines
11 KiB
TypeScript
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();
|