- WinterCMS-style shell: header with search and theme toggle, grouped sidebar, on-page TOC, pager, page actions, callouts, heading permalinks, footer and a 404 page - fenced code highlighted at build time by chroma/v2 into tok-* classes, with a copy button; no inline script, style or handler - vendored DM Sans/DM Mono fonts and Lucide icons with their licences - client-side search over search-index.json built with textContent only - summer docs:serve builds into a temp dir, serves on loopback by default, returns 404.html with status 404 and rebuilds on change
229 lines
6.9 KiB
JavaScript
229 lines
6.9 KiB
JavaScript
// SummerCMS docs: theme toggle, mobile drawer, TOC scroll spy, active
|
|
// sidebar item and copy buttons. No framework; every DOM change uses
|
|
// element APIs and textContent.
|
|
(function () {
|
|
"use strict";
|
|
|
|
var STORAGE_KEY = "summer-docs-theme";
|
|
var THEMES = ["system", "light", "dark"];
|
|
var LABELS = { system: "System", light: "Light", dark: "Dark" };
|
|
var root = document.documentElement;
|
|
var live = document.getElementById("live-region");
|
|
|
|
function announce(text) {
|
|
if (!live) {
|
|
return;
|
|
}
|
|
live.textContent = "";
|
|
window.setTimeout(function () {
|
|
live.textContent = text;
|
|
}, 50);
|
|
}
|
|
|
|
// Theme toggle: System -> Light -> Dark -> System. If storage throws the
|
|
// choice still applies for this page view and no error is shown.
|
|
function currentTheme() {
|
|
var t = root.getAttribute("data-theme");
|
|
return THEMES.indexOf(t) >= 0 ? t : "system";
|
|
}
|
|
|
|
function applyTheme(theme, button) {
|
|
root.classList.remove("light", "dark");
|
|
if (theme !== "system") {
|
|
root.classList.add(theme);
|
|
}
|
|
root.setAttribute("data-theme", theme);
|
|
if (button) {
|
|
var next = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
|
|
var label = "Color theme: " + LABELS[theme] + ". Switch to " + LABELS[next] + ".";
|
|
button.setAttribute("data-theme", theme);
|
|
button.setAttribute("aria-label", label);
|
|
button.setAttribute("title", label);
|
|
}
|
|
}
|
|
|
|
var toggle = document.querySelector(".theme-toggle");
|
|
if (toggle) {
|
|
applyTheme(currentTheme(), toggle);
|
|
toggle.addEventListener("click", function () {
|
|
var theme = THEMES[(THEMES.indexOf(currentTheme()) + 1) % THEMES.length];
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, theme);
|
|
} catch (e) {
|
|
// Session-only choice.
|
|
}
|
|
applyTheme(theme, toggle);
|
|
});
|
|
}
|
|
|
|
// Mobile drawer (below 1024px).
|
|
var menu = document.querySelector(".menu-button");
|
|
var panel = document.getElementById("sidebar");
|
|
var scrim = document.querySelector(".scrim");
|
|
|
|
function drawerOpen() {
|
|
return panel && panel.classList.contains("open");
|
|
}
|
|
|
|
function focusables() {
|
|
return panel ? Array.prototype.slice.call(panel.querySelectorAll("a[href], button:not([disabled])")) : [];
|
|
}
|
|
|
|
function openDrawer() {
|
|
if (!panel || !menu) {
|
|
return;
|
|
}
|
|
panel.classList.add("open");
|
|
if (scrim) {
|
|
scrim.hidden = false;
|
|
}
|
|
document.body.classList.add("drawer-open");
|
|
menu.setAttribute("aria-expanded", "true");
|
|
menu.setAttribute("aria-label", "Close navigation");
|
|
var items = focusables();
|
|
if (items.length) {
|
|
items[0].focus();
|
|
}
|
|
}
|
|
|
|
function closeDrawer(returnFocus) {
|
|
if (!drawerOpen()) {
|
|
return;
|
|
}
|
|
panel.classList.remove("open");
|
|
if (scrim) {
|
|
scrim.hidden = true;
|
|
}
|
|
document.body.classList.remove("drawer-open");
|
|
menu.setAttribute("aria-expanded", "false");
|
|
menu.setAttribute("aria-label", "Open navigation");
|
|
if (returnFocus) {
|
|
menu.focus();
|
|
}
|
|
}
|
|
|
|
if (menu && panel) {
|
|
menu.addEventListener("click", function () {
|
|
if (drawerOpen()) {
|
|
closeDrawer(true);
|
|
} else {
|
|
openDrawer();
|
|
}
|
|
});
|
|
if (scrim) {
|
|
scrim.addEventListener("click", function () {
|
|
closeDrawer(true);
|
|
});
|
|
}
|
|
panel.addEventListener("click", function (e) {
|
|
if (e.target.closest && e.target.closest("a")) {
|
|
closeDrawer(false);
|
|
}
|
|
});
|
|
document.addEventListener("keydown", function (e) {
|
|
if (!drawerOpen()) {
|
|
return;
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
closeDrawer(true);
|
|
return;
|
|
}
|
|
if (e.key !== "Tab") {
|
|
return;
|
|
}
|
|
// Keep focus inside the drawer and its close button.
|
|
var items = [menu].concat(focusables());
|
|
var first = items[0];
|
|
var last = items[items.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
} else if (items.indexOf(document.activeElement) < 0) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
});
|
|
window.matchMedia("(min-width: 1024px)").addEventListener("change", function (m) {
|
|
if (m.matches) {
|
|
closeDrawer(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Scroll the active sidebar item into view, without animation.
|
|
var active = document.querySelector('.sidebar a[aria-current="page"]');
|
|
// The sticky sidebar is fully on screen at 1024px and up, so "nearest"
|
|
// scrolls only the sidebar, never the page (a #fragment jump is kept).
|
|
if (active && active.scrollIntoView && window.matchMedia("(min-width: 1024px)").matches) {
|
|
active.scrollIntoView({ block: "nearest" });
|
|
}
|
|
|
|
// TOC scroll spy: the heading nearest the top is current.
|
|
var tocLinks = Array.prototype.slice.call(document.querySelectorAll(".toc a"));
|
|
if (tocLinks.length && "IntersectionObserver" in window) {
|
|
var headings = [];
|
|
tocLinks.forEach(function (link) {
|
|
var id = decodeURIComponent(link.getAttribute("href").slice(1));
|
|
var h = document.getElementById(id);
|
|
if (h) {
|
|
headings.push({ el: h, link: link });
|
|
}
|
|
});
|
|
var setCurrent = function () {
|
|
var current = null;
|
|
for (var i = 0; i < headings.length; i++) {
|
|
if (headings[i].el.getBoundingClientRect().top <= 112) {
|
|
current = headings[i];
|
|
}
|
|
}
|
|
if (!current && headings.length) {
|
|
current = headings[0];
|
|
}
|
|
tocLinks.forEach(function (link) {
|
|
link.removeAttribute("aria-current");
|
|
});
|
|
if (current) {
|
|
current.link.setAttribute("aria-current", "location");
|
|
}
|
|
};
|
|
var observer = new IntersectionObserver(setCurrent, { rootMargin: "-64px 0px -60% 0px" });
|
|
headings.forEach(function (h) {
|
|
observer.observe(h.el);
|
|
});
|
|
setCurrent();
|
|
}
|
|
|
|
// Copy buttons, only when the clipboard API exists.
|
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
|
Array.prototype.forEach.call(document.querySelectorAll("figure.code .copy-button"), function (button) {
|
|
button.hidden = false;
|
|
button.addEventListener("click", function () {
|
|
var code = button.parentNode.querySelector("pre code");
|
|
if (!code) {
|
|
return;
|
|
}
|
|
var copy = code.cloneNode(true);
|
|
Array.prototype.forEach.call(copy.querySelectorAll(".tok-prompt"), function (p) {
|
|
p.parentNode.removeChild(p);
|
|
});
|
|
navigator.clipboard.writeText(copy.textContent).then(
|
|
function () {
|
|
button.classList.add("copied");
|
|
announce("Copied");
|
|
window.setTimeout(function () {
|
|
button.classList.remove("copied");
|
|
}, 2000);
|
|
},
|
|
function () {
|
|
announce("Copy failed. Select the code and copy it manually.");
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
})();
|