const print = document.currentScript.hasAttribute("data-print");
const comments = !print && !document.currentScript.hasAttribute("data-no-comments");
const widgetModules = {};
globalThis.__devpageWidget = (name) => widgetModules[name]?.default ?? (() => { throw new Error(`<${name}> is not a widget`); });
const widgetUrls = {};
const widgetLoadErrors = {};
async function loadWidget(name, seen = new Set()) {
seen.add(name);
const url = (window.devpageWidgets || {})[name];
if (!url) throw new Error(`<${name}> is not a widget`);
if (widgetUrls[name] !== url) {
try {
widgetModules[name] = await import(url);
} catch (error) {
delete widgetModules[name];
delete widgetUrls[name];
throw error;
}
widgetUrls[name] = url;
document.head.querySelector(`style[data-widget="${name}"]`)?.remove();
const css = widgetModules[name].css;
if (css) {
const style = document.createElement("style");
style.dataset.widget = name;
style.textContent = css;
document.head.append(style);
}
}
const module = widgetModules[name];
await Promise.all((module.__devpageUses || []).filter((used) => !seen.has(used)).map((used) => loadWidget(used, seen)
.then(() => { delete widgetLoadErrors[used]; })
.catch((error) => { widgetLoadErrors[used] = error; })));
return module;
}
const roots = new Map();
const keys = new Map();
function placeholdersIn(root) {
return [...root.querySelectorAll("[data-widget]:not(style)")].filter((el) => !el.parentElement.closest("[data-widget]"));
}
function keyOf(placeholder) {
const names = new Set([placeholder, ...placeholder.querySelectorAll("[data-widget]")].map((el) => el.dataset.widget));
for (const name of names) for (const used of widgetModules[name]?.__devpageUses ?? []) names.add(used);
const urls = window.devpageWidgets || {};
return [placeholder.outerHTML, ...[...names].sort().map((name) => `${name} ${urls[name] ?? ""}`)].join("\n");
}
function unmountGone() {
for (const [el, root] of roots) {
if (el.isConnected) continue;
roots.delete(el);
keys.delete(el);
try {
root.unmount();
} catch (error) {
console.error(error);
}
}
}
async function mountWidgets(root) {
const placeholders = placeholdersIn(root);
if (!placeholders.length) return;
const names = new Set([...root.querySelectorAll("[data-widget]")].map((el) => el.dataset.widget));
const [React, { createRoot }] = await Promise.all([
import("react"),
import("react-dom/client"),
...[...names].map((name) => loadWidget(name)
.then(() => { delete widgetLoadErrors[name]; })
.catch((error) => { widgetLoadErrors[name] = error; })),
]);
for (const el of placeholders) keys.set(el, keyOf(el));
class Boundary extends React.Component {
constructor(props) { super(props); this.state = { error: null }; }
static getDerivedStateFromError(error) { return { error }; }
render() {
if (!this.state.error) return this.props.children;
return React.createElement("pre", { className: "widget-error" }, `<${this.props.name}>: ${this.state.error.message}`);
}
}
const attributes = (el) => {
const props = {};
for (const { name, value } of el.attributes) {
if (name === "class") props.className = value;
else if (name === "for") props.htmlFor = value;
else if (name === "style") props.style = Object.fromEntries(value.split(";").filter(Boolean).map((d) => {
const [k, ...v] = d.split(":");
return [k.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase()), v.join(":").trim()];
}));
else props[name] = value;
}
return props;
};
const toReact = (node, key) => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return null;
if (node.dataset.widget) return widgetElement(node, key);
const props = { ...attributes(node), key };
if (!node.querySelector("[data-widget]")) {
if (["BR", "HR", "IMG", "INPUT"].includes(node.tagName)) return React.createElement(node.tagName.toLowerCase(), props);
return React.createElement(node.tagName.toLowerCase(), { ...props, dangerouslySetInnerHTML: { __html: node.innerHTML } });
}
return React.createElement(node.tagName.toLowerCase(), props, ...[...node.childNodes].map((child, i) => toReact(child, i)));
};
const widgetElement = (node, key) => {
const name = node.dataset.widget;
let props;
try {
props = new Function(`return (${node.dataset.props || "{}"});`)();
} catch (error) {
return React.createElement("pre", { className: "widget-error", key }, `<${name}>: ${error.message}`);
}
const children = [...node.childNodes].map((child, i) => toReact(child, i)).filter((c) => c !== null && c !== "\n");
const component = widgetModules[name]?.default;
if (!component) return React.createElement("pre", { className: "widget-error", key }, `<${name}>: ${widgetLoadErrors[name]?.message ?? "could not be loaded"}`);
return React.createElement(component, { ...props, key, source: node.dataset.widgetSource ?? "", sourceHtml: node.dataset.widgetSourceHtml, html: node.innerHTML, children: children.length ? children : undefined });
};
const mount = (els) => Promise.all(els.map((el) => new Promise((resolve) => {
el.dataset.mounted = "";
const element = React.createElement(Boundary, { name: el.dataset.widget }, widgetElement(el, 0));
const root = createRoot(el);
roots.set(el, root);
root.render(React.createElement(Mounted, { onMount: resolve }, element));
})));
await mount(placeholders);
for (let depth = 0; depth < 8; depth++) {
const left = [...root.querySelectorAll("[data-widget]:not([data-mounted]):not(style)")]
.filter((el) => !el.parentElement.closest("[data-widget]:not([data-mounted])"));
if (!left.length) break;
await mount(left);
}
function Mounted({ onMount, children }) {
React.useEffect(() => { onMount(); }, []);
return children;
}
}
let showInSidebar = null;
const sidebarData = (doc) => {
const script = doc.getElementById("devpage-sidebar");
return script ? JSON.parse(script.textContent) : null;
};
async function mountSidebar() {
const nav = document.querySelector("#devpage-nav[data-sidebar]");
const data = sidebarData(document);
if (!nav || !data) return;
const [React, { hydrateRoot, flushSync }, module] = await Promise.all([
import("react"),
import("react-dom/client"),
loadWidget("DevpageSidebar"),
]);
await new Promise((resolve) => {
function Sidebar() {
const [shown, setShown] = React.useState(data);
React.useEffect(() => {
showInSidebar = (next) => flushSync(() => setShown(next));
resolve();
}, []);
return React.createElement(module.default, shown);
}
hydrateRoot(nav, React.createElement(Sidebar), {
onRecoverableError: (error) => console.error("devpage: the sidebar's markup and DevpageSidebar differ", error),
});
});
}
const sidebarMounted = print ? Promise.resolve() : mountSidebar().catch((error) => {
console.error("devpage: the sidebar stays as the server drew it", error);
});
function markBroken(root) {
const bar = root.querySelector(".problems[data-broken]");
const broken = new Set(bar ? JSON.parse(bar.dataset.broken) : []);
for (const element of root.querySelectorAll("a[href], img[src]")) {
const target = element.getAttribute(element.tagName === "IMG" ? "src" : "href");
element.classList.toggle("broken", broken.has(target));
}
}
let designScript = null;
function drawDesigns() {
if (!designScript && !document.querySelector("canvas.design")) return;
designScript ??= import("/__devpage/design.js");
designScript
.then((designs) => designs.scan())
.catch((error) => {
console.error("devpage: the design script didn't load", error);
for (const canvas of document.querySelectorAll("canvas.design:not([data-failed])")) {
canvas.dataset.failed = "true";
const note = document.createElement("p");
note.className = "design-failed";
note.textContent = "This design can't be drawn: the design script didn't load.";
canvas.before(note);
}
});
}
let designsChanged = false;
const holdsDesign = (node) =>
node.nodeType === Node.ELEMENT_NODE && (node.matches("canvas.design") || node.querySelector("canvas.design"));
new MutationObserver((records) => {
if (designsChanged) return;
for (const { addedNodes, removedNodes } of records) {
if (![...addedNodes, ...(designScript ? removedNodes : [])].some(holdsDesign)) continue;
designsChanged = true;
queueMicrotask(() => {
designsChanged = false;
drawDesigns();
});
return;
}
}).observe(document.body, { childList: true, subtree: true });
function keepUnchanged(page, staging) {
const mounted = [...keys.keys()].filter((el) => page.contains(el));
const kept = [];
for (const placeholder of placeholdersIn(staging)) {
const key = keyOf(placeholder);
const at = mounted.findIndex((old) => keys.get(old) === key);
if (at < 0) continue;
const marker = document.createElement("template");
placeholder.replaceWith(marker);
kept.push([marker, mounted.splice(at, 1)[0]]);
}
return () => {
const outside = (embed) => !embed.closest("[data-widget]");
const designs = [...page.querySelectorAll(".design-embed")].filter(outside);
for (const embed of [...staging.querySelectorAll(".design-embed")].filter(outside)) {
const src = embed.querySelector("canvas.design")?.getAttribute("src");
const at = designs.findIndex((old) => old.querySelector("canvas.design")?.getAttribute("src") === src);
if (at >= 0) embed.replaceWith(designs.splice(at, 1)[0]);
}
for (const [marker, old] of kept) marker.replaceWith(old);
};
}
function frameOf(body) {
const classes = [...body.classList].filter((name) => name !== "show-comments").sort();
return [...classes, `build:${body.dataset.build}`].join(" ");
}
const pageKey = (url) => { const u = new URL(url, location.href); u.hash = ""; return u.href; };
let shown = { url: pageKey(location.href), etag: null };
const seeded = print ? Promise.resolve() : fetch(location.href, { cache: "only-if-cached", mode: "same-origin" })
.then((response) => { if (response.ok && shown.etag === null) shown.etag = response.headers.get("etag"); })
.catch(() => {});
function adoptPreloads(next) {
for (const link of next.head.querySelectorAll('link[rel="modulepreload"], link[rel="preload"]')) {
const href = link.getAttribute("href");
const known = [...document.head.querySelectorAll(`link[rel="${link.rel}"]`)].some((have) => have.getAttribute("href") === href);
if (!known) document.head.append(document.importNode(link, true));
}
}
const pathOf = (url) => { const u = new URL(url, location.href); return u.pathname + u.search; };
let shownPath = pathOf(location.href);
if (!print) history.scrollRestoration = "manual";
function remember() {
history.replaceState({ ...history.state, scrollY }, "");
}
function toFragment(hash) {
const id = decodeURIComponent(hash.slice(1));
const target = id ? document.getElementById(id) : null;
target?.scrollIntoView();
return !!target;
}
let loads = 0;
let controller = null;
let running = null;
let again = false;
function load(url, how = "live") {
const generation = ++loads;
controller?.abort();
controller = new AbortController();
const loading = swap(new URL(url, location.href), how, generation, controller.signal)
.catch((error) => {
if (error.name === "AbortError") return "stale";
console.error("devpage: the page couldn't be swapped in", error);
return false;
})
.finally(() => {
if (running !== loading) return;
running = null;
if (again) {
again = false;
load(location.href);
}
});
running = loading;
return loading;
}
function refresh() {
if (running) again = true;
else load(location.href);
}
async function swap(url, how, generation, signal) {
const stale = () => generation !== loads;
await seeded;
const response = await fetch(url, { cache: "no-cache", signal });
if (stale()) return "stale";
if (!response.ok || !response.headers.get("content-type")?.includes("text/html")) return false;
const etag = response.headers.get("etag");
if (etag && etag === shown.etag && pageKey(url) === shown.url) return true;
const next = new DOMParser().parseFromString(await response.text(), "text/html");
if (stale()) return "stale";
if (frameOf(next.body) !== frameOf(document.body)) {
if (how === "push") location.assign(url);
else location.replace(url);
return "navigating";
}
adoptPreloads(next);
const list = [...next.scripts].find((script) => script.textContent.startsWith("window.devpageWidgets"));
if (list) new Function(list.textContent)();
const page = document.getElementById("devpage-page");
const fresh = next.getElementById("devpage-page");
const staging = document.createElement(page.tagName);
staging.className = fresh?.className ?? page.className;
staging.inert = true;
staging.style.cssText = `position: absolute; top: 0; left: 0; width: ${page.getBoundingClientRect().width}px; visibility: hidden; pointer-events: none;`;
if (fresh) staging.append(...fresh.childNodes);
document.body.append(staging);
const keep = keepUnchanged(page, staging);
showSlide(staging);
const drop = () => {
staging.remove();
unmountGone();
};
try {
await mountWidgets(staging);
} catch (error) {
drop();
throw error;
}
await sidebarMounted;
if (stale()) {
drop();
return "stale";
}
const scroll = scrollY;
keep();
page.className = staging.className;
page.replaceChildren(...staging.childNodes);
staging.remove();
const sidebar = sidebarData(next);
if (sidebar && showInSidebar) {
showInSidebar(sidebar);
document.getElementById("devpage-nav").dataset.sidebar = sidebar.document;
}
for (const id of ["devpage-nav", "devpage-navbar"]) {
if (id === "devpage-nav" && sidebar && showInSidebar) continue;
const part = next.getElementById(id);
if (part) document.getElementById(id)?.replaceChildren(...part.childNodes);
}
shown = { url: pageKey(url), etag };
if (how === "push") history.pushState(null, "", url);
shownPath = pathOf(url);
document.getElementById("devpage-navbar")?.classList.remove("open");
applyTheme();
applyCommentMode();
document.title = next.title;
showSlide();
markBroken(page);
if (how === "live") scrollTo(scrollX, scroll);
else if (how === "traverse" && typeof history.state?.scrollY === "number") scrollTo(0, history.state.scrollY);
else if (!toFragment(url.hash)) scrollTo(0, 0);
unmountGone();
drawDesigns();
loadComments();
return true;
}
document.addEventListener("click", async (event) => {
const link = event.target.closest("a[href]");
if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return;
const url = new URL(link.getAttribute("href"), location.href);
if (url.origin !== location.origin) return;
const found = link.dataset.found ? JSON.parse(link.dataset.found) : null;
clearFound();
if (!url.pathname.endsWith(".md")) {
if (found) handOver(url, found);
return;
}
event.preventDefault();
remember();
if (pathOf(url) === shownPath) {
if (url.hash && url.hash === location.hash) toFragment(url.hash);
else if (url.hash) location.hash = url.hash;
else if (found) {
if (url.href !== location.href) history.pushState(null, "", url);
scrollTo(0, 0);
}
if (found) land(url, found);
return;
}
const loaded = await load(url, "push");
if (found && loaded === true) land(url, found);
if (found && (loaded === false || loaded === "navigating")) handOver(url, found);
if (loaded === false) location.href = url;
});
window.addEventListener("popstate", () => {
if (location.href !== foundAt) clearFound();
if (pathOf(location.href) !== shownPath) {
load(location.href, "traverse");
return;
}
showSlide();
if (typeof history.state?.scrollY === "number") scrollTo(0, history.state.scrollY);
else if (location.hash) toFragment(location.hash);
});
const UNMARKED = "pre, code, svg, canvas, script, style, [data-widget], .design-embed, .problems, mark.devpage-found";
const HEADING = /^H[1-6]$/;
let foundAt = null;
function land(url, words) {
clearFound();
foundAt = url.href;
const page = document.getElementById("devpage-page");
if (!page || !words.length) return;
markFound(sectionText(page, url.hash), words);
if (!page.classList.contains("deck")) firstFound()?.scrollIntoView({ block: "center" });
}
const firstFound = () => document.querySelector("#devpage-page mark.devpage-found");
function sectionText(page, hash) {
const deck = page.classList.contains("deck");
let root = page;
let start = null;
if (deck) {
const slides = [...page.children].filter((el) => el.classList.contains("slide"));
root = slides[Math.min(Math.max(parseInt(hash.slice(1)) || 1, 1), slides.length) - 1];
} else {
const target = hash.length > 1 ? document.getElementById(decodeURIComponent(hash.slice(1))) : null;
if (target && HEADING.test(target.tagName)) start = target;
else if (target && page.contains(target)) root = target;
}
if (!root) return [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
acceptNode: (node) => (node.nodeType === Node.ELEMENT_NODE && node.matches(UNMARKED) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
});
const texts = [];
let inside = !start;
let heading = null;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (deck || !HEADING.test(node.tagName)) continue;
if (inside) break;
inside = node === start;
heading = node;
} else if (inside && !heading?.contains(node)) {
texts.push(node);
}
}
return texts;
}
function markFound(texts, words) {
const escaped = words.filter(Boolean).sort((a, b) => b.length - a.length).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
if (!escaped.length) return;
const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${escaped.join("|")})(?![\\p{L}\\p{N}_])`, "giu");
for (const text of texts) {
for (const match of [...text.data.matchAll(pattern)].reverse()) {
const word = text.splitText(match.index);
word.splitText(match[0].length);
const mark = document.createElement("mark");
mark.className = "devpage-found";
word.replaceWith(mark);
mark.append(word);
}
}
}
function clearFound() {
foundAt = null;
for (const mark of document.querySelectorAll("mark.devpage-found")) {
const word = mark.firstChild;
if (mark.childNodes.length !== 1 || word.nodeType !== Node.TEXT_NODE) {
mark.replaceWith(...mark.childNodes);
continue;
}
const before = mark.previousSibling;
const text = before?.nodeType === Node.TEXT_NODE ? before : word;
if (text === before) text.appendData(word.data);
mark.replaceWith(...(text === word ? [word] : []));
const after = text.nextSibling;
if (after?.nodeType === Node.TEXT_NODE) {
text.appendData(after.data);
after.remove();
}
}
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") clearFound();
});
const HANDED_OVER = "devpage-found";
function handOver(url, words) {
try {
sessionStorage.setItem(HANDED_OVER, JSON.stringify({ url: url.href, words }));
} catch {
}
}
function landHandedOver() {
try {
const handed = JSON.parse(sessionStorage.getItem(HANDED_OVER));
sessionStorage.removeItem(HANDED_OVER);
if (handed?.url !== location.href) return;
land(new URL(handed.url), handed.words);
if (document.readyState !== "complete") {
addEventListener("load", () => requestAnimationFrame(() => {
if (foundAt === handed.url) firstFound()?.scrollIntoView({ block: "center" });
}), { once: true });
}
} catch {
}
}
const sources = () => [...document.querySelectorAll("#devpage-page [data-source]")];
const CONTEXT = 32;
function textModel(section) {
const chars = [];
let text = "";
const walker = document.createTreeWalker(section, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => (node.parentElement?.closest(".comment-ui") ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
});
while (walker.nextNode()) {
const node = walker.currentNode;
for (let offset = 0; offset < node.data.length; offset++) {
const white = /\s/.test(node.data[offset]);
if (white && text.endsWith(" ")) continue;
text += white ? " " : node.data[offset];
chars.push({ node, offset });
}
}
return { text, chars };
}
function indexOf(model, container, offset) {
const point = document.createRange();
point.setStart(container, offset);
const index = model.chars.findIndex(({ node, offset: at }) => point.comparePoint(node, at) >= 0);
return index < 0 ? model.text.length : index;
}
function anchorFor(model, start, end) {
while (start < end && model.text[start] === " ") start++;
while (end > start && model.text[end - 1] === " ") end--;
return {
quote: model.text.slice(start, end),
prefix: model.text.slice(Math.max(0, start - CONTEXT), start),
suffix: model.text.slice(end, end + CONTEXT),
};
}
function find(model, anchor) {
if (!anchor.quote) return null;
const quote = anchor.quote.replace(/\s+/g, " ");
const prefix = anchor.prefix.replace(/\s+/g, " ");
const suffix = anchor.suffix.replace(/\s+/g, " ");
let at = model.text.indexOf(prefix + quote + suffix);
if (at >= 0) at += prefix.length;
else at = model.text.indexOf(quote);
return at < 0 ? null : [at, at + quote.length];
}
function highlight(model, [start, end], id) {
const marks = [];
let i = start;
while (i < end) {
const { node, offset } = model.chars[i];
let last = i;
while (last + 1 < end && model.chars[last + 1].node === node) last++;
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, model.chars[last].offset + 1);
const mark = document.createElement("mark");
mark.className = "comment";
mark.dataset.thread = id;
range.surroundContents(mark);
marks.push(mark);
i = last + 1;
}
return marks;
}
function panel() {
let aside = document.getElementById("devpage-comments");
if (!aside) {
aside = document.createElement("aside");
aside.id = "devpage-comments";
aside.className = "comment-ui";
document.body.append(aside);
}
return aside;
}
let commentsGeneration = 0;
async function loadComments() {
if (!comments) return;
const generation = ++commentsGeneration;
const fetched = await Promise.all(sources().map(async (section) => {
const page = section.dataset.source;
const response = await fetch(`/__devpage/comments?page=${encodeURIComponent(page)}`, { cache: "no-cache" });
return { section, page, threads: response.ok ? await response.json() : [] };
}));
if (generation !== commentsGeneration) return;
document.querySelectorAll("mark.comment").forEach((mark) => mark.replaceWith(...mark.childNodes));
sources().forEach((section) => section.normalize());
const aside = panel();
aside.querySelectorAll(".thread:not(.composer), .detached-heading").forEach((card) => card.remove());
const placed = [];
const detached = [];
for (const { section, page, threads } of fetched) {
if (!section.isConnected) continue;
for (const thread of threads) {
const card = threadCard(page, thread);
const model = textModel(section);
const at = find(model, thread.anchor);
if (!at) {
detached.push(card);
continue;
}
const marks = highlight(model, at, thread.id);
for (const mark of marks) mark.addEventListener("click", () => focusThread(thread.id));
card.anchorMark = marks[0];
placed.push(card);
}
}
if (detached.length) {
const heading = document.createElement("h3");
heading.className = "detached-heading";
heading.textContent = "Detached comments";
aside.prepend(heading, ...detached);
}
aside.append(...placed);
layoutComments();
}
function toggleButton() {
let button = document.getElementById("devpage-comments-toggle");
if (!button) {
button = document.createElement("button");
button.id = "devpage-comments-toggle";
button.className = "comment-ui";
button.addEventListener("click", () => document.body.classList.toggle("show-comments"));
document.body.append(button);
}
return button;
}
function layoutComments() {
const aside = document.getElementById("devpage-comments");
if (!aside) return;
const beside = !document.body.classList.contains("slides") && matchMedia("(min-width: 1100px)").matches;
aside.classList.toggle("beside", beside);
let floor = 0;
for (const card of aside.children) {
card.style.top = "";
if (!beside) continue;
const top = aside.getBoundingClientRect().top;
const want = card.anchorMark ? card.anchorMark.getBoundingClientRect().top - top : floor;
const at = Math.max(want, floor);
card.style.top = `${at}px`;
floor = at + card.offsetHeight + 12;
}
aside.style.minHeight = beside ? `${floor}px` : "";
const count = aside.querySelectorAll(".thread:not(.composer)").length;
const button = toggleButton();
button.textContent = `Comments (${count})`;
button.classList.toggle("shown", !beside && count > 0);
}
window.addEventListener("resize", layoutComments);
function focusThread(id) {
document.body.classList.add("show-comments");
const card = document.querySelector(`#devpage-comments .thread[data-thread="${CSS.escape(id)}"]`);
card?.scrollIntoView({ block: "nearest" });
card?.querySelector("textarea")?.focus();
}
function messageLine(message) {
const p = document.createElement("p");
const author = document.createElement("b");
author.textContent = message.author;
const time = document.createElement("time");
time.textContent = message.at.slice(0, 16).replace("T", " ");
p.append(author, " ", time, document.createElement("br"), message.text);
return p;
}
function threadCard(page, thread) {
const card = document.createElement("div");
card.className = "thread";
card.dataset.thread = thread.id;
const quote = document.createElement("blockquote");
quote.textContent = thread.anchor.quote;
card.append(quote, ...thread.thread.map(messageLine));
const resolve = document.createElement("button");
resolve.textContent = "Resolve";
resolve.addEventListener("click", () => send("resolve", { page, id: thread.id }, card));
card.append(editor("Reply", (text) => send("reply", { page, id: thread.id, text }, card), [resolve]));
return card;
}
function editor(label, submit, extra = [], cancel = null) {
const form = document.createElement("form");
form.className = "comment-editor";
const box = document.createElement("textarea");
box.rows = 2;
box.placeholder = label === "Reply" ? "Reply" : "Comment";
const ok = document.createElement("button");
ok.type = "submit";
ok.textContent = label;
const error = document.createElement("p");
error.className = "comment-error";
form.append(box, error, ok, ...extra);
if (cancel) {
const no = document.createElement("button");
no.type = "button";
no.textContent = "Cancel";
no.addEventListener("click", cancel);
form.append(no);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const text = box.value.trim();
if (!text) return;
error.textContent = (await submit(text)) ?? "";
if (!error.textContent) box.value = "";
});
box.addEventListener("keydown", (event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) form.requestSubmit();
if (event.key === "Escape" && cancel) cancel();
event.stopPropagation();
});
return form;
}
async function send(action, body, card) {
const response = await fetch(action ? `/__devpage/comments/${action}` : "/__devpage/comments", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const reason = await response.text();
if (card && action === "resolve") card.querySelector(".comment-error").textContent = reason;
return reason || `${response.status}`;
}
loadComments();
}
function compose(section, anchor, near) {
document.querySelectorAll(".comment-button").forEach((button) => button.remove());
document.querySelector("#devpage-comments .composer")?.remove();
const aside = panel();
const card = document.createElement("div");
card.className = "thread composer";
const quote = document.createElement("blockquote");
quote.textContent = anchor.quote;
const close = () => {
card.remove();
layoutComments();
};
card.append(quote, editor("Comment", async (text) => {
const refused = await send("", { page: section.dataset.source, anchor, text });
if (!refused) close();
return refused;
}, [], close));
card.anchorMark = near;
aside.prepend(card);
document.body.classList.add("show-comments");
layoutComments();
card.querySelector("textarea").focus();
}
const commentIcon =
'<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M4 3.5h12A1.5 1.5 0 0 1 17.5 5v8a1.5 1.5 0 0 1-1.5 1.5H9l-3.5 3v-3H4A1.5 1.5 0 0 1 2.5 13V5A1.5 1.5 0 0 1 4 3.5Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
function commentButton(label, title, top, left, onClick) {
document.querySelectorAll(".comment-button").forEach((button) => button.remove());
const button = document.createElement("button");
button.className = label ? "comment-button comment-ui" : "comment-button comment-icon comment-ui";
button.innerHTML = commentIcon;
if (label) button.append(label);
button.setAttribute("aria-label", title);
button.title = title;
button.style.cssText = `position:absolute;top:${top}px;left:${left}px`;
button.addEventListener("mousedown", (event) => event.preventDefault());
button.addEventListener("click", onClick);
document.body.append(button);
return button;
}
function commentMode() {
try {
return localStorage.getItem("devpage-comment-mode") === "on";
} catch {
return document.documentElement.dataset.commentMode === "on";
}
}
function applyCommentMode(on = commentMode()) {
document.documentElement.dataset.commentMode = on ? "on" : "off";
for (const button of document.querySelectorAll(".comment-mode")) {
button.setAttribute("aria-pressed", String(on));
}
if (!on) {
document.querySelectorAll(".comment-button").forEach((button) => button.remove());
hovered = null;
}
}
document.addEventListener("click", (event) => {
if (!event.target.closest?.(".comment-mode")) return;
const on = !commentMode();
try {
localStorage.setItem("devpage-comment-mode", on ? "on" : "off");
} catch {
}
applyCommentMode(on);
});
const blocks = "p, li, pre, table, .diagram, blockquote, h1, h2, h3, h4";
let hovered = null;
applyCommentMode();
document.addEventListener("mouseover", (event) => {
if (!comments || !commentMode() || !getSelection().isCollapsed) return;
const block = event.target.closest?.(blocks);
const section = block?.closest("[data-source]");
if (!block || block === hovered || !section || block.closest(".comment-ui")) return;
hovered = block;
const rect = block.getBoundingClientRect();
const size = 28;
const room = document.documentElement.clientWidth - 8;
const left = rect.right + 10 + size <= room ? rect.right + 10 : Math.max(Math.min(rect.right, room) - size - 4, 0);
commentButton("", "Comment on this paragraph", scrollY + rect.top, scrollX + left, () => {
const model = textModel(section);
const anchor = anchorFor(model, indexOf(model, block, 0), indexOf(model, block, block.childNodes.length));
if (anchor.quote) compose(section, anchor, block);
});
});
document.addEventListener("mouseup", (event) => {
if (!comments || !commentMode() || event.target.closest?.(".comment-ui")) return;
setTimeout(() => {
const selection = getSelection();
if (selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const common = range.commonAncestorContainer;
const section = (common.nodeType === Node.ELEMENT_NODE ? common : common.parentElement)?.closest("[data-source]");
if (!section) return;
const model = textModel(section);
const anchor = anchorFor(model, indexOf(model, range.startContainer, range.startOffset), indexOf(model, range.endContainer, range.endOffset));
if (!anchor.quote) return;
const rect = range.getBoundingClientRect();
commentButton("Comment", "Comment on the selection", scrollY + rect.bottom + 6, scrollX + rect.left, () => {
selection.removeAllRanges();
compose(section, anchor, range.startContainer.parentElement);
});
});
});
let stream = null;
function connect() {
if (stream) return;
stream = new EventSource("/__devpage/events");
stream.onmessage = (event) => {
if (event.data.startsWith("comments ")) {
if (sources().some((s) => s.dataset.source === event.data.slice(9))) loadComments();
} else {
refresh();
}
};
}
function disconnect() {
stream?.close();
stream = null;
}
document.addEventListener("visibilitychange", () => {
if (print) return;
if (document.hidden) {
disconnect();
} else {
connect();
refresh(); }
});
window.addEventListener("pagehide", () => {
disconnect();
if (!print) remember();
});
function showSlide(page = document.getElementById("devpage-page")) {
if (print || !page?.classList.contains("deck")) return;
const slides = [...page.children].filter((el) => el.classList.contains("slide"));
if (!slides.length) return;
const index = Math.min(Math.max(parseInt(location.hash.slice(1)) || 1, 1), slides.length);
const scale = Math.min(innerWidth / 1920, innerHeight / 1080);
const left = (innerWidth - 1920 * scale) / 2, top = (innerHeight - 1080 * scale) / 2;
slides.forEach((slide, i) => {
slide.classList.toggle("shown", i === index - 1);
slide.style.transform = `translate(${left}px, ${top}px) scale(${scale})`;
});
return { index, count: slides.length };
}
document.addEventListener("keydown", (event) => {
if (event.target.closest?.("input, textarea") || event.metaKey || event.ctrlKey || event.altKey) return;
const state = showSlide();
if (!state) return;
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
const go = (n) => { location.hash = `#${Math.min(Math.max(n, 1), state.count)}`; };
if (["ArrowRight", " ", "PageDown", "ArrowDown"].includes(key)) go(state.index + 1);
else if (["ArrowLeft", "PageUp", "ArrowUp"].includes(key)) go(state.index - 1);
else if (key === "Home") go(1);
else if (key === "End") go(state.count);
else if (key === "c" && comments) document.body.classList.toggle("show-comments");
else if (key === "f") document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen();
else return;
event.preventDefault();
});
window.addEventListener("hashchange", showSlide);
window.addEventListener("resize", showSlide);
function followOutline() {
const links = [...document.querySelectorAll(".outline ul a")];
if (!links.length) return;
const targets = links.map((link) => document.getElementById(decodeURIComponent(link.hash.slice(1)))).filter(Boolean);
const mark = () => {
let current = targets[0];
for (const target of targets) if (target.getBoundingClientRect().top < innerHeight / 3) current = target;
for (const link of links) link.classList.toggle("current", link.hash === `#${current?.id}`);
};
addEventListener("scroll", mark, { passive: true });
mark();
}
drawDesigns();
Promise.all([mountWidgets(document).catch((error) => console.error(error)), sidebarMounted]).finally(() => {
drawDesigns();
markBroken(document);
showSlide();
if (!print && typeof history.state?.scrollY === "number") scrollTo(0, history.state.scrollY);
if (!print) landHandedOver();
followOutline();
loadComments();
document.documentElement.dataset.ready = "true";
});
if (!print && !document.hidden) connect();
const themeQuery = matchMedia("(prefers-color-scheme: dark)");
function savedTheme() {
if (!document.querySelector(".theme-toggle")) return null;
try {
const saved = localStorage.getItem("devpage-theme");
return saved === "light" || saved === "dark" ? saved : null;
} catch {
return null;
}
}
function applyTheme(theme = savedTheme()) {
const root = document.documentElement;
root.dataset.theme = theme || (themeQuery.matches ? "dark" : "light");
const label = root.dataset.theme === "dark" ? "Switch to light" : "Switch to dark";
for (const button of document.querySelectorAll(".theme-toggle")) {
button.title = label;
button.setAttribute("aria-label", label);
}
}
applyTheme();
themeQuery.addEventListener("change", () => applyTheme());
document.addEventListener("click", (event) => {
if (!event.target.closest?.(".theme-toggle")) return;
const theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
try {
localStorage.setItem("devpage-theme", theme);
} catch {
}
applyTheme(theme);
});
function closeMenus(except) {
for (const menu of document.querySelectorAll(".navbar-menu.open")) {
if (menu === except) continue;
menu.classList.remove("open");
menu.querySelector(":scope > button").setAttribute("aria-expanded", "false");
}
}
function openMenu(menu, focusFirst) {
closeMenus(menu);
menu.classList.add("open");
menu.querySelector(":scope > button").setAttribute("aria-expanded", "true");
if (focusFirst) menu.querySelector(".navbar-entry")?.focus();
}
document.addEventListener("click", (event) => {
const toggle = event.target.closest?.(".navbar-toggle");
if (toggle) {
const bar = document.getElementById("devpage-navbar");
const open = bar.classList.toggle("open");
toggle.setAttribute("aria-expanded", String(open));
return;
}
const button = event.target.closest?.(".navbar-menu > button");
if (button) {
const menu = button.parentElement;
if (menu.classList.contains("open")) closeMenus();
else openMenu(menu, false);
return;
}
if (!event.target.closest?.(".navbar-dropdown") || event.target.closest("a")) closeMenus();
if (event.target.closest?.("#devpage-navbar a")) document.getElementById("devpage-navbar")?.classList.remove("open");
});
document.addEventListener("keydown", (event) => {
const menu = event.target.closest?.(".navbar-menu");
if (!menu) return;
const entries = [...menu.querySelectorAll(".navbar-entry")];
const at = entries.indexOf(document.activeElement);
if (event.key === "Escape") {
closeMenus();
menu.querySelector(":scope > button").focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
if (!menu.classList.contains("open")) openMenu(menu, true);
else entries[(at + 1) % entries.length]?.focus();
} else if (event.key === "ArrowUp" && menu.classList.contains("open")) {
event.preventDefault();
entries[(at - 1 + entries.length) % entries.length]?.focus();
}
});