<% // ── Helpers used inside this template ───────────────────────────── // Escape for double-quoted HTML attributes. IMPORTANT: emit this with the RAW EJS output // tag, never the escaping one — escaping it a second time turned og:title "Jason's" into the // double-escaped "Jason&#39;s", and naive OG scrapers (Signal/WhatsApp) show that literally. // We deliberately do NOT escape the apostrophe: it is safe inside a double-quoted attribute and // a literal apostrophe is what link-preview scrapers expect. function _e(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"'); } const safeSite = site || {}; const safeUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; const safeAccent = safeSite.accent && /^#[0-9a-fA-F]{6}$/.test(safeSite.accent) ? safeSite.accent : '#e8b04b'; const lang = safeSite.language || 'nl'; const ogLocale = safeSite.og_locale || (lang === 'nl' ? 'nl_NL' : (lang.length === 2 ? lang + '_' + lang.toUpperCase() : 'en_US')); const homePath = safeUrlBase + '/'; const isPostPage = bodyClass && bodyClass.indexOf('on-post') >= 0; const isHomePage = bodyClass && bodyClass.indexOf('on-home') >= 0; const isSpecialPg = bodyClass && bodyClass.indexOf('on-special') >= 0; const isAdminPage = bodyClass && bodyClass.indexOf('on-admin') >= 0; // ── via site.title_template ────────────────────────────── // Template: '{title} — {site}'. If pageTitle equals site.title (homepage) we // just use the site title alone, otherwise apply the template. const _siteTitle = safeSite.title || 'Klonkt'; const _rawTitle = pageTitle || _siteTitle; const _tpl = safeSite.title_template || '{title} — {site}'; const _finalTitle = (_rawTitle === _siteTitle) ? _siteTitle : _tpl.replace('{title}', _rawTitle).replace('{site}', _siteTitle); // ── Robots: noindex on listing pages and on per-post override ───── let _shouldIndex = safeSite.robots_index !== 0; if (typeof post !== 'undefined' && post && post.noindex) _shouldIndex = false; // Listing pages (search/tag/type/archive) shouldn't be indexed (dupe content) if (currentPath) { if (/^\/(?:search|tag|type|archive|users|account|admin)(?:$|\/)/.test(currentPath)) { _shouldIndex = false; } } // Special-flagged views from routes opt out too if (isSpecialPg && (currentPath === '/search' || /^\/(tag|type|archive|users)\//.test(currentPath))) { _shouldIndex = false; } // ── Canonical URL: per-site override (admin SEO), else the .env base // (PUBLIC_BASE_URL, via ogOrigin → falls back to the request host) ── let _canonical = null; const _canonBase = safeSite.canonical || (typeof ogOrigin !== 'undefined' ? ogOrigin : ''); if (_canonBase) { const _base = _canonBase.replace(/\/+$/, ''); let _path = '/'; if (typeof post !== 'undefined' && post && post.slug) _path = '/' + post.slug; else if (currentPath) _path = currentPath; _canonical = _base + _path; } // ── Social bits (OG/Twitter) ────────────────────────────────────── const _socialTitle = (typeof post !== 'undefined' && post && post.title) ? post.title : _siteTitle; const _socialDescr = (typeof socialDescr !== 'undefined' && socialDescr) ? socialDescr : (safeSite.default_description || safeSite.description || ''); // og:image — custom (post/site) first; otherwise the auto-generated themed card // (/og/<slug>.png), so every site has a branded social preview by default. let _socialImage = '', _ogGenerated = false; if (typeof socialImage !== 'undefined' && socialImage) _socialImage = socialImage; else if (safeSite.og_image_default) _socialImage = safeSite.og_image_default; else if (safeSite.default_cover) _socialImage = safeSite.default_cover; else if (safeSite.slug && typeof ogOrigin !== 'undefined' && ogOrigin) { _socialImage = ogOrigin + '/og/' + encodeURIComponent(safeSite.slug) + '.png'; _ogGenerated = true; } // og:image / twitter:image / JSON-LD image MUST be absolute (OGP spec). A post cover arrives as // a relative /media/... path; strict scrapers (WhatsApp/Signal/some fediverse clients) won't // resolve it against the page URL → no preview image. Absolutize against the canonical origin. if (_socialImage && _socialImage.charAt(0) === '/' && _socialImage.charAt(1) !== '/' && typeof ogOrigin !== 'undefined' && ogOrigin) { _socialImage = ogOrigin + _socialImage; } const _ogType = isPostPage ? 'article' : 'website'; // ── JSON-LD ─────────────────────────────────────────────────────── const _publisher = { '@type': safeSite.schema_type === 'Organization' ? 'Organization' : 'Person', name: safeSite.publisher_name || _siteTitle, url: safeSite.publisher_url || (_canonical ? _canonical.split(/(?<=^[^/]*\/\/[^/]+)\//)[0] + '/' : null), }; if (safeSite.publisher_logo) { _publisher.logo = { '@type': 'ImageObject', url: safeSite.publisher_logo }; } // Dezelfde koppeling die de fediverse-actor draagt, hier in de JSON-LD // (shaer-mbz). sameAs is schema.org-eigen, dus dit is geen extra vocabulaire -- // het is hetzelfde feit, verteld aan de andere lezer. if (safeSite.mb_artist_id) { _publisher.sameAs = 'https://musicbrainz.org/artist/' + safeSite.mb_artist_id; } let _jsonLd = null; if (typeof post !== 'undefined' && post && post.slug) { _jsonLd = { '@context': 'https://schema.org', '@type': (post.type === 'foto' || post.type === 'video') ? 'CreativeWork' : 'Article', headline: post.title || _siteTitle, description: _socialDescr, datePublished: post.published_at || post.created_at || new Date().toISOString(), dateModified: post.updated_at || post.published_at || new Date().toISOString(), publisher: _publisher, }; if (_socialImage) _jsonLd.image = _socialImage; if (post.author_username) _jsonLd.author = { '@type': 'Person', name: post.author_username }; if (Array.isArray(post.tags) && post.tags.length) _jsonLd.keywords = post.tags.join(', '); } else if (isHomePage) { _jsonLd = { '@context': 'https://schema.org', '@type': 'WebSite', name: _siteTitle, description: safeSite.description || safeSite.default_description || '', publisher: _publisher, }; } %><!DOCTYPE html> <html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <meta name="color-scheme" content="dark light"> <title><%= _finalTitle %> <% if (safeSite.author) { %><% } %> <% if (_canonical) { %><% } %> <% if (safeSite.google_verification) { %><% } %> <% if (safeSite.bing_verification) { %><% } %> <% if (safeSite.pinterest_verification) { %><% } %> <% if (safeSite.yandex_verification) { %><% } %> <% if (site) { %> <% } %> <% if (_socialImage) { %> <% if (_ogGenerated) { %> <% } %> <% } %> <% if (_canonical) { %><% } %> <% // Fediverse/social PLAYER card for posts with audio: instead of shipping the raw // mp3, point at our embeddable player (/embed?post=slug) so Mastodon shows an // inline player that streams via the gated /audio/stream (no downloadable file). const _postAudio = !!(typeof post !== 'undefined' && post && typeof postHasPlayableAudio !== 'undefined' && postHasPlayableAudio && typeof premiumUnlocked !== 'undefined' && premiumUnlocked); const _embedUrl = _postAudio ? ((typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + safeUrlBase + '/embed?post=' + encodeURIComponent(post.slug)) : ''; %> <% if (_postAudio) { %> <% } %> <% if (typeof post !== 'undefined' && post && post.published_at) { %> <% if (post.author_username) { %><% } %> <% } %> <% if (safeSite.facebook_app_id) { %><% } %> <% if (_postAudio) { %> <% } %> <% if (_socialImage) { %><% } %> <% if (safeSite.twitter) { %><% } %> <% if (_jsonLd) { %> <% } %> <% if (typeof musicLd !== 'undefined' && musicLd) { %> <%# Music posts also carry standard schema.org MusicRecording/MusicAlbum data (Phase 1 of music federation): real web standard, read by search engines + generic consumers. %> <% } %> <%- include('partials/shared-styles') %> <% if (safeSite.custom_css) { %> <% } %> <% if (safeSite.custom_head_html) { %> <%- safeSite.custom_head_html %> <% } %> = 0 ? safeSite.feed_alt_view : "reader")) %>" data-feed-alt="<%- _e(["timeline","auto"].indexOf(safeSite.feed_alt_view) >= 0 ? safeSite.feed_alt_view : "reader") %>" data-reader-pages="<%- safeSite.reader_full_page ? 1 : 0 %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>"> <% if (typeof isViewer !== 'undefined' && isViewer) { %>
Kijker-modus — je kunt alles bekijken, maar niets wijzigen.
<% } %> <%# Site-chrome (topnav + profielkop + view-switcher) in één vaste slot #pcms-chrome. Bij htmx-navigatie wordt dit slot out-of-band ververst (zie chrome.ejs + render.js), zodat de kop ALTIJD bij de nieuwe pagina/artiest hoort terwijl de audioplayer (los in document.body) blijft leven → geen verspringen. Op de hub-landing is het slot leeg: de hero is daar de header. %> <%- include('partials/chrome') %> <%# Geen hx-history-elt: htmx' eigen history staat uit (zie de link-boost). Back/forward wordt door onze popstate-listener gedaan, die de partial her-fetcht (incl. correcte OOB-chrome). Met hx-history-elt + htmx-history aan dumpte htmx de partial ongefilterd hier → dubbele kop. %>
<%- pageContent %>
<%- include('partials/footer') %> <% if (!(typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0)) { %> <%- include('partials/bottom-tab') %> <% } %> <% if (user) { %> <%- include('partials/profile-sheet') %> <% } %> <% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %> <% } %> <% if (safeSite.custom_foot_html) { %> <%- safeSite.custom_foot_html %> <% } %>