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>
168 lines
5.1 KiB
TypeScript
168 lines
5.1 KiB
TypeScript
// Reading a community theme's actual shipped format.
|
|
//
|
|
// Omarchy's own 22 themes carry a `colors.toml` in the semantic shape `palette.ts` expects — three
|
|
// background depths, four foreground weights, an accent, a selection, a muted. A community theme on
|
|
// omarchythemes.com ships none of that: it's per-app config files in the classic 16-ANSI-slot style,
|
|
// because that's what predates Omarchy 4's own format and what most single-theme repos still use.
|
|
//
|
|
// This file reads the two shapes that cover almost every repo: `alacritty.toml` (structured, typed,
|
|
// preferred) and `ghostty.conf`/`ghostty-theme` (flat `key = value` lines, fallback). Either becomes an
|
|
// `AnsiSource` — the 16 ANSI slots plus primary background/foreground — which `from-ansi.ts` turns into
|
|
// the same `Palette` shape the official pipeline consumes, so every downstream guarantee (contrast
|
|
// checking, the 84-token mapping) applies unchanged.
|
|
|
|
export type AnsiSource = {
|
|
background: string;
|
|
foreground: string;
|
|
black: string;
|
|
red: string;
|
|
green: string;
|
|
yellow: string;
|
|
blue: string;
|
|
magenta: string;
|
|
cyan: string;
|
|
white: string;
|
|
brightBlack: string;
|
|
brightRed: string;
|
|
brightGreen: string;
|
|
brightYellow: string;
|
|
brightBlue: string;
|
|
brightMagenta: string;
|
|
brightCyan: string;
|
|
brightWhite: string;
|
|
};
|
|
|
|
const HEX = /#[0-9a-fA-F]{3,8}/;
|
|
|
|
function normalizeHex(v: string): string | undefined {
|
|
const m = v.match(HEX);
|
|
if (!m) return undefined;
|
|
let h = m[0].slice(1).toLowerCase();
|
|
if (h.length === 3)
|
|
h = h
|
|
.split('')
|
|
.map((c) => c + c)
|
|
.join('');
|
|
if (h.length >= 6) h = h.slice(0, 6);
|
|
return `#${h}`;
|
|
}
|
|
|
|
/** A deliberately small TOML reader: flat `key = "value"` lines plus `[section]` headers. */
|
|
export function parseAlacrittyToml(text: string): AnsiSource | undefined {
|
|
let section = '';
|
|
const rows: Record<string, Record<string, string>> = {};
|
|
for (const rawLine of text.split('\n')) {
|
|
const line = rawLine.trim();
|
|
const sectionMatch = line.match(/^\[([\w.]+)]$/);
|
|
if (sectionMatch) {
|
|
section = sectionMatch[1]!;
|
|
rows[section] ??= {};
|
|
continue;
|
|
}
|
|
// Quoted specifically, not "anything but a comment" — a hex value starts with `#`, the same
|
|
// character that opens a trailing comment, so the two can only be told apart by the quotes.
|
|
const kv = line.match(/^([\w-]+)\s*=\s*(['"])(.*?)\2/);
|
|
if (kv && section) {
|
|
const hex = normalizeHex(kv[3]!);
|
|
if (hex) rows[section]![kv[1]!] = hex;
|
|
}
|
|
}
|
|
|
|
const primary = rows['colors.primary'];
|
|
const normal = rows['colors.normal'];
|
|
const bright = rows['colors.bright'];
|
|
if (!primary?.background || !primary?.foreground || !normal || !bright) return undefined;
|
|
|
|
const need = (r: Record<string, string>, k: string): string | undefined => r[k];
|
|
const n = (k: string) => need(normal, k);
|
|
const b = (k: string) => need(bright, k);
|
|
if (
|
|
!n('black') ||
|
|
!n('red') ||
|
|
!n('green') ||
|
|
!n('yellow') ||
|
|
!n('blue') ||
|
|
!n('magenta') ||
|
|
!n('cyan') ||
|
|
!n('white')
|
|
)
|
|
return undefined;
|
|
if (
|
|
!b('black') ||
|
|
!b('red') ||
|
|
!b('green') ||
|
|
!b('yellow') ||
|
|
!b('blue') ||
|
|
!b('magenta') ||
|
|
!b('cyan') ||
|
|
!b('white')
|
|
)
|
|
return undefined;
|
|
|
|
return {
|
|
background: primary.background,
|
|
foreground: primary.foreground,
|
|
black: n('black')!,
|
|
red: n('red')!,
|
|
green: n('green')!,
|
|
yellow: n('yellow')!,
|
|
blue: n('blue')!,
|
|
magenta: n('magenta')!,
|
|
cyan: n('cyan')!,
|
|
white: n('white')!,
|
|
brightBlack: b('black')!,
|
|
brightRed: b('red')!,
|
|
brightGreen: b('green')!,
|
|
brightYellow: b('yellow')!,
|
|
brightBlue: b('blue')!,
|
|
brightMagenta: b('magenta')!,
|
|
brightCyan: b('cyan')!,
|
|
brightWhite: b('white')!,
|
|
};
|
|
}
|
|
|
|
/** Ghostty's config: flat `key = value`, ANSI palette as sixteen `palette = N=#hex` lines. */
|
|
export function parseGhosttyConf(text: string): AnsiSource | undefined {
|
|
let background: string | undefined;
|
|
let foreground: string | undefined;
|
|
const palette: Record<number, string> = {};
|
|
|
|
for (const rawLine of text.split('\n')) {
|
|
const line = rawLine.trim();
|
|
if (line.startsWith('#')) continue;
|
|
const bg = line.match(/^background\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
|
if (bg) background = normalizeHex(bg[1]!);
|
|
const fg = line.match(/^foreground\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
|
if (fg) foreground = normalizeHex(fg[1]!);
|
|
const pal = line.match(/^palette\s*=\s*(\d+)\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
|
if (pal) {
|
|
const hex = normalizeHex(pal[2]!);
|
|
if (hex) palette[Number(pal[1])] = hex;
|
|
}
|
|
}
|
|
|
|
if (!background || !foreground) return undefined;
|
|
for (let i = 0; i <= 15; i++) if (!palette[i]) return undefined;
|
|
|
|
return {
|
|
background,
|
|
foreground,
|
|
black: palette[0]!,
|
|
red: palette[1]!,
|
|
green: palette[2]!,
|
|
yellow: palette[3]!,
|
|
blue: palette[4]!,
|
|
magenta: palette[5]!,
|
|
cyan: palette[6]!,
|
|
white: palette[7]!,
|
|
brightBlack: palette[8]!,
|
|
brightRed: palette[9]!,
|
|
brightGreen: palette[10]!,
|
|
brightYellow: palette[11]!,
|
|
brightBlue: palette[12]!,
|
|
brightMagenta: palette[13]!,
|
|
brightCyan: palette[14]!,
|
|
brightWhite: palette[15]!,
|
|
};
|
|
}
|