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 @@ + +