#!/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=] [--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; /** Omitted means GitHub, which is where all but a couple of these live. */ host?: 'gitlab'; kind: string; dark: boolean; }; type Repo = { host: 'github' | 'gitlab'; path: string }; const repoUrl = (r: Repo): string => `https://${r.host === 'gitlab' ? 'gitlab.com' : 'github.com'}/${r.path}`; // 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 gitlabDefaultBranch(path: string): Promise { const json = await fetchText(`https://gitlab.com/api/v4/projects/${encodeURIComponent(path)}`); if (!json) return 'main'; try { return (JSON.parse(json).default_branch as string) || '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 gh = html.match(/https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)/); if (gh) return { host: 'github', path: `${gh[1]}/${gh[2]}` }; // A couple of authors host on GitLab instead. Its group paths can nest, hence the greedier match. const gl = html.match(/https:\/\/gitlab\.com\/([\w.-]+(?:\/[\w.-]+)+?)(?=["'\s<]|$)/); if (gl) return { host: 'gitlab', path: gl[1]!.replace(/\.git$/, '') }; return undefined; } async function sourceFor( repo: Repo, slug: string, ): Promise<{ palette: Palette; kind: string; derived: boolean } | undefined> { const branch = repo.host === 'gitlab' ? await gitlabDefaultBranch(repo.path) : ghApiDefaultBranch(repo.path); const raw = (p: string) => repo.host === 'gitlab' ? `https://gitlab.com/${repo.path}/-/raw/${branch}/${p}` : `https://raw.githubusercontent.com/${repo.path}/${branch}/${p}`; // Most repos hold one theme at the root. A few are packs that nest one directory per theme, so also // look under the directory named after THIS slug — an exact match is the only safe pick, since a pack // with variant names of its own (`abyss-dark`, `lagoon-light`, …) has no one theme that is "the" one. const prefixes = ['', `themes/${slug}/`, `omarchy/themes/${slug}/`, `${slug}/`]; for (const prefix of prefixes) { // `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(`${prefix}colors.toml`)); if (tomlText) { const kv = parseTomlText(tomlText); try { return { palette: paletteFromRecord(kv, `${repo.path}/${prefix}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(`${prefix}${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(`${prefix}${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/gitlab repo link found on its omarchythemes.com page' }; const source = await sourceFor(repo, slug); if (!source) return { skip: `no parseable colors.toml/alacritty.toml/ghostty.conf in ${repo.path}` }; 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: ${repoUrl(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: repo.path, ...(repo.host === 'gitlab' ? { host: 'gitlab' as const } : {}), 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();