Files
claude-code-router/docs/src/layouts/DocsLayout.astro
T

996 lines
36 KiB
Plaintext
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.
---
import "../styles/global.css";
import {
ArrowLeft,
ArrowRight,
ChevronDown,
Download,
Github,
List,
Moon,
PenLine,
Search,
Sun,
} from "lucide-astro";
import { enToZhPath, zhToEnPath } from "../docs-structure";
const {
title = "Documentation",
docTitle,
isHome = false,
description,
htmlLang = "zh-CN",
locale = "zh",
languageLabel = "中文",
languageOptions = [
{ locale: "zh", label: "中文", href: "/" },
{ locale: "en", label: "English", href: "/en/" },
],
navItems = ["Documentation", "Guides", "API reference", "Changelog"],
sidebarTree = [],
sidebarGroups = [],
sidebarLinks = {},
sectionNav = [],
tocTitle = "On this page",
tocItems = [],
prevPage,
nextPage,
editUrl,
ui = {
searchLabel: "Search docs",
searchPlaceholder: "Search...",
downloadLabel: "Download",
githubLabel: "GitHub repository",
themeLabel: "Theme",
starsFallback: "Stars",
},
} = Astro.props;
const baseUrl = import.meta.env.BASE_URL ?? "/";
const absoluteUrlPattern = /^[a-z][a-z\d+.-]*:/i;
const withBase = (href) => {
if (!href || href.startsWith("#") || href.startsWith("//") || absoluteUrlPattern.test(href)) {
return href;
}
const basePath = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
if (href === "/") {
return basePath;
}
const normalizedHref = href.startsWith("/") ? href.slice(1) : href;
return `${basePath}${normalizedHref}`;
};
const absoluteHref = (href) => {
const resolvedHref = withBase(href);
if (absoluteUrlPattern.test(resolvedHref) || !Astro.site) {
return resolvedHref;
}
return new URL(resolvedHref, Astro.site).toString();
};
const ensureTrailingSlash = (path) => {
if (!path) return "/";
return path.endsWith("/") ? path : `${path}/`;
};
const basePath = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
const rawCurrentPath = ensureTrailingSlash(Astro.url.pathname || "/");
const currentPath =
basePath !== "/" && rawCurrentPath.startsWith(basePath)
? ensureTrailingSlash(`/${rawCurrentPath.slice(basePath.length)}`)
: rawCurrentPath;
const toEnglishPath = (path) => {
const normalizedPath = ensureTrailingSlash(path);
if (normalizedPath === "/") return "/en/";
if (normalizedPath.startsWith("/en/")) return normalizedPath;
if (zhToEnPath[normalizedPath]) return zhToEnPath[normalizedPath];
return "/en/";
};
const toChinesePath = (path) => {
const normalizedPath = ensureTrailingSlash(path);
if (normalizedPath === "/" || !normalizedPath.startsWith("/en/")) return normalizedPath;
if (normalizedPath === "/en/") return "/";
if (enToZhPath[normalizedPath]) return enToZhPath[normalizedPath];
return ensureTrailingSlash(normalizedPath.slice("/en".length));
};
const languageHref = (option) => {
if (option.locale === "en") return toEnglishPath(currentPath);
if (option.locale === "zh") return toChinesePath(currentPath);
return option.href;
};
const resolvedLanguageOptions = languageOptions.map((option) => ({
...option,
href: withBase(languageHref(option)),
absoluteHref: absoluteHref(languageHref(option)),
}));
const resolvedNavItems = navItems.map((item, index) => {
const navItem = typeof item === "string" ? { label: item, href: "#" } : item;
return {
...navItem,
href: withBase(navItem.href ?? "#"),
active: navItem.active ?? index === 0,
};
});
const homeHref = withBase(locale === "en" ? "/en/" : "/");
const faviconHref = withBase("/ccr-icon.png");
const logoSrc = withBase("/logo.png");
const pageDescription =
description ??
(locale === "zh"
? "Claude Code RouterCCR)文档:安装、配置、供应商接入与故障排查指南。"
: "Claude Code Router (CCR) documentation: installation, configuration, provider setup, and troubleshooting guides.");
const pageTitle = isHome && docTitle ? String(docTitle) : `${title} | Claude Code Router`;
const canonicalUrl = absoluteHref(currentPath);
const ogImageUrl = absoluteHref("/logo.png");
const sitemapUrl = withBase("/sitemap-index.xml");
const searchIndexUrl = withBase(`/search-index-${locale}.json`);
const currentYear = new Date().getFullYear();
const searchNoResults = locale === "zh" ? "没有匹配结果" : "No results";
const sidebarToggleLabel = locale === "zh" ? "打开目录" : "Open navigation";
const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation";
const featuredNavLabel = locale === "zh" ? "主要入口" : "Primary navigation";
---
<!doctype html>
<html lang={htmlLang}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={pageDescription} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={ogImageUrl} />
<meta name="twitter:card" content="summary" />
<link rel="sitemap" href={sitemapUrl} />
<script is:inline>
(() => {
const storageKey = "ccr-docs-theme";
try {
const storedTheme = localStorage.getItem(storageKey);
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = storedTheme === "light" || storedTheme === "dark"
? storedTheme
: prefersDark
? "dark"
: "light";
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.themeSource = storedTheme ? "user" : "system";
} catch {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.dataset.theme = prefersDark ? "dark" : "light";
document.documentElement.dataset.themeSource = "system";
}
})();
</script>
<link rel="icon" href={faviconHref} type="image/png" />
{
resolvedLanguageOptions.map((option) => (
<link
rel="alternate"
hreflang={option.locale === "zh" ? "zh-CN" : "en"}
href={option.absoluteHref}
/>
))
}
<title>{pageTitle}</title>
</head>
<body>
<div class="shell">
<header class="topbar" data-topbar>
<div class="top-left">
<a class="brand" href={homeHref} aria-label="Claude Code Router Docs">
<img src={logoSrc} alt="" class="brand-logo" />
<span>CCR docs</span>
</a>
<details class="language-switcher">
<summary>
<span>{languageLabel}</span>
<ChevronDown size={16} aria-hidden="true" />
</summary>
<div class="language-menu">
{
resolvedLanguageOptions.map((option) => (
<a
class:list={{ active: option.locale === locale }}
href={option.href}
hreflang={option.locale === "zh" ? "zh-CN" : "en"}
>
{option.label}
</a>
))
}
</div>
</details>
</div>
<nav class="top-tabs" aria-label={featuredNavLabel}>
{
resolvedNavItems.map((item) => (
<a class:list={["top-tab", { active: item.active }]} href={item.href}>
{item.label}
</a>
))
}
</nav>
<div class="top-right">
<div class="top-actions">
<button
class="icon-button sidebar-toggle"
type="button"
aria-label={sidebarToggleLabel}
aria-controls="docs-sidebar"
aria-expanded="false"
data-sidebar-toggle
>
<List size={19} aria-hidden="true" />
</button>
<button
class="icon-button search-toggle"
type="button"
aria-label={ui.searchLabel}
aria-expanded="false"
data-search-toggle
>
<Search size={19} aria-hidden="true" />
</button>
<div class="search" role="search" aria-label={ui.searchLabel}>
<Search size={18} aria-hidden="true" />
<input
type="search"
placeholder={ui.searchPlaceholder}
aria-label={ui.searchLabel}
autocomplete="off"
spellcheck="false"
role="combobox"
aria-expanded="false"
aria-controls="docs-search-results"
data-search-input
/>
<kbd>⌘K</kbd>
<div class="search-results" id="docs-search-results" role="listbox" hidden data-search-results></div>
</div>
<a
class="icon-button github-button hide-small"
href="https://github.com/musistudio/claude-code-router"
aria-label={ui.githubLabel}
>
<Github size={18} aria-hidden="true" />
<strong id="github-stars" aria-label="GitHub stars">Stars</strong>
</a>
<button class="icon-button theme-toggle" type="button" aria-label={ui.themeLabel} data-theme-toggle>
<span class="theme-icon theme-icon-sun" aria-hidden="true">
<Sun size={19} />
</span>
<span class="theme-icon theme-icon-moon" aria-hidden="true">
<Moon size={19} />
</span>
</button>
<a
class="cta-button"
href="https://github.com/musistudio/claude-code-router/releases"
aria-label={ui.downloadLabel}
>
<Download size={16} aria-hidden="true" />
<span>{ui.downloadLabel}</span>
</a>
</div>
</div>
</header>
<button class="sidebar-backdrop" type="button" aria-label={sidebarCloseLabel} data-sidebar-backdrop></button>
<div class="content-grid">
<aside class="sidebar" id="docs-sidebar" aria-label="Documentation sidebar" data-sidebar>
<div class="sidebar-inner">
{
sectionNav.length > 0 && (
<nav class="sidebar-sections" aria-label={featuredNavLabel}>
<h2 class="sidebar-sections-title">
{locale === "zh" ? "全部栏目" : "All sections"}
</h2>
<ul>
{sectionNav.map((item) => (
<li>
<a
class:list={["sidebar-link", "sidebar-section-link", { active: item.active }]}
href={withBase(item.href)}
>
<span>{item.label}</span>
</a>
</li>
))}
</ul>
</nav>
)
}
{
sidebarTree.length > 0 ? (
<nav class="sidebar-directory" aria-label="Documentation navigation">
<ul class="directory-list">
{sidebarTree.map((section) => (
<li>
<a
class:list={["sidebar-link", "directory-summary", { active: section.active }]}
href={withBase(section.href)}
>
<span>{section.label}</span>
</a>
{section.open && section.groups.length > 0 && (
<ul class="sidebar-children directory-children">
{section.groups.map((group) => (
<li class="directory-group">
{(section.groups.length > 1 || group.label !== section.label) && (
<div class="directory-group-label">
<span>{group.label}</span>
</div>
)}
<ul>
{group.items.map((item) => {
const isExpandable = item.children.length > 0;
return (
<li>
<a
class:list={["sidebar-link", { active: item.active }]}
href={withBase(item.href)}
>
<span>{item.label}</span>
</a>
{isExpandable && (
<ul class="sidebar-children">
{item.children.map((child) => (
<li>
<a
class:list={["sidebar-child-link", { active: child.active }]}
href={withBase(child.href)}
>
{child.label}
</a>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
) : (
sidebarGroups.map((group) => {
return (
<section class="sidebar-group">
<h2>{group.label}</h2>
<ul>
{group.items.map((item) => (
<li>
<a
class:list={["sidebar-link", { active: group.active === item }]}
href={withBase(sidebarLinks[item] ?? "#")}
>
<span>{item}</span>
</a>
</li>
))}
</ul>
</section>
);
})
)
}
</div>
</aside>
<main class="doc-main">
<slot />
{
(prevPage || nextPage) && (
<nav class="page-nav" aria-label={ui.pageNavLabel}>
{prevPage ? (
<a class="page-nav-link page-nav-prev" href={withBase(prevPage.href)}>
<small>{ui.prevPage}</small>
<span class="page-nav-title">
<ArrowLeft size={15} aria-hidden="true" />
<span>{prevPage.label}</span>
</span>
</a>
) : (
<span class="page-nav-spacer" aria-hidden="true" />
)}
{nextPage ? (
<a class="page-nav-link page-nav-next" href={withBase(nextPage.href)}>
<small>{ui.nextPage}</small>
<span class="page-nav-title">
<span>{nextPage.label}</span>
<ArrowRight size={15} aria-hidden="true" />
</span>
</a>
) : (
<span class="page-nav-spacer" aria-hidden="true" />
)}
</nav>
)
}
<footer class="site-footer">
{
editUrl && (
<a class="edit-page" href={editUrl} target="_blank" rel="noreferrer">
<PenLine size={15} aria-hidden="true" />
<span>{ui.editPage}</span>
</a>
)
}
<p class="copyright">© {currentYear} Claude Code Router</p>
</footer>
</main>
<aside class="toc" aria-label={tocTitle}>
<div class="toc-card">
<h2>
<List size={18} aria-hidden="true" />
{tocTitle}
</h2>
<nav>
{
tocItems.map((item, index) => {
const tocItem = typeof item === "string"
? { label: item, href: `#section-${index + 1}`, depth: 2 }
: { depth: 2, ...item };
const depth = Math.min(Math.max(Number(tocItem.depth), 2), 6);
return (
<a
class:list={[`toc-depth-${depth}`, { active: index === 0 }]}
href={tocItem.href}
>
{tocItem.label}
</a>
);
})
}
</nav>
</div>
</aside>
</div>
</div>
<script define:vars={{ starsFallback: ui.starsFallback }}>
const starBadge = document.querySelector("#github-stars");
const formatStars = (count) => {
if (!Number.isFinite(count)) return starsFallback;
if (count >= 1000) {
const rounded = Math.round(count / 100) / 10;
return `${rounded.toFixed(rounded % 1 === 0 ? 0 : 1)}k`;
}
return count.toLocaleString();
};
fetch("https://api.github.com/repos/musistudio/claude-code-router", {
headers: { Accept: "application/vnd.github+json" },
})
.then((response) => {
if (!response.ok) throw new Error("GitHub request failed");
return response.json();
})
.then((repo) => {
if (starBadge) {
starBadge.textContent = formatStars(repo.stargazers_count);
}
})
.catch(() => {
if (starBadge) {
starBadge.textContent = starsFallback;
}
});
</script>
<script define:vars={{ searchNoResults, searchIndexUrl, searchLoadingLabel: ui.searchLoading }}>
const themeToggle = document.querySelector("[data-theme-toggle]");
const themeStorageKey = "ccr-docs-theme";
const themeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const getStoredTheme = () => {
try {
const storedTheme = localStorage.getItem(themeStorageKey);
return storedTheme === "light" || storedTheme === "dark" ? storedTheme : null;
} catch {
return null;
}
};
const getSystemTheme = () => (themeMediaQuery.matches ? "dark" : "light");
const applyTheme = (theme, source = "system") => {
const nextTheme = theme === "dark" ? "dark" : "light";
document.documentElement.dataset.theme = nextTheme;
document.documentElement.dataset.themeSource = source;
document.documentElement.style.colorScheme = nextTheme;
if (themeToggle instanceof HTMLButtonElement) {
themeToggle.setAttribute("aria-pressed", nextTheme === "dark" ? "true" : "false");
}
};
applyTheme(getStoredTheme() ?? getSystemTheme(), getStoredTheme() ? "user" : "system");
themeToggle?.addEventListener("click", () => {
const currentTheme = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
const nextTheme = currentTheme === "dark" ? "light" : "dark";
try {
localStorage.setItem(themeStorageKey, nextTheme);
} catch {}
applyTheme(nextTheme, "user");
});
themeMediaQuery.addEventListener("change", () => {
if (!getStoredTheme()) {
applyTheme(getSystemTheme(), "system");
}
});
const languageSwitcher = document.querySelector(".language-switcher");
if (languageSwitcher instanceof HTMLDetailsElement) {
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node)) return;
if (languageSwitcher.open && !languageSwitcher.contains(event.target)) {
languageSwitcher.open = false;
}
});
}
const sidebarToggle = document.querySelector("[data-sidebar-toggle]");
const sidebarBackdrop = document.querySelector("[data-sidebar-backdrop]");
const docsSidebar = document.querySelector("[data-sidebar]");
const topbar = document.querySelector("[data-topbar]");
const sidebarMediaQuery = window.matchMedia("(max-width: 920px)");
const syncTopbarHeight = () => {
if (!(topbar instanceof HTMLElement)) return;
document.documentElement.style.setProperty(
"--topbar-height",
`${Math.ceil(topbar.getBoundingClientRect().height)}px`
);
};
syncTopbarHeight();
window.addEventListener("resize", syncTopbarHeight);
if (topbar instanceof HTMLElement && "ResizeObserver" in window) {
const topbarResizeObserver = new ResizeObserver(syncTopbarHeight);
topbarResizeObserver.observe(topbar);
}
const setSidebarOpen = (open) => {
const nextOpen = Boolean(open) && sidebarMediaQuery.matches;
document.body.classList.toggle("sidebar-open", nextOpen);
if (sidebarToggle instanceof HTMLButtonElement) {
sidebarToggle.setAttribute("aria-expanded", nextOpen ? "true" : "false");
}
if (docsSidebar instanceof HTMLElement) {
docsSidebar.setAttribute("aria-hidden", sidebarMediaQuery.matches && !nextOpen ? "true" : "false");
}
};
const syncSidebarForViewport = () => {
if (!sidebarMediaQuery.matches) {
setSidebarOpen(false);
if (docsSidebar instanceof HTMLElement) {
docsSidebar.setAttribute("aria-hidden", "false");
}
return;
}
setSidebarOpen(document.body.classList.contains("sidebar-open"));
};
sidebarToggle?.addEventListener("click", () => {
setSidebarOpen(!document.body.classList.contains("sidebar-open"));
});
sidebarBackdrop?.addEventListener("click", () => setSidebarOpen(false));
docsSidebar?.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", () => setSidebarOpen(false));
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && document.body.classList.contains("sidebar-open")) {
setSidebarOpen(false);
}
});
sidebarMediaQuery.addEventListener("change", syncSidebarForViewport);
syncSidebarForViewport();
const searchInput = document.querySelector("[data-search-input]");
const searchResults = document.querySelector("[data-search-results]");
const searchToggle = document.querySelector("[data-search-toggle]");
let activeSearchIndex = -1;
let visibleSearchEntries = [];
let searchIndex = null;
let searchIndexPromise = null;
const normalizeSearchText = (value) =>
String(value ?? "")
.trim()
.toLocaleLowerCase();
const escapeSearchHtml = (value) =>
String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[character]);
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const highlightTerms = (escapedText, terms) => {
let result = escapedText;
for (const term of terms) {
if (!term) continue;
result = result.replace(
new RegExp(`(${escapeRegExp(escapeSearchHtml(term))})`, "gi"),
"<mark>$1</mark>"
);
}
return result;
};
const buildSearchSnippet = (text, terms) => {
const source = String(text ?? "").trim();
if (!source) return "";
const lowerSource = source.toLocaleLowerCase();
let firstIndex = -1;
for (const term of terms) {
const index = lowerSource.indexOf(term);
if (index !== -1 && (firstIndex === -1 || index < firstIndex)) {
firstIndex = index;
}
}
const start = firstIndex === -1 ? 0 : Math.max(0, firstIndex - 48);
const snippet = source.slice(start, start + 180);
const prefix = start > 0 ? "…" : "";
const suffix = start + snippet.length < source.length ? "…" : "";
return prefix + highlightTerms(escapeSearchHtml(snippet), terms) + suffix;
};
const loadSearchIndex = () => {
if (!searchIndexPromise) {
searchIndexPromise = fetch(searchIndexUrl)
.then((response) => {
if (!response.ok) throw new Error("Search index request failed");
return response.json();
})
.then((data) => {
searchIndex = Array.isArray(data) ? data : [];
})
.catch(() => {
searchIndex = [];
});
}
return searchIndexPromise;
};
const setSearchOpen = (open) => {
document.body.classList.toggle("search-open", Boolean(open));
if (searchToggle instanceof HTMLButtonElement) {
searchToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
};
const closeSearchResults = () => {
if (!(searchInput instanceof HTMLInputElement) || !(searchResults instanceof HTMLElement)) return;
searchResults.hidden = true;
searchResults.innerHTML = "";
searchInput.setAttribute("aria-expanded", "false");
searchInput.removeAttribute("aria-activedescendant");
activeSearchIndex = -1;
visibleSearchEntries = [];
};
const setActiveSearchIndex = (index) => {
if (!(searchInput instanceof HTMLInputElement) || !(searchResults instanceof HTMLElement)) return;
const options = Array.from(searchResults.querySelectorAll("[data-search-option]"));
activeSearchIndex = options.length === 0 ? -1 : Math.max(0, Math.min(index, options.length - 1));
options.forEach((option, optionIndex) => {
option.classList.toggle("active", optionIndex === activeSearchIndex);
});
const activeOption = options[activeSearchIndex];
if (activeOption instanceof HTMLElement) {
searchInput.setAttribute("aria-activedescendant", activeOption.id);
activeOption.scrollIntoView({ block: "nearest" });
}
};
const renderSearchResults = async () => {
if (!(searchInput instanceof HTMLInputElement) || !(searchResults instanceof HTMLElement)) return;
const query = normalizeSearchText(searchInput.value);
const terms = query.split(/\s+/).filter(Boolean);
if (terms.length === 0) {
closeSearchResults();
return;
}
if (!searchIndex) {
searchResults.innerHTML = `<div class="search-empty">${searchLoadingLabel}</div>`;
searchResults.hidden = false;
searchInput.setAttribute("aria-expanded", "true");
await loadSearchIndex();
if (normalizeSearchText(searchInput.value) !== query) return;
}
visibleSearchEntries = (searchIndex ?? [])
.map((entry) => {
const title = normalizeSearchText(entry.title);
const breadcrumb = normalizeSearchText(entry.breadcrumb);
const excerpt = normalizeSearchText(entry.excerpt);
const haystack = `${title} ${breadcrumb} ${excerpt}`;
if (!terms.every((term) => haystack.includes(term))) return null;
const score =
entry.kind === "page" && title.includes(query) ? 0 :
title.includes(query) ? 1 :
excerpt.includes(query) ? 2 :
3;
return { ...entry, score };
})
.filter(Boolean)
.sort((left, right) => left.score - right.score || left.title.localeCompare(right.title))
.slice(0, 10);
searchResults.hidden = false;
searchInput.setAttribute("aria-expanded", "true");
if (visibleSearchEntries.length === 0) {
searchResults.innerHTML = `<div class="search-empty">${searchNoResults}</div>`;
activeSearchIndex = -1;
searchInput.removeAttribute("aria-activedescendant");
return;
}
searchResults.innerHTML = visibleSearchEntries
.map((entry, index) => {
const snippet = buildSearchSnippet(entry.excerpt, terms);
return `
<a
class="search-result"
id="docs-search-result-${index}"
role="option"
href="${escapeSearchHtml(entry.path)}"
data-search-option
>
<span>${highlightTerms(escapeSearchHtml(entry.title), terms)}</span>
${entry.breadcrumb ? `<small class="search-breadcrumb">${escapeSearchHtml(entry.breadcrumb)}</small>` : ""}
${snippet ? `<small class="search-snippet">${snippet}</small>` : ""}
</a>
`;
})
.join("");
setActiveSearchIndex(0);
};
if (searchInput instanceof HTMLInputElement && searchResults instanceof HTMLElement) {
searchInput.addEventListener("input", renderSearchResults);
searchInput.addEventListener("focus", () => {
loadSearchIndex();
if (searchInput.value.trim()) renderSearchResults();
});
searchToggle?.addEventListener("click", () => {
const nextOpen = !document.body.classList.contains("search-open");
setSearchOpen(nextOpen);
if (nextOpen) {
loadSearchIndex();
searchInput.focus();
}
});
searchInput.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeSearchResults();
setSearchOpen(false);
searchInput.blur();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
if (searchResults.hidden) {
renderSearchResults();
} else {
setActiveSearchIndex(activeSearchIndex + 1);
}
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveSearchIndex(activeSearchIndex - 1);
return;
}
if (event.key === "Enter" && visibleSearchEntries[activeSearchIndex]) {
event.preventDefault();
window.location.href = visibleSearchEntries[activeSearchIndex].path;
}
});
searchResults.addEventListener("mousedown", (event) => {
event.preventDefault();
});
searchResults.addEventListener("click", (event) => {
const option = event.target instanceof Element ? event.target.closest("[data-search-option]") : null;
if (option instanceof HTMLAnchorElement) {
setSearchOpen(false);
window.location.href = option.href;
}
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
if (event.target.closest("[data-search-toggle]")) return;
if (!searchInput.closest(".search")?.contains(event.target)) {
closeSearchResults();
setSearchOpen(false);
}
});
document.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "k") {
event.preventDefault();
setSearchOpen(true);
loadSearchIndex();
searchInput.focus();
searchInput.select();
}
});
}
const tocNav = document.querySelector(".toc nav");
const tocLinks = tocNav ? Array.from(tocNav.querySelectorAll("a")) : [];
const tocHeadings = tocLinks
.map((link) => {
const href = link.getAttribute("href");
if (!href || !href.startsWith("#")) return null;
return document.querySelector(`.doc-markdown [id="${href.slice(1)}"]`);
})
.filter(Boolean);
if (tocLinks.length > 0 && tocHeadings.length > 0) {
const syncTocActive = () => {
const topOffset = window.innerHeight * 0.25;
let activeIndex = 0;
for (let i = 0; i < tocHeadings.length; i++) {
if (tocHeadings[i].getBoundingClientRect().top <= topOffset) {
activeIndex = i;
} else {
break;
}
}
tocLinks.forEach((link, index) => {
link.classList.toggle("active", index === activeIndex);
});
};
window.addEventListener("scroll", syncTocActive, { passive: true });
syncTocActive();
}
// Smooth height transition for the mobile "On this page" disclosure.
const tocInline = document.querySelector("details.toc-inline");
const tocSummary = tocInline ? tocInline.querySelector("summary") : null;
const tocPanel = tocInline ? tocInline.querySelector("nav") : null;
if (tocInline && tocSummary && tocPanel) {
const reduceTocMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let tocAnimating = false;
tocSummary.addEventListener("click", (event) => {
if (tocAnimating) {
event.preventDefault();
return;
}
if (reduceTocMotion) return; // let the browser toggle instantly
event.preventDefault();
tocAnimating = true;
const DURATION = 180;
const finish = () => {
tocPanel.style.transition = "";
tocPanel.style.height = "";
tocPanel.style.overflow = "";
tocAnimating = false;
};
if (!tocInline.open) {
// Expanding: reveal at height 0, then animate to the target height
// (clamped by the CSS max-height so long lists don't jump at the end).
tocPanel.style.overflow = "hidden";
tocPanel.style.height = "0px";
tocInline.open = true;
tocPanel.getBoundingClientRect(); // force reflow before transitioning
const max = parseFloat(getComputedStyle(tocPanel).maxHeight);
const target = Number.isNaN(max)
? tocPanel.scrollHeight
: Math.min(tocPanel.scrollHeight, max);
tocPanel.style.transition = `height ${DURATION}ms ease`;
tocPanel.style.height = `${target}px`;
const onEnd = (e) => {
if (e.propertyName !== "height") return;
tocPanel.removeEventListener("transitionend", onEnd);
finish();
};
tocPanel.addEventListener("transitionend", onEnd);
window.setTimeout(() => {
if (tocAnimating) {
tocPanel.removeEventListener("transitionend", onEnd);
finish();
}
}, DURATION + 80);
} else {
// Collapsing: animate the current height down to 0, then close.
tocPanel.style.overflow = "hidden";
tocPanel.style.height = `${tocPanel.offsetHeight}px`;
tocPanel.getBoundingClientRect(); // force reflow before transitioning
tocPanel.style.transition = `height ${DURATION}ms ease`;
tocPanel.style.height = "0px";
const onEnd = (e) => {
if (e.propertyName !== "height") return;
tocPanel.removeEventListener("transitionend", onEnd);
tocInline.open = false;
finish();
};
tocPanel.addEventListener("transitionend", onEnd);
window.setTimeout(() => {
if (tocAnimating) {
tocPanel.removeEventListener("transitionend", onEnd);
tocInline.open = false;
finish();
}
}, DURATION + 80);
}
});
}
</script>
</body>
</html>