+
+404
+get('Page not found'); ?>
+get('The page you are looking for does not exist or has moved.'); ?>
+ + + + get('Back to home'); ?> +diff --git a/css/style.css b/css/style.css index 7fa6ef3..558b370 100644 --- a/css/style.css +++ b/css/style.css @@ -1718,82 +1718,67 @@ footer.footer-modern a:hover { } /* ======================================== - COPY-TO-CLIPBOARD ON CODE BLOCKS + CODE BLOCKS — header bar (language label + copy button) ======================================== */ .code-block { - position: relative; + margin: var(--space-md) 0; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + overflow: hidden; +} + +/* The frame is owned by .code-block, so the inner
drops its own */
+.code-block pre {
+ margin: 0;
+ border: none;
+ border-radius: 0;
+}
+
+.code-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.3rem 0.5rem 0.3rem 0.9rem;
+ background: var(--bg-subtle);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.code-lang {
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ font-family: var(--font-sans);
}
.code-copy {
- position: absolute;
- top: 0.5rem;
- right: 0.5rem;
- z-index: 2;
- width: 32px;
- height: 32px;
- padding: 0;
- display: grid;
- place-items: center;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-sm);
- background: var(--bg-subtle);
- color: var(--text-secondary);
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
-}
-
-.code-block:hover .code-copy,
-.code-copy:focus {
- opacity: 1;
-}
-
-.code-copy:hover {
- color: var(--text-primary);
- border-color: var(--accent);
-}
-
-.code-copy.is-copied {
- opacity: 1;
- color: var(--accent-contrast);
- background: var(--accent);
- border-color: var(--accent);
-}
-
-.code-copy svg {
- display: block;
-}
-
-/* "Copied" toast — pops up just left of the button */
-.code-copy-toast {
- position: absolute;
- top: 0.5rem;
- right: calc(0.5rem + 32px + 8px); /* clears the 32px button + gap */
- height: 32px;
- display: flex;
+ display: inline-flex;
align-items: center;
- padding: 0 0.6rem;
+ gap: 0.35rem;
+ padding: 0.25rem 0.6rem;
+ border: 1px solid transparent;
border-radius: var(--radius-sm);
- background: var(--accent);
- color: var(--accent-contrast);
+ background: transparent;
+ color: var(--text-secondary);
font-size: var(--font-size-xs);
font-weight: 600;
- white-space: nowrap;
- pointer-events: none;
- opacity: 0;
- transform: translateX(6px);
- transition: opacity 0.15s ease, transform 0.15s ease;
+ font-family: var(--font-sans);
+ cursor: pointer;
+ transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
-.code-copy-toast.is-visible {
- opacity: 1;
- transform: translateX(0);
+.code-copy:hover { color: var(--text-primary); background: var(--bg-hover); }
+
+.code-copy.is-copied {
+ color: var(--accent-contrast);
+ background: var(--accent);
+ border-color: var(--accent);
}
-/* Touch devices have no hover — keep the button discoverable */
-@media (hover: none) {
- .code-copy { opacity: 0.7; }
-}
+.code-copy svg { display: block; }
+.code-copy-label { line-height: 1; }
/* ========================================
GPU HINTS — promote animated layers ahead of time
@@ -2086,6 +2071,172 @@ button:focus:not(:focus-visible) {
line-height: 1.3;
}
+/* ========================================
+ CALLOUTS / ADMONITIONS (> [!NOTE] etc.)
+ ======================================== */
+.callout {
+ margin: var(--space-md) 0;
+ padding: var(--space-sm) var(--space-md);
+ border-left: 4px solid var(--cl-color, var(--accent));
+ border-radius: var(--radius-sm);
+ background: var(--cl-bg, var(--bg-subtle));
+ color: var(--text-primary);
+ font-style: normal;
+}
+
+.callout > :last-child { margin-bottom: 0; }
+
+.callout-title {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-bottom: 0.4rem;
+ font-weight: 700;
+ color: var(--cl-color, var(--accent));
+}
+
+.callout-icon { flex: 0 0 auto; }
+
+.callout-note { --cl-color: #3b82f6; --cl-bg: rgba(59, 130, 246, 0.08); }
+.callout-tip { --cl-color: #22c55e; --cl-bg: rgba(34, 197, 94, 0.08); }
+.callout-important { --cl-color: #a855f7; --cl-bg: rgba(168, 85, 247, 0.08); }
+.callout-warning { --cl-color: #f59e0b; --cl-bg: rgba(245, 158, 11, 0.10); }
+.callout-caution { --cl-color: #ef4444; --cl-bg: rgba(239, 68, 68, 0.09); }
+
+/* ========================================
+ BREADCRUMBS
+ ======================================== */
+.breadcrumbs {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem;
+ margin-bottom: var(--space-sm);
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+.breadcrumbs a { color: var(--text-secondary); }
+.breadcrumbs a:hover { color: var(--accent); text-decoration: none; }
+.breadcrumb-sep { color: var(--text-muted); }
+.breadcrumb-current {
+ color: var(--text-muted);
+ max-width: 50ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* ========================================
+ CUSTOM 404
+ ======================================== */
+.page-404 .error-body {
+ text-align: center;
+ padding: var(--space-xl) var(--space-md);
+}
+
+.error-code {
+ margin: 0;
+ font-size: 5rem;
+ font-weight: 800;
+ line-height: 1;
+ color: var(--accent);
+}
+
+.error-text { color: var(--text-secondary); margin-bottom: var(--space-lg); }
+.error-search { max-width: 420px; margin: 0 auto var(--space-lg); }
+.error-home { display: inline-flex; align-items: center; gap: 0.4rem; }
+
+/* ========================================
+ FIGURES (image + caption)
+ ======================================== */
+.content figure,
+.content-figure {
+ margin: var(--space-md) 0;
+ text-align: center;
+}
+
+.content figcaption,
+.content-figure figcaption {
+ margin-top: 0.5rem;
+ font-size: var(--font-size-sm);
+ font-style: italic;
+ color: var(--text-muted);
+}
+
+/* ========================================
+ MOBILE TABLE OF CONTENTS (floating button + drawer)
+ ======================================== */
+.toc-fab {
+ position: fixed;
+ left: 1.5rem;
+ bottom: 1.5rem;
+ z-index: 1035;
+ width: 44px;
+ height: 44px;
+ display: grid;
+ place-items: center;
+ border: none;
+ border-radius: 50%;
+ background: var(--accent);
+ color: var(--accent-contrast);
+ box-shadow: var(--shadow-md);
+ cursor: pointer;
+}
+
+.toc-fab svg { display: block; }
+
+.toc-drawer {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: flex-end;
+ background: rgba(0, 0, 0, 0.5);
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.2s ease, visibility 0s linear 0.2s;
+}
+
+.toc-drawer.is-open {
+ opacity: 1;
+ visibility: visible;
+ transition: opacity 0.2s ease;
+}
+
+.toc-drawer-panel {
+ width: 100%;
+ max-height: 70vh;
+ overflow-y: auto;
+ padding: var(--space-lg);
+ background: var(--bg-surface);
+ border-top-left-radius: var(--radius-lg);
+ border-top-right-radius: var(--radius-lg);
+ transform: translateY(100%);
+ transition: transform 0.25s ease;
+}
+
+.toc-drawer.is-open .toc-drawer-panel { transform: translateY(0); }
+
+.toc-drawer-title {
+ margin-bottom: var(--space-sm);
+ font-size: var(--font-size-xs);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-muted);
+}
+
+.toc-drawer .toc-list { list-style: none; padding: 0; margin: 0; }
+.toc-drawer .toc-link { display: block; padding: 0.5rem 0; color: var(--text-secondary); }
+.toc-drawer .toc-link:hover { color: var(--accent); text-decoration: none; }
+
+/* The sidebar ToC covers desktop, so the floating button is mobile-only */
+@media (min-width: 768px) {
+ .toc-fab,
+ .toc-drawer { display: none !important; }
+}
+
/* ========================================
RESPONSIVE
======================================== */
diff --git a/js/blowdit.js b/js/blowdit.js
index a18a63c..ddf2f1d 100644
--- a/js/blowdit.js
+++ b/js/blowdit.js
@@ -22,7 +22,8 @@
goToSlide: I18N.goToSlide || 'Go to slide',
prevImage: I18N.prevImage || 'Previous image',
nextImage: I18N.nextImage || 'Next image',
- imageViewer: I18N.imageViewer || 'Image viewer'
+ imageViewer: I18N.imageViewer || 'Image viewer',
+ onThisPage: I18N.onThisPage || 'On this page'
};
// Clipboard helper (Clipboard API with a legacy fallback).
@@ -623,58 +624,82 @@
});
}
- // Inline SVGs — centred in their own viewBox, so no icon-font baseline /
- // side-bearing offsets to fight.
- var SVG_COPY = '';
- var SVG_CHECK = '';
+ // Pretty display names for common language classes.
+ var LANG = {
+ js: 'JavaScript', javascript: 'JavaScript', ts: 'TypeScript', typescript: 'TypeScript',
+ json: 'JSON', html: 'HTML', xml: 'XML', css: 'CSS', scss: 'SCSS', bash: 'Bash',
+ sh: 'Shell', shell: 'Shell', zsh: 'Zsh', console: 'Shell', powershell: 'PowerShell',
+ ps1: 'PowerShell', python: 'Python', py: 'Python', php: 'PHP', go: 'Go', golang: 'Go',
+ rust: 'Rust', rs: 'Rust', c: 'C', cpp: 'C++', csharp: 'C#', cs: 'C#', java: 'Java',
+ kotlin: 'Kotlin', ruby: 'Ruby', rb: 'Ruby', sql: 'SQL', yaml: 'YAML', yml: 'YAML',
+ toml: 'TOML', ini: 'INI', dockerfile: 'Dockerfile', docker: 'Dockerfile', nginx: 'Nginx',
+ apache: 'Apache', diff: 'Diff', md: 'Markdown', markdown: 'Markdown', text: 'Text',
+ plaintext: 'Text'
+ };
+
+ function langName(code) {
+ if (!code) return '';
+ var m = (code.className || '').match(/language-([\w-]+)/i);
+ if (!m) return '';
+ var key = m[1].toLowerCase();
+ if (key === 'tabs' || key === 'carousel') return ''; // theme fences, not languages
+ return LANG[key] || (key.charAt(0).toUpperCase() + key.slice(1));
+ }
+
function enhance(pre) {
// Skip if already wrapped
if (pre.parentNode && pre.parentNode.classList &&
pre.parentNode.classList.contains('code-block')) return;
+ var code = pre.querySelector('code');
+
var wrap = document.createElement('div');
wrap.className = 'code-block';
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(pre);
+ // Header bar: language label (left) + copy button (right)
+ var header = document.createElement('div');
+ header.className = 'code-header';
+
+ var lang = document.createElement('span');
+ lang.className = 'code-lang';
+ lang.textContent = langName(code);
+ header.appendChild(lang);
+
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'code-copy';
btn.setAttribute('aria-label', T.copyCode);
- btn.innerHTML = SVG_COPY;
+ btn.innerHTML = SVG_COPY + '' + T.copy + '';
+ header.appendChild(btn);
- // "Copied" toast that pops up next to the button
- var toast = document.createElement('span');
- toast.className = 'code-copy-toast';
- toast.textContent = T.copied;
+ wrap.insertBefore(header, pre);
var resetTimer = null;
btn.addEventListener('click', function () {
- var code = pre.querySelector('code');
var text = (code ? code.textContent : pre.textContent) || '';
copyText(text).then(function () {
btn.classList.add('is-copied');
- btn.innerHTML = SVG_CHECK;
+ btn.innerHTML = SVG_CHECK + '' + T.copied + '';
btn.setAttribute('aria-label', T.copied);
- toast.classList.add('is-visible');
clearTimeout(resetTimer);
resetTimer = setTimeout(function () {
btn.classList.remove('is-copied');
- btn.innerHTML = SVG_COPY;
+ btn.innerHTML = SVG_COPY + '' + T.copy + '';
btn.setAttribute('aria-label', T.copyCode);
- toast.classList.remove('is-visible');
- }, 1000);
+ }, 1200);
}).catch(function () {});
});
-
- wrap.appendChild(btn);
- wrap.appendChild(toast);
}
var pres = document.querySelectorAll('.content pre, .tab-panel pre');
@@ -969,4 +994,119 @@
});
})();
+ /* --------------------------------------------------------
+ Callout / admonition boxes from GitHub-style blockquotes:
+ > [!NOTE] / [!TIP] / [!IMPORTANT] / [!WARNING] / [!CAUTION] (or [!DANGER]).
+ -------------------------------------------------------- */
+ (function () {
+ var content = document.querySelector('.content');
+ if (!content) return;
+
+ function svg(inner) {
+ return '';
+ }
+ var ICONS = {
+ note: svg(' '),
+ tip: svg(' '),
+ important: svg(' '),
+ warning: svg(' '),
+ caution: svg(' ')
+ };
+ var LABELS = { note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' };
+ var ALIAS = { danger: 'caution' };
+
+ Array.prototype.forEach.call(content.querySelectorAll('blockquote'), function (bq) {
+ var first = bq.querySelector('p');
+ if (!first) return;
+ var m = first.innerHTML.match(/^\s*\[!(\w+)\]\s*(
)?\s*/i);
+ if (!m) return;
+ var type = m[1].toLowerCase();
+ if (ALIAS[type]) type = ALIAS[type];
+ if (!ICONS[type]) return;
+
+ first.innerHTML = first.innerHTML.replace(m[0], '');
+ if (!first.innerHTML.trim()) { first.parentNode.removeChild(first); }
+
+ bq.classList.add('callout', 'callout-' + type);
+ var title = document.createElement('div');
+ title.className = 'callout-title';
+ title.innerHTML = ICONS[type] + '' + LABELS[type] + '';
+ bq.insertBefore(title, bq.firstChild);
+ });
+ })();
+
+ /* --------------------------------------------------------
+ Figures: turn a standalone content image WITH a title into a
+ + (the markdown title becomes the caption).
+ Runs after the lightbox so it can wrap the .zoomable-wrap.
+ -------------------------------------------------------- */
+ (function () {
+ var content = document.querySelector('.content');
+ if (!content) return;
+ Array.prototype.forEach.call(content.querySelectorAll('img'), function (img) {
+ var cap = img.getAttribute('title');
+ if (!cap) return;
+ if (img.closest('figure')) return;
+ var node = img.closest('.zoomable-wrap') || img;
+ var p = node.parentNode;
+ // Only convert images that sit alone in their paragraph.
+ if (!p || p.tagName !== 'P' || p.textContent.trim() !== '' || p.querySelectorAll('img').length !== 1) return;
+ var fig = document.createElement('figure');
+ fig.className = 'content-figure';
+ fig.appendChild(node);
+ var capEl = document.createElement('figcaption');
+ capEl.textContent = cap;
+ fig.appendChild(capEl);
+ p.parentNode.replaceChild(fig, p);
+ });
+ })();
+
+ /* --------------------------------------------------------
+ Mobile ToC — a floating button that opens the table of contents in a
+ drawer (the sidebar ToC is desktop-only). Mirrors the generated list.
+ -------------------------------------------------------- */
+ (function () {
+ var tocNav = document.getElementById('toc-nav');
+ if (!tocNav) return;
+ var list = tocNav.querySelector('.toc-list');
+ if (!list) return; // no headings -> no ToC
+
+ var fab = document.createElement('button');
+ fab.type = 'button';
+ fab.className = 'toc-fab';
+ fab.setAttribute('aria-label', T.onThisPage);
+ fab.innerHTML = '';
+
+ var drawer = document.createElement('div');
+ drawer.className = 'toc-drawer';
+ var panel = document.createElement('div');
+ panel.className = 'toc-drawer-panel';
+ var title = document.createElement('div');
+ title.className = 'toc-drawer-title';
+ title.textContent = T.onThisPage;
+ panel.appendChild(title);
+ var clone = list.cloneNode(true);
+ panel.appendChild(clone);
+ drawer.appendChild(panel);
+
+ document.body.appendChild(fab);
+ document.body.appendChild(drawer);
+
+ function open() { drawer.classList.add('is-open'); document.body.style.overflow = 'hidden'; }
+ function close() { drawer.classList.remove('is-open'); document.body.style.overflow = ''; }
+
+ fab.addEventListener('click', open);
+ drawer.addEventListener('click', function (e) { if (e.target === drawer) close(); });
+ Array.prototype.forEach.call(clone.querySelectorAll('a'), function (a) {
+ a.addEventListener('click', close);
+ });
+ document.addEventListener('keydown', function (e) {
+ if ((e.key === 'Escape' || e.key === 'Esc') && drawer.classList.contains('is-open')) close();
+ });
+ })();
+
})();
diff --git a/languages/en.json b/languages/en.json
index afc10d3..845050e 100644
--- a/languages/en.json
+++ b/languages/en.json
@@ -18,6 +18,11 @@
"Copy link": "Copy link",
"Link copied": "Link copied",
"Related posts": "Related posts",
+ "Home": "Home",
+ "On this page": "On this page",
+ "Page not found": "Page not found",
+ "The page you are looking for does not exist or has moved.": "The page you are looking for does not exist or has moved.",
+ "Back to home": "Back to home",
"Previous": "Previous",
"Next": "Next",
"Updated": "Updated",
diff --git a/metadata.json b/metadata.json
index 8ff721f..f1c864d 100644
--- a/metadata.json
+++ b/metadata.json
@@ -2,9 +2,9 @@
"author": "spleenftw",
"email": "",
"website": "https://github.com/Spleenftw/blowdit",
- "version": "1.4.0",
+ "version": "1.5.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. 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)."
+ "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). v1.5.0: callout/admonition boxes (> [!NOTE]/[!TIP]/[!WARNING]/...), code-block header bar with language label + copy button, visual breadcrumbs, custom 404 page, image figure/figcaption from the markdown title, a mobile table-of-contents drawer, and third-party resource hints."
}
diff --git a/php/head.php b/php/head.php
index b3a42db..cf4ded3 100644
--- a/php/head.php
+++ b/php/head.php
@@ -21,6 +21,13 @@
+
+
+
+
+
+
+
diff --git a/php/page.php b/php/page.php
index fef8207..29bcf20 100644
--- a/php/page.php
+++ b/php/page.php
@@ -1,3 +1,20 @@
+notFound()) : ?>
+
+
+
+
+ 404
+ get('Page not found'); ?>
+ get('The page you are looking for does not exist or has moved.'); ?>
+
+
+
+ get('Back to home'); ?>
+
+
+
+
+
@@ -12,6 +29,20 @@
+
+
+ isStatic() && !$url->notFound()) : ?>
+
+
+
title()); ?>
@@ -155,3 +186,5 @@
+
+