Files
summercms/internal/docsite/theme/assets/site.js
Jakub Zych dd11bdb0c7 feat(11.1-02): add the documentation theme, chroma highlighting and docs:serve
- 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
2026-09-30 21:57:55 +02:00

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.");
}
);
});
});
}
})();