mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
996 lines
36 KiB
Plaintext
996 lines
36 KiB
Plaintext
---
|
||
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 Router(CCR)文档:安装、配置、供应商接入与故障排查指南。"
|
||
: "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) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
})[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>
|