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>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { discoverThemes, readThemeCss } from '../../themes/discover';
|
||||
|
||||
/**
|
||||
* Installed themes — the ones in `platform/themes/`, not the ones the platform ships.
|
||||
*
|
||||
* The shipped themes are compiled into the bundle and need no server. These are read off disk at request
|
||||
* time, so dropping a directory in makes a theme appear on the next page load. No install step, no
|
||||
* rebuild, no restart — a theme is CSS custom properties, and none of that needs compiling.
|
||||
*
|
||||
* Read-only on purpose. Installing a theme is `git clone` into a directory, exactly as it is for plugins;
|
||||
* an upload endpoint would be a way to write arbitrary CSS into the page from a request, which is a worse
|
||||
* trade than a shell command.
|
||||
*/
|
||||
export const themesRouter = createRouter();
|
||||
|
||||
// GET /themes — what is installed, and what is nearly installed.
|
||||
//
|
||||
// `problems` is returned rather than swallowed: a theme with a typo in its JSON otherwise just fails to
|
||||
// appear, and the author has nowhere to look. The app store can render these.
|
||||
themesRouter.get('/', async (ctx: Context) => {
|
||||
const { themes, problems } = discoverThemes();
|
||||
return ctx.json({ themes, problems });
|
||||
});
|
||||
|
||||
// GET /themes/:id/theme.css — the stylesheet, injected by the client as a <link>.
|
||||
//
|
||||
// Served as CSS rather than embedded in the JSON above so the browser caches it, and so a theme with a
|
||||
// syntax error breaks only itself rather than the payload that lists every theme.
|
||||
themesRouter.get('/:id/theme.css', async (ctx: Context) => {
|
||||
const css = readThemeCss(ctx.req.param('id'));
|
||||
if (css === null) return ctx.text('/* no such theme */', 404, { 'content-type': 'text/css; charset=utf-8' });
|
||||
|
||||
return ctx.body(css, 200, {
|
||||
'content-type': 'text/css; charset=utf-8',
|
||||
// Short, because editing a theme and reloading is the whole authoring loop. Long enough that a page
|
||||
// load does not refetch fourteen stylesheets.
|
||||
'cache-control': 'public, max-age=60',
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { HonoVariables } from './create-router';
|
||||
import { authRouter } from './api/auth';
|
||||
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
||||
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||
import { themesRouter } from './api/themes/themes';
|
||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { apiKeysRouter } from './api/api-keys/router';
|
||||
@@ -198,6 +199,7 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
|
||||
app.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
||||
app.route('/api/auth', authRouter);
|
||||
app.route('/api/landing-page-data', landingPageDataRouter);
|
||||
app.route('/api/themes', themesRouter);
|
||||
app.route('/api/waitlist', waitlistRouter);
|
||||
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
|
||||
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
|
||||
|
||||
@@ -123,6 +123,17 @@ const CORE_REGISTRY: Permission[] = [
|
||||
api: ['/user', '/dock', '/api-keys'],
|
||||
routes: ['/settings/profile'],
|
||||
},
|
||||
{
|
||||
key: 'themes',
|
||||
label: 'Themes',
|
||||
description: 'Read the themes installed on this machine',
|
||||
// `core`, and not deniable. Themes are how the interface looks; an account that could not read the
|
||||
// list would render unstyled, which is not a permission decision anyone means to make. It is read-only
|
||||
// — installing a theme is a directory on disk, not a request.
|
||||
kind: 'core',
|
||||
api: ['/themes'],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
key: 'bug-report',
|
||||
label: 'Report a problem',
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user