- Timestamp:
- 10/25/2026 09:52:01 AM (2 days ago)
- 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)
- Location:
- src
- Files:
-
- 2 added
- 9 edited
-
config/database.js (modified) (1 diff)
-
middleware/render.js (modified) (4 diffs)
-
routes/admin-sites.js (modified) (5 diffs)
-
server.js (modified) (2 diffs)
-
services/ActivityPubService.js (modified) (2 diffs)
-
services/ThemeService.js (modified) (2 diffs)
-
services/i18n.js (modified) (3 diffs)
-
themes/sidebar/layout.css (added)
-
themes/sidebar/theme.json (added)
-
views/pages/admin-site-edit.ejs (modified) (1 diff)
-
views/shell.ejs (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r5d0d3f35 r495b019 249 249 // elke byte van de canonieke vorm, dus herschrijven zou hem breken. 250 250 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'); 251 254 ensureColumn('sites', 'show_search', 'INTEGER DEFAULT 1'); 252 255 ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1'); -
src/middleware/render.js
r5d0d3f35 r495b019 21 21 import { emojiHtml, emojiName, parseQuote } from '../services/NoteRender.js'; 22 22 import ActivityPubService from '../services/ActivityPubService.js'; 23 import ThemeService from '../services/ThemeService.js'; 23 24 import { imgProxyUrl } from '../services/ThumbnailService.js'; 24 25 import { audioEnabled as audioFeatureEnabled } from '../config/features.js'; … … 187 188 defaultLang: getSetting('default_lang'), 188 189 }); 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)))]; 189 202 190 203 // Common locals … … 252 265 appVersion: APP_VERSION, 253 266 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, 254 276 // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op 255 277 // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/. … … 299 321 const _navJs = ('chrome ' + (locals.pageJs || '')).trim(); 300 322 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' }, 302 324 pcmsPostSwap: data.post ? { 303 325 title: data.post.title, -
src/routes/admin-sites.js
r5d0d3f35 r495b019 191 191 users: listOwnerCandidates(), 192 192 palettes: ThemeService.listPalettes(), 193 layouts: ThemeService.listLayouts(), 193 194 accents: ThemeService.listAccents(), 194 195 platforms: listPlatforms(), … … 268 269 users: listOwnerCandidates(), 269 270 palettes: ThemeService.listPalettes(), 271 layouts: ThemeService.listLayouts(), 270 272 accents: ThemeService.listAccents(), 271 273 platforms: listPlatforms(), … … 334 336 // "Auto" — defer to user's prefers-color-scheme on first paint. 335 337 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; 336 342 337 343 // accent: only accept colors from the curated ACCENTS list. Falls back to … … 342 348 UPDATE sites SET 343 349 title = ?, description = ?, tagline = ?, language = ?, 344 palette = ?, accent = ?, theme_override = ?, profile_photo = ?,350 palette = ?, accent = ?, theme_override = ?, layout = ?, profile_photo = ?, 345 351 profile_enabled = ?, 346 352 ap_aliases = ?, … … 361 367 accent, 362 368 themeOverride, 369 layout, 363 370 f.profile_photo || null, 364 371 f.profile_enabled ? 1 : 0, -
src/server.js
r5d0d3f35 r495b019 17 17 import http from 'http'; 18 18 import db, { initializeDatabase } from './config/database.js'; 19 import ThemeService from './services/ThemeService.js'; 19 20 import { startScheduler } from './services/Scheduler.js'; 20 21 import { SqliteSessionStore } from './services/SqliteSessionStore.js'; … … 238 239 239 240 app.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. 245 app.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 }); 240 252 241 253 // On-demand cover thumbnails: /media/thumb/<w>/<path> → a small lanczos-downscaled WebP -
src/services/ActivityPubService.js
r5d0d3f35 r495b019 20 20 import path from 'path'; 21 21 import db, { NU_ISO, isoSql } from '../config/database.js'; 22 import ThemeService from './ThemeService.js'; 22 23 import HtmlSanitizerService from './HtmlSanitizerService.js'; 23 24 import AudioEmbedService from './AudioEmbedService.js'; … … 385 386 // (guardianship module owns these). 386 387 ...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), 387 393 // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths. 388 394 // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration -
src/services/ThemeService.js
r5d0d3f35 r495b019 1 import fs from 'fs'; 2 import path from 'path'; 3 import { fileURLToPath } from 'url'; 4 1 5 /** 2 6 * ThemeService — Palette and theme management … … 207 211 }; 208 212 } 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 } 209 341 } 210 342 -
src/services/i18n.js
r5d0d3f35 r495b019 289 289 'asite.theme_light': 'Licht', 290 290 '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.', 291 294 'asite.palette': 'Palet', 292 295 'asite.behavior': 'Gedrag', … … 1299 1302 'asite.theme_light': 'Light', 1300 1303 '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.', 1301 1307 'asite.palette': 'Palette', 1302 1308 'asite.behavior': 'Behavior', … … 2308 2314 'asite.theme_light': 'Hell', 2309 2315 '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.', 2310 2319 'asite.palette': 'Palette', 2311 2320 'asite.behavior': 'Verhalten', -
src/views/pages/admin-site-edit.ejs
r5d0d3f35 r495b019 103 103 </fieldset> 104 104 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 105 128 <fieldset class="palette-grid"> 106 129 <legend><%= t('asite.palette') %></legend> -
src/views/shell.ejs
r5d0d3f35 r495b019 132 132 const MOD_V = 68; 133 133 %><!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') %>"> 135 135 <head> 136 136 <meta charset="utf-8"> … … 234 234 <!-- v9 stylesheet (full palette system) --> 235 235 <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 <% }); } %> 236 243 <script> 237 244 /* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in … … 485 492 if (d.palette && /^[a-z0-9-]+$/i.test(d.palette)) { 486 493 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); 487 501 } 488 502
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)