Files
documentation/assets/app.js

653 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
var THEME_KEY = "vyla-theme";
var ICONS = {
system: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>',
light: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4"/>',
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
menu: '<path d="M4 6h16M4 12h10M4 18h16"/>',
doc: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h6"/>',
hash: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/>',
check: '<path d="m5 12 5 5 9-10"/>',
chevron: '<path d="m6 9 6 6 6-6"/>'
};
var searchData = null;
var searchResults = [];
var searchActive = 0;
function icon(name) {
return '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + ICONS[name] + "</svg>";
}
function esc(s) {
return String(s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
function getThemeMode() {
var v = localStorage.getItem(THEME_KEY);
return v === "light" || v === "dark" ? v : "system";
}
function applyTheme() {
var mode = getThemeMode();
var dark = mode === "dark" || (mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
var logo = document.getElementById("brand-logo");
if (logo) logo.src = dark ? "/assets/images/logo.png" : "/assets/images/dark_logo.png";
}
function setTheme(mode) {
if (mode === "system") localStorage.removeItem(THEME_KEY);
else localStorage.setItem(THEME_KEY, mode);
applyTheme();
refreshThemeUI();
var menu = document.getElementById("theme-menu");
if (menu) menu.classList.remove("open");
}
function toggleThemeMenu(e) {
e.stopPropagation();
document.getElementById("theme-menu").classList.toggle("open");
}
function refreshThemeUI() {
var mode = getThemeMode();
var btn = document.getElementById("theme-toggle");
if (btn) btn.innerHTML = icon(mode);
document.querySelectorAll("#theme-menu button, #theme-seg button").forEach(function (b) {
b.classList.toggle("on", b.getAttribute("data-mode") === mode);
});
}
function buildThemeControls() {
var btn = document.getElementById("theme-toggle");
var links = document.querySelector("#navbar .links");
if (!btn || !links) return;
var wrap = document.createElement("div");
wrap.className = "theme-wrap";
btn.parentNode.insertBefore(wrap, btn);
wrap.appendChild(btn);
var modes = ["system", "light", "dark"];
var menu = document.createElement("div");
menu.id = "theme-menu";
menu.innerHTML = modes.map(function (m) {
return '<button type="button" data-mode="' + m + '">' + icon(m) + "<span>" + m.charAt(0).toUpperCase() + m.slice(1) + '</span><i class="check">✓</i></button>';
}).join("");
wrap.appendChild(menu);
var seg = document.createElement("div");
seg.id = "theme-seg";
seg.innerHTML = modes.map(function (m) {
return '<button type="button" data-mode="' + m + '" aria-label="' + m + '">' + icon(m) + "</button>";
}).join("");
links.appendChild(seg);
[menu, seg].forEach(function (el) {
el.addEventListener("click", function (e) {
var b = e.target.closest("button");
if (b) setTheme(b.getAttribute("data-mode"));
});
});
refreshThemeUI();
}
function toggleSidebar() {
var sidebar = document.getElementById("sidebar");
var nav = document.getElementById("navbar");
var top = Math.max(0, Math.round(nav.getBoundingClientRect().bottom));
sidebar.style.setProperty("--sidebar-top", top + "px");
var open = sidebar.classList.toggle("open");
document.body.classList.toggle("nav-open", open);
}
function findActiveTab(root) {
var hrefs = {};
root.querySelectorAll("#sidebar .group li a").forEach(function (a) {
hrefs[a.getAttribute("href")] = true;
});
var tabs = root.querySelectorAll("#navbar .tabs a");
for (var i = 0; i < tabs.length; i++) {
if (hrefs[tabs[i].getAttribute("href")]) return tabs[i];
}
return root.querySelector("#navbar .tabs a.active");
}
function fixActiveTab() {
var current = findActiveTab(document);
if (!current) return;
document.querySelectorAll("#navbar .tabs a").forEach(function (a) {
a.classList.toggle("active", a === current);
});
}
function activeLink() {
return document.querySelector("#sidebar .group:not(.mobile-tabs) li a.active");
}
function activeGroupTitle() {
var a = activeLink();
var g = a && a.closest(".group");
var t = g && g.querySelector(".group-title");
return t ? t.textContent.trim() : "";
}
function buildMobileBar() {
var nav = document.getElementById("navbar");
var sidebar = document.getElementById("sidebar");
if (!nav) return;
var a = activeLink();
var group = activeGroupTitle();
var bar = document.createElement("div");
bar.id = "mobile-bar";
bar.innerHTML =
'<button id="mobile-toggle" type="button" aria-label="Toggle navigation" onclick="toggleSidebar()">' + icon("menu") + "</button>" +
(group ? "<span>" + esc(group) + '</span><span class="sep">›</span>' : "") +
'<span class="crumb-current">' + esc(a ? a.textContent.trim() : document.title) + "</span>";
nav.parentNode.insertBefore(bar, nav.nextSibling);
var tabs = document.querySelectorAll("#navbar .tabs a");
if (sidebar && tabs.length) {
var currentTab = document.querySelector("#navbar .tabs a.active") || tabs[0];
var g = document.createElement("div");
g.className = "mobile-tabs";
g.innerHTML =
'<button type="button" class="mt-btn" aria-expanded="false"><span>' + esc(currentTab.textContent.trim()) + "</span>" + icon("chevron") + "</button>" +
'<div class="mt-menu">' + Array.prototype.map.call(tabs, function (t) {
return '<a href="' + t.getAttribute("href") + '"' + (t === currentTab ? ' class="on"' : "") + ">" + esc(t.textContent.trim()) + icon("check") + "</a>";
}).join("") + "</div>";
sidebar.insertBefore(g, sidebar.firstChild);
g.querySelector(".mt-btn").addEventListener("click", function () {
g.classList.toggle("open");
});
document.addEventListener("click", function (e) {
if (!g.contains(e.target)) g.classList.remove("open");
});
g.querySelectorAll(".mt-menu a").forEach(function (a) {
a.addEventListener("click", function (e) {
e.preventDefault();
e.stopPropagation();
var href = a.getAttribute("href");
g.querySelectorAll(".mt-menu a").forEach(function (link) {
link.classList.remove("on");
});
a.classList.add("on");
g.querySelector(".mt-btn span").textContent = a.textContent.trim();
g.classList.remove("open");
var sidebar = document.getElementById("sidebar");
if (sidebar) {
var mobileTabs = sidebar.querySelector(".mobile-tabs");
var nav = document.getElementById("navbar");
var top = Math.max(0, Math.round(nav.getBoundingClientRect().bottom));
sidebar.style.setProperty("--sidebar-top", top + "px");
fetch(href)
.then(function (r) { return r.text(); })
.then(function (html) {
var doc = new DOMParser().parseFromString(html, "text/html");
var newSidebar = doc.querySelector("#sidebar");
if (newSidebar) {
var groups = newSidebar.querySelectorAll(".group");
sidebar.innerHTML = "";
if (mobileTabs) sidebar.appendChild(mobileTabs);
groups.forEach(function (group) {
sidebar.appendChild(group.cloneNode(true));
});
addEyebrow();
}
})
.catch(function () { });
if (!sidebar.classList.contains("open")) {
sidebar.classList.add("open");
document.body.classList.add("nav-open");
}
}
});
});
}
}
function addEyebrow() {
var h1 = document.querySelector("main.prose h1");
var group = activeGroupTitle();
if (!h1 || !group) return;
var el = document.createElement("div");
el.className = "eyebrow";
el.textContent = group;
h1.parentNode.insertBefore(el, h1);
}
function addCopyButtons() {
document.querySelectorAll(".code-group").forEach(function (cg) {
if (cg.querySelector(".cg-copy-btn")) return;
var b = document.createElement("button");
b.type = "button";
b.className = "cg-copy-btn";
b.setAttribute("aria-label", "Copy");
b.innerHTML = icon("copy");
b.onclick = function () {
var activePre = cg.querySelector(".code-group-panel.active pre");
if (!activePre) return;
navigator.clipboard.writeText(activePre.innerText).then(function () {
b.innerHTML = icon("check");
setTimeout(function () { b.innerHTML = icon("copy"); }, 1500);
});
};
cg.appendChild(b);
});
document.querySelectorAll("main.prose pre").forEach(function (pre) {
if (pre.closest(".code-group")) return;
if (pre.parentNode.classList.contains("code-wrap")) return;
var wrap = document.createElement("div");
wrap.className = "code-wrap";
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(pre);
var b = document.createElement("button");
b.type = "button";
b.className = "copy-btn";
b.setAttribute("aria-label", "Copy");
b.innerHTML = icon("copy");
b.onclick = function () {
navigator.clipboard.writeText(pre.innerText).then(function () {
b.innerHTML = icon("check");
setTimeout(function () { b.innerHTML = icon("copy"); }, 1500);
});
};
wrap.appendChild(b);
});
}
function bindScrollFade(box, getScroller) {
function update() {
var el = getScroller();
var max = el.scrollWidth - el.clientWidth;
var left = 0;
var right = 0;
if (max > 1) {
left = Math.min(el.scrollLeft, 56);
right = Math.min(Math.max(Math.round(max - el.scrollLeft), 0), 56);
}
box.style.setProperty("--fade-l", left + "px");
box.style.setProperty("--fade-r", right + "px");
}
box.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
window.addEventListener("load", update);
update();
}
function initCodeFades() {
document.querySelectorAll("main.prose pre").forEach(function (pre) {
var code = pre.querySelector("code");
bindScrollFade(pre, function () {
return code && code.scrollWidth > code.clientWidth + 1 ? code : pre;
});
});
}
function wrapTables() {
document.querySelectorAll("main.prose table").forEach(function (table) {
if (table.parentNode.classList.contains("table-wrap")) return;
var wrap = document.createElement("div");
wrap.className = "table-wrap";
table.parentNode.insertBefore(wrap, table);
wrap.appendChild(table);
bindScrollFade(wrap, function () {
return wrap;
});
});
}
function initTocSpy() {
var links = Array.prototype.slice.call(document.querySelectorAll("aside#toc a[href^='#']"));
if (!links.length) return;
var heads = links.map(function (a) {
return document.getElementById(decodeURIComponent(a.getAttribute("href").slice(1)));
});
function update() {
var idx = 0;
heads.forEach(function (h, i) {
if (h && h.getBoundingClientRect().top < 140) idx = i;
});
links.forEach(function (a, i) { a.classList.toggle("active", i === idx); });
}
window.addEventListener("scroll", update, { passive: true });
update();
}
var SEARCH_KEY = "vyla-search-v1";
var searchPromise = null;
function getRecent() {
try { return JSON.parse(localStorage.getItem("vyla-recent-v2")) || []; } catch (e) { return []; }
}
function saveRecent(item) {
if (!item) return;
var slim = { title: item.title, crumbs: item.crumbs, href: item.href, type: item.type, text: "" };
var list = getRecent().filter(function (r) { return r.href !== slim.href; });
list.unshift(slim);
localStorage.setItem("vyla-recent-v2", JSON.stringify(list.slice(0, 5)));
}
function cleanText(s) {
return s.replace(/\s+/g, " ").trim();
}
function fetchDoc(href) {
return fetch(href)
.then(function (r) {
if (!r.ok) throw new Error(href);
return r.text();
})
.then(function (html) {
return { href: href, doc: new DOMParser().parseFromString(html, "text/html") };
})
.catch(function () {
return null;
});
}
function indexDoc(res) {
var doc = res.doc;
var href = res.href.split("#")[0];
var prose = doc.querySelector("main.prose");
if (!prose) return [];
var tabEl = findActiveTab(doc);
var activeEl = doc.querySelector("#sidebar li a.active");
var groupEl = activeEl && activeEl.closest(".group");
var groupTitleEl = groupEl && groupEl.querySelector(".group-title");
var h1 = prose.querySelector("h1");
var pageTitle = h1 ? cleanText(h1.textContent) : activeEl ? cleanText(activeEl.textContent) : href;
var base = [];
if (tabEl) base.push(cleanText(tabEl.textContent));
if (groupTitleEl) base.push(cleanText(groupTitleEl.textContent));
var current = { title: pageTitle, id: "", type: "page", crumbs: base.slice(), text: [] };
var entries = [current];
Array.prototype.forEach.call(prose.children, function (el) {
if (el.tagName === "H1") return;
if (el.tagName === "H2" || el.tagName === "H3") {
var title = cleanText(el.textContent);
current = { title: title, id: el.id, type: "heading", crumbs: base.concat([pageTitle, title]), text: [] };
entries.push(current);
return;
}
current.text.push(cleanText(el.textContent));
});
return entries.map(function (e) {
return {
title: e.title,
crumbs: e.crumbs,
href: e.id ? href + "#" + e.id : href,
type: e.type,
text: e.text.join(" ").slice(0, 4000)
};
});
}
function crawlSite() {
var seen = {};
var all = [];
function visit(hrefs) {
var fresh = hrefs.map(function (h) {
return new URL(h, window.location.href);
}).filter(function (u) {
if (u.origin !== window.location.origin || seen[u.pathname]) return false;
seen[u.pathname] = true;
return true;
}).map(function (u) {
return u.pathname;
});
return Promise.all(fresh.map(fetchDoc)).then(function (results) {
var next = [];
results.forEach(function (res) {
if (!res) return;
all = all.concat(indexDoc(res));
res.doc.querySelectorAll("#sidebar li a").forEach(function (a) {
var h = a.getAttribute("href");
if (h && h.charAt(0) !== "#") next.push(h);
});
});
return next.length ? visit(next) : all;
});
}
var start = Array.prototype.map.call(document.querySelectorAll("#navbar .tabs a"), function (a) {
return a.getAttribute("href");
});
start.push(window.location.pathname);
return visit(start).then(function () {
return all;
});
}
function loadSearchData() {
if (searchData) return Promise.resolve(searchData);
if (searchPromise) return searchPromise;
try {
var cached = JSON.parse(sessionStorage.getItem(SEARCH_KEY));
if (cached && cached.data && cached.data.length && Date.now() - cached.time < 600000) {
searchData = cached.data;
return Promise.resolve(searchData);
}
} catch (e) { }
searchPromise = crawlSite().then(function (all) {
searchData = all;
try { sessionStorage.setItem(SEARCH_KEY, JSON.stringify({ time: Date.now(), data: all })); } catch (e) { }
return all;
});
return searchPromise;
}
function escRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function hl(text, tokens) {
if (!tokens.length) return esc(text);
var re = new RegExp("(" + tokens.map(escRe).join("|") + ")", "ig");
return text.split(re).map(function (part, i) {
return i % 2 ? '<b class="hit">' + esc(part) + "</b>" : esc(part);
}).join("");
}
function lower(it) {
if (!it._l) it._l = { t: it.title.toLowerCase(), c: it.crumbs.join(" ").toLowerCase(), x: (it.text || "").toLowerCase() };
return it._l;
}
function scoreEntry(it, tokens, q) {
var low = lower(it);
var score = 0;
for (var i = 0; i < tokens.length; i++) {
var t = tokens[i];
var inTitle = low.t.indexOf(t) !== -1;
var inCrumbs = low.c.indexOf(t) !== -1;
var inText = low.x.indexOf(t) !== -1;
if (!inTitle && !inCrumbs && !inText) return 0;
score += (inTitle ? 20 : 0) + (inCrumbs ? 4 : 0) + (inText ? 1 : 0);
}
if (low.t === q) score += 100;
else if (low.t.indexOf(q) === 0) score += 50;
else if (low.t.indexOf(q) !== -1) score += 25;
if (it.type === "page") score += 5;
return score;
}
function makeSnippet(it, tokens) {
var low = lower(it);
var inTitle = tokens.every(function (t) { return low.t.indexOf(t) !== -1; });
if (inTitle || !it.text) return "";
var hit = -1;
tokens.forEach(function (t) {
var k = low.x.indexOf(t);
if (k !== -1 && (hit === -1 || k < hit)) hit = k;
});
if (hit === -1) return "";
var start = Math.max(0, hit - 50);
var end = hit + 120;
return (start > 0 ? "…" : "") + it.text.slice(start, end) + (end < it.text.length ? "…" : "");
}
function renderSearch(keepActive) {
var q = document.getElementById("search-input").value.trim().toLowerCase();
var tokens = q.split(/\s+/).filter(Boolean);
var box = document.getElementById("search-results");
if (!keepActive) searchActive = 0;
if (tokens.length) {
if (!searchData) {
searchResults = [];
box.innerHTML = '<div class="search-label">Indexing pages...</div>';
return;
}
searchResults = searchData
.map(function (it) { return { it: it, s: scoreEntry(it, tokens, q) }; })
.filter(function (r) { return r.s > 0; })
.sort(function (a, b) { return b.s - a.s; })
.slice(0, 12)
.map(function (r) { return r.it; });
} else {
searchResults = getRecent();
}
if (!searchResults.length) {
box.innerHTML = '<div class="search-label">' + (tokens.length ? "No results found" : "No recent searches") + "</div>";
return;
}
box.innerHTML = (tokens.length ? "" : '<div class="search-label">Recent searches</div>') + searchResults.map(function (it, i) {
var snippet = tokens.length ? makeSnippet(it, tokens) : "";
var crumbs = it.crumbs.map(function (c) { return hl(c, tokens); }).join('<span class="sep">›</span>');
return '<a class="sr' + (i === searchActive ? " on" : "") + '" href="' + esc(it.href) + '" data-i="' + i + '">' +
icon(it.type === "heading" ? "hash" : "doc") +
'<div class="sr-body"><div class="t">' + esc(it.title) + "</div>" +
(snippet ? '<div class="s">' + hl(snippet, tokens) + "</div>" : "") +
'<div class="p">' + crumbs + "</div></div></a>";
}).join("");
if (keepActive) {
var on = box.querySelector(".sr.on");
if (on) on.scrollIntoView({ block: "nearest" });
}
}
function openSearch() {
var input = document.getElementById("search-input");
document.getElementById("search-modal").classList.add("open");
document.body.style.overflow = "hidden";
input.value = "";
renderSearch(false);
loadSearchData().then(function () { renderSearch(false); });
input.focus();
}
function closeSearch() {
document.getElementById("search-modal").classList.remove("open");
document.body.style.overflow = "";
}
function onSearchKey(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openSearch();
return;
}
var modal = document.getElementById("search-modal");
if (!modal || !modal.classList.contains("open")) return;
if (e.key === "Escape") {
closeSearch();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
var n = searchResults.length || 1;
searchActive = (searchActive + (e.key === "ArrowDown" ? 1 : -1) + n) % n;
renderSearch(true);
} else if (e.key === "Enter" && searchResults[searchActive]) {
var item = searchResults[searchActive];
saveRecent(item);
closeSearch();
window.location.href = item.href;
}
}
function buildSearch() {
var trigger = document.getElementById("search-trigger");
if (trigger) trigger.innerHTML = icon("search") + "<span>Search...</span><kbd>Ctrl K</kbd>";
var modal = document.createElement("div");
modal.id = "search-modal";
modal.innerHTML =
'<div class="search-backdrop"></div><div class="search-panel"><div class="search-input-row">' + icon("search") +
'<input id="search-input" type="text" placeholder="Search..." autocomplete="off" spellcheck="false"></div>' +
'<div id="search-results"></div><div class="search-footer"><span><kbd>↑</kbd><kbd>↓</kbd>Select</span><span><kbd>↵</kbd>Open</span><span class="close-hint"><kbd>esc</kbd>Close</span></div></div>';
document.body.appendChild(modal);
modal.querySelector(".search-backdrop").addEventListener("click", closeSearch);
document.getElementById("search-input").addEventListener("input", function () { renderSearch(false); });
document.getElementById("search-results").addEventListener("click", function (e) {
var a = e.target.closest(".sr");
if (a) {
saveRecent(searchResults[Number(a.getAttribute("data-i"))]);
closeSearch();
}
});
document.addEventListener("keydown", onSearchKey);
}
applyTheme();
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme);
function dismissBanner() {
var banner = document.getElementById("banner");
if (banner) banner.setAttribute("data-dismissed", "true");
document.body.classList.add("banner-dismissed");
localStorage.setItem("vyla-banner-dismissed", "true");
}
function cgSwitch(btn) {
var group = btn.closest(".code-group");
var index = btn.getAttribute("data-tab-index");
group.querySelectorAll(".code-group-tab").forEach(function (t) {
t.classList.toggle("active", t === btn);
});
group.querySelectorAll(".code-group-panel").forEach(function (p, i) {
p.classList.toggle("active", String(i) === index);
});
window.dispatchEvent(new Event("resize"));
}
document.addEventListener("DOMContentLoaded", function () {
if (localStorage.getItem("vyla-banner-dismissed") === "true") {
var banner = document.getElementById("banner");
if (banner) banner.setAttribute("data-dismissed", "true");
document.body.classList.add("banner-dismissed");
}
applyTheme();
fixActiveTab();
buildMobileBar();
addEyebrow();
addCopyButtons();
initCodeFades();
wrapTables();
buildThemeControls();
buildSearch();
setTimeout(loadSearchData, 1500);
initTocSpy();
document.addEventListener("click", function (e) {
var sidebar = document.getElementById("sidebar");
var menu = document.getElementById("theme-menu");
if (menu && !e.target.closest(".theme-wrap")) menu.classList.remove("open");
if (
sidebar &&
sidebar.classList.contains("open") &&
!sidebar.contains(e.target) &&
!e.target.closest("#mobile-toggle") &&
!e.target.closest(".theme-wrap") &&
!e.target.closest("#theme-seg")
) {
sidebar.classList.remove("open");
document.body.classList.remove("nav-open");
}
});
var sidebarLinks = document.querySelectorAll("#sidebar .group:not(.mobile-tabs) li a");
sidebarLinks.forEach(function (a) {
a.addEventListener("click", function () {
var sidebar = document.getElementById("sidebar");
if (sidebar && sidebar.classList.contains("open")) {
sidebar.classList.remove("open");
document.body.classList.remove("nav-open");
}
});
});
});