Files
platform/src/servers/themes/discover.ts
T
pastilhasandClaude Opus 5 0d29e242a3 themes are installable, like plugins
`platform/themes/<id>/` — untracked, same rule as `plugins/`, only the instructions
tracked. Drop a directory in and the theme appears in the picker on the next page load.

  themes/my-theme/theme.css     required — `[data-theme='my-theme'] { … }`
  themes/my-theme/theme.json    required — { name, family, dark }
  themes/my-theme/assets/       optional — wallpaper, logo

── Why this is cheaper than installing a plugin ──

A plugin is code: it needs a manifest module, mounting, and a frontend rebuild to reach
the bundler. A theme is a block of CSS custom properties, so it needs none of that. The
server reads the directory at request time and the client adds a `<link>`. No install
step, no rebuild, no restart — which is the whole reason this is eighty lines of
discovery rather than four hundred.

The id is the DIRECTORY NAME, not a manifest field, for the same reason a plugin's
`appName` is not one: an id that can disagree with where its files sit eventually will.
Discovery checks that the CSS actually targets its own directory name, because the
failure is otherwise silent — the theme appears, is selected, and nothing changes.

── Problems are reported, not swallowed ──

A malformed theme returns a reason rather than vanishing:

  broken-probe: theme.css does not target [data-theme='broken-probe']

The client logs those, so an author has somewhere to look instead of wondering why
their theme never appeared.

── Boundaries kept ──

The `theme` package still performs no IO. It gained an `extraThemes` prop; Officer
fetches and injects, exactly as it supplies the panel registry to `workspaces`. A
failure to fetch is not an error — no server, no session, no directory all mean "you get
the built-in themes", which is a complete product.

`/api/themes` is a `core` capability: read-only, and not deniable, because an account
that cannot read the theme list renders unstyled. Installing remains a directory on
disk; an upload endpoint would be a way to write arbitrary CSS into the page from a
request.

Verified in-process: listing, the problem report, CSS served as text/css, and a
traversal attempt answered 404.

── Needs a restart ──

Server-side change. Until then the client's fetch 404s and silently keeps the built-ins,
so nothing breaks in the meantime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 01:52:21 +00:00

129 lines
4.9 KiB
TypeScript

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/<id>/theme.css required — `[data-theme='<id>'] { … }`
* themes/<id>/theme.json required — the name a person sees, and how to group it
* themes/<id>/assets/ optional — a wallpaper, a logo; served at /themes/<id>/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;
}
}