feat: enhance theme functionality and add social sharing metadata
- Added new UI strings for theme toggle, copy functionality, and navigation. - Implemented dynamic theme color for mobile browsers based on user selection. - Introduced Open Graph and Twitter Card metadata for improved social sharing. - Added JSON-LD structured data for articles to enhance SEO. - Created a new JavaScript file for theme behavior, including theme picker, reading progress bar, back-to-top button, and image lightbox functionality. - Enhanced code blocks with copy-to-clipboard buttons and improved tabbed code block functionality. - Implemented a random navigation order feature and a table of contents generator for articles.
This commit is contained in:
1 parent
73dfdc895e
commit
231f5a195d
5 files changed
+988
-466
No files matched your search
+159
@@ -1539,6 +1539,165 @@ footer.footer-modern a:hover {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
SKIP-TO-CONTENT LINK (keyboard / a11y)
|
||||||
|
======================================== */
|
||||||
|
.skip-link {
|
||||||
|
position: fixed;
|
||||||
|
top: -120px;
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 3000;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: top 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 1rem;
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
text-decoration: none;
|
||||||
|
outline: 2px solid var(--accent-contrast);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The skip target shouldn't paint a focus ring when jumped to */
|
||||||
|
#main-content:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
READING PROGRESS BAR
|
||||||
|
======================================== */
|
||||||
|
.reading-progress {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
z-index: 1040; /* above the fixed-top navbar (Bootstrap z-index 1030) */
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.05s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BACK-TO-TOP BUTTON
|
||||||
|
======================================== */
|
||||||
|
.back-to-top {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 1035;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s linear 0.2s, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-top.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s linear 0s, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-top:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
COPY-TO-CLIPBOARD ON CODE BLOCKS
|
||||||
|
======================================== */
|
||||||
|
.code-block {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: 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 i {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch devices have no hover — keep the button discoverable */
|
||||||
|
@media (hover: none) {
|
||||||
|
.code-copy { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
GPU HINTS — promote animated layers ahead of time
|
||||||
|
======================================== */
|
||||||
|
.lightbox-overlay img,
|
||||||
|
.carousel-slide img {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect users who prefer reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reading-progress-fill { transition: none; }
|
||||||
|
.back-to-top,
|
||||||
|
.back-to-top.is-visible { transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
</head>
|
</head>
|
||||||
<body<?php echo $blowditHasHeadings ? ' class="has-toc"' : ''; ?>>
|
<body<?php echo $blowditHasHeadings ? ' class="has-toc"' : ''; ?>>
|
||||||
|
|
||||||
|
<!-- Skip to content (keyboard / screen-reader navigation) -->
|
||||||
|
<a class="skip-link" href="#main-content"><?php echo $L->get('Skip to content'); ?></a>
|
||||||
|
|
||||||
<!-- Load Bludit Plugins: Site Body Begin -->
|
<!-- Load Bludit Plugins: Site Body Begin -->
|
||||||
<?php Theme::plugins('siteBodyBegin'); ?>
|
<?php Theme::plugins('siteBodyBegin'); ?>
|
||||||
|
|
||||||
@@ -105,7 +108,7 @@
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="<?php echo $hasHeadings ? 'container-wide' : 'container'; ?>">
|
<div id="main-content" tabindex="-1" class="<?php echo $hasHeadings ? 'container-wide' : 'container'; ?>">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
||||||
<?php if ($hasHeadings): ?>
|
<?php if ($hasHeadings): ?>
|
||||||
@@ -157,473 +160,31 @@
|
|||||||
echo Theme::jsBootstrap();
|
echo Theme::jsBootstrap();
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<!-- Theme picker -->
|
<!-- Theme behaviour: deferred, cached external bundle.
|
||||||
|
The anti-flash theme bootstrap stays inline in head.php — it must
|
||||||
|
run before the first paint and so cannot be deferred. -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
// Localised UI strings for the deferred bundle. $L->get() falls back to
|
||||||
var STORAGE_KEY = 'blowdit-theme';
|
// the key itself when a translation is missing, so this is safe in any language.
|
||||||
var THEMES = ['light', 'dark', 'nord', 'dracula', 'catppuccin'];
|
window.BLOWDIT_I18N = {
|
||||||
var ICONS = {
|
copy: <?php echo json_encode($L->get('Copy')); ?>,
|
||||||
light: 'bi-brightness-high',
|
copied: <?php echo json_encode($L->get('Copied')); ?>,
|
||||||
dark: 'bi-moon-stars',
|
copyCode: <?php echo json_encode($L->get('Copy code')); ?>,
|
||||||
nord: 'bi-snow',
|
backToTop: <?php echo json_encode($L->get('Back to top')); ?>,
|
||||||
dracula: 'bi-droplet',
|
prevSlide: <?php echo json_encode($L->get('Previous slide')); ?>,
|
||||||
catppuccin: 'bi-cup'
|
nextSlide: <?php echo json_encode($L->get('Next slide')); ?>,
|
||||||
|
goToSlide: <?php echo json_encode($L->get('Go to slide')); ?>,
|
||||||
|
prevImage: <?php echo json_encode($L->get('Previous image')); ?>,
|
||||||
|
nextImage: <?php echo json_encode($L->get('Next image')); ?>,
|
||||||
|
imageViewer: <?php echo json_encode($L->get('Image viewer')); ?>
|
||||||
};
|
};
|
||||||
var root = document.documentElement;
|
|
||||||
var btn = document.getElementById('theme-toggle');
|
|
||||||
var picker = document.getElementById('theme-picker');
|
|
||||||
|
|
||||||
function syncIcon(theme) {
|
|
||||||
if (!btn) return;
|
|
||||||
var icon = btn.querySelector('i');
|
|
||||||
if (!icon) return;
|
|
||||||
icon.className = 'bi ' + (ICONS[theme] || 'bi-circle-half');
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncSwatches(theme) {
|
|
||||||
if (!picker) return;
|
|
||||||
Array.prototype.forEach.call(picker.querySelectorAll('.swatch'), function (s) {
|
|
||||||
s.classList.toggle('is-active', s.getAttribute('data-pick') === theme);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function apply(theme) {
|
|
||||||
root.setAttribute('data-theme', theme);
|
|
||||||
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
|
|
||||||
document.cookie = 'blowdit-theme=' + theme + '; path=/; max-age=31536000; SameSite=Lax';
|
|
||||||
var bg = window.BLOWDIT_THEME_BG || {};
|
|
||||||
if (bg[theme]) { root.style.backgroundColor = bg[theme]; }
|
|
||||||
root.style.colorScheme = (theme === 'light') ? 'light' : 'dark';
|
|
||||||
syncIcon(theme);
|
|
||||||
syncSwatches(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPicker() {
|
|
||||||
if (!picker) return;
|
|
||||||
picker.classList.add('is-open');
|
|
||||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePicker() {
|
|
||||||
if (!picker) return;
|
|
||||||
picker.classList.remove('is-open');
|
|
||||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swatch clicks
|
|
||||||
if (picker) {
|
|
||||||
Array.prototype.forEach.call(picker.querySelectorAll('.swatch'), function (s) {
|
|
||||||
s.addEventListener('click', function () {
|
|
||||||
apply(s.getAttribute('data-pick'));
|
|
||||||
closePicker();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle button: open / close picker
|
|
||||||
if (btn) {
|
|
||||||
btn.addEventListener('click', function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
picker && picker.classList.contains('is-open') ? closePicker() : openPicker();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (!picker || !picker.classList.contains('is-open')) return;
|
|
||||||
if (btn && btn.contains(e.target)) return;
|
|
||||||
if (picker.contains(e.target)) return;
|
|
||||||
closePicker();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialise icons + active swatch
|
|
||||||
var current = root.getAttribute('data-theme') || 'light';
|
|
||||||
if (THEMES.indexOf(current) === -1) current = 'light';
|
|
||||||
syncIcon(current);
|
|
||||||
syncSwatches(current);
|
|
||||||
|
|
||||||
// Follow OS preference unless the visitor chose explicitly
|
|
||||||
if (window.matchMedia) {
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
|
|
||||||
try { if (localStorage.getItem(STORAGE_KEY)) return; } catch (err) {}
|
|
||||||
apply(e.matches ? 'dark' : 'light');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Randomise the order of the navigation / pages list on each load -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
function shuffle(arr) {
|
|
||||||
for (var i = arr.length - 1; i > 0; i--) {
|
|
||||||
var j = Math.floor(Math.random() * (i + 1));
|
|
||||||
var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
var lists = document.querySelectorAll('.js-random-nav ul');
|
|
||||||
Array.prototype.forEach.call(lists, function (ul) {
|
|
||||||
var items = Array.prototype.filter.call(ul.children, function (el) {
|
|
||||||
return el.tagName === 'LI';
|
|
||||||
});
|
|
||||||
shuffle(items).forEach(function (li) { ul.appendChild(li); });
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Table of Contents: generate links from article headings + scroll-spy -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var tocNav = document.getElementById('toc-nav');
|
|
||||||
if (!tocNav) return;
|
|
||||||
|
|
||||||
var content = document.querySelector('.content');
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
var headings = content.querySelectorAll('h2, h3, h4');
|
|
||||||
if (headings.length === 0) return;
|
|
||||||
|
|
||||||
// Ensure every heading has a stable anchor ID
|
|
||||||
var usedIds = {};
|
|
||||||
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, '') || 'heading';
|
|
||||||
var id = base, n = 2;
|
|
||||||
while (usedIds[id]) { id = base + '-' + (n++); }
|
|
||||||
usedIds[id] = true;
|
|
||||||
h.id = id;
|
|
||||||
} else {
|
|
||||||
usedIds[h.id] = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build the list
|
|
||||||
var ul = document.createElement('ul');
|
|
||||||
ul.className = 'toc-list';
|
|
||||||
Array.prototype.forEach.call(headings, function (h) {
|
|
||||||
var li = document.createElement('li');
|
|
||||||
li.className = 'toc-item toc-' + h.tagName.toLowerCase();
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.href = '#' + h.id;
|
|
||||||
a.textContent = h.textContent;
|
|
||||||
a.className = 'toc-link';
|
|
||||||
li.appendChild(a);
|
|
||||||
ul.appendChild(li);
|
|
||||||
});
|
|
||||||
tocNav.appendChild(ul);
|
|
||||||
|
|
||||||
// Scroll-spy: highlight the last heading that has scrolled past the top
|
|
||||||
var headingArr = Array.prototype.slice.call(headings);
|
|
||||||
|
|
||||||
function updateActive() {
|
|
||||||
var scrollY = window.scrollY || window.pageYOffset;
|
|
||||||
var threshold = scrollY + 120; // offset for navbar height
|
|
||||||
var active = null;
|
|
||||||
headingArr.forEach(function (h) {
|
|
||||||
var top = h.getBoundingClientRect().top + scrollY;
|
|
||||||
if (top <= threshold) active = h;
|
|
||||||
});
|
|
||||||
var links = tocNav.querySelectorAll('.toc-link');
|
|
||||||
Array.prototype.forEach.call(links, function (a) { a.classList.remove('active'); });
|
|
||||||
if (active) {
|
|
||||||
var link = tocNav.querySelector('a[href="#' + active.id + '"]');
|
|
||||||
if (link) link.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('scroll', updateActive, { passive: true });
|
|
||||||
updateActive();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Tabbed code blocks: transforms ```tabs fences into a tab UI -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
// Match both "language-tabs" (Parsedown ≥ 1.7) and bare "tabs" (older Bludit builds)
|
|
||||||
var blocks = document.querySelectorAll(
|
|
||||||
'pre > code.language-tabs, pre > code.tabs'
|
|
||||||
);
|
|
||||||
Array.prototype.forEach.call(blocks, function (code) {
|
|
||||||
var pre = code.parentNode;
|
|
||||||
var raw = code.textContent;
|
|
||||||
|
|
||||||
// Supports "[Name]" and "@tab Name" as tab headers
|
|
||||||
var tabs = [];
|
|
||||||
var current = null;
|
|
||||||
raw.split('\n').forEach(function (line) {
|
|
||||||
line = line.replace(/\r$/, ''); // strip Windows CR
|
|
||||||
var m = line.match(/^\[([^\]]+)\]\s*$/) ||
|
|
||||||
line.match(/^@tab\s+(.+?)\s*$/);
|
|
||||||
if (m) {
|
|
||||||
current = { name: m[1], lines: [] };
|
|
||||||
tabs.push(current);
|
|
||||||
} else if (current) {
|
|
||||||
current.lines.push(line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!tabs.length) return;
|
|
||||||
|
|
||||||
// Trim leading / trailing blank lines from each tab
|
|
||||||
tabs.forEach(function (t) {
|
|
||||||
while (t.lines.length && !t.lines[0].trim()) t.lines.shift();
|
|
||||||
while (t.lines.length && !t.lines[t.lines.length - 1].trim()) t.lines.pop();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build the tab group
|
|
||||||
var group = document.createElement('div');
|
|
||||||
group.className = 'tab-group';
|
|
||||||
|
|
||||||
var list = document.createElement('div');
|
|
||||||
list.className = 'tab-list';
|
|
||||||
list.setAttribute('role', 'tablist');
|
|
||||||
group.appendChild(list);
|
|
||||||
|
|
||||||
var panels = [];
|
|
||||||
tabs.forEach(function (t, i) {
|
|
||||||
// Tab button
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'tab-btn' + (i === 0 ? ' is-active' : '');
|
|
||||||
btn.setAttribute('role', 'tab');
|
|
||||||
btn.textContent = t.name;
|
|
||||||
list.appendChild(btn);
|
|
||||||
|
|
||||||
// Tab panel
|
|
||||||
var panel = document.createElement('div');
|
|
||||||
panel.className = 'tab-panel' + (i === 0 ? ' is-active' : '');
|
|
||||||
panel.setAttribute('role', 'tabpanel');
|
|
||||||
var panelPre = document.createElement('pre');
|
|
||||||
var panelCode = document.createElement('code');
|
|
||||||
panelCode.textContent = t.lines.join('\n');
|
|
||||||
panelPre.appendChild(panelCode);
|
|
||||||
panel.appendChild(panelPre);
|
|
||||||
group.appendChild(panel);
|
|
||||||
panels.push({ btn: btn, panel: panel });
|
|
||||||
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
panels.forEach(function (p) {
|
|
||||||
p.btn.classList.remove('is-active');
|
|
||||||
p.panel.classList.remove('is-active');
|
|
||||||
});
|
|
||||||
btn.classList.add('is-active');
|
|
||||||
panel.classList.add('is-active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
pre.parentNode.replaceChild(group, pre);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Image carousel: transforms ```carousel fences into a slideshow -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var blocks = document.querySelectorAll('pre > code.language-carousel');
|
|
||||||
Array.prototype.forEach.call(blocks, function (code) {
|
|
||||||
var pre = code.parentNode;
|
|
||||||
|
|
||||||
// Accepts "", "url | caption", or bare "url"
|
|
||||||
var slides = [];
|
|
||||||
code.textContent.split('\n').forEach(function (line) {
|
|
||||||
line = line.replace(/\r$/, '').trim();
|
|
||||||
if (!line) return;
|
|
||||||
// Markdown image syntax: 
|
|
||||||
var md = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
|
||||||
if (md) {
|
|
||||||
slides.push({ url: md[2].trim(), caption: md[1].trim() });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Plain: "url | caption" or bare url
|
|
||||||
var parts = line.split('|');
|
|
||||||
var url = parts[0].trim();
|
|
||||||
if (url) slides.push({ url: url, caption: (parts[1] || '').trim() });
|
|
||||||
});
|
|
||||||
if (!slides.length) return;
|
|
||||||
|
|
||||||
var car = document.createElement('div');
|
|
||||||
car.className = 'carousel';
|
|
||||||
|
|
||||||
var track = document.createElement('div');
|
|
||||||
track.className = 'carousel-track';
|
|
||||||
car.appendChild(track);
|
|
||||||
|
|
||||||
var slideEls = [];
|
|
||||||
var dotEls = [];
|
|
||||||
var current = 0;
|
|
||||||
|
|
||||||
slides.forEach(function (s, i) {
|
|
||||||
var slide = document.createElement('div');
|
|
||||||
slide.className = 'carousel-slide' + (i === 0 ? ' is-active' : '');
|
|
||||||
var img = document.createElement('img');
|
|
||||||
img.src = s.url;
|
|
||||||
img.alt = s.caption;
|
|
||||||
img.loading = 'lazy';
|
|
||||||
slide.appendChild(img);
|
|
||||||
if (s.caption) {
|
|
||||||
var cap = document.createElement('p');
|
|
||||||
cap.className = 'carousel-caption';
|
|
||||||
cap.textContent = s.caption;
|
|
||||||
slide.appendChild(cap);
|
|
||||||
}
|
|
||||||
track.appendChild(slide);
|
|
||||||
slideEls.push(slide);
|
|
||||||
});
|
|
||||||
|
|
||||||
function goTo(idx) {
|
|
||||||
slideEls[current].classList.remove('is-active');
|
|
||||||
if (dotEls[current]) dotEls[current].classList.remove('is-active');
|
|
||||||
current = (idx + slides.length) % slides.length;
|
|
||||||
slideEls[current].classList.add('is-active');
|
|
||||||
if (dotEls[current]) dotEls[current].classList.add('is-active');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slides.length > 1) {
|
|
||||||
var prev = document.createElement('button');
|
|
||||||
prev.type = 'button'; prev.className = 'carousel-arrow carousel-prev';
|
|
||||||
prev.textContent = '‹';
|
|
||||||
prev.addEventListener('click', function () { goTo(current - 1); });
|
|
||||||
car.appendChild(prev);
|
|
||||||
|
|
||||||
var next = document.createElement('button');
|
|
||||||
next.type = 'button'; next.className = 'carousel-arrow carousel-next';
|
|
||||||
next.textContent = '›';
|
|
||||||
next.addEventListener('click', function () { goTo(current + 1); });
|
|
||||||
car.appendChild(next);
|
|
||||||
|
|
||||||
var dots = document.createElement('div');
|
|
||||||
dots.className = 'carousel-dots';
|
|
||||||
slides.forEach(function (_, i) {
|
|
||||||
var dot = document.createElement('button');
|
|
||||||
dot.type = 'button';
|
|
||||||
dot.className = 'carousel-dot' + (i === 0 ? ' is-active' : '');
|
|
||||||
dot.addEventListener('click', function () { goTo(i); });
|
|
||||||
dots.appendChild(dot);
|
|
||||||
dotEls.push(dot);
|
|
||||||
});
|
|
||||||
car.appendChild(dots);
|
|
||||||
|
|
||||||
// Swipe support
|
|
||||||
var startX = 0;
|
|
||||||
track.addEventListener('touchstart', function (e) { startX = e.touches[0].clientX; }, { passive: true });
|
|
||||||
track.addEventListener('touchend', function (e) {
|
|
||||||
var dx = e.changedTouches[0].clientX - startX;
|
|
||||||
if (Math.abs(dx) > 40) goTo(dx < 0 ? current + 1 : current - 1);
|
|
||||||
}, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.parentNode.replaceChild(car, pre);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Lightbox: click any article image to zoom; carousel-aware prev/next -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var content = document.querySelector('.content');
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
var images = content.querySelectorAll('img');
|
|
||||||
if (!images.length) return;
|
|
||||||
|
|
||||||
// Build overlay with optional prev/next arrows for carousel galleries
|
|
||||||
var overlay = document.createElement('div');
|
|
||||||
overlay.className = 'lightbox-overlay';
|
|
||||||
|
|
||||||
var prevBtn = document.createElement('button');
|
|
||||||
prevBtn.type = 'button';
|
|
||||||
prevBtn.className = 'lightbox-arrow lightbox-prev';
|
|
||||||
prevBtn.textContent = '‹'; // ‹
|
|
||||||
|
|
||||||
var zoomed = document.createElement('img');
|
|
||||||
|
|
||||||
var nextBtn = document.createElement('button');
|
|
||||||
nextBtn.type = 'button';
|
|
||||||
nextBtn.className = 'lightbox-arrow lightbox-next';
|
|
||||||
nextBtn.textContent = '›'; // ›
|
|
||||||
|
|
||||||
overlay.appendChild(prevBtn);
|
|
||||||
overlay.appendChild(zoomed);
|
|
||||||
overlay.appendChild(nextBtn);
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
var gallery = []; // images in the current carousel (empty = single image)
|
|
||||||
var galIdx = 0;
|
|
||||||
|
|
||||||
function showAt(idx) {
|
|
||||||
galIdx = (idx + gallery.length) % gallery.length;
|
|
||||||
zoomed.src = gallery[galIdx].src;
|
|
||||||
zoomed.alt = gallery[galIdx].alt || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function open(el) {
|
|
||||||
// If the image lives inside a carousel, collect all slides for navigation
|
|
||||||
var car = el.closest ? el.closest('.carousel') : null;
|
|
||||||
if (car) {
|
|
||||||
gallery = Array.prototype.slice.call(car.querySelectorAll('.carousel-slide img'));
|
|
||||||
galIdx = gallery.indexOf(el);
|
|
||||||
if (galIdx === -1) galIdx = 0;
|
|
||||||
} else {
|
|
||||||
gallery = [el];
|
|
||||||
galIdx = 0;
|
|
||||||
}
|
|
||||||
var hasMultiple = gallery.length > 1;
|
|
||||||
prevBtn.style.display = hasMultiple ? '' : 'none';
|
|
||||||
nextBtn.style.display = hasMultiple ? '' : 'none';
|
|
||||||
zoomed.src = el.src;
|
|
||||||
zoomed.alt = el.alt || '';
|
|
||||||
overlay.classList.add('is-open');
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
overlay.classList.remove('is-open');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
prevBtn.addEventListener('click', function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
showAt(galIdx - 1);
|
|
||||||
});
|
|
||||||
nextBtn.addEventListener('click', function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
showAt(galIdx + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
function wrapImage(el) {
|
|
||||||
if (el.naturalWidth > 0 && el.naturalWidth < 100) return;
|
|
||||||
if (el.naturalHeight > 0 && el.naturalHeight < 100) return;
|
|
||||||
var wrap = document.createElement('span');
|
|
||||||
wrap.className = 'zoomable-wrap';
|
|
||||||
el.parentNode.insertBefore(wrap, el);
|
|
||||||
wrap.appendChild(el);
|
|
||||||
el.addEventListener('click', function () { open(el); });
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.prototype.forEach.call(images, function (el) {
|
|
||||||
if (el.complete && el.naturalWidth) {
|
|
||||||
wrapImage(el);
|
|
||||||
} else {
|
|
||||||
el.addEventListener('load', function () { wrapImage(el); });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close on backdrop click (not on arrows or image)
|
|
||||||
overlay.addEventListener('click', function (e) {
|
|
||||||
if (e.target === overlay) close();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
|
||||||
if (!overlay.classList.contains('is-open')) return;
|
|
||||||
if (e.key === 'Escape' || e.key === 'Esc') close();
|
|
||||||
if (e.key === 'ArrowLeft' && gallery.length > 1) showAt(galIdx - 1);
|
|
||||||
if (e.key === 'ArrowRight' && gallery.length > 1) showAt(galIdx + 1);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
|
<?php
|
||||||
|
// Cache-busted by file mtime (mirrors the style.css approach in head.php).
|
||||||
|
$jsVersion = @filemtime(THEME_DIR_JS . 'blowdit.js');
|
||||||
|
$jsSrc = DOMAIN_THEME . 'js/blowdit.js' . ($jsVersion ? '?v=' . $jsVersion : '');
|
||||||
|
?>
|
||||||
|
<script defer src="<?php echo $jsSrc; ?>"></script>
|
||||||
|
|
||||||
<!-- Load Bludit Plugins: Site Body End -->
|
<!-- Load Bludit Plugins: Site Body End -->
|
||||||
<?php Theme::plugins('siteBodyEnd'); ?>
|
<?php Theme::plugins('siteBodyEnd'); ?>
|
||||||
|
|||||||
+670
@@ -0,0 +1,670 @@
|
|||||||
|
/* ============================================================
|
||||||
|
BLOWDIT — theme behaviour
|
||||||
|
Deferred, cacheable bundle of all client-side enhancements.
|
||||||
|
Loaded with <script defer> from index.php, so the DOM is fully
|
||||||
|
parsed before any of these IIFEs run (no DOMContentLoaded needed).
|
||||||
|
|
||||||
|
NOTE: the anti-flash theme bootstrap stays inline in head.php —
|
||||||
|
it must run before the first paint and cannot be deferred.
|
||||||
|
============================================================ */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Localised UI strings (overridable via window.BLOWDIT_I18N, set in index.php).
|
||||||
|
var I18N = window.BLOWDIT_I18N || {};
|
||||||
|
var T = {
|
||||||
|
copy: I18N.copy || 'Copy',
|
||||||
|
copied: I18N.copied || 'Copied',
|
||||||
|
copyCode: I18N.copyCode || 'Copy code',
|
||||||
|
backToTop: I18N.backToTop || 'Back to top',
|
||||||
|
prevSlide: I18N.prevSlide || 'Previous slide',
|
||||||
|
nextSlide: I18N.nextSlide || 'Next slide',
|
||||||
|
goToSlide: I18N.goToSlide || 'Go to slide',
|
||||||
|
prevImage: I18N.prevImage || 'Previous image',
|
||||||
|
nextImage: I18N.nextImage || 'Next image',
|
||||||
|
imageViewer: I18N.imageViewer || 'Image viewer'
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Theme picker
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
var STORAGE_KEY = 'blowdit-theme';
|
||||||
|
var THEMES = ['light', 'dark', 'nord', 'dracula', 'catppuccin'];
|
||||||
|
var ICONS = {
|
||||||
|
light: 'bi-brightness-high',
|
||||||
|
dark: 'bi-moon-stars',
|
||||||
|
nord: 'bi-snow',
|
||||||
|
dracula: 'bi-droplet',
|
||||||
|
catppuccin: 'bi-cup'
|
||||||
|
};
|
||||||
|
var root = document.documentElement;
|
||||||
|
var btn = document.getElementById('theme-toggle');
|
||||||
|
var picker = document.getElementById('theme-picker');
|
||||||
|
|
||||||
|
function syncIcon(theme) {
|
||||||
|
if (!btn) return;
|
||||||
|
var icon = btn.querySelector('i');
|
||||||
|
if (!icon) return;
|
||||||
|
icon.className = 'bi ' + (ICONS[theme] || 'bi-circle-half');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSwatches(theme) {
|
||||||
|
if (!picker) return;
|
||||||
|
Array.prototype.forEach.call(picker.querySelectorAll('.swatch'), function (s) {
|
||||||
|
s.classList.toggle('is-active', s.getAttribute('data-pick') === theme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(theme) {
|
||||||
|
root.setAttribute('data-theme', theme);
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
|
||||||
|
document.cookie = 'blowdit-theme=' + theme + '; path=/; max-age=31536000; SameSite=Lax';
|
||||||
|
var bg = window.BLOWDIT_THEME_BG || {};
|
||||||
|
if (bg[theme]) { root.style.backgroundColor = bg[theme]; }
|
||||||
|
root.style.colorScheme = (theme === 'light') ? 'light' : 'dark';
|
||||||
|
syncIcon(theme);
|
||||||
|
syncSwatches(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
if (!picker) return;
|
||||||
|
picker.classList.add('is-open');
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
if (!picker) return;
|
||||||
|
picker.classList.remove('is-open');
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swatch clicks
|
||||||
|
if (picker) {
|
||||||
|
Array.prototype.forEach.call(picker.querySelectorAll('.swatch'), function (s) {
|
||||||
|
s.addEventListener('click', function () {
|
||||||
|
apply(s.getAttribute('data-pick'));
|
||||||
|
closePicker();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle button: open / close picker
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
picker && picker.classList.contains('is-open') ? closePicker() : openPicker();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!picker || !picker.classList.contains('is-open')) return;
|
||||||
|
if (btn && btn.contains(e.target)) return;
|
||||||
|
if (picker.contains(e.target)) return;
|
||||||
|
closePicker();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialise icons + active swatch
|
||||||
|
var current = root.getAttribute('data-theme') || 'light';
|
||||||
|
if (THEMES.indexOf(current) === -1) current = 'light';
|
||||||
|
syncIcon(current);
|
||||||
|
syncSwatches(current);
|
||||||
|
|
||||||
|
// Follow OS preference unless the visitor chose explicitly
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
|
||||||
|
try { if (localStorage.getItem(STORAGE_KEY)) return; } catch (err) {}
|
||||||
|
apply(e.matches ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Randomise the order of the navigation / pages list on each load
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
function shuffle(arr) {
|
||||||
|
for (var i = arr.length - 1; i > 0; i--) {
|
||||||
|
var j = Math.floor(Math.random() * (i + 1));
|
||||||
|
var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
var lists = document.querySelectorAll('.js-random-nav ul');
|
||||||
|
Array.prototype.forEach.call(lists, function (ul) {
|
||||||
|
var items = Array.prototype.filter.call(ul.children, function (el) {
|
||||||
|
return el.tagName === 'LI';
|
||||||
|
});
|
||||||
|
shuffle(items).forEach(function (li) { ul.appendChild(li); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Table of Contents: generate links from article headings + scroll-spy
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
var tocNav = document.getElementById('toc-nav');
|
||||||
|
if (!tocNav) return;
|
||||||
|
|
||||||
|
var content = document.querySelector('.content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
var headings = content.querySelectorAll('h2, h3, h4');
|
||||||
|
if (headings.length === 0) return;
|
||||||
|
|
||||||
|
// Ensure every heading has a stable anchor ID
|
||||||
|
var usedIds = {};
|
||||||
|
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, '') || 'heading';
|
||||||
|
var id = base, n = 2;
|
||||||
|
while (usedIds[id]) { id = base + '-' + (n++); }
|
||||||
|
usedIds[id] = true;
|
||||||
|
h.id = id;
|
||||||
|
} else {
|
||||||
|
usedIds[h.id] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the list
|
||||||
|
var ul = document.createElement('ul');
|
||||||
|
ul.className = 'toc-list';
|
||||||
|
Array.prototype.forEach.call(headings, function (h) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'toc-item toc-' + h.tagName.toLowerCase();
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = '#' + h.id;
|
||||||
|
a.textContent = h.textContent;
|
||||||
|
a.className = 'toc-link';
|
||||||
|
li.appendChild(a);
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
tocNav.appendChild(ul);
|
||||||
|
|
||||||
|
// Scroll-spy: highlight the last heading that has scrolled past the top
|
||||||
|
var headingArr = Array.prototype.slice.call(headings);
|
||||||
|
|
||||||
|
function updateActive() {
|
||||||
|
var scrollY = window.scrollY || window.pageYOffset;
|
||||||
|
var threshold = scrollY + 120; // offset for navbar height
|
||||||
|
var active = null;
|
||||||
|
headingArr.forEach(function (h) {
|
||||||
|
var top = h.getBoundingClientRect().top + scrollY;
|
||||||
|
if (top <= threshold) active = h;
|
||||||
|
});
|
||||||
|
var links = tocNav.querySelectorAll('.toc-link');
|
||||||
|
Array.prototype.forEach.call(links, function (a) { a.classList.remove('active'); });
|
||||||
|
if (active) {
|
||||||
|
var link = tocNav.querySelector('a[href="#' + active.id + '"]');
|
||||||
|
if (link) link.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', updateActive, { passive: true });
|
||||||
|
updateActive();
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Tabbed code blocks: transforms ```tabs fences into a tab UI
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
var tabGroupSeq = 0;
|
||||||
|
// Match both "language-tabs" (Parsedown >= 1.7) and bare "tabs" (older Bludit builds)
|
||||||
|
var blocks = document.querySelectorAll(
|
||||||
|
'pre > code.language-tabs, pre > code.tabs'
|
||||||
|
);
|
||||||
|
Array.prototype.forEach.call(blocks, function (code) {
|
||||||
|
var pre = code.parentNode;
|
||||||
|
var raw = code.textContent;
|
||||||
|
|
||||||
|
// Supports "[Name]" and "@tab Name" as tab headers
|
||||||
|
var tabs = [];
|
||||||
|
var current = null;
|
||||||
|
raw.split('\n').forEach(function (line) {
|
||||||
|
line = line.replace(/\r$/, ''); // strip Windows CR
|
||||||
|
var m = line.match(/^\[([^\]]+)\]\s*$/) ||
|
||||||
|
line.match(/^@tab\s+(.+?)\s*$/);
|
||||||
|
if (m) {
|
||||||
|
current = { name: m[1], lines: [] };
|
||||||
|
tabs.push(current);
|
||||||
|
} else if (current) {
|
||||||
|
current.lines.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!tabs.length) return;
|
||||||
|
|
||||||
|
// Trim leading / trailing blank lines from each tab
|
||||||
|
tabs.forEach(function (t) {
|
||||||
|
while (t.lines.length && !t.lines[0].trim()) t.lines.shift();
|
||||||
|
while (t.lines.length && !t.lines[t.lines.length - 1].trim()) t.lines.pop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the tab group
|
||||||
|
var groupId = 'tabgroup-' + (++tabGroupSeq);
|
||||||
|
var group = document.createElement('div');
|
||||||
|
group.className = 'tab-group';
|
||||||
|
|
||||||
|
var list = document.createElement('div');
|
||||||
|
list.className = 'tab-list';
|
||||||
|
list.setAttribute('role', 'tablist');
|
||||||
|
group.appendChild(list);
|
||||||
|
|
||||||
|
var panels = [];
|
||||||
|
tabs.forEach(function (t, i) {
|
||||||
|
var active = (i === 0);
|
||||||
|
var tabId = groupId + '-tab-' + i;
|
||||||
|
var panelId = groupId + '-panel-' + i;
|
||||||
|
|
||||||
|
// Tab button
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'tab-btn' + (active ? ' is-active' : '');
|
||||||
|
btn.id = tabId;
|
||||||
|
btn.setAttribute('role', 'tab');
|
||||||
|
btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||||
|
btn.setAttribute('aria-controls', panelId);
|
||||||
|
btn.setAttribute('tabindex', active ? '0' : '-1');
|
||||||
|
btn.textContent = t.name;
|
||||||
|
list.appendChild(btn);
|
||||||
|
|
||||||
|
// Tab panel
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.className = 'tab-panel' + (active ? ' is-active' : '');
|
||||||
|
panel.id = panelId;
|
||||||
|
panel.setAttribute('role', 'tabpanel');
|
||||||
|
panel.setAttribute('aria-labelledby', tabId);
|
||||||
|
panel.setAttribute('tabindex', '0');
|
||||||
|
if (!active) panel.hidden = true;
|
||||||
|
var panelPre = document.createElement('pre');
|
||||||
|
var panelCode = document.createElement('code');
|
||||||
|
panelCode.textContent = t.lines.join('\n');
|
||||||
|
panelPre.appendChild(panelCode);
|
||||||
|
panel.appendChild(panelPre);
|
||||||
|
group.appendChild(panel);
|
||||||
|
panels.push({ btn: btn, panel: panel });
|
||||||
|
|
||||||
|
function activate() {
|
||||||
|
panels.forEach(function (p) {
|
||||||
|
p.btn.classList.remove('is-active');
|
||||||
|
p.btn.setAttribute('aria-selected', 'false');
|
||||||
|
p.btn.setAttribute('tabindex', '-1');
|
||||||
|
p.panel.classList.remove('is-active');
|
||||||
|
p.panel.hidden = true;
|
||||||
|
});
|
||||||
|
btn.classList.add('is-active');
|
||||||
|
btn.setAttribute('aria-selected', 'true');
|
||||||
|
btn.setAttribute('tabindex', '0');
|
||||||
|
panel.classList.add('is-active');
|
||||||
|
panel.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', activate);
|
||||||
|
btn.addEventListener('keydown', function (e) {
|
||||||
|
var nextIdx = null;
|
||||||
|
if (e.key === 'ArrowRight') nextIdx = (i + 1) % panels.length;
|
||||||
|
else if (e.key === 'ArrowLeft') nextIdx = (i - 1 + panels.length) % panels.length;
|
||||||
|
if (nextIdx !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
panels[nextIdx].btn.click();
|
||||||
|
panels[nextIdx].btn.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pre.parentNode.replaceChild(group, pre);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Image carousel: transforms ```carousel fences into a slideshow
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
var blocks = document.querySelectorAll('pre > code.language-carousel');
|
||||||
|
Array.prototype.forEach.call(blocks, function (code) {
|
||||||
|
var pre = code.parentNode;
|
||||||
|
|
||||||
|
// Accepts "", "url | caption", or bare "url"
|
||||||
|
var slides = [];
|
||||||
|
code.textContent.split('\n').forEach(function (line) {
|
||||||
|
line = line.replace(/\r$/, '').trim();
|
||||||
|
if (!line) return;
|
||||||
|
// Markdown image syntax: 
|
||||||
|
var md = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
||||||
|
if (md) {
|
||||||
|
slides.push({ url: md[2].trim(), caption: md[1].trim() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Plain: "url | caption" or bare url
|
||||||
|
var parts = line.split('|');
|
||||||
|
var url = parts[0].trim();
|
||||||
|
if (url) slides.push({ url: url, caption: (parts[1] || '').trim() });
|
||||||
|
});
|
||||||
|
if (!slides.length) return;
|
||||||
|
|
||||||
|
var car = document.createElement('div');
|
||||||
|
car.className = 'carousel';
|
||||||
|
car.setAttribute('role', 'group');
|
||||||
|
car.setAttribute('aria-roledescription', 'carousel');
|
||||||
|
|
||||||
|
var track = document.createElement('div');
|
||||||
|
track.className = 'carousel-track';
|
||||||
|
car.appendChild(track);
|
||||||
|
|
||||||
|
var slideEls = [];
|
||||||
|
var dotEls = [];
|
||||||
|
var current = 0;
|
||||||
|
|
||||||
|
slides.forEach(function (s, i) {
|
||||||
|
var slide = document.createElement('div');
|
||||||
|
slide.className = 'carousel-slide' + (i === 0 ? ' is-active' : '');
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = s.url;
|
||||||
|
img.alt = s.caption;
|
||||||
|
img.loading = 'lazy';
|
||||||
|
slide.appendChild(img);
|
||||||
|
if (s.caption) {
|
||||||
|
var cap = document.createElement('p');
|
||||||
|
cap.className = 'carousel-caption';
|
||||||
|
cap.textContent = s.caption;
|
||||||
|
slide.appendChild(cap);
|
||||||
|
}
|
||||||
|
track.appendChild(slide);
|
||||||
|
slideEls.push(slide);
|
||||||
|
});
|
||||||
|
|
||||||
|
function goTo(idx) {
|
||||||
|
slideEls[current].classList.remove('is-active');
|
||||||
|
if (dotEls[current]) dotEls[current].classList.remove('is-active');
|
||||||
|
current = (idx + slides.length) % slides.length;
|
||||||
|
slideEls[current].classList.add('is-active');
|
||||||
|
if (dotEls[current]) dotEls[current].classList.add('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slides.length > 1) {
|
||||||
|
var prev = document.createElement('button');
|
||||||
|
prev.type = 'button'; prev.className = 'carousel-arrow carousel-prev';
|
||||||
|
prev.setAttribute('aria-label', T.prevSlide);
|
||||||
|
prev.textContent = '‹';
|
||||||
|
prev.addEventListener('click', function () { goTo(current - 1); });
|
||||||
|
car.appendChild(prev);
|
||||||
|
|
||||||
|
var next = document.createElement('button');
|
||||||
|
next.type = 'button'; next.className = 'carousel-arrow carousel-next';
|
||||||
|
next.setAttribute('aria-label', T.nextSlide);
|
||||||
|
next.textContent = '›';
|
||||||
|
next.addEventListener('click', function () { goTo(current + 1); });
|
||||||
|
car.appendChild(next);
|
||||||
|
|
||||||
|
var dots = document.createElement('div');
|
||||||
|
dots.className = 'carousel-dots';
|
||||||
|
slides.forEach(function (_, i) {
|
||||||
|
var dot = document.createElement('button');
|
||||||
|
dot.type = 'button';
|
||||||
|
dot.className = 'carousel-dot' + (i === 0 ? ' is-active' : '');
|
||||||
|
dot.setAttribute('aria-label', T.goToSlide + ' ' + (i + 1));
|
||||||
|
dot.addEventListener('click', function () { goTo(i); });
|
||||||
|
dots.appendChild(dot);
|
||||||
|
dotEls.push(dot);
|
||||||
|
});
|
||||||
|
car.appendChild(dots);
|
||||||
|
|
||||||
|
// Swipe support
|
||||||
|
var startX = 0;
|
||||||
|
track.addEventListener('touchstart', function (e) { startX = e.touches[0].clientX; }, { passive: true });
|
||||||
|
track.addEventListener('touchend', function (e) {
|
||||||
|
var dx = e.changedTouches[0].clientX - startX;
|
||||||
|
if (Math.abs(dx) > 40) goTo(dx < 0 ? current + 1 : current - 1);
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.parentNode.replaceChild(car, pre);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Lightbox: click any article image to zoom; carousel-aware prev/next
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
var content = document.querySelector('.content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
var images = content.querySelectorAll('img');
|
||||||
|
if (!images.length) return;
|
||||||
|
|
||||||
|
// Build overlay with optional prev/next arrows for carousel galleries
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.className = 'lightbox-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', T.imageViewer);
|
||||||
|
|
||||||
|
var prevBtn = document.createElement('button');
|
||||||
|
prevBtn.type = 'button';
|
||||||
|
prevBtn.className = 'lightbox-arrow lightbox-prev';
|
||||||
|
prevBtn.setAttribute('aria-label', T.prevImage);
|
||||||
|
prevBtn.textContent = '‹'; // ‹
|
||||||
|
|
||||||
|
var zoomed = document.createElement('img');
|
||||||
|
|
||||||
|
var nextBtn = document.createElement('button');
|
||||||
|
nextBtn.type = 'button';
|
||||||
|
nextBtn.className = 'lightbox-arrow lightbox-next';
|
||||||
|
nextBtn.setAttribute('aria-label', T.nextImage);
|
||||||
|
nextBtn.textContent = '›'; // ›
|
||||||
|
|
||||||
|
overlay.appendChild(prevBtn);
|
||||||
|
overlay.appendChild(zoomed);
|
||||||
|
overlay.appendChild(nextBtn);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
var gallery = []; // images in the current carousel (empty = single image)
|
||||||
|
var galIdx = 0;
|
||||||
|
|
||||||
|
function showAt(idx) {
|
||||||
|
galIdx = (idx + gallery.length) % gallery.length;
|
||||||
|
zoomed.src = gallery[galIdx].src;
|
||||||
|
zoomed.alt = gallery[galIdx].alt || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(el) {
|
||||||
|
// If the image lives inside a carousel, collect all slides for navigation
|
||||||
|
var car = el.closest ? el.closest('.carousel') : null;
|
||||||
|
if (car) {
|
||||||
|
gallery = Array.prototype.slice.call(car.querySelectorAll('.carousel-slide img'));
|
||||||
|
galIdx = gallery.indexOf(el);
|
||||||
|
if (galIdx === -1) galIdx = 0;
|
||||||
|
} else {
|
||||||
|
gallery = [el];
|
||||||
|
galIdx = 0;
|
||||||
|
}
|
||||||
|
var hasMultiple = gallery.length > 1;
|
||||||
|
prevBtn.style.display = hasMultiple ? '' : 'none';
|
||||||
|
nextBtn.style.display = hasMultiple ? '' : 'none';
|
||||||
|
zoomed.src = el.src;
|
||||||
|
zoomed.alt = el.alt || '';
|
||||||
|
overlay.classList.add('is-open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
overlay.classList.remove('is-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
prevBtn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
showAt(galIdx - 1);
|
||||||
|
});
|
||||||
|
nextBtn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
showAt(galIdx + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function wrapImage(el) {
|
||||||
|
if (el.naturalWidth > 0 && el.naturalWidth < 100) return;
|
||||||
|
if (el.naturalHeight > 0 && el.naturalHeight < 100) return;
|
||||||
|
var wrap = document.createElement('span');
|
||||||
|
wrap.className = 'zoomable-wrap';
|
||||||
|
el.parentNode.insertBefore(wrap, el);
|
||||||
|
wrap.appendChild(el);
|
||||||
|
el.addEventListener('click', function () { open(el); });
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(images, function (el) {
|
||||||
|
if (el.complete && el.naturalWidth) {
|
||||||
|
wrapImage(el);
|
||||||
|
} else {
|
||||||
|
el.addEventListener('load', function () { wrapImage(el); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on backdrop click (not on arrows or image)
|
||||||
|
overlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === overlay) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (!overlay.classList.contains('is-open')) return;
|
||||||
|
if (e.key === 'Escape' || e.key === 'Esc') close();
|
||||||
|
if (e.key === 'ArrowLeft' && gallery.length > 1) showAt(galIdx - 1);
|
||||||
|
if (e.key === 'ArrowRight' && gallery.length > 1) showAt(galIdx + 1);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Copy-to-clipboard buttons on code blocks
|
||||||
|
Runs after the tabs/carousel transforms so it only targets real
|
||||||
|
code blocks (including the generated tab panels), not raw fences.
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
function copyText(text) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
return navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
// Fallback for older / non-secure-context browsers
|
||||||
|
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); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhance(pre) {
|
||||||
|
// Skip if already wrapped
|
||||||
|
if (pre.parentNode && pre.parentNode.classList &&
|
||||||
|
pre.parentNode.classList.contains('code-block')) return;
|
||||||
|
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.className = 'code-block';
|
||||||
|
pre.parentNode.insertBefore(wrap, pre);
|
||||||
|
wrap.appendChild(pre);
|
||||||
|
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'code-copy';
|
||||||
|
btn.setAttribute('aria-label', T.copyCode);
|
||||||
|
btn.innerHTML = '<i class="bi bi-clipboard" aria-hidden="true"></i>';
|
||||||
|
|
||||||
|
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 = '<i class="bi bi-check-lg" aria-hidden="true"></i>';
|
||||||
|
btn.setAttribute('aria-label', T.copied);
|
||||||
|
clearTimeout(resetTimer);
|
||||||
|
resetTimer = setTimeout(function () {
|
||||||
|
btn.classList.remove('is-copied');
|
||||||
|
btn.innerHTML = '<i class="bi bi-clipboard" aria-hidden="true"></i>';
|
||||||
|
btn.setAttribute('aria-label', T.copyCode);
|
||||||
|
}, 1800);
|
||||||
|
}).catch(function () {});
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pres = document.querySelectorAll('.content pre, .tab-panel pre');
|
||||||
|
Array.prototype.forEach.call(pres, enhance);
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Reading progress bar — tracks scroll position over the page.
|
||||||
|
Only shown when there's article content to read.
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
if (!document.querySelector('.content')) return;
|
||||||
|
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.className = 'reading-progress';
|
||||||
|
bar.setAttribute('role', 'progressbar');
|
||||||
|
bar.setAttribute('aria-hidden', 'true');
|
||||||
|
var fill = document.createElement('div');
|
||||||
|
fill.className = 'reading-progress-fill';
|
||||||
|
bar.appendChild(fill);
|
||||||
|
document.body.appendChild(bar);
|
||||||
|
|
||||||
|
var ticking = false;
|
||||||
|
function update() {
|
||||||
|
var h = document.documentElement;
|
||||||
|
var max = h.scrollHeight - h.clientHeight;
|
||||||
|
var pct = max > 0 ? (h.scrollTop / max) * 100 : 0;
|
||||||
|
fill.style.width = pct + '%';
|
||||||
|
ticking = false;
|
||||||
|
}
|
||||||
|
function onScroll() {
|
||||||
|
if (!ticking) {
|
||||||
|
ticking = true;
|
||||||
|
window.requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll, { passive: true });
|
||||||
|
update();
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
Back-to-top button — appears after scrolling down.
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'back-to-top';
|
||||||
|
btn.setAttribute('aria-label', T.backToTop);
|
||||||
|
btn.innerHTML = '<i class="bi bi-arrow-up" aria-hidden="true"></i>';
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var reduce = window.matchMedia &&
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
var ticking = false;
|
||||||
|
function update() {
|
||||||
|
btn.classList.toggle('is-visible', (window.scrollY || window.pageYOffset) > 400);
|
||||||
|
ticking = false;
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', function () {
|
||||||
|
if (!ticking) {
|
||||||
|
ticking = true;
|
||||||
|
window.requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
update();
|
||||||
|
})();
|
||||||
|
|
||||||
|
})();
|
||||||
+12
-1
@@ -5,5 +5,16 @@
|
|||||||
"description": "A Bludit theme inspired by the Blowfish Hugo theme. Right sidebar, reading time, a light/dark mode toggle and a navbar with social icons and static pages."
|
"description": "A Bludit theme inspired by the Blowfish Hugo theme. Right sidebar, reading time, a light/dark mode toggle and a navbar with social icons and static pages."
|
||||||
},
|
},
|
||||||
"Toggle theme": "Toggle theme",
|
"Toggle theme": "Toggle theme",
|
||||||
"Author": "Author"
|
"Author": "Author",
|
||||||
|
"Skip to content": "Skip to content",
|
||||||
|
"Copy": "Copy",
|
||||||
|
"Copied": "Copied",
|
||||||
|
"Copy code": "Copy code",
|
||||||
|
"Back to top": "Back to top",
|
||||||
|
"Previous slide": "Previous slide",
|
||||||
|
"Next slide": "Next slide",
|
||||||
|
"Go to slide": "Go to slide",
|
||||||
|
"Previous image": "Previous image",
|
||||||
|
"Next image": "Next image",
|
||||||
|
"Image viewer": "Image viewer"
|
||||||
}
|
}
|
||||||
+121
@@ -2,6 +2,20 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<meta name="generator" content="Bludit">
|
<meta name="generator" content="Bludit">
|
||||||
|
|
||||||
|
<!-- Tint the mobile browser chrome to match the navbar (--chrome-bg per theme).
|
||||||
|
Set server-side from the theme cookie so it's correct on the first paint. -->
|
||||||
|
<?php
|
||||||
|
$blowditChromeColors = array(
|
||||||
|
'light' => '#1f1f1f',
|
||||||
|
'dark' => '#1f1f1f',
|
||||||
|
'nord' => '#3b4252',
|
||||||
|
'dracula' => '#343746',
|
||||||
|
'catppuccin' => '#313244',
|
||||||
|
);
|
||||||
|
$blowditChrome = isset($blowditChromeColors[$blowditTheme]) ? $blowditChromeColors[$blowditTheme] : '#1f1f1f';
|
||||||
|
?>
|
||||||
|
<meta name="theme-color" content="<?php echo $blowditChrome; ?>">
|
||||||
|
|
||||||
<!-- Set the colour theme as early as possible to avoid a white flash.
|
<!-- Set the colour theme as early as possible to avoid a white flash.
|
||||||
Sets data-theme AND paints the html background/color-scheme inline, so the
|
Sets data-theme AND paints the html background/color-scheme inline, so the
|
||||||
very first frame (before style.css loads) already uses the theme colour. -->
|
very first frame (before style.css loads) already uses the theme colour. -->
|
||||||
@@ -36,6 +50,113 @@
|
|||||||
<!-- Dynamic description tag -->
|
<!-- Dynamic description tag -->
|
||||||
<?php echo Theme::metaTags('description'); ?>
|
<?php echo Theme::metaTags('description'); ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// ---- Social sharing metadata (Open Graph + Twitter Card) ----
|
||||||
|
// $blowditIsArticle is pre-computed in index.php before this file is included.
|
||||||
|
$ogIsArticle = !empty($blowditIsArticle);
|
||||||
|
|
||||||
|
// ISO 8601 publish date — formatted explicitly so it's valid regardless of
|
||||||
|
// the admin's configured date format. Falls back to dateRaw() if Date is unavailable.
|
||||||
|
if (!function_exists('blowdit_iso_date')) {
|
||||||
|
function blowdit_iso_date($page, $field) {
|
||||||
|
$raw = $page->getValue($field);
|
||||||
|
if (!$raw) { return ''; }
|
||||||
|
if (class_exists('Date')) {
|
||||||
|
$fmt = Date::format($raw, DB_DATE_FORMAT, 'c');
|
||||||
|
if ($fmt) { return $fmt; }
|
||||||
|
}
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ogIsArticle) {
|
||||||
|
$ogTitle = $page->title();
|
||||||
|
$ogDesc = $page->description();
|
||||||
|
$ogUrl = $page->permalink(true);
|
||||||
|
$ogImage = $page->coverImage(true);
|
||||||
|
$ogPublishedIso = blowdit_iso_date($page, 'date');
|
||||||
|
} else {
|
||||||
|
$ogTitle = $site->title();
|
||||||
|
$ogDesc = '';
|
||||||
|
$ogUrl = Theme::siteUrl();
|
||||||
|
$ogImage = '';
|
||||||
|
}
|
||||||
|
// Fallbacks
|
||||||
|
if (!$ogDesc) { $ogDesc = $site->description() ?: $site->slogan(); }
|
||||||
|
if (!$ogImage) { $ogImage = DOMAIN_THEME . 'img/spleenftw.jpeg'; }
|
||||||
|
|
||||||
|
$ogType = $ogIsArticle ? 'article' : 'website';
|
||||||
|
$ogSiteName = $site->title();
|
||||||
|
?>
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="<?php echo htmlspecialchars($ogType, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<meta property="og:site_name" content="<?php echo htmlspecialchars($ogSiteName, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<meta property="og:title" content="<?php echo htmlspecialchars($ogTitle, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<meta property="og:description" content="<?php echo htmlspecialchars($ogDesc, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<meta property="og:url" content="<?php echo htmlspecialchars($ogUrl, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<meta property="og:image" content="<?php echo htmlspecialchars($ogImage, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<?php if ($ogIsArticle): ?>
|
||||||
|
<meta property="article:published_time" content="<?php echo htmlspecialchars($ogPublishedIso, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<meta property="article:author" content="<?php echo htmlspecialchars($page->username(), ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<?php endif ?>
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="<?php echo htmlspecialchars($ogTitle, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<meta name="twitter:description" content="<?php echo htmlspecialchars($ogDesc, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
<meta name="twitter:image" content="<?php echo htmlspecialchars($ogImage, ENT_QUOTES, 'UTF-8'); ?>">
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// ---- JSON-LD structured data (BlogPosting) — articles only ----
|
||||||
|
if ($ogIsArticle) {
|
||||||
|
$ldDescription = $page->description() ?: ($site->description() ?: $site->slogan());
|
||||||
|
$ldImage = $page->coverImage(true) ?: (DOMAIN_THEME . 'img/spleenftw.jpeg');
|
||||||
|
$ldPublished = $ogPublishedIso;
|
||||||
|
|
||||||
|
// dateModified: fall back to published date when not tracked.
|
||||||
|
$ldModified = blowdit_iso_date($page, 'dateModified');
|
||||||
|
if (!$ldModified) { $ldModified = $ldPublished; }
|
||||||
|
|
||||||
|
// timeRequired as an ISO 8601 duration (e.g. "PT8M") parsed from readingTime().
|
||||||
|
$ldTimeRequired = '';
|
||||||
|
if (preg_match('/\d+/', $page->readingTime(), $rtMatch)) {
|
||||||
|
$ldTimeRequired = 'PT' . $rtMatch[0] . 'M';
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonLd = array(
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'BlogPosting',
|
||||||
|
'headline' => $page->title(),
|
||||||
|
'description' => $ldDescription,
|
||||||
|
'image' => $ldImage,
|
||||||
|
'datePublished' => $ldPublished,
|
||||||
|
'dateModified' => $ldModified,
|
||||||
|
'author' => array(
|
||||||
|
'@type' => 'Person',
|
||||||
|
'name' => $page->username(),
|
||||||
|
'url' => Theme::siteUrl(),
|
||||||
|
),
|
||||||
|
'publisher' => array(
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $site->title(),
|
||||||
|
'logo' => array(
|
||||||
|
'@type' => 'ImageObject',
|
||||||
|
'url' => DOMAIN_THEME . 'img/favicon.png',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'mainEntityOfPage' => array(
|
||||||
|
'@type' => 'WebPage',
|
||||||
|
'@id' => $page->permalink(true),
|
||||||
|
),
|
||||||
|
'wordCount' => str_word_count(strip_tags($page->content())),
|
||||||
|
);
|
||||||
|
if ($ldTimeRequired) { $jsonLd['timeRequired'] = $ldTimeRequired; }
|
||||||
|
|
||||||
|
echo '<script type="application/ld+json">'
|
||||||
|
. json_encode($jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||||
|
. '</script>' . "\n";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
<!-- Include Favicon -->
|
<!-- Include Favicon -->
|
||||||
<?php echo Theme::favicon('img/favicon.png'); ?>
|
<?php echo Theme::favicon('img/favicon.png'); ?>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user