Poked at the 62 "no parseable source" skips from the first pass and found two things the pipeline didn't handle: several community repos genuinely ship colors.toml too — some in Omarchy 4's own semantic schema (best case, no derivation needed at all, same quality as the official 22), others reusing the filename for an unrelated flat color0..color15 scheme — and a handful of alacritty.toml files use 0xrrggbb instead of #rrggbb. palette.ts's loadPalette is now a thin wrapper over two exported pieces (parseTomlText, paletteFromRecord) so the community pipeline can attempt the real schema first without duplicating the reader; falls through to the color16 or ANSI-derived paths otherwise. generate-themes.ts --check still reports 0 changes for the official set — the refactor is behavior-preserving there, the lowercase-hex normalization it picked up along the way touched 7 official theme files cosmetically (their source colors.toml has uppercase hex), regenerated for real rather than left inconsistent with their own source. 147 -> 191 community themes ported. Of the original 62 unparseable repos, most turned out to be simply gone (404 on GitHub — repos deleted or renamed since the site listed them); the rest needed one of the two format additions above. 20 themes remain stuck: 16 dead repos, 2 with no repo link at all, and the same handful of genuinely low-contrast source palettes (mostly Solarized variants) where fixing contrast would mean changing the author's actual colours rather than porting them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
298 lines
13 KiB
TypeScript
298 lines
13 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 type { Palette } from './themes/palette';
|
|
import { paletteFromRecord, parseTomlText } from './themes/palette';
|
|
import { deriveOmarchyPalette } from './themes/from-ansi';
|
|
import { parseAlacrittyToml, parseColor16Toml, 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: string;
|
|
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<{ palette: Palette; kind: string; derived: boolean } | undefined> {
|
|
const branch = ghApiDefaultBranch(repo);
|
|
const raw = (p: string) => `https://raw.githubusercontent.com/${repo}/${branch}/${p}`;
|
|
|
|
// `colors.toml` is ambiguous across community repos: it might genuinely be Omarchy 4's own semantic
|
|
// schema (in which case no derivation is needed at all — same quality as the official 22), or it might
|
|
// reuse the filename for an unrelated flat `color0..color15` ANSI scheme. Try the real schema first;
|
|
// only fall through to the ANSI reading if the required keys aren't there.
|
|
const tomlText = await fetchText(raw('colors.toml'));
|
|
if (tomlText) {
|
|
const kv = parseTomlText(tomlText);
|
|
try {
|
|
return {
|
|
palette: paletteFromRecord(kv, `${repo}/colors.toml`),
|
|
kind: 'colors.toml (Omarchy schema)',
|
|
derived: false,
|
|
};
|
|
} catch {
|
|
const ansi = parseColor16Toml(kv);
|
|
if (ansi) return { palette: deriveOmarchyPalette(ansi), kind: 'colors.toml (16-colour ANSI)', derived: true };
|
|
}
|
|
}
|
|
|
|
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 { palette: deriveOmarchyPalette(ansi), kind: 'alacritty.toml', derived: true };
|
|
}
|
|
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 { palette: deriveOmarchyPalette(ansi), kind: 'ghostty.conf', derived: true };
|
|
}
|
|
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 colors.toml/alacritty.toml/ghostty.conf in ${repo}` };
|
|
|
|
const palette = source.palette;
|
|
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 modeLine = source.kind.startsWith('colors.toml (Omarchy')
|
|
? `${palette.mode === 'dark' ? 'Dark theme' : 'Light theme'} (${palette.mode}) — the source repo's own colors.toml says so directly.`
|
|
: `${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.`;
|
|
const provenanceLine = source.derived
|
|
? `went through deriveOmarchyPalette (scripts/themes/from-ansi.ts) because the source has no Omarchy 4\n colors.toml — some tokens here are reconstructed, not authored.`
|
|
: `came straight from the repo's own colors.toml — the same schema and reader the official 22 themes\n use, so nothing here is reconstructed.`;
|
|
const css = `/*
|
|
${name} — ported from a community Omarchy theme, not from basecamp/omarchy's own set.
|
|
|
|
${modeLine}
|
|
|
|
Source: https://github.com/${repo} (${source.kind}), read by scripts/port-community-themes.ts. Values
|
|
${provenanceLine} 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();
|