diff --git a/css/style.css b/css/style.css
index 91d9aa2..69c1706 100644
--- a/css/style.css
+++ b/css/style.css
@@ -846,9 +846,16 @@ img.nav-svg-icon {
overflow: hidden;
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
+ /* Reserve space at a fixed ratio so the cover doesn't shift layout while it
+ loads (Cumulative Layout Shift). */
+ aspect-ratio: 16 / 9;
}
.cover-image-wrapper .card-img-top {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
transition: transform 0.4s ease;
}
@@ -1904,10 +1911,181 @@ button:focus:not(:focus-visible) {
pre, blockquote, table, figure, img { page-break-inside: avoid; }
}
+/* ========================================
+ HEADING ANCHOR LINKS
+ ======================================== */
+.content h2,
+.content h3,
+.content h4 { position: relative; }
+
+.heading-anchor {
+ margin-left: 0.4rem;
+ color: var(--text-muted);
+ font-weight: 400;
+ text-decoration: none;
+ opacity: 0;
+ transition: opacity 0.15s ease, color 0.15s ease;
+}
+
+.content h2:hover .heading-anchor,
+.content h3:hover .heading-anchor,
+.content h4:hover .heading-anchor,
+.heading-anchor:focus { opacity: 0.55; }
+
+.heading-anchor:hover { color: var(--accent); opacity: 1; text-decoration: none; }
+
+@media (hover: none) { .heading-anchor { opacity: 0.4; } }
+
+/* ========================================
+ SHARE BUTTONS
+ ======================================== */
+.post-share {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem;
+ margin-top: var(--space-lg);
+ padding-top: var(--space-md);
+ border-top: 1px solid var(--border-subtle);
+}
+
+.post-share-label {
+ font-size: var(--font-size-xs);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-muted);
+ margin-right: 0.3rem;
+}
+
+.post-share-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.3rem 0.8rem;
+ border: 1px solid var(--border-color);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ font-family: var(--font-sans);
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.post-share-btn:hover,
+.post-share-btn.is-copied {
+ background: var(--accent);
+ color: var(--accent-contrast);
+ border-color: var(--accent);
+ text-decoration: none;
+}
+
+.post-share-btn .bd-icon { margin-right: 0; }
+
+/* ========================================
+ PREV / NEXT ARTICLE NAV
+ ======================================== */
+.post-nav {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-sm);
+ margin: var(--space-lg) 0;
+}
+
+.post-nav-link {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ padding: var(--space-sm) var(--space-md);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ transition: all 0.2s ease;
+}
+
+.post-nav-link:hover {
+ border-color: var(--accent);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+ text-decoration: none;
+}
+
+.post-nav-next { justify-content: flex-end; text-align: right; }
+
+.post-nav-text { display: flex; flex-direction: column; min-width: 0; }
+
+.post-nav-meta {
+ font-size: var(--font-size-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+}
+
+.post-nav-title {
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.post-nav .bd-icon { flex: 0 0 auto; color: var(--text-muted); margin: 0; }
+
+/* ========================================
+ RELATED POSTS
+ ======================================== */
+.related-posts { margin: var(--space-xl) 0 var(--space-lg); }
+
+.related-posts-title { font-size: 1.15rem; margin-bottom: var(--space-md); }
+
+.related-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: var(--space-sm);
+}
+
+.related-card {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ transition: all 0.2s ease;
+}
+
+.related-card:hover {
+ border-color: var(--accent);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+ text-decoration: none;
+}
+
+.related-cover {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background-size: cover;
+ background-position: center;
+ background-color: var(--bg-subtle);
+}
+
+.related-card-title {
+ padding: 0.7rem 0.85rem;
+ font-weight: 600;
+ font-size: 0.95rem;
+ line-height: 1.3;
+}
+
/* ========================================
RESPONSIVE
======================================== */
@media (max-width: 767.98px) {
+ .post-nav { grid-template-columns: 1fr; }
h1.title { font-size: 1.9rem; }
h2.title { font-size: 1.5rem; }
diff --git a/img/mail.svg b/img/mail.svg
new file mode 100644
index 0000000..7d85f0c
--- /dev/null
+++ b/img/mail.svg
@@ -0,0 +1 @@
+
diff --git a/index.php b/index.php
index 245d97e..8da5c72 100644
--- a/index.php
+++ b/index.php
@@ -3,6 +3,18 @@
// first so blowdit_icon() is available to head.php and every body template.
include(THEME_DIR_PHP.'icons.php');
+ // ---- Theme contact / social config ----
+ // The mailto icon uses Bludit's "Sender email" (Settings -> Advanced ->
+ // Email account settings). Leave it to auto-detect, or hard-set $blowditEmail
+ // below to override. Empty -> the icon is hidden.
+ $blowditEmail = '';
+ if (isset($site) && method_exists($site, 'getField')) {
+ $blowditEmail = $site->getField('emailFromAddress'); // Bludit's "Sender email"
+ if (empty($blowditEmail)) { $blowditEmail = $site->getField('email'); }
+ }
+ // $blowditEmail = 'you@example.com'; // <- uncomment to set manually
+ $blowditTwitterHandle = '@spleenftw'; // twitter:site / twitter:creator; '' to omit
+
// Read the theme from a cookie so the root element can be rendered
// pre-coloured. This paints the page in the theme colour from the first
// byte of every response, eliminating the white flash between navigations.
diff --git a/js/blowdit.js b/js/blowdit.js
index 19e3f1b..a18a63c 100644
--- a/js/blowdit.js
+++ b/js/blowdit.js
@@ -25,6 +25,26 @@
imageViewer: I18N.imageViewer || 'Image viewer'
};
+ // Clipboard helper (Clipboard API with a legacy fallback).
+ function copyToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ return navigator.clipboard.writeText(text);
+ }
+ return new Promise(function (resolve, reject) {
+ try {
+ var ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ resolve();
+ } catch (e) { reject(e); }
+ });
+ }
+
/* --------------------------------------------------------
Mobile navbar toggle (replaces Bootstrap's collapse JS)
Toggles the .show class that Bootstrap's CSS already styles.
@@ -870,4 +890,83 @@
});
})();
+ /* --------------------------------------------------------
+ Heading anchor links — hover a heading to reveal a "#" that links to it.
+ IDs are assigned by the ToC script; assign any missing ones here too.
+ -------------------------------------------------------- */
+ (function () {
+ var content = document.querySelector('.content');
+ if (!content) return;
+ var headings = content.querySelectorAll('h2, h3, h4');
+ if (!headings.length) return;
+
+ var used = {};
+ Array.prototype.forEach.call(content.querySelectorAll('[id]'), function (el) {
+ used[el.id] = true;
+ });
+
+ Array.prototype.forEach.call(headings, function (h) {
+ if (!h.id) {
+ var base = h.textContent.trim().toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'section';
+ var id = base, n = 2;
+ while (used[id]) { id = base + '-' + (n++); }
+ used[id] = true;
+ h.id = id;
+ }
+ if (h.querySelector('.heading-anchor')) return;
+ var a = document.createElement('a');
+ a.className = 'heading-anchor';
+ a.href = '#' + h.id;
+ a.setAttribute('aria-label', 'Link to this section');
+ a.textContent = '#';
+ h.appendChild(a);
+ });
+ })();
+
+ /* --------------------------------------------------------
+ Harden outbound links in article content: rel="noopener noreferrer"
+ (anti reverse-tabnabbing + no referrer leak) and open in a new tab.
+ -------------------------------------------------------- */
+ (function () {
+ var content = document.querySelector('.content');
+ if (!content) return;
+ Array.prototype.forEach.call(content.querySelectorAll('a[href]'), function (a) {
+ if (!/^https?:\/\//i.test(a.getAttribute('href') || '')) return;
+ if (a.host === window.location.host) return; // same-site: leave alone
+ var rel = (a.getAttribute('rel') || '').split(/\s+/).filter(Boolean);
+ ['noopener', 'noreferrer'].forEach(function (r) {
+ if (rel.indexOf(r) === -1) rel.push(r);
+ });
+ a.setAttribute('rel', rel.join(' '));
+ if (!a.getAttribute('target')) a.setAttribute('target', '_blank');
+ });
+ })();
+
+ /* --------------------------------------------------------
+ "Copy link" share button.
+ -------------------------------------------------------- */
+ (function () {
+ var btns = document.querySelectorAll('.js-copy-link');
+ if (!btns.length) return;
+ Array.prototype.forEach.call(btns, function (btn) {
+ var label = btn.querySelector('.post-share-copy-label');
+ var resetTimer = null;
+ btn.addEventListener('click', function () {
+ var url = btn.getAttribute('data-url') || window.location.href;
+ copyToClipboard(url).then(function () {
+ btn.classList.add('is-copied');
+ var original = label ? label.textContent : '';
+ if (label) label.textContent = T.copied;
+ clearTimeout(resetTimer);
+ resetTimer = setTimeout(function () {
+ btn.classList.remove('is-copied');
+ if (label) label.textContent = original;
+ }, 1500);
+ }).catch(function () {});
+ });
+ });
+ })();
+
})();
diff --git a/languages/en.json b/languages/en.json
index fb44154..109de9a 100644
--- a/languages/en.json
+++ b/languages/en.json
@@ -9,6 +9,16 @@
"Skip to content": "Skip to content",
"Pagination": "Pagination",
"RSS": "RSS",
+ "Email": "Email",
+ "Share": "Share",
+ "Share on X": "Share on X",
+ "Share on LinkedIn": "Share on LinkedIn",
+ "Copy link": "Copy link",
+ "Link copied": "Link copied",
+ "Related posts": "Related posts",
+ "Previous": "Previous",
+ "Next": "Next",
+ "Updated": "Updated",
"Copy": "Copy",
"Copied": "Copied",
"Copy code": "Copy code",
diff --git a/manifest.webmanifest b/manifest.webmanifest
new file mode 100644
index 0000000..93049f6
--- /dev/null
+++ b/manifest.webmanifest
@@ -0,0 +1,18 @@
+{
+ "name": "Blog",
+ "short_name": "Blog",
+ "description": "A Bludit blog using the Blowdit theme.",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#171717",
+ "theme_color": "#1f1f1f",
+ "icons": [
+ {
+ "src": "img/favicon.png",
+ "sizes": "any",
+ "type": "image/png",
+ "purpose": "any"
+ }
+ ]
+}
diff --git a/metadata.json b/metadata.json
index 2ad03de..8ff721f 100644
--- a/metadata.json
+++ b/metadata.json
@@ -2,9 +2,9 @@
"author": "spleenftw",
"email": "",
"website": "https://github.com/Spleenftw/blowdit",
- "version": "1.3.0",
+ "version": "1.4.0",
"releaseDate": "2026-06-02",
"license": "MIT",
"compatible": "3.22.0",
- "notes": "A Bludit theme inspired by the Blowfish Hugo theme. Five-theme swatch picker (Light/Dark/Nord/Dracula/Catppuccin), sticky ToC sidebar on articles, image lightbox with zoom, image carousel (```carousel fence), tabbed code blocks (```tabs fence), smart sidebar per page type, and clean responsive typography. v1.3.0: SEO (Open Graph, Twitter Card, JSON-LD, theme-color, cover preload), per-theme syntax highlighting (self-hosted highlight.js), copy-to-clipboard on code blocks, numbered pagination, inline-SVG icons (dropped the Bootstrap Icons webfont), removed jQuery/Bootstrap JS, deferred cached JS bundle, reading progress bar, back-to-top, randomised sidebar selection, lazy-loaded images, accessibility (skip link, ARIA, :focus-visible) and a print stylesheet."
+ "notes": "A Bludit theme inspired by the Blowfish Hugo theme. Five-theme swatch picker (Light/Dark/Nord/Dracula/Catppuccin), sticky ToC sidebar on articles, image lightbox with zoom, image carousel (```carousel fence), tabbed code blocks (```tabs fence), smart sidebar per page type, and clean responsive typography. v1.3.0: SEO (Open Graph, Twitter Card, JSON-LD, theme-color, cover preload), per-theme syntax highlighting (self-hosted highlight.js), copy-to-clipboard on code blocks, numbered pagination, inline-SVG icons (dropped the Bootstrap Icons webfont), removed jQuery/Bootstrap JS, deferred cached JS bundle, reading progress bar, back-to-top, randomised sidebar selection, lazy-loaded images, accessibility (skip link, ARIA, :focus-visible) and a print stylesheet. v1.4.0: canonical URL, WebSite + BreadcrumbList + author sameAs JSON-LD, og:image:alt / twitter:site+creator, cover-image aspect-ratio (CLS), heading anchor links, prev/next article nav, related posts, share buttons (X/LinkedIn/copy-link), mailto social icon, PWA web app manifest, 'updated on' date, and outbound-link hardening (noopener noreferrer)."
}
diff --git a/php/head.php b/php/head.php
index 4a75736..b3a42db 100644
--- a/php/head.php
+++ b/php/head.php
@@ -93,6 +93,9 @@
$ogType = $ogIsArticle ? 'article' : 'website';
$ogSiteName = $site->title();
?>
+
+
+
@@ -100,18 +103,58 @@
+
+
+
+
+
+
+
'
+ . json_encode($doc, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+ . '' . "\n";
+ };
+
+ // Social profile URLs, used as schema.org "sameAs".
+ $blowditSameAs = array();
+ foreach (Theme::socialNetworks() as $snKey => $snLabel) {
+ $snUrl = $site->{$snKey}();
+ if (!empty($snUrl)) { $blowditSameAs[] = $snUrl; }
+ }
+
+ // WebSite (every page) — declares the site + a sitelinks search box.
+ $siteLd = array(
+ '@context' => 'https://schema.org',
+ '@type' => 'WebSite',
+ 'name' => blowdit_plain($site->title()),
+ 'url' => Theme::siteUrl(),
+ );
+ $siteDesc = $site->description() ?: $site->slogan();
+ if ($siteDesc) { $siteLd['description'] = blowdit_plain($siteDesc); }
+ $siteLd['potentialAction'] = array(
+ '@type' => 'SearchAction',
+ 'target' => array(
+ '@type' => 'EntryPoint',
+ 'urlTemplate' => rtrim(Theme::siteUrl(), '/') . '/search/{search_term_string}',
+ ),
+ 'query-input' => 'required name=search_term_string',
+ );
+ $blowditEmitLd($siteLd);
+
+ // BlogPosting + BreadcrumbList — articles only.
if ($ogIsArticle) {
$ldDescription = $page->description() ?: ($site->description() ?: $site->slogan());
$ldImage = $page->coverImage(true) ?: (DOMAIN_THEME . 'img/spleenftw.jpeg');
@@ -127,6 +170,13 @@
$ldTimeRequired = 'PT' . $rtMatch[0] . 'M';
}
+ $author = array(
+ '@type' => 'Person',
+ 'name' => blowdit_plain($page->username()),
+ 'url' => Theme::siteUrl(),
+ );
+ if (!empty($blowditSameAs)) { $author['sameAs'] = $blowditSameAs; }
+
$jsonLd = array(
'@context' => 'https://schema.org',
'@type' => 'BlogPosting',
@@ -135,11 +185,7 @@
'image' => $ldImage,
'datePublished' => $ldPublished,
'dateModified' => $ldModified,
- 'author' => array(
- '@type' => 'Person',
- 'name' => blowdit_plain($page->username()),
- 'url' => Theme::siteUrl(),
- ),
+ 'author' => $author,
'publisher' => array(
'@type' => 'Organization',
'name' => blowdit_plain($site->title()),
@@ -155,10 +201,29 @@
'wordCount' => str_word_count(strip_tags($page->content())),
);
if ($ldTimeRequired) { $jsonLd['timeRequired'] = $ldTimeRequired; }
+ $blowditEmitLd($jsonLd);
- echo '' . "\n";
+ // Breadcrumb: Home > [Category] > Article
+ $crumbs = array(array(
+ '@type' => 'ListItem', 'position' => 1,
+ 'name' => blowdit_plain($site->title()), 'item' => Theme::siteUrl(),
+ ));
+ $pos = 2;
+ if ($page->categoryKey()) {
+ $crumbs[] = array(
+ '@type' => 'ListItem', 'position' => $pos++,
+ 'name' => blowdit_plain($page->category()), 'item' => $page->categoryPermalink(),
+ );
+ }
+ $crumbs[] = array(
+ '@type' => 'ListItem', 'position' => $pos,
+ 'name' => blowdit_plain($page->title()), 'item' => $page->permalink(true),
+ );
+ $blowditEmitLd(array(
+ '@context' => 'https://schema.org',
+ '@type' => 'BreadcrumbList',
+ 'itemListElement' => $crumbs,
+ ));
}
?>
@@ -168,6 +233,10 @@
+
+
+
+
diff --git a/php/home.php b/php/home.php
index 677e03e..675ce8e 100644
--- a/php/home.php
+++ b/php/home.php
@@ -26,6 +26,12 @@
+
+
+
+
+
+
diff --git a/php/icons.php b/php/icons.php
index 027fded..cbbb55e 100644
--- a/php/icons.php
+++ b/php/icons.php
@@ -20,6 +20,8 @@ if (!function_exists('blowdit_icon')) {
'chevron-left' => '