| 1 | /**
|
|---|
| 2 | * Render helper — THE pattern for the entire app.
|
|---|
| 3 | *
|
|---|
| 4 | * Two modes:
|
|---|
| 5 | * 1. HTMX request → render just the page content (no shell)
|
|---|
| 6 | * 2. Full page request → render content, then embed in shell
|
|---|
| 7 | *
|
|---|
| 8 | * Usage:
|
|---|
| 9 | * renderPage(req, res, 'pages/home', { posts, ...data })
|
|---|
| 10 | */
|
|---|
| 11 |
|
|---|
| 12 | import path from 'path';
|
|---|
| 13 | import { fileURLToPath } from 'url';
|
|---|
| 14 | import ejs from 'ejs';
|
|---|
| 15 | import db from '../config/database.js';
|
|---|
| 16 | import PermissionsService from '../services/PermissionsService.js';
|
|---|
| 17 | import { PLATFORMS as PLATFORMS_CATALOG } from '../services/PlatformIcons.js';
|
|---|
| 18 |
|
|---|
| 19 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| 20 | const VIEWS_DIR = path.join(__dirname, '..', 'views');
|
|---|
| 21 |
|
|---|
| 22 | const formatDate = (iso) => {
|
|---|
| 23 | if (!iso) return '';
|
|---|
| 24 | const d = new Date(iso);
|
|---|
| 25 | const months = ['januari','februari','maart','april','mei','juni','juli','augustus','september','oktober','november','december'];
|
|---|
| 26 | return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
|
|---|
| 27 | };
|
|---|
| 28 |
|
|---|
| 29 | const formatDateTime = (iso) => {
|
|---|
| 30 | if (!iso) return '';
|
|---|
| 31 | const d = new Date(iso);
|
|---|
| 32 | return d.toLocaleString('nl-NL', { dateStyle: 'medium', timeStyle: 'short' });
|
|---|
| 33 | };
|
|---|
| 34 |
|
|---|
| 35 | export async function renderPage(req, res, viewName, data = {}) {
|
|---|
| 36 | // Decide: partial (HTMX) or full?
|
|---|
| 37 | const isPartial = req.headers['hx-request'] === 'true' || req.query.partial === '1';
|
|---|
| 38 |
|
|---|
| 39 | // Bezit deze (niet-god) user een eigen site? Bepaalt of 'ie een "Beheer"-
|
|---|
| 40 | // ingang ziet (artiest-zelfbeheer). god ziet beheer sowieso (op rol).
|
|---|
| 41 | const _u = req.session?.user || null;
|
|---|
| 42 | const userOwnsSite = !!(_u && _u.role !== 'god' &&
|
|---|
| 43 | db.prepare('SELECT 1 FROM sites WHERE owner_id = ? LIMIT 1').get(_u.id));
|
|---|
| 44 |
|
|---|
| 45 | // Common locals
|
|---|
| 46 | const locals = {
|
|---|
| 47 | user: _u,
|
|---|
| 48 | userOwnsSite,
|
|---|
| 49 | site: data.site || res.locals.site || null,
|
|---|
| 50 | audioTracks: data.audioTracks || res.locals.audioTracks || [],
|
|---|
| 51 | siteUrlBase: res.locals.siteUrlBase || '',
|
|---|
| 52 | tenancy: res.locals.tenancy || 'solo',
|
|---|
| 53 | platforms_catalog: PLATFORMS_CATALOG,
|
|---|
| 54 | permissions: PermissionsService,
|
|---|
| 55 | formatDate,
|
|---|
| 56 | formatDateTime,
|
|---|
| 57 | pageTitle: data.pageTitle || (data.site && data.site.title) || 'PrutCMS',
|
|---|
| 58 | bodyClass: data.bodyClass || 'on-home',
|
|---|
| 59 | socialDescr: data.socialDescr || '',
|
|---|
| 60 | socialImage: data.socialImage || '',
|
|---|
| 61 | cspNonce: () => '',
|
|---|
| 62 | currentPath: req.path,
|
|---|
| 63 | ...data,
|
|---|
| 64 | };
|
|---|
| 65 |
|
|---|
| 66 | try {
|
|---|
| 67 | // Step 1: Render the page view to HTML
|
|---|
| 68 | const viewPath = path.join(VIEWS_DIR, viewName + '.ejs');
|
|---|
| 69 | const pageContent = await ejs.renderFile(viewPath, locals, { async: false });
|
|---|
| 70 |
|
|---|
| 71 | if (isPartial) {
|
|---|
| 72 | // HTMX: just send the content. Set HX-Trigger for body class swap
|
|---|
| 73 | res.setHeader('HX-Trigger-After-Settle', JSON.stringify({
|
|---|
| 74 | pcmsNav: { bodyClass: locals.bodyClass },
|
|---|
| 75 | pcmsPostSwap: data.post ? {
|
|---|
| 76 | title: data.post.title,
|
|---|
| 77 | slug: data.post.slug,
|
|---|
| 78 | pageTitle: locals.pageTitle,
|
|---|
| 79 | } : null,
|
|---|
| 80 | }));
|
|---|
| 81 | return res.send(pageContent);
|
|---|
| 82 | }
|
|---|
| 83 |
|
|---|
| 84 | // Full: wrap content in shell
|
|---|
| 85 | locals.pageContent = pageContent;
|
|---|
| 86 | res.render('shell', locals);
|
|---|
| 87 | } catch (err) {
|
|---|
| 88 | console.error('[renderPage] Error rendering', viewName, err);
|
|---|
| 89 | if (process.env.NODE_ENV === 'production') {
|
|---|
| 90 | return res.status(500).send('Internal Server Error');
|
|---|
| 91 | }
|
|---|
| 92 | // Dev: surface the underlying cause prominently. EJS rewrites err.message
|
|---|
| 93 | // to include the file/line/code-context, so we also surface name+stack
|
|---|
| 94 | // separately in case the message was truncated or empty.
|
|---|
| 95 | const escape = (s) => String(s == null ? '' : s)
|
|---|
| 96 | .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|---|
| 97 | res.status(500).send(`<!doctype html>
|
|---|
| 98 | <meta charset="utf-8">
|
|---|
| 99 | <title>Render error: ${escape(viewName)}</title>
|
|---|
| 100 | <style>
|
|---|
| 101 | body { font: 14px/1.5 ui-monospace, monospace; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; background:#1a1a1a; color:#eee; }
|
|---|
| 102 | h1 { color:#dc2626; font-family: ui-sans-serif, system-ui; }
|
|---|
| 103 | h2 { color:#fb923c; font-size:1rem; margin-top:1.5rem; }
|
|---|
| 104 | pre { background:#0a0a0a; border:1px solid #333; border-radius:6px; padding:1rem; overflow:auto; white-space:pre-wrap; word-break:break-word; }
|
|---|
| 105 | .cause { background:#3d0a0a; border-color:#7a1a1a; color:#fca5a5; font-weight:600; }
|
|---|
| 106 | </style>
|
|---|
| 107 | <h1>Render error in ${escape(viewName)}</h1>
|
|---|
| 108 | <h2>Cause</h2>
|
|---|
| 109 | <pre class="cause">${escape(err.name || 'Error')}: ${escape(err.message || '(no message)')}</pre>
|
|---|
| 110 | <h2>Stack</h2>
|
|---|
| 111 | <pre>${escape(err.stack || '(no stack)')}</pre>
|
|---|
| 112 | ${err.path ? `<h2>File</h2><pre>${escape(err.path)}</pre>` : ''}
|
|---|
| 113 | `);
|
|---|
| 114 | }
|
|---|
| 115 | }
|
|---|