Files
73bda6801d feat(website): switch homepage hero svg with site theme
The homepage hero rendered once, against the svg exporter's own fixed
dark canvas background, so it always looked like a dark-mode prompt
even when a reader had the site in light mode.

export_themes.mjs now renders the default config twice - once
unchanged for dark, once with --background-color=#ffffff (Infima's
own light background) for light - and hero.json carries both as svg/
svgLight. The homepage ships both renders in its static HTML and picks
between them with plain CSS keyed off Docusaurus's own
html[data-theme] attribute, the same convention custom.css's
--omp-card-background override already uses, so the switch is instant
and needs no client re-render.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Entire-Checkpoint: e4462dff49f1
2026-07-30 19:44:41 +02:00

448 lines
18 KiB
JavaScript

import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { promises } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { VICTOR_MONO } from './font-metrics.mjs';
const execFileAsync = promisify(execFile);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// VICTOR_MONO (family name plus CELL_WIDTH/LINE_HEIGHT/FILL_ASCENT/FILL_DESCENT) lives in
// font-metrics.mjs now, not here - that file has no Node imports, so it can also be imported by
// the studio's browser-side code (website/src/components/Studio), which cannot import this
// script (it pulls in node:fs et al.). See font-metrics.mjs for the derivation comments; keep
// them there, not duplicated here.
const CONFIG = {
THEMES_CONFIG_DIR: join(__dirname, '../themes'),
// Read by plugins/themes at build time (usePluginData('oh-my-posh-themes')) and
// inlined into docs/themes.mdx via <ThemeGallery/>. Not under static/: nothing
// references these SVGs by URL any more (they're inlined, not <img>-loaded), so
// living outside static/ keeps them from also being copied into the deployed
// build/ output as dead weight. Regenerated on every build (see the "themes" npm
// script) and gitignored, exactly like the PNGs it replaces.
MANIFEST_FILE: join(__dirname, 'generated/themes.json'),
// The prompt the homepage shows, rendered from oh-my-posh's own built-in default config
// (src/config/default.go) rather than any bundled theme, so it is exactly what a reader gets
// after running the install command directly above it. Written on its own rather than read out
// of the manifest: that file is ~500KB of inlined SVG, and a plain import of it from
// src/pages/index.js would put every one of the 124 themes into the landing page's own chunk -
// see plugins/themes/index.js on why the gallery pays that cost only on its own page.
HERO_FILE: join(__dirname, 'generated/hero.json'),
CONCURRENCY: 8,
THEME_EXTENSIONS: ['.omp.json', '.omp.toml', '.omp.yaml'],
SEGMENT_DATA_FILE: join(__dirname, 'segment_data.json'),
GITHUB_BASE_URL: 'https://github.com/JanDeDobbeleer/oh-my-posh/blob/main/themes',
// "Open in Studio" (ThemeGallery/index.js) fetches a theme's config from here at click time,
// rather than this exporter reading the file content into the manifest. That keeps the gallery
// reading the actual current file on every click - not a snapshot frozen at whenever the site
// was last built - and keeps generated/themes.json from growing by the size of every theme's
// source on top of its already-large inlined SVGs.
RAW_GITHUB_BASE_URL: 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes',
// Infima's own light-theme background (no --ifm-background-color override lives in
// custom.css, so this is Infima's default) - every SVG this exporter renders for the site's
// own light/dark toggle (the hero, and each theme's own light-mode render below) needs an
// explicit canvas background for its light half: most bundled themes (and always the hero's
// built-in default config) set no terminal background of their own, so without this flag they
// fall back to svg's own dark default (svg.go's defaultCanvasBackground) regardless of which
// mode the reader's browser is in. A theme that *does* set its own terminal background (e.g.
// tokyonight_storm) is unaffected either way - config_export_image.go's --background-color
// always loses to a theme's own background, exactly like a real terminal would.
LIGHT_BACKGROUND: '#ffffff',
FONT_FAMILY: VICTOR_MONO.FONT_FAMILY,
CELL_WIDTH: VICTOR_MONO.CELL_WIDTH,
LINE_HEIGHT: VICTOR_MONO.LINE_HEIGHT,
FILL_ASCENT: VICTOR_MONO.FILL_ASCENT,
FILL_DESCENT: VICTOR_MONO.FILL_DESCENT,
};
const TRENDING_FETCH_TIMEOUT_MS = 4000;
/**
* Small, tasteful deny-list used as a safety net behind each source's own
* explicit-content flag. Matched word-boundary aware so substrings inside
* unrelated words (e.g. "sex" in "Essex") don't trigger a false positive.
*/
const CONTENT_DENY_LIST = ['fuck', 'shit', 'bitch', 'nigga', 'cunt', 'motherfucker'];
const CONTENT_DENY_REGEX = new RegExp(`\\b(${CONTENT_DENY_LIST.join('|')})\\b`, 'i');
// There used to be a THEME_CONFIG_OVERRIDES map here (per-theme author/bgColor
// overrides). Both only ever fed the PNG path's --author/--background-color
// flags - image.Settings.Author's caption and image.Renderer's canvas fill -
// neither of which the svg format reads (see config_export_svg.go: it takes
// the terminal background from the theme's own config, not a CLI flag). Now
// that every theme exports as svg, the overrides had no effect left to have.
function isValidTheme(fileName) {
return CONFIG.THEME_EXTENSIONS.some((ext) => fileName.endsWith(ext));
}
function getThemeNameFromFile(fileName) {
const lastDotIndex = fileName.lastIndexOf('.');
const secondLastDotIndex = fileName.lastIndexOf('.', lastDotIndex - 1);
return fileName.slice(0, secondLastDotIndex);
}
// The format string the studio's editor/parser (ConfigEditor/serialize.js) and CONFIG_FORMATS
// (Studio/config.js) key on - derived from the same extension isValidTheme already checked,
// so a theme file's own on-disk syntax is what "Open in Studio" loads it as.
function getThemeFormat(fileName) {
if (fileName.endsWith('.omp.json')) {
return 'json';
}
if (fileName.endsWith('.omp.toml')) {
return 'toml';
}
return 'yaml';
}
async function fetchJsonWithTimeout(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRENDING_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return await response.json();
} finally {
clearTimeout(timeout);
}
}
/**
* Plain-text safety net behind each source's own explicit flag - intentionally
* short rather than a comprehensive filter.
*/
function isClean(title, artist) {
return !CONTENT_DENY_REGEX.test(`${title} ${artist}`);
}
async function trendingFromDeezer() {
const body = await fetchJsonWithTimeout('https://api.deezer.com/chart/0/tracks?limit=25');
const tracks = body?.data;
if (!Array.isArray(tracks) || tracks.length === 0) {
throw new Error('no tracks returned');
}
const track = tracks.find((entry) => !entry.explicit_lyrics && isClean(entry.title, entry.artist?.name));
if (!track) {
throw new Error('no clean track found in chart');
}
return { artist: track.artist.name, track: track.title };
}
async function trendingFromAppleRSS() {
const body = await fetchJsonWithTimeout('https://rss.marketingtools.apple.com/api/v2/us/music/most-played/25/songs.json');
const tracks = body?.feed?.results;
if (!Array.isArray(tracks) || tracks.length === 0) {
throw new Error('no tracks returned');
}
// Apple only includes contentAdvisoryRating when a track is explicit, so presence of the key
// (not its value) is the signal to filter on. The value is the literal string "Explict" (sic),
// Apple's own misspelling, verified against the live feed - do not "correct" it here.
const track = tracks.find((entry) => !('contentAdvisoryRating' in entry) && isClean(entry.name, entry.artistName));
if (!track) {
throw new Error('no clean track found in feed');
}
return { artist: track.artistName, track: track.name };
}
/**
* Fetches a trending track (Deezer, then Apple Music RSS as a fallback) and injects
* it into the spotify and ytm segment payloads of a temporary copy of the committed
* data file. The committed file is never modified; on any failure the committed
* file is used as-is.
*/
async function buildDataFileWithTrending() {
let trending;
try {
trending = await trendingFromDeezer();
} catch (error) {
console.warn(`Trending track lookup via Deezer failed: ${error.message}`);
try {
trending = await trendingFromAppleRSS();
} catch (fallbackError) {
console.warn(`Trending track lookup via Apple Music RSS failed: ${fallbackError.message}`);
}
}
if (!trending) {
return CONFIG.SEGMENT_DATA_FILE;
}
try {
const raw = await promises.readFile(CONFIG.SEGMENT_DATA_FILE, 'utf8');
const data = JSON.parse(raw);
for (const key of ['spotify', 'ytm']) {
const segment = data.segments?.[key];
if (!segment) {
continue;
}
segment.Artist = trending.artist;
segment.Track = trending.track;
}
const tempPath = join(tmpdir(), `segment_data.${process.pid}.${Date.now()}.json`);
await promises.writeFile(tempPath, JSON.stringify(data, null, 2));
console.log(`Using trending track "${trending.track}" by ${trending.artist} for previews`);
return tempPath;
} catch (error) {
console.warn(`Unable to build data file with trending track: ${error.message}`);
return CONFIG.SEGMENT_DATA_FILE;
}
}
// OMP_BIN points the export at a specific oh-my-posh binary instead of
// whatever is on PATH, so a gallery can be regenerated from a local build
// without installing it over the one the developer actually uses. CI and the
// normal build leave it unset and get PATH, as before. execFile spawns the
// binary directly with an argv array (no shell in between), so - unlike the
// exec()+string command this replaced - the path never needs quoting even
// when it contains spaces.
const OMP_BIN = process.env.OMP_BIN || 'oh-my-posh';
// configPath is null for the homepage's own render: oh-my-posh with no --config falls back to
// the config it builds in Go (src/config/default.go), which is the prompt someone sees before
// they have configured anything. There is no file to point at, and no bundled theme matches it.
//
// backgroundColor is optional (see LIGHT_BACKGROUND) - it is what makes a theme's own light-mode
// render actually light when the theme sets no terminal background of its own; a theme that does
// (e.g. tokyonight_storm) keeps its own look regardless, since --background-color always loses
// to a theme's own background (config_export_image.go).
function buildPoshArgs(configPath, outputPath, backgroundColor) {
return [
'config',
'export',
'image',
...(configPath ? [`--config=${configPath}`] : []),
`--output=${outputPath}`,
`--font-family=${CONFIG.FONT_FAMILY}`,
`--cell-width=${CONFIG.CELL_WIDTH}`,
`--line-height=${CONFIG.LINE_HEIGHT}`,
`--fill-ascent=${CONFIG.FILL_ASCENT}`,
`--fill-descent=${CONFIG.FILL_DESCENT}`,
...(backgroundColor ? [`--background-color=${backgroundColor}`] : []),
// segment_data.json is hand-written on purpose (see buildDataFileWithTrending):
// its synthetic values are what make the renders look like a plausible
// machine. oh-my-posh warns that the file carries no recorder marker, which is
// expected here and handled as a non-fatal stderr line below. --data-derive
// would not silence it - that flag only forces an already-recorded file to
// re-derive, so it is a no-op for a hand-written one.
`--data=${CONFIG.SEGMENT_DATA_FILE}`,
// Without this the build machine leaks into the gallery. A segment the data
// file does not cover used to derive itself from whatever machine ran the
// export: free-ukraine and markbull published a worktree count read from the
// exporting checkout's own .git/worktrees, so the number changed with whoever
// built the site. --data-only cuts the environment off, leaving a segment
// either rendering from this file or reporting itself absent - never from the
// machine. It does not suppress segments the file misses: path and session own
// no entry and still render from the env section's pinned PWD and user.
'--data-only',
];
}
// buildManifestEntry builds the one object plugins/themes hands to
// <ThemeGallery/> per theme: the raw svg markup (inlined verbatim via
// dangerouslySetInnerHTML, never parsed as MDX/JSX - MDX compiles a document's body as JSX, and
// the exporter's raw SVG attributes are not valid JSX, so the markup has to arrive as an opaque
// string rather than live in the .mdx source itself), the name to label it
// with, and the GitHub URL both the heading and the render link to. rawConfigUrl and format are
// what "Open in Studio" (ThemeGallery/index.js) needs to fetch and parse the theme's actual
// config at click time - see RAW_GITHUB_BASE_URL's own comment for why that's a URL, not the
// file content itself. svgLight is the theme's own light-mode render (see LIGHT_BACKGROUND) -
// <ThemeCard/> ships both and picks between them the same way the homepage hero does.
function buildManifestEntry(themeName, themeFile, svg, svgLight) {
return {
name: themeName,
githubUrl: `${CONFIG.GITHUB_BASE_URL}/${themeFile}`,
rawConfigUrl: `${CONFIG.RAW_GITHUB_BASE_URL}/${themeFile}`,
format: getThemeFormat(themeFile),
svg,
svgLight,
};
}
async function* asyncPool(concurrency, iterable, iteratorFn) {
const executing = new Set();
async function consume() {
const [promise, value] = await Promise.race(executing);
executing.delete(promise);
return value;
}
for (const item of iterable) {
const promise = (async () => await iteratorFn(item))().then(
value => [promise, value]
);
executing.add(promise);
if (executing.size >= concurrency) {
yield await consume();
}
}
while (executing.size) {
yield await consume();
}
}
async function exportTheme(themeFile) {
if (!isValidTheme(themeFile)) {
return null;
}
const configPath = join(CONFIG.THEMES_CONFIG_DIR, themeFile);
const themeName = getThemeNameFromFile(themeFile);
// A per-run scratch path, like buildDataFileWithTrending's temp data file below:
// the svg only needs to exist long enough to be read back into the manifest, so
// it lives in the OS temp dir rather than under website/, leaving nothing behind
// for git status to notice.
const svg = await renderSVG(configPath, themeFile);
const svgLight = await renderSVG(configPath, `${themeFile} (light)`, CONFIG.LIGHT_BACKGROUND);
console.info(`Exported ${themeFile}`);
return { entry: buildManifestEntry(themeName, themeFile, svg, svgLight), fileName: themeFile };
}
// One render, returning the SVG. label only ever appears in messages, so the caller can name
// what it asked for - a theme file, or the built-in default config, which has no file at all.
// backgroundColor is optional (see buildPoshArgs).
async function renderSVG(configPath, label, backgroundColor) {
// A per-run scratch path, like buildDataFileWithTrending's temp data file: the svg only needs
// to exist long enough to be read back, so it lives in the OS temp dir rather than under
// website/, leaving nothing behind for git status to notice.
const outputPath = join(tmpdir(), `omp-theme-${randomUUID()}.svg`);
let stderr;
try {
({ stderr } = await execFileAsync(OMP_BIN, buildPoshArgs(configPath, outputPath, backgroundColor)));
} catch (error) {
// execFileAsync only rejects on a non-zero exit code - a genuine render failure, not
// incidental stderr output. Fail the build loudly instead of silently dropping the render.
throw new Error(`Failed to export ${label}: ${error.message}`);
}
if (stderr) {
// A non-zero exit already threw above, so stderr here is incidental (e.g. the
// hand-written-data-file warning) and must not fail the build.
console.warn(`${label}: ${stderr.trim()}`);
}
try {
return await promises.readFile(outputPath, 'utf8');
} finally {
await promises.unlink(outputPath).catch(() => {});
}
}
async function ensureDirectories() {
// recursive: true is idempotent (no error if the directory already exists), so there is nothing
// an access()-then-mkdir() dance would catch that a bare mkdir() doesn't already handle.
await promises.mkdir(dirname(CONFIG.MANIFEST_FILE), { recursive: true });
}
async function main() {
try {
console.log('Starting theme export process...');
await ensureDirectories();
const committedDataFile = CONFIG.SEGMENT_DATA_FILE;
CONFIG.SEGMENT_DATA_FILE = await buildDataFileWithTrending();
const themes = await promises.readdir(CONFIG.THEMES_CONFIG_DIR);
const validThemes = themes.filter(isValidTheme);
console.log(`Found ${validThemes.length} valid themes to process`);
const resultsMap = new Map();
for await (const result of asyncPool(CONFIG.CONCURRENCY, validThemes, exportTheme)) {
if (result) {
// Use the original filename as the key for efficient sorting
resultsMap.set(result.fileName, result);
}
}
// Sort by filename keys alphabetically - the same order the page has shown
// since the PNG era, preserved here even though the sort key (the source
// config's filename) is no longer part of the emitted manifest entry itself.
const sortedFileNames = Array.from(resultsMap.keys()).sort();
const manifest = sortedFileNames.map((fileName) => resultsMap.get(fileName).entry);
await promises.writeFile(CONFIG.MANIFEST_FILE, JSON.stringify(manifest));
console.log(`Successfully exported ${manifest.length} themes to ${CONFIG.MANIFEST_FILE}`);
const heroSVG = await renderSVG(null, 'the default config');
const heroSVGLight = await renderSVG(null, 'the default config (light)', CONFIG.LIGHT_BACKGROUND);
await promises.writeFile(CONFIG.HERO_FILE, JSON.stringify({ svg: heroSVG, svgLight: heroSVGLight }));
console.log(`Wrote the default config to ${CONFIG.HERO_FILE} for the homepage`);
// buildDataFileWithTrending only returns a path other than the committed file when it
// actually wrote a scratch copy with the trending track baked in (see its own comment); on any
// failure - fetch failed, write failed - it falls back to returning the committed file itself.
// Only clean up the scratch copy: the committed file must never be deleted, and skipping this
// check would do exactly that on every run where trending lookup failed.
if (CONFIG.SEGMENT_DATA_FILE !== committedDataFile) {
await promises.unlink(CONFIG.SEGMENT_DATA_FILE).catch(() => {});
}
} catch (error) {
console.error('Export process failed:', error.message);
process.exit(1);
}
}
// Execute main function if this file is run directly
// In ES modules, we check if import.meta.url matches the process argv
if (process.argv[1] === __filename) {
main();
}
export {
CONFIG,
exportTheme,
isValidTheme,
getThemeNameFromFile,
getThemeFormat,
buildManifestEntry,
asyncPool,
main,
fetchJsonWithTimeout,
trendingFromDeezer,
trendingFromAppleRSS,
isClean,
buildDataFileWithTrending,
};