From d448574ff8414a01d739c78730132786d1126ee6 Mon Sep 17 00:00:00 2001 From: interlope Date: Tue, 2 Jun 2026 23:10:32 +0200 Subject: [PATCH] feat: add email sharing functionality, canonical URLs, and enhance metadata for SEO --- css/style.css | 178 +++++++++++++++++++++++++++++++++++++++++++ img/mail.svg | 1 + index.php | 12 +++ js/blowdit.js | 99 ++++++++++++++++++++++++ languages/en.json | 10 +++ manifest.webmanifest | 18 +++++ metadata.json | 4 +- php/head.php | 87 ++++++++++++++++++--- php/home.php | 6 ++ php/icons.php | 2 + php/navbar.php | 10 +++ php/page.php | 98 +++++++++++++++++++++++- 12 files changed, 513 insertions(+), 12 deletions(-) create mode 100644 img/mail.svg create mode 100644 manifest.webmanifest 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 @@ +Email 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 @@ <?php echo $L->get('RSS'); ?> + + + + <?php echo $L->get('Email'); ?> + + 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' => '', 'chevron-right' => '', 'house' => '', + 'link' => '', + 'share' => '', // --- Theme picker (mirror in blowdit.js) --- 'sun' => '', 'moon' => '', diff --git a/php/navbar.php b/php/navbar.php index 4b6c9ad..f2717ca 100644 --- a/php/navbar.php +++ b/php/navbar.php @@ -47,6 +47,16 @@ get('RSS'); ?> + + + + + diff --git a/php/page.php b/php/page.php index 359b939..100666f 100644 --- a/php/page.php +++ b/php/page.php @@ -16,10 +16,23 @@

title()); ?>

isStatic() && !$url->notFound()): ?> - + + getValue('date'); + $bdModRaw = $page->getValue('dateModified'); + $bdUpdated = ''; + if ($bdModRaw && class_exists('Date') + && substr($bdModRaw, 0, 10) !== substr($bdPubRaw, 0, 10) + && $bdModRaw > $bdPubRaw) { + $bdUpdated = Date::format($bdModRaw, DB_DATE_FORMAT, 'M j, Y'); + } + ?> @@ -52,9 +65,92 @@ + + isStatic() && !$url->notFound()) : ?> + permalink(true); $bdShareTitle = blowdit_plain($page->title()); ?> +
+ get('Share'); ?> + X + LinkedIn + +
+ + + +isStatic() && !$url->notFound()) { + $bdOlder = null; $bdNewer = null; $bdRelated = array(); + try { + global $pages; + if (isset($pages) && method_exists($pages, 'getList')) { + $bdKeys = $pages->getList(1, 10000, true); // published, newest-first + if (is_array($bdKeys)) { + $bdKeys = array_values($bdKeys); + $bdCur = $page->key(); + $bdI = array_search($bdCur, $bdKeys, true); + if ($bdI !== false) { + if (isset($bdKeys[$bdI + 1])) { $bdOlder = new Page($bdKeys[$bdI + 1]); } + if ($bdI > 0 && isset($bdKeys[$bdI - 1])) { $bdNewer = new Page($bdKeys[$bdI - 1]); } + } + $bdCat = $page->categoryKey(); + $bdTags = array_keys($page->tags(true)); + foreach ($bdKeys as $bdK) { + if ($bdK === $bdCur) { continue; } + if (count($bdRelated) >= 3) { break; } + $bdCand = new Page($bdK); + $bdMatch = ($bdCat && $bdCand->categoryKey() === $bdCat); + if (!$bdMatch && $bdTags) { + $bdMatch = (bool) array_intersect($bdTags, array_keys($bdCand->tags(true))); + } + if ($bdMatch) { $bdRelated[] = $bdCand; } + } + } + } + } catch (\Throwable $e) { + $bdOlder = null; $bdNewer = null; $bdRelated = array(); + } + ?> + + + + + + + + + +