Changeset 495b019 in Klonkt


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

Files:
3 added
9 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r5d0d3f35 r495b019  
    249249  // elke byte van de canonieke vorm, dus herschrijven zou hem breken.
    250250  ensureColumn('sites', 'identity_proofs', 'TEXT');
     251  // De lay-out die de beheerder koos (experimental/theme): het id van een
     252  // thema in src/themes/, of leeg voor de gewone kop. Geen bezoekerskeuze.
     253  ensureColumn('sites', 'layout', 'TEXT');
    251254  ensureColumn('sites', 'show_search',     'INTEGER DEFAULT 1');
    252255  ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
  • src/middleware/render.js

    r5d0d3f35 r495b019  
    2121import { emojiHtml, emojiName, parseQuote } from '../services/NoteRender.js';
    2222import ActivityPubService from '../services/ActivityPubService.js';
     23import ThemeService from '../services/ThemeService.js';
    2324import { imgProxyUrl } from '../services/ThumbnailService.js';
    2425import { audioEnabled as audioFeatureEnabled } from '../config/features.js';
    … …  
    187188    defaultLang: getSetting('default_lang'),
    188189  });
     190
     191  // Lay-out (experimental/theme): wie de site beheert, mag met ?layout= een
     192  // ander thema voorproeven; een bezoeker kiest niets.
     193  const _canManageSite = !!(_u && (_role === 'god' || _role === 'admin' || userOwnsSite));
     194  const _isAdminPage = String(viewName || '').startsWith('pages/admin')
     195    || String(data.bodyClass || '').split(/\s+/).includes('on-admin');
     196  const _preview = typeof req.query.layout === 'string' ? req.query.layout : null;
     197  const _layout = ThemeService.resolveLayout(_site, { isAdminPage: _isAdminPage, canManage: _canManageSite, preview: _preview });
     198  const _siteLayout = _site && _site.layout ? ThemeService.getLayout(_site.layout) : null;
     199  const _previewLayout = (_canManageSite && _preview) ? ThemeService.getLayout(_preview) : null;
     200  const _layoutSheets = [...new Set([_siteLayout, _previewLayout].filter(Boolean)
     201    .map((m) => ThemeService.layoutStylesheetUrl(m)))];
    189202
    190203  // Common locals
    … …  
    252265    appVersion: APP_VERSION,
    253266    bodyClass: data.bodyClass || 'on-home',
     267    // De lay-out van deze pagina (experimental/theme). null = de gewone kop.
     268    // Beheerpagina's altijd de gewone kop; ?layout= alleen voor wie de site
     269    // beheert (zie ThemeService.resolveLayout).
     270    layoutTheme: _layout ? { id: _layout.id, name: _layout.name } : null,
     271    // De stylesheets die mee moeten: die van de site en, bij een voorproefje,
     272    // die van het voorproefje. Ze staan er ook op een pagina die zelf geen
     273    // lay-out draagt (beheer): hun regels gelden alleen onder data-layout, en
     274    // wie vanuit het beheer naar de site navigeert heeft ze dan al.
     275    layoutSheets: _layoutSheets,
    254276    // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op
    255277    // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.
    … …  
    299321      const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
    300322      const triggerJson = JSON.stringify({
    301         pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
     323        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs, layout: locals.layoutTheme ? locals.layoutTheme.id : 'top' },
    302324        pcmsPostSwap: data.post ? {
    303325          title: data.post.title,
  • src/routes/admin-sites.js

    r5d0d3f35 r495b019  
    191191    users: listOwnerCandidates(),
    192192    palettes: ThemeService.listPalettes(),
     193    layouts: ThemeService.listLayouts(),
    193194    accents: ThemeService.listAccents(),
    194195    platforms: listPlatforms(),
    … …  
    268269    users: listOwnerCandidates(),
    269270    palettes: ThemeService.listPalettes(),
     271    layouts: ThemeService.listLayouts(),
    270272    accents: ThemeService.listAccents(),
    271273    platforms: listPlatforms(),
    … …  
    334336  // "Auto" — defer to user's prefers-color-scheme on first paint.
    335337  const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
     338  // layout: alleen een thema dat deze Klonkt kent (experimental/theme). Leeg
     339  // is de gewone kop. Een onbekend id opslaan zou een site maken die naar
     340  // niets verwijst, en die valt dan stil terug -- liever meteen leeg.
     341  const layout = ThemeService.getLayout(f.layout) ? f.layout : null;
    336342
    337343  // accent: only accept colors from the curated ACCENTS list. Falls back to
    … …  
    342348    UPDATE sites SET
    343349      title = ?, description = ?, tagline = ?, language = ?,
    344       palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
     350      palette = ?, accent = ?, theme_override = ?, layout = ?, profile_photo = ?,
    345351      profile_enabled = ?,
    346352      ap_aliases = ?,
    … …  
    361367    accent,
    362368    themeOverride,
     369    layout,
    363370    f.profile_photo || null,
    364371    f.profile_enabled ? 1 : 0,
  • src/server.js

    r5d0d3f35 r495b019  
    1717import http from 'http';
    1818import db, { initializeDatabase } from './config/database.js';
     19import ThemeService from './services/ThemeService.js';
    1920import { startScheduler } from './services/Scheduler.js';
    2021import { SqliteSessionStore } from './services/SqliteSessionStore.js';
    … …  
    238239
    239240app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
     241// Lay-outthema's (experimental/theme). De versie staat in het pad, dus een
     242// nieuwe versie is een nieuwe URL en deze cache van een jaar klopt altijd --
     243// geen ?v= om bij te houden. Alleen wat het pakket zelf noemt wordt geserveerd
     244// (ThemeService.layoutFile); een pad uit de URL komt nergens in een join.
     245app.get('/themes/:id/:version/:file', (req, res) => {
     246  const file = ThemeService.layoutFile(req.params.id, req.params.version, req.params.file);
     247  if (!file) return res.status(404).end();
     248  res.set('Cache-Control', isDev ? 'no-cache' : 'public, max-age=31536000, immutable');
     249  res.set('X-Content-Type-Options', 'nosniff');
     250  res.sendFile(file);
     251});
    240252
    241253// On-demand cover thumbnails: /media/thumb/<w>/<path> → a small lanczos-downscaled WebP
  • 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',
  • src/views/pages/admin-site-edit.ejs

    r5d0d3f35 r495b019  
    103103      </fieldset>
    104104
     105      <%# ── De lay-out (experimental/theme) ──────────────────────────────
     106          Een thema uit src/themes/. Alleen de beheerder kiest; een bezoeker
     107          niet. Het beheer zelf houdt altijd de gewone kop, zodat een kapot
     108          thema je nooit buitensluit van deze instelling. %>
     109      <% if (typeof layouts !== 'undefined' && layouts && layouts.length) { %>
     110      <fieldset class="layout-choice">
     111        <legend><%= t('asite.layout') %></legend>
     112        <% const lc = site.layout || ''; %>
     113        <label class="cb">
     114          <input type="radio" name="layout" value="" <%= lc === '' ? 'checked' : '' %>>
     115          <span><%= t('asite.layout_top') %></span>
     116        </label>
     117        <% layouts.forEach(function (l) { %>
     118        <label class="cb">
     119          <input type="radio" name="layout" value="<%= l.id %>" <%= lc === l.id ? 'checked' : '' %>>
     120          <span><%= l.name %></span>
     121          <% if (site.slug) { %><a class="layout-preview" href="/?layout=<%= encodeURIComponent(l.id) %>" target="_blank" rel="noopener">voorproefje</a><% } %>
     122        </label>
     123        <% }); %>
     124        <p class="hint"><%= t('asite.layout_hint') %></p>
     125      </fieldset>
     126      <% } %>
     127
    105128      <fieldset class="palette-grid">
    106129        <legend><%= t('asite.palette') %></legend>
  • src/views/shell.ejs

    r5d0d3f35 r495b019  
    132132const MOD_V = 68;
    133133%><!DOCTYPE html>
    134 <html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>">
     134<html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>" data-layout="<%- _e((typeof layoutTheme !== 'undefined' && layoutTheme) ? layoutTheme.id : 'top') %>">
    135135<head>
    136136<meta charset="utf-8">
    … …  
    234234<!-- v9 stylesheet (full palette system) -->
    235235<link rel="stylesheet" href="/assets/css/style.css?v=111">
     236<%# De lay-outthema's (experimental/theme): de stylesheet van het thema dat de
     237    site koos, plus die van een voorproefje voor wie de site beheert. Hun regels
     238    gelden alleen onder <html data-layout="...">, dat de server hierboven al zet
     239    -- dus geen script, geen flits, en geen bezoeker die iets anders kiest. %>
     240<% if (typeof layoutSheets !== 'undefined' && layoutSheets) { layoutSheets.forEach(function (href) { %>
     241<link rel="stylesheet" href="<%= href %>">
     242<% }); } %>
    236243<script>
    237244/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
    … …  
    485492    if (d.palette && /^[a-z0-9-]+$/i.test(d.palette)) {
    486493      document.documentElement.setAttribute('data-palette', d.palette);
     494    }
     495    // De lay-out van de NIEUWE pagina (experimental/theme). De <html> wordt bij
     496    // htmx-navigatie niet opnieuw gerenderd, dus zonder dit hield het beheer de
     497    // zijbalk van de pagina ervoor -- terwijl beheer altijd de gewone kop hoort
     498    // te hebben. Zelfde vorm als een thema-id, dus niets anders kan erin.
     499    if (typeof d.layout === 'string' && /^[a-z0-9][a-z0-9.-]{0,63}$/.test(d.layout)) {
     500      document.documentElement.setAttribute('data-layout', d.layout);
    487501    }
    488502
Note: See TracChangeset for help on using the changeset viewer.