The documentation moves from READMEs into a site in docs/: getting started, a user guide, operations, a reference, and development pages, written in the style of network vendors' documentation (docs/STYLE.md). bin/bgp/README.md and docs/RFCs.md are folded into it. - A theme of its own in the International Typographic Style, in blues, with self-hosted Inter and JetBrains Mono, search over Zola's index, dark mode, and no resources loaded from other hosts. - Example configurations in docs/examples/, shown on the pages and checked with `bgp --check` in CI. - A test that fails when a field of config.proto is missing from the configuration reference. - A CI job that builds the site; Zola fails on broken links and anchors. - A hardened systemd unit, tried under systemd with kernel routes on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
9.2 KiB
JavaScript
243 lines
9.2 KiB
JavaScript
// The little the site does in the browser: the theme switch, the contents
|
|
// on narrow screens, copying code, following the reader in "On this page",
|
|
// and search over the index Zola builds. Nothing is fetched from elsewhere.
|
|
"use strict";
|
|
|
|
(function () {
|
|
var root = document.documentElement;
|
|
|
|
// --- Theme ---------------------------------------------------------------
|
|
|
|
function currentTheme() {
|
|
if (root.dataset.theme) return root.dataset.theme;
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
}
|
|
|
|
function remember(theme) {
|
|
try { localStorage.setItem("theme", theme); } catch (e) {}
|
|
}
|
|
|
|
document.querySelectorAll("[data-theme-toggle]").forEach(function (button) {
|
|
button.addEventListener("click", function () {
|
|
var next = currentTheme() === "dark" ? "light" : "dark";
|
|
root.dataset.theme = next;
|
|
remember(next);
|
|
});
|
|
});
|
|
|
|
// --- Contents, on narrow screens -----------------------------------------
|
|
|
|
var menuButton = document.querySelector("[data-menu-toggle]");
|
|
function setMenu(open) {
|
|
document.body.classList.toggle("menu-open", open);
|
|
if (menuButton) menuButton.setAttribute("aria-expanded", String(open));
|
|
}
|
|
if (menuButton) {
|
|
menuButton.addEventListener("click", function () {
|
|
setMenu(!document.body.classList.contains("menu-open"));
|
|
});
|
|
document.addEventListener("click", function (event) {
|
|
if (!document.body.classList.contains("menu-open")) return;
|
|
if (event.target.closest("[data-sidebar], [data-menu-toggle]")) return;
|
|
setMenu(false);
|
|
});
|
|
}
|
|
|
|
// --- Code blocks: a button to copy ----------------------------------------
|
|
|
|
var COPY = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8.5" y="8.5" width="12" height="12" rx="2"/><path d="M15.5 8.5V5.5a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h3"/></svg>';
|
|
var DONE = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
|
|
|
|
document.querySelectorAll(".prose pre").forEach(function (pre) {
|
|
var wrap = document.createElement("div");
|
|
wrap.className = "code-block";
|
|
pre.parentNode.insertBefore(wrap, pre);
|
|
wrap.appendChild(pre);
|
|
|
|
var code = pre.querySelector("code");
|
|
|
|
var button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "copy-button";
|
|
button.setAttribute("aria-label", "Copy");
|
|
button.innerHTML = COPY;
|
|
button.addEventListener("click", function () {
|
|
var text = (code || pre).innerText.replace(/\n$/, "");
|
|
navigator.clipboard.writeText(text).then(function () {
|
|
button.innerHTML = DONE;
|
|
button.classList.add("copied");
|
|
setTimeout(function () {
|
|
button.innerHTML = COPY;
|
|
button.classList.remove("copied");
|
|
}, 1400);
|
|
});
|
|
});
|
|
wrap.appendChild(button);
|
|
});
|
|
|
|
// --- On this page: the heading being read --------------------------------
|
|
|
|
var tocLinks = Array.prototype.slice.call(document.querySelectorAll("[data-toc-link]"));
|
|
if (tocLinks.length && "IntersectionObserver" in window) {
|
|
var byId = {};
|
|
var headings = [];
|
|
tocLinks.forEach(function (link) {
|
|
var id = decodeURIComponent(link.hash.slice(1));
|
|
var heading = document.getElementById(id);
|
|
if (heading) { byId[id] = link; headings.push(heading); }
|
|
});
|
|
var visible = new Set();
|
|
function mark() {
|
|
// The first heading on screen; failing that, the last one above it.
|
|
var current = null;
|
|
for (var i = 0; i < headings.length; i++) {
|
|
if (visible.has(headings[i])) { current = headings[i]; break; }
|
|
if (headings[i].getBoundingClientRect().top < 120) current = headings[i];
|
|
}
|
|
tocLinks.forEach(function (link) { link.classList.remove("active"); });
|
|
if (current) byId[current.id].classList.add("active");
|
|
}
|
|
var observer = new IntersectionObserver(function (entries) {
|
|
entries.forEach(function (entry) {
|
|
if (entry.isIntersecting) visible.add(entry.target); else visible.delete(entry.target);
|
|
});
|
|
mark();
|
|
}, { rootMargin: "-60px 0px -55% 0px" });
|
|
headings.forEach(function (heading) { observer.observe(heading); });
|
|
}
|
|
|
|
// --- Search --------------------------------------------------------------
|
|
|
|
var search = document.querySelector("[data-search]");
|
|
var input = document.querySelector("[data-search-input]");
|
|
var results = document.querySelector("[data-search-results]");
|
|
var empty = document.querySelector("[data-search-empty]");
|
|
var index = null;
|
|
var loading = null;
|
|
var selected = -1;
|
|
|
|
function load(src) {
|
|
return new Promise(function (resolve, reject) {
|
|
var script = document.createElement("script");
|
|
script.src = src;
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function loadIndex() {
|
|
if (!loading) {
|
|
loading = load(document.body.dataset.searchEngine)
|
|
.then(function () { return load(document.body.dataset.searchIndex); })
|
|
.then(function () { index = window.elasticlunr.Index.load(window.searchIndex); });
|
|
}
|
|
return loading;
|
|
}
|
|
|
|
function openSearch() {
|
|
search.hidden = false;
|
|
document.body.style.overflow = "hidden";
|
|
input.focus();
|
|
input.select();
|
|
loadIndex().then(run);
|
|
}
|
|
|
|
function closeSearch() {
|
|
search.hidden = true;
|
|
document.body.style.overflow = "";
|
|
}
|
|
|
|
function escape(text) {
|
|
return text.replace(/[&<>"]/g, function (c) {
|
|
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
|
});
|
|
}
|
|
|
|
// A piece of the page around the first word searched for.
|
|
function excerpt(body, terms) {
|
|
var lower = body.toLowerCase();
|
|
var at = -1;
|
|
for (var i = 0; i < terms.length && at < 0; i++) at = lower.indexOf(terms[i]);
|
|
var start = Math.max(0, at - 60);
|
|
var text = (start > 0 ? "…" : "") + body.slice(start, start + 220);
|
|
var html = escape(text);
|
|
terms.forEach(function (term) {
|
|
if (term.length < 2) return;
|
|
var pattern = new RegExp("(" + term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ")", "gi");
|
|
html = html.replace(pattern, "<mark>$1</mark>");
|
|
});
|
|
return html;
|
|
}
|
|
|
|
function sectionOf(url) {
|
|
var path = url.replace(/^https?:\/\/[^/]+/, "");
|
|
var parts = path.split("/").filter(Boolean);
|
|
if (parts[0] !== "docs" || parts.length < 3) return "";
|
|
return parts[1].replace(/-/g, " ").replace(/^./, function (c) { return c.toUpperCase(); });
|
|
}
|
|
|
|
function run() {
|
|
if (!index) return;
|
|
var query = input.value.trim();
|
|
results.innerHTML = "";
|
|
selected = -1;
|
|
empty.hidden = true;
|
|
if (!query) return;
|
|
var found = index.search(query, {
|
|
bool: "AND",
|
|
expand: true,
|
|
fields: { title: { boost: 3 }, description: { boost: 2 }, body: { boost: 1 } },
|
|
}).slice(0, 10);
|
|
if (!found.length) { empty.hidden = false; return; }
|
|
var terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
found.forEach(function (hit, i) {
|
|
var doc = index.documentStore.getDoc(hit.ref);
|
|
var item = document.createElement("li");
|
|
var section = sectionOf(hit.ref);
|
|
item.innerHTML =
|
|
'<a href="' + escape(hit.ref) + '" role="option" id="search-' + i + '">' +
|
|
(section ? '<span class="search-result-section">' + escape(section) + "</span>" : "") +
|
|
'<span class="search-result-title">' + escape(doc.title || "Ferrix BGP") + "</span>" +
|
|
'<span class="search-result-text">' + excerpt(doc.body || doc.description || "", terms) + "</span></a>";
|
|
results.appendChild(item);
|
|
});
|
|
select(0);
|
|
}
|
|
|
|
function select(i) {
|
|
var links = results.querySelectorAll("a");
|
|
if (!links.length) return;
|
|
selected = (i + links.length) % links.length;
|
|
links.forEach(function (link, j) { link.setAttribute("aria-selected", String(j === selected)); });
|
|
links[selected].scrollIntoView({ block: "nearest" });
|
|
}
|
|
|
|
if (search && input) {
|
|
document.querySelectorAll("[data-search-open]").forEach(function (button) {
|
|
button.addEventListener("click", openSearch);
|
|
});
|
|
document.querySelectorAll("[data-search-close]").forEach(function (el) {
|
|
el.addEventListener("click", closeSearch);
|
|
});
|
|
input.addEventListener("input", run);
|
|
input.addEventListener("keydown", function (event) {
|
|
if (event.key === "ArrowDown") { event.preventDefault(); select(selected + 1); }
|
|
else if (event.key === "ArrowUp") { event.preventDefault(); select(selected - 1); }
|
|
else if (event.key === "Enter") {
|
|
var link = results.querySelectorAll("a")[selected];
|
|
if (link) window.location.href = link.href;
|
|
}
|
|
});
|
|
document.addEventListener("keydown", function (event) {
|
|
var typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName) || document.activeElement.isContentEditable;
|
|
if (event.key === "Escape" && !search.hidden) { closeSearch(); return; }
|
|
if (event.key === "Escape") { setMenu(false); return; }
|
|
if ((event.key === "/" && !typing) || (event.key === "k" && (event.metaKey || event.ctrlKey))) {
|
|
event.preventDefault();
|
|
openSearch();
|
|
}
|
|
});
|
|
}
|
|
})();
|