Changeset 495b019 in Klonkt for src/services/ThemeService.js


Ignore:
Timestamp:
10/25/2026 09:52:01 AM (2 days ago)
Author:
Bart <bart@…>
Children:
b4aaadd
Parents:
5d0d3f35
git-author:
Bart <bart@…> (10/14/2026 02:48:46 PM)
git-committer:
Bart <bart@…> (10/25/2026 09:52:01 AM)
Message:

Lay-outthema's: een pakket, de beheerder kiest, en de zijbalk als eerste

Een lay-out is nu een pakket: een map in src/themes/ met een theme.json (id,
naam, versie, contract, stylesheet) en de stylesheet zelf. De zijbalk is het
eerste ingebouwde thema; een thema van buiten installeren komt later en gaat
door dezelfde lader, zodat er één vorm is.

Dezelfde markup, een ander stelsel: een thema verandert geen template. Het
contract (1) is de afspraak over de ids en klassen waar het op mag bouwen; een
thema op een ander contract valt terug op de gewone kop in plaats van half te
werken. Elke regel staat onder html[data-layout="<id>"], en een toets bewaakt
dat, zodat een thema niets raakt zolang het niet gekozen is.

WIE KIEST: de beheerder, in het sitebeheer (sites.layout). Een bezoeker niet
-- de server zet data-layout zelf, het bezoekersscript met localStorage is
weg, en ?layout= werkt alleen als voorproefje voor wie de site beheert. Het
beheer houdt altijd de gewone kop, ook bij navigeren via htmx (de lay-out reist
mee in pcmsNav): een kapot thema mag je nooit buitensluiten van de instelling
waarmee je het uitzet.

Geserveerd op /themes/<id>/<versie>/<bestand>, met de versie in het pad als
cache-buster; alleen wat het pakket zelf noemt komt eruit. De actor noemt het
gekozen thema als shaer:theme, zodat Shaer het van zijn eigen site kan halen
in plaats van er zelf een te kiezen.

Co-Authored-By: Claude Opus 5.5 <claude@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ThemeService.js

    r5d0d3f35 r495b019  
     1import fs from 'fs';
     2import path from 'path';
     3import { fileURLToPath } from 'url';
     4
    15/**
    26 * ThemeService — Palette and theme management
    … …  
    207211    };
    208212  }
     213
     214  // ── Lay-outthema's (experimental/theme, op weg naar 2.0) ─────────────
     215  //
     216  // Een lay-out is een PAKKET: een map met een theme.json en een stylesheet.
     217  // De ingebouwde staan in src/themes/<id>/; een thema van buiten installeren
     218  // komt later en gaat door dezelfde lader, zodat er één vorm is.
     219  //
     220  // Wie kiest: de BEHEERDER van een site (sites.layout). Een bezoeker niet --
     221  // `?layout=` is alleen een voorproefje voor wie de site beheert.
     222
     223  /** Het DOM-contract dat thema's mogen aanspreken. Gaat omhoog zodra de
     224   *  markup verandert op een manier die thema's breekt; een thema op een ander
     225   *  contract valt dan terug op de standaardlay-out in plaats van half te
     226   *  werken. */
     227  static LAYOUT_CONTRACT = 1;
     228
     229  static THEMES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'themes');
     230
     231  /** Een id is een naam en geen pad. Kort, kleine letters, punten en
     232   *  streepjes, zodat er niets anders van te maken is dan een mapnaam. */
     233  static isLayoutId(id) {
     234    return typeof id === 'string' && /^[a-z0-9][a-z0-9.-]{0,63}$/.test(id) && !id.includes('..');
     235  }
     236
     237  /** Een theme.json die klopt, of null -- met de reden in de log, want een
     238   *  thema dat stil wegvalt is een raadsel voor wie het installeerde. */
     239  static validateLayoutManifest(m, dirName) {
     240    const fout = (waarom) => { console.warn(`[theme] ${dirName || '?'}: ${waarom}`); return null; };
     241    if (!m || typeof m !== 'object') return fout('geen geldige theme.json');
     242    if (!ThemeService.isLayoutId(m.id)) return fout(`ongeldig id ${JSON.stringify(m.id)}`);
     243    if (dirName && m.id !== dirName) return fout(`id ${m.id} hoort in een map met die naam`);
     244    if (typeof m.name !== 'string' || !m.name.trim()) return fout('naam ontbreekt');
     245    if (typeof m.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(m.version)) return fout('versie is geen x.y.z');
     246    if (m.contract !== ThemeService.LAYOUT_CONTRACT) return fout(`contract ${m.contract}, deze Klonkt kent ${ThemeService.LAYOUT_CONTRACT}`);
     247    if (typeof m.stylesheet !== 'string' || !/^[a-z0-9][a-z0-9._-]*\.css$/i.test(m.stylesheet)) return fout('stylesheet ontbreekt of is geen bestandsnaam');
     248    return {
     249      id: m.id,
     250      name: m.name.trim().slice(0, 80),
     251      description: typeof m.description === 'string' ? m.description.slice(0, 500) : '',
     252      version: m.version,
     253      contract: m.contract,
     254      stylesheet: m.stylesheet,
     255      author: typeof m.author === 'string' ? m.author.slice(0, 200) : '',
     256      license: typeof m.license === 'string' ? m.license.slice(0, 64) : '',
     257      builtIn: true,
     258    };
     259  }
     260
     261  static _layouts = null;
     262
     263  /** Alle lay-outs die deze Klonkt kent, op naam. Eén keer gelezen per proces:
     264   *  een pakket verandert niet terwijl de server draait, en de shell vraagt
     265   *  hier bij elk verzoek om. */
     266  static listLayouts() {
     267    if (ThemeService._layouts) return ThemeService._layouts;
     268    const uit = [];
     269    let mappen = [];
     270    try { mappen = fs.readdirSync(ThemeService.THEMES_DIR, { withFileTypes: true }); } catch { mappen = []; }
     271    for (const d of mappen) {
     272      if (!d.isDirectory() || !ThemeService.isLayoutId(d.name)) continue;
     273      const dir = path.join(ThemeService.THEMES_DIR, d.name);
     274      let ruw;
     275      try { ruw = JSON.parse(fs.readFileSync(path.join(dir, 'theme.json'), 'utf8')); } catch { console.warn(`[theme] ${d.name}: theme.json onleesbaar`); continue; }
     276      const m = ThemeService.validateLayoutManifest(ruw, d.name);
     277      if (!m) continue;
     278      if (!fs.existsSync(path.join(dir, m.stylesheet))) { console.warn(`[theme] ${d.name}: ${m.stylesheet} ontbreekt`); continue; }
     279      uit.push(m);
     280    }
     281    uit.sort((a, b) => a.name.localeCompare(b.name));
     282    ThemeService._layouts = uit;
     283    return uit;
     284  }
     285
     286  static getLayout(id) {
     287    if (!ThemeService.isLayoutId(id)) return null;
     288    return ThemeService.listLayouts().find((m) => m.id === id) || null;
     289  }
     290
     291  /** Waar een thema zijn bestanden laat zien. De versie zit in het pad, dus
     292   *  een nieuwe versie is een nieuwe URL en de cache van een jaar kan blijven. */
     293  static layoutBaseUrl(m) { return `/themes/${m.id}/${m.version}`; }
     294  static layoutStylesheetUrl(m) { return `${ThemeService.layoutBaseUrl(m)}/${m.stylesheet}`; }
     295  static layoutManifestUrl(m) { return `${ThemeService.layoutBaseUrl(m)}/theme.json`; }
     296
     297  /**
     298   * Welke lay-out deze pagina draagt, of null voor de gewone kop.
     299   *
     300   *   - Beheerpagina's ALTIJD de gewone kop: een kapot thema mag de beheerder
     301   *     nooit buitensluiten van de instelling waarmee hij het uitzet.
     302   *   - `preview` (uit ?layout=) alleen voor wie de site beheert. Een bezoeker
     303   *     kiest niet; de site kiest.
     304   *   - Anders wat de site gekozen heeft.
     305   */
     306  static resolveLayout(site, { isAdminPage = false, canManage = false, preview = null } = {}) {
     307    if (isAdminPage) return null;
     308    if (canManage && typeof preview === 'string') {
     309      if (preview === 'top') return null;
     310      const p = ThemeService.getLayout(preview);
     311      if (p) return p;
     312    }
     313    return site && site.layout ? ThemeService.getLayout(site.layout) : null;
     314  }
     315
     316  /** Het bestand achter /themes/<id>/<versie>/<naam>, of null. Alleen wat het
     317   *  pakket zelf noemt -- de stylesheet en de theme.json -- en alleen van de
     318   *  versie die er nu is. Geen pad uit de URL komt ooit in een join terecht. */
     319  static layoutFile(id, version, file) {
     320    const m = ThemeService.getLayout(id);
     321    if (!m || version !== m.version) return null;
     322    if (file !== m.stylesheet && file !== 'theme.json') return null;
     323    return path.join(ThemeService.THEMES_DIR, m.id, file);
     324  }
     325
     326  /** Wat de actor erover zegt (shaer:theme), zodat Shaer het thema van zijn
     327   *  eigen site kan ophalen in plaats van er zelf een te kiezen. */
     328  static layoutActorProp(base, site) {
     329    const m = site && site.layout ? ThemeService.getLayout(site.layout) : null;
     330    if (!m) return {};
     331    return {
     332      'shaer:theme': {
     333        type: 'shaer:Theme',
     334        id: `${base}${ThemeService.layoutManifestUrl(m)}`,
     335        name: m.name,
     336        'shaer:version': m.version,
     337        'shaer:contract': m.contract,
     338      },
     339    };
     340  }
    209341}
    210342
Note: See TracChangeset for help on using the changeset viewer.