Changeset 495b019 in Klonkt for src/services


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@…>

Location:
src/services
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r5d0d3f35 r495b019  
    2020import path from 'path';
    2121import db, { NU_ISO, isoSql } from '../config/database.js';
     22import ThemeService from './ThemeService.js';
    2223import HtmlSanitizerService from './HtmlSanitizerService.js';
    2324import AudioEmbedService from './AudioEmbedService.js';
    … …  
    385386    // (guardianship module owns these).
    386387    ...Guardianship.guardianshipActorProps(id, site.slug),
     388    // Het thema dat de beheerder koos (experimental/theme). Shaer haalt het hier
     389    // op in plaats van er zelf een te kiezen: één keuze kleedt de site en de app.
     390    // Alleen als er een gekozen is; zonder staat er niets en houdt elke client
     391    // zijn eigen standaard.
     392    ...ThemeService.layoutActorProp(base, site),
    387393    // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
    388394    // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
  • 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
  • src/services/i18n.js

    r5d0d3f35 r495b019  
    289289    'asite.theme_light': 'Licht',
    290290    'asite.theme_dark': 'Donker',
     291    'asite.layout': 'Lay-out',
     292    'asite.layout_top': 'Gewone kop',
     293    'asite.layout_hint': 'Hoe de site eruitziet op een breed scherm. Bezoekers kiezen niet; deze keuze geldt voor iedereen. Het beheer houdt altijd de gewone kop.',
    291294    'asite.palette': 'Palet',
    292295    'asite.behavior': 'Gedrag',
    … …  
    12991302    'asite.theme_light': 'Light',
    13001303    'asite.theme_dark': 'Dark',
     1304    'asite.layout': 'Layout',
     1305    'asite.layout_top': 'Top bar',
     1306    'asite.layout_hint': 'How the site looks on a wide screen. Visitors do not choose; this applies to everyone. The admin always keeps the top bar.',
    13011307    'asite.palette': 'Palette',
    13021308    'asite.behavior': 'Behavior',
    … …  
    23082314    'asite.theme_light': 'Hell',
    23092315    'asite.theme_dark': 'Dunkel',
     2316    'asite.layout': 'Layout',
     2317    'asite.layout_top': 'Kopfleiste',
     2318    'asite.layout_hint': 'Wie die Seite auf einem breiten Bildschirm aussieht. Besucher wählen nicht; dies gilt für alle. Die Verwaltung behält immer die Kopfleiste.',
    23102319    'asite.palette': 'Palette',
    23112320    'asite.behavior': 'Verhalten',
Note: See TracChangeset for help on using the changeset viewer.