import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { PLATFORM_DIR } from '../data-path'; /** * Themes installed on this machine, as opposed to the ones the platform ships. * * ── Why this is simpler than plugin discovery ── * * A plugin is code: it has a manifest module, routers, tables, a sidecar, panels, and the platform has to * import and mount it. A theme is a block of CSS custom properties. Nothing to import, nothing to mount, * nothing to bundle — which is why an installed theme takes effect without a frontend rebuild, and why * this file is eighty lines rather than four hundred. * * Discovery is by CONVENTION, the same as plugins. Presence is the declaration: * * themes//theme.css required — `[data-theme=''] { … }` * themes//theme.json required — the name a person sees, and how to group it * themes//assets/ optional — a wallpaper, a logo; served at /themes//assets/… * * The id is the DIRECTORY NAME and is not declared in the manifest, for the reason `appName` is not * declared in a plugin's: an id that can disagree with where the files sit eventually will. * * ── What this does not do ── * * It answers "what is on disk". It does not read the database, validate colour values, or care whether a * theme is any good. A theme whose CSS is nonsense will simply look wrong, which is the author's business * and is visible immediately. */ /** Where installed themes live. Sibling of `plugins/`, and gitignored the same way. */ export const THEMES_DIR = join(PLATFORM_DIR, 'themes'); export type InstalledTheme = { /** The directory name. Also the `data-theme` value its CSS must target. */ id: string; name: string; family: string; /** * Whether to put `.dark` on the document alongside `data-theme`. * * A compatibility flag, not a description: several hundred `dark:` utilities are still written into * components and resolve against that class. A dark theme wants it. */ dark: boolean; }; export type ThemeProblem = { id: string; problem: string }; const isDir = (path: string): boolean => { try { return statSync(path).isDirectory(); } catch { return false; } }; /** * Read one directory as a theme, or say why it is not one. * * Returns problems rather than throwing, and never stops the scan: one malformed theme must not hide the * eleven good ones next to it. The problems are reported so the app store can render them, which is the * same posture `discoverPlugins` takes. */ function readTheme(id: string): InstalledTheme | ThemeProblem { const dir = join(THEMES_DIR, id); const cssPath = join(dir, 'theme.css'); const manifestPath = join(dir, 'theme.json'); if (!existsSync(cssPath)) return { id, problem: 'no theme.css' }; if (!existsSync(manifestPath)) return { id, problem: 'no theme.json' }; let parsed: unknown; try { parsed = JSON.parse(readFileSync(manifestPath, 'utf-8')); } catch (err) { return { id, problem: `theme.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }; } const manifest = parsed as { name?: unknown; family?: unknown; dark?: unknown }; if (typeof manifest.name !== 'string' || !manifest.name.trim()) return { id, problem: 'theme.json needs a "name"' }; /* The CSS must target its own directory name. Checked because the failure is otherwise silent and baffling: the theme appears in the picker, is selected, and nothing changes — because its rules are scoped to a `data-theme` value nobody sets. */ const css = readFileSync(cssPath, 'utf-8'); if (!css.includes(`[data-theme='${id}']`) && !css.includes(`[data-theme="${id}"]`)) { return { id, problem: `theme.css does not target [data-theme='${id}'] — it must match the directory name` }; } return { id, name: manifest.name, family: typeof manifest.family === 'string' && manifest.family.trim() ? manifest.family : manifest.name, dark: manifest.dark === true, }; } export function discoverThemes(): { themes: InstalledTheme[]; problems: ThemeProblem[] } { if (!existsSync(THEMES_DIR)) return { themes: [], problems: [] }; const themes: InstalledTheme[] = []; const problems: ThemeProblem[] = []; for (const id of readdirSync(THEMES_DIR).sort()) { if (id.startsWith('.') || !isDir(join(THEMES_DIR, id))) continue; const result = readTheme(id); if ('problem' in result) problems.push(result); else themes.push(result); } return { themes, problems }; } /** The CSS of one installed theme, or null. `id` is validated, so it cannot climb out of the directory. */ export function readThemeCss(id: string): string | null { if (!/^[a-z0-9][a-z0-9-]*$/i.test(id)) return null; const path = join(THEMES_DIR, id, 'theme.css'); if (!existsSync(path)) return null; try { return readFileSync(path, 'utf-8'); } catch { return null; } }