%
// ── 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'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/.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,
};
}
%>
<%= _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 %>
<% } %>