import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
/**
* ThemeService — Palette and theme management
*
* 8 Built-in Palettes — 1 neutral default + 7 real colours:
* - Klonkt (DEFAULT, clean white/black neutral + gold accent; was 'Paper')
* - Forest (green) · Ocean (blue) · Teal · Lilac (purple)
* - Sunset (pink) · Candy (red) · Amber (warm)
* Colour palettes are tinted (like forest/lilac), not navy-with-accent.
*
* Dark/Light mode toggle stored per user
*/
class ThemeService {
// Paper/ink values map 1-to-1 from the [data-palette] CSS in style.css (= what
// is ACTUALLY applied); the accent dot is a representative color per palette.
static PALETTES = {
// DEFAULT — clean neutral (formerly 'Paper'), renamed to the brand 'Klonkt'.
// White → near-black, with the brand gold as accent. The old navy 'Klonkt' is gone.
klonkt: {
name: 'Klonkt',
light: { paper: '#ffffff', ink: '#09090b', accent: '#c98a2a' },
dark: { paper: '#0a0a0a', ink: '#fafafa', accent: '#e8b04b' }
},
// 7 real-colour palettes (tinted paper, like forest/lilac — NOT navy-with-accent).
forest: {
name: 'Forest',
light: { paper: '#f2f6ed', ink: '#1a2e15', accent: '#4d7c2a' },
dark: { paper: '#0d1f12', ink: '#dcf2d0', accent: '#6fae3f' }
},
ocean: {
name: 'Ocean',
light: { paper: '#eef4fb', ink: '#0f2942', accent: '#1d6fe0' },
dark: { paper: '#081726', ink: '#d6e8fb', accent: '#5ba0f5' }
},
teal: {
name: 'Teal',
light: { paper: '#ecf7f5', ink: '#0c2e2a', accent: '#0d9488' },
dark: { paper: '#06201d', ink: '#d4f2ec', accent: '#2dd4bf' }
},
lilac: {
name: 'Lilac',
light: { paper: '#faf4fb', ink: '#2a1830', accent: '#a855f7' },
dark: { paper: '#170a1c', ink: '#f3e2f7', accent: '#c084fc' }
},
sunset: {
name: 'Sunset',
light: { paper: '#fdf4f3', ink: '#2e1618', accent: '#d6477f' },
dark: { paper: '#1f0a14', ink: '#fce7f3', accent: '#f06fa3' }
},
candy: {
name: 'Candy',
light: { paper: '#fdf1f3', ink: '#3a1018', accent: '#e11d48' },
dark: { paper: '#220810', ink: '#fde0e6', accent: '#fb6f8b' }
},
amber: {
name: 'Amber',
light: { paper: '#fdf6e9', ink: '#3a2a0c', accent: '#d97706' },
dark: { paper: '#221a08', ink: '#fdeecb', accent: '#f0a93a' }
}
};
/**
* Curated accent palette — admins pick one of these instead of a free-form
* hex picker. Keeps the brand consistent and avoids unreadable combinations.
* Each color works against both light and dark themes.
*/
// Balanced across the color wheel — fewer greens/blues (4 of 12),
// more warm + purple/pink variation. All readable on both light and dark.
static ACCENTS = [
{ key: 'klonkt', name: 'Klonkt-geel', color: '#e8b04b' },
{ key: 'red', name: 'Candy-rood', color: '#e11d48' },
{ key: 'amber', name: 'Amber', color: '#d97706' },
{ key: 'forest', name: 'Groen', color: '#16a34a' },
{ key: 'teal', name: 'Turquoise', color: '#0d9488' },
{ key: 'ocean', name: 'Blauw', color: '#2563eb' },
{ key: 'indigo', name: 'Indigo', color: '#4f46e5' },
{ key: 'violet', name: 'Violet', color: '#7c3aed' },
{ key: 'plum', name: 'Magenta', color: '#c026d3' },
{ key: 'pink', name: 'Roze', color: '#db2777' },
];
static listAccents() {
return this.ACCENTS;
}
/**
* Validate an accent color. Returns the canonical hex if it's in the curated
* set (case-insensitive match), or null if it's not. Server-side validation
* uses this so we never persist arbitrary hex from the form.
*/
static validateAccent(hex) {
if (!hex || typeof hex !== 'string') return null;
const target = hex.trim().toLowerCase();
const found = this.ACCENTS.find(a => a.color.toLowerCase() === target);
return found ? found.color : null;
}
/**
* Get palette data
*/
static getPalette(paletteKey) {
return this.PALETTES[paletteKey] || this.PALETTES.klonkt;
}
/**
* Get all available palettes (with full color data so a picker UI can
* render true previews of paper/ink/accent for both light and dark).
*/
static listPalettes() {
return Object.entries(this.PALETTES).map(([key, data]) => ({
key,
name: data.name,
light: data.light,
dark: data.dark,
}));
}
/**
* Generate CSS variables for palette + theme
*/
static generateCSSVars(paletteKey, theme = 'dark', accentColor = null) {
const palette = this.getPalette(paletteKey);
const colors = theme === 'dark' ? palette.dark : palette.light;
const accent = accentColor || colors.accent;
return `
:root {
--palette: ${paletteKey};
--theme: ${theme};
--paper: ${colors.paper};
--ink: ${colors.ink};
--accent: ${accent};
}
`.trim();
}
/**
* Update user's theme preference
*/
static updateUserTheme(db, userId, theme, palette) {
if (!['dark', 'light'].includes(theme)) {
throw new Error('Invalid theme. Must be "dark" or "light"');
}
if (!this.PALETTES[palette]) {
throw new Error('Invalid palette');
}
db.prepare(`
UPDATE users SET theme = ?, palette = ? WHERE id = ?
`).run(theme, palette, userId);
}
/**
* Update site's palette + accent
*/
static updateSitePalette(db, siteId, paletteKey, accentColor) {
if (!this.PALETTES[paletteKey]) {
throw new Error('Invalid palette');
}
if (!/^#[0-9a-f]{6}$/i.test(accentColor)) {
throw new Error('Invalid accent color. Must be hex #RRGGBB');
}
db.prepare(`
UPDATE sites SET palette = ?, accent = ? WHERE id = ?
`).run(paletteKey, accentColor, siteId);
}
/**
* Generate full HTML theme meta tags
*/
static generateThemeMeta(userTheme, userPalette, siteTheme, siteAccent) {
const theme = userTheme || siteTheme || 'dark';
const palette = userPalette || 'klonkt';
const accent = siteAccent || '#e8b04b';
const paletteData = this.getPalette(palette);
const colors = theme === 'dark' ? paletteData.dark : paletteData.light;
return {
colorScheme: 'dark light',
themeColor: accent,
appleMobileWebAppStatusBarStyle: 'black-translucent',
cssVars: this.generateCSSVars(palette, theme, accent),
inline: `
`.trim()
};
}
// ── Lay-outthema's (experimental/theme, op weg naar 2.0) ─────────────
//
// Een lay-out is een PAKKET: een map met een theme.json en een stylesheet.
// De ingebouwde staan in src/themes//; een thema van buiten installeren
// komt later en gaat door dezelfde lader, zodat er één vorm is.
//
// Wie kiest: de BEHEERDER van een site (sites.layout). Een bezoeker niet --
// `?layout=` is alleen een voorproefje voor wie de site beheert.
/** Het DOM-contract dat thema's mogen aanspreken. Gaat omhoog zodra de
* markup verandert op een manier die thema's breekt; een thema op een ander
* contract valt dan terug op de standaardlay-out in plaats van half te
* werken. */
static LAYOUT_CONTRACT = 1;
static THEMES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'themes');
/** Een id is een naam en geen pad. Kort, kleine letters, punten en
* streepjes, zodat er niets anders van te maken is dan een mapnaam. */
static isLayoutId(id) {
return typeof id === 'string' && /^[a-z0-9][a-z0-9.-]{0,63}$/.test(id) && !id.includes('..');
}
/** Een theme.json die klopt, of null -- met de reden in de log, want een
* thema dat stil wegvalt is een raadsel voor wie het installeerde. */
static validateLayoutManifest(m, dirName) {
const fout = (waarom) => { console.warn(`[theme] ${dirName || '?'}: ${waarom}`); return null; };
if (!m || typeof m !== 'object') return fout('geen geldige theme.json');
if (!ThemeService.isLayoutId(m.id)) return fout(`ongeldig id ${JSON.stringify(m.id)}`);
if (dirName && m.id !== dirName) return fout(`id ${m.id} hoort in een map met die naam`);
if (typeof m.name !== 'string' || !m.name.trim()) return fout('naam ontbreekt');
if (typeof m.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(m.version)) return fout('versie is geen x.y.z');
if (m.contract !== ThemeService.LAYOUT_CONTRACT) return fout(`contract ${m.contract}, deze Klonkt kent ${ThemeService.LAYOUT_CONTRACT}`);
if (typeof m.stylesheet !== 'string' || !/^[a-z0-9][a-z0-9._-]*\.css$/i.test(m.stylesheet)) return fout('stylesheet ontbreekt of is geen bestandsnaam');
return {
id: m.id,
name: m.name.trim().slice(0, 80),
description: typeof m.description === 'string' ? m.description.slice(0, 500) : '',
version: m.version,
contract: m.contract,
stylesheet: m.stylesheet,
author: typeof m.author === 'string' ? m.author.slice(0, 200) : '',
license: typeof m.license === 'string' ? m.license.slice(0, 64) : '',
builtIn: true,
};
}
static _layouts = null;
/** Alle lay-outs die deze Klonkt kent, op naam. Eén keer gelezen per proces:
* een pakket verandert niet terwijl de server draait, en de shell vraagt
* hier bij elk verzoek om. */
static listLayouts() {
if (ThemeService._layouts) return ThemeService._layouts;
const uit = [];
let mappen = [];
try { mappen = fs.readdirSync(ThemeService.THEMES_DIR, { withFileTypes: true }); } catch { mappen = []; }
for (const d of mappen) {
if (!d.isDirectory() || !ThemeService.isLayoutId(d.name)) continue;
const dir = path.join(ThemeService.THEMES_DIR, d.name);
let ruw;
try { ruw = JSON.parse(fs.readFileSync(path.join(dir, 'theme.json'), 'utf8')); } catch { console.warn(`[theme] ${d.name}: theme.json onleesbaar`); continue; }
const m = ThemeService.validateLayoutManifest(ruw, d.name);
if (!m) continue;
if (!fs.existsSync(path.join(dir, m.stylesheet))) { console.warn(`[theme] ${d.name}: ${m.stylesheet} ontbreekt`); continue; }
uit.push(m);
}
uit.sort((a, b) => a.name.localeCompare(b.name));
ThemeService._layouts = uit;
return uit;
}
static getLayout(id) {
if (!ThemeService.isLayoutId(id)) return null;
return ThemeService.listLayouts().find((m) => m.id === id) || null;
}
/** Waar een thema zijn bestanden laat zien. De versie zit in het pad, dus
* een nieuwe versie is een nieuwe URL en de cache van een jaar kan blijven. */
static layoutBaseUrl(m) { return `/themes/${m.id}/${m.version}`; }
static layoutStylesheetUrl(m) { return `${ThemeService.layoutBaseUrl(m)}/${m.stylesheet}`; }
static layoutManifestUrl(m) { return `${ThemeService.layoutBaseUrl(m)}/theme.json`; }
/**
* Welke lay-out deze pagina draagt, of null voor de gewone kop.
*
* - Beheerpagina's ALTIJD de gewone kop: een kapot thema mag de beheerder
* nooit buitensluiten van de instelling waarmee hij het uitzet.
* - `preview` (uit ?layout=) alleen voor wie de site beheert. Een bezoeker
* kiest niet; de site kiest.
* - Anders wat de site gekozen heeft.
*/
static resolveLayout(site, { isAdminPage = false, canManage = false, preview = null } = {}) {
if (isAdminPage) return null;
if (canManage && typeof preview === 'string') {
if (preview === 'top') return null;
const p = ThemeService.getLayout(preview);
if (p) return p;
}
return site && site.layout ? ThemeService.getLayout(site.layout) : null;
}
/** Het bestand achter /themes///, of null. Alleen wat het
* pakket zelf noemt -- de stylesheet en de theme.json -- en alleen van de
* versie die er nu is. Geen pad uit de URL komt ooit in een join terecht. */
static layoutFile(id, version, file) {
const m = ThemeService.getLayout(id);
if (!m || version !== m.version) return null;
if (file !== m.stylesheet && file !== 'theme.json') return null;
return path.join(ThemeService.THEMES_DIR, m.id, file);
}
/** Wat de actor erover zegt (shaer:theme), zodat Shaer het thema van zijn
* eigen site kan ophalen in plaats van er zelf een te kiezen. */
static layoutActorProp(base, site) {
const m = site && site.layout ? ThemeService.getLayout(site.layout) : null;
if (!m) return {};
return {
'shaer:theme': {
type: 'shaer:Theme',
id: `${base}${ThemeService.layoutManifestUrl(m)}`,
name: m.name,
'shaer:version': m.version,
'shaer:contract': m.contract,
},
};
}
}
export default ThemeService;