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
+989
-467
No files matched your search
@@ -27,6 +27,9 @@
|
||||
</head>
|
||||
<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 -->
|
||||
<?php Theme::plugins('siteBodyBegin'); ?>
|
||||
|
||||
@@ -105,7 +108,7 @@
|
||||
?>
|
||||
|
||||
<!-- 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">
|
||||
|
||||
<?php if ($hasHeadings): ?>
|
||||
@@ -157,473 +160,31 @@
|
||||
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>
|
||||
(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');
|
||||
});
|
||||
}
|
||||
})();
|
||||
</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);
|
||||
});
|
||||
})();
|
||||
// Localised UI strings for the deferred bundle. $L->get() falls back to
|
||||
// the key itself when a translation is missing, so this is safe in any language.
|
||||
window.BLOWDIT_I18N = {
|
||||
copy: <?php echo json_encode($L->get('Copy')); ?>,
|
||||
copied: <?php echo json_encode($L->get('Copied')); ?>,
|
||||
copyCode: <?php echo json_encode($L->get('Copy code')); ?>,
|
||||
backToTop: <?php echo json_encode($L->get('Back to top')); ?>,
|
||||
prevSlide: <?php echo json_encode($L->get('Previous slide')); ?>,
|
||||
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')); ?>
|
||||
};
|
||||
</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 -->
|
||||
<?php Theme::plugins('siteBodyEnd'); ?>
|
||||
|
||||
Reference in new issue
Block a user