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
This commit is contained in:
228
internal/docsite/theme/assets/site.js
Normal file
228
internal/docsite/theme/assets/site.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// 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.");
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user