Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

406 lines · 23.1 KBRaw History
1/** @jsxImportSource hono/jsx */
2/**
3 * The public docs at /docs, readable without signing in, plus the formats AI
4 * agents read: /docs/<page>.md (or any page with `Accept: text/markdown`),
5 * /llms.txt, /llms-full.txt, /docs/api/openapi.json and /docs/search.json.
6 * Also serves the CLI download at /-/cli/diffinery.mjs, and the scripts that install it:
7 * `curl -fsSL https://diffinery.com/install | bash` and `irm https://diffinery.com/install.ps1 | iex`;
8 * and the extension for VS Code, Cursor and other editors built on VS Code (apps/vscode) at
9 * /-/vscode/diffinery.vsix, with /-/vscode/version.json (which it updates itself from) and its
10 * install scripts at /install-editor and /install-editor.ps1; and the plugin for JetBrains IDEs (apps/jetbrains) at
11 * /-/jetbrains/diffinery-jetbrains.zip, with /-/jetbrains/updatePlugins.xml, the custom plugin repository the IDEs
12 * install and update it from; and the Claude Code plugins (claude-plugins/) at /-/claude/<name>.zip, for claude.ai's
13 * Upload plugin.
14 */
15import { raw } from "hono/html";
16import type { AppContext } from "../auth/principal.js";
17import type { Env } from "../env.js";
18import { imageProxy } from "../lib/camo.js";
19import { isValidLogin, isValidName } from "../lib/util.js";
20import { fenceLangs, renderMarkdown } from "../web/markdown.js";
21import { fenceHighlighter } from "../web/highlight.js";
22import { ICONS } from "../web/assets.js";
23import { render, type WebApp } from "../web/ui.js";
24import { AGENT_SETUP_SH, CLAUDE_PLUGINS, CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_SHA256, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH, JETBRAINS_PLUGIN_DESCRIPTION, JETBRAINS_PLUGIN_SHA256, JETBRAINS_PLUGIN_SINCE_BUILD, JETBRAINS_PLUGIN_VERSION, JETBRAINS_PLUGIN_ZIP_B64 } from "./content.generated.js";
25import { allPages, getPage, headings, llmsFullTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, SLUGS, type Page } from "./pages.js";
26import { openApiDocument } from "./reference.js";
27
28const origin = (c: AppContext) => c.env.PUBLIC_URL.replace(/\/$/, "");
29
30/** Static per deploy; a short shared cache keeps them fresh after a deploy. */
31const PUBLIC = { "cache-control": "public, max-age=300", "access-control-allow-origin": "*", "x-content-type-options": "nosniff" };
32
33const htmlCache = new Map<string, string>();
34async function pageHtml(p: Page, origin: string, env: Env): Promise<string> {
35 const key = `${origin} ${p.slug}`;
36 let html = htmlCache.get(key);
37 if (html === undefined) {
38 html = renderMarkdown(p.markdown, { idPrefix: "", highlight: await fenceHighlighter(fenceLangs(p.markdown)), image: imageProxy(env) });
39 htmlCache.set(key, html);
40 }
41 return html;
42}
43
44/** Agents ask for Markdown with `Accept: text/markdown`; browsers never do. */
45const wantsMarkdown = (c: AppContext) => /\btext\/markdown\b/i.test(c.req.header("accept") ?? "");
46
47function markdownResponse(c: AppContext, body: string): Response {
48 return c.body(body, 200, { ...PUBLIC, "content-type": "text/markdown; charset=utf-8", vary: "Accept" });
49}
50
51const Sidebar = ({ current }: { current: string }) => (
52 <nav class="docs-nav" aria-label="Documentation">
53 <div class="docs-search">
54 <input type="search" placeholder="Search the docs" aria-label="Search the docs" autocomplete="off" spellcheck={false} data-docs-search="1" />
55 <ul class="docs-results" role="listbox" hidden></ul>
56 </div>
57 {nav().map((s) => (
58 <div class="docs-sec">
59 <h4>{s.title}</h4>
60 <ul>
61 {s.pages.map((p) => (
62 <li>
63 <a href={pageUrl(p.slug)} class={p.slug === current ? "on" : undefined} aria-current={p.slug === current ? "page" : undefined}>
64 {p.title}
65 </a>
66 </li>
67 ))}
68 </ul>
69 </div>
70 ))}
71 <div class="docs-sec">
72 <h4>For AI agents</h4>
73 <ul>
74 <li>
75 <a href="/llms.txt">llms.txt</a>
76 </li>
77 <li>
78 <a href="/llms-full.txt">llms-full.txt</a>
79 </li>
80 <li>
81 <a href="/docs/api/openapi.json">OpenAPI 3.1</a>
82 </li>
83 </ul>
84 </div>
85 </nav>
86);
87
88async function docsPage(c: AppContext, p: Page | null, status = 200): Promise<Response> {
89 const o = origin(c);
90 const html = p ? await pageHtml(p, o, c.env) : "";
91 const slug = p?.slug ?? "";
92 const i = SLUGS.indexOf(slug);
93 const prev = i > 0 ? getPage(SLUGS[i - 1]!, o) : null;
94 const next = i >= 0 && i < SLUGS.length - 1 ? getPage(SLUGS[i + 1]!, o) : null;
95 const toc = p ? headings(p.markdown).filter((h) => h.level === 2 || p.markdown.length < 60_000) : [];
96 const body = (
97 <div class="docs">
98 <style>{raw(DOCS_CSS)}</style>
99 <details class="docs-mnav">
100 <summary>{p ? `${p.section}: ${p.title}` : "Documentation"}</summary>
101 <Sidebar current={slug} />
102 </details>
103 <aside class="docs-side">
104 <Sidebar current={slug} />
105 </aside>
106 <article class="docs-main">
107 {p ? (
108 <>
109 <div class="docs-crumbs">
110 <a href="/docs">Docs</a> <span class="faint">/</span> <span class="muted">{p.section}</span>
111 </div>
112 <h1>{p.title}</h1>
113 <p class="docs-lead">{p.description}</p>
114 <div class="docs-actions">
115 <a class="btn sm" href={markdownUrl(slug)} title="This page as plain Markdown">
116 {raw(ICONS.file)} View as Markdown
117 </a>
118 <button type="button" class="btn sm" data-copy-page={markdownUrl(slug)} title="Copy this page as Markdown, to paste into an AI assistant">
119 {raw(ICONS.copy)} <span>Copy page</span>
120 </button>
121 </div>
122 <div class="markdown docs-body">{raw(html)}</div>
123 <nav class="docs-pn" aria-label="Previous and next page">
124 {prev ? (
125 <a href={pageUrl(prev.slug)} class="prev">
126 <span class="faint small">Previous</span>
127 <span>{prev.title}</span>
128 </a>
129 ) : (
130 <span />
131 )}
132 {next ? (
133 <a href={pageUrl(next.slug)} class="next">
134 <span class="faint small">Next</span>
135 <span>{next.title}</span>
136 </a>
137 ) : (
138 <span />
139 )}
140 </nav>
141 </>
142 ) : (
143 <>
144 <h1>Page not found</h1>
145 <p class="docs-lead">There's no docs page at this address. Search the docs, or start from the overview.</p>
146 <p>
147 <a class="btn primary" href="/docs">
148 Go to the overview
149 </a>
150 </p>
151 </>
152 )}
153 </article>
154 <aside class="docs-toc" aria-label="On this page">
155 {toc.length ? (
156 <>
157 <h4>On this page</h4>
158 <ul>
159 {toc.map((h) => (
160 <li class={h.level === 3 ? "l3" : undefined}>
161 <a href={`#${h.id}`}>{h.text}</a>
162 </li>
163 ))}
164 </ul>
165 </>
166 ) : null}
167 </aside>
168 </div>
169 );
170 if (p) {
171 c.header("link", `<${o}${markdownUrl(slug)}>; rel="alternate"; type="text/markdown", <${o}/llms.txt>; rel="describedby"; type="text/plain"`);
172 c.header("vary", "Accept");
173 }
174 return render(
175 c,
176 {
177 title: p ? (slug === "index" ? "Documentation" : `${p.title} · Docs`) : "Page not found · Docs",
178 description: p?.description,
179 status,
180 wide: true,
181 head: p ? <link rel="alternate" type="text/markdown" href={markdownUrl(slug)} title={`${p.title} (Markdown)`} /> : null,
182 script: DOCS_JS,
183 },
184 body,
185 );
186}
187
188/** Pages that moved, old slug to new: links people and agents saved keep working. */
189const MOVED: Record<string, string> = {
190 "agents/claude-and-codex": "connect",
191};
192
193/** The custom plugin repository JetBrains IDEs read: one plugin, the version this deploy serves. */
194export function jetbrainsRepository(origin: string, fileName: string): string {
195 const description = JETBRAINS_PLUGIN_DESCRIPTION.replaceAll("https://diffinery.com", origin).replaceAll("]]>", "]]]]><![CDATA[>");
196 return `<?xml version="1.0" encoding="UTF-8"?>
197<plugins>
198 <plugin id="com.diffinery" url="${origin}/-/jetbrains/${fileName}" version="${JETBRAINS_PLUGIN_VERSION}">
199 <idea-version since-build="${JETBRAINS_PLUGIN_SINCE_BUILD}"/>
200 <name>Diffinery</name>
201 <vendor url="${origin}">Diffinery</vendor>
202 <description><![CDATA[${description}]]></description>
203 </plugin>
204</plugins>
205`;
206}
207
208export function registerDocs(web: WebApp): void {
209 web.get("/llms.txt", (c) => c.body(llmsTxt(origin(c)), 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8" }));
210 web.get("/llms-full.txt", (c) => c.body(llmsFullTxt(origin(c)), 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8" }));
211 web.get("/docs/llms.txt", (c) => c.redirect("/llms.txt", 301));
212 web.get("/docs/llms-full.txt", (c) => c.redirect("/llms-full.txt", 301));
213 web.get("/docs/api/openapi.json", (c) => c.body(JSON.stringify(openApiDocument(origin(c), CLI_VERSION), null, 2), 200, { ...PUBLIC, "content-type": "application/json; charset=utf-8" }));
214 web.get("/docs/search.json", (c) => c.body(JSON.stringify(searchIndex(origin(c))), 200, { ...PUBLIC, "content-type": "application/json; charset=utf-8" }));
215 web.get("/-/cli/diffinery.mjs", (c) =>
216 c.body(CLI_BUNDLE, 200, {
217 ...PUBLIC,
218 "content-type": "text/javascript; charset=utf-8",
219 "content-disposition": 'inline; filename="diffinery.mjs"',
220 "x-diffinery-cli-version": CLI_VERSION,
221 }),
222 );
223
224 // The install scripts download the CLI from the server that served them (a self-hosted instance installs its own build).
225 const installScript = (c: AppContext, script: string) =>
226 c.body(script.replaceAll("__DIFFINERY_URL__", origin(c)), 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8", "x-diffinery-cli-version": CLI_VERSION });
227 web.get("/install", (c) => installScript(c, CLI_INSTALL_SH));
228 web.get("/install.sh", (c) => installScript(c, CLI_INSTALL_SH));
229 web.get("/install.ps1", (c) => installScript(c, CLI_INSTALL_PS1));
230 // Agent setup's script (lib/agent-setup.ts): the same for everyone, with the repository's name in it. It
231 // looks nothing up, so it says nothing about whether the repository exists, and it holds no secret.
232 web.get("/:owner/:repo/agent-setup.sh", (c) => {
233 const owner = c.req.param("owner");
234 const repo = c.req.param("repo");
235 if (!isValidLogin(owner) || !isValidName(repo)) return c.text("not a repository name\n", 404);
236 const script = AGENT_SETUP_SH.replaceAll("__DIFFINERY_URL__", origin(c)).replaceAll("__DIFFINERY_REPO__", `${owner}/${repo}`).replaceAll("__DIFFINERY_CLI_SHA256__", CLI_SHA256);
237 return c.body(script, 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8", "x-diffinery-cli-version": CLI_VERSION });
238 });
239
240 const vsixName = `diffinery-${EDITOR_EXTENSION_VERSION}.vsix`;
241 let vsix: Uint8Array<ArrayBuffer> | undefined;
242 const vsixBody = (c: AppContext) => {
243 vsix ??= Uint8Array.from(atob(EDITOR_EXTENSION_VSIX_B64), (ch) => ch.charCodeAt(0));
244 return c.body(vsix, 200, {
245 ...PUBLIC,
246 "content-type": "application/octet-stream",
247 "content-disposition": `attachment; filename="${vsixName}"`,
248 "x-diffinery-extension-version": EDITOR_EXTENSION_VERSION,
249 });
250 };
251 web.get("/-/vscode/diffinery.vsix", vsixBody);
252 web.get(`/-/vscode/${vsixName}`, vsixBody);
253 web.get("/-/vscode/version.json", (c) =>
254 c.json({ version: EDITOR_EXTENSION_VERSION, url: `/-/vscode/${vsixName}`, sha256: EDITOR_EXTENSION_SHA256 }, 200, PUBLIC),
255 );
256 web.get("/install-editor", (c) => installScript(c, EDITOR_INSTALL_SH));
257 web.get("/install-editor.sh", (c) => installScript(c, EDITOR_INSTALL_SH));
258 web.get("/install-editor.ps1", (c) => installScript(c, EDITOR_INSTALL_PS1));
259
260 // JetBrains IDEs install the plugin from a custom plugin repository: Settings → Plugins → ⚙ → Manage Plugin
261 // Repositories → this updatePlugins.xml. They check it for updates like the Marketplace.
262 const pluginName = `diffinery-jetbrains-${JETBRAINS_PLUGIN_VERSION}.zip`;
263 let pluginZip: Uint8Array<ArrayBuffer> | undefined;
264 const pluginBody = (c: AppContext) => {
265 pluginZip ??= Uint8Array.from(atob(JETBRAINS_PLUGIN_ZIP_B64), (ch) => ch.charCodeAt(0));
266 return c.body(pluginZip, 200, {
267 ...PUBLIC,
268 "content-type": "application/zip",
269 "content-disposition": `attachment; filename="${pluginName}"`,
270 "x-diffinery-plugin-version": JETBRAINS_PLUGIN_VERSION,
271 "x-diffinery-plugin-sha256": JETBRAINS_PLUGIN_SHA256,
272 });
273 };
274 web.get("/-/jetbrains/diffinery-jetbrains.zip", pluginBody);
275 web.get(`/-/jetbrains/${pluginName}`, pluginBody);
276 web.get("/-/jetbrains/updatePlugins.xml", (c) =>
277 c.body(jetbrainsRepository(origin(c), pluginName), 200, { ...PUBLIC, "content-type": "application/xml; charset=utf-8" }),
278 );
279
280 // The Claude Code plugins, for claude.ai's Customize → Plugins → Add → Upload plugin, which can't read the plugin
281 // repository. /-/claude/<name>.zip is always the current version; the versioned name stays the same bytes.
282 const claudeZips = new Map<string, Uint8Array<ArrayBuffer>>();
283 web.get("/-/claude/:file", (c) => {
284 const file = c.req.param("file");
285 const m = /^([a-z][a-z0-9-]*?)(?:-(\d+\.\d+\.\d+))?\.zip$/.exec(file);
286 const plugin = m && Object.hasOwn(CLAUDE_PLUGINS, m[1]!) ? CLAUDE_PLUGINS[m[1]!]! : null;
287 if (!m || !plugin || (m[2] !== undefined && m[2] !== plugin.version)) return c.text("Not found", 404);
288 let bytes = claudeZips.get(m[1]!);
289 if (!bytes) claudeZips.set(m[1]!, (bytes = Uint8Array.from(atob(plugin.zip), (ch) => ch.charCodeAt(0))));
290 return c.body(bytes, 200, {
291 ...PUBLIC,
292 "content-type": "application/zip",
293 "content-disposition": `attachment; filename="${m[1]}-${plugin.version}.zip"`,
294 "x-diffinery-plugin-version": plugin.version,
295 "x-diffinery-plugin-sha256": plugin.sha256,
296 });
297 });
298
299 // Pictures for the docs pages (docs/images/), decoded once per isolate.
300 const images = new Map<string, Uint8Array<ArrayBuffer>>();
301 web.get("/docs/images/:file", (c) => {
302 const name = c.req.param("file");
303 const img = Object.hasOwn(DOCS_IMAGES, name) ? DOCS_IMAGES[name]! : null;
304 if (!img) return c.text("Not found", 404);
305 let bytes = images.get(name);
306 if (!bytes) images.set(name, (bytes = Uint8Array.from(atob(img.data), (ch) => ch.charCodeAt(0))));
307 return c.body(bytes, 200, { ...PUBLIC, "cache-control": "public, max-age=86400", "content-type": img.type });
308 });
309
310 web.get("/docs", (c) => {
311 const p = getPage("index", origin(c))!;
312 return wantsMarkdown(c) ? markdownResponse(c, pageMarkdown(p, origin(c))) : docsPage(c, p);
313 });
314 web.get("/docs/:slug{.+}", (c) => {
315 let slug = c.req.param("slug").replace(/\/+$/, "");
316 if (c.req.path.endsWith("/") && slug) return c.redirect(pageUrl(slug), 301);
317 const md = slug.endsWith(".md");
318 if (md) slug = slug.slice(0, -3);
319 if (slug === "index" && !md) return c.redirect("/docs", 301);
320 if (Object.hasOwn(MOVED, slug)) return c.redirect(md ? markdownUrl(MOVED[slug]!) : pageUrl(MOVED[slug]!), 301);
321 const p = getPage(slug, origin(c));
322 if (!p) {
323 if (md) return c.body(`# Not found\n\nThere is no docs page "${slug}". See ${origin(c)}/llms.txt for every page.\n`, 404, { "content-type": "text/markdown; charset=utf-8" });
324 return docsPage(c, null, 404);
325 }
326 return md || wantsMarkdown(c) ? markdownResponse(c, pageMarkdown(p, origin(c))) : docsPage(c, p);
327 });
328}
329
330/** Every docs URL, for the sitemap. */
331export const docsSitemapPaths = (): string[] => SLUGS.map(pageUrl);
332export { allPages };
333
334const DOCS_CSS = `
335main.wrap:has(.docs){padding-top:0}
336.docs{display:grid;grid-template-columns:248px minmax(0,1fr) 220px;gap:40px;max-width:1240px;margin:0 auto}
337.docs-side,.docs-toc{position:sticky;top:52px;align-self:start;max-height:calc(100vh - 52px);overflow:auto;padding:20px 0 40px}
338.docs-nav h4,.docs-toc h4{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--fg3);margin:18px 0 6px;font-weight:600}
339.docs-nav ul,.docs-toc ul{list-style:none;margin:0;padding:0}
340.docs-nav li a{display:block;padding:4px 10px;border-radius:6px;color:var(--fg2);font-size:13.5px}
341.docs-nav li a:hover{background:var(--bg-hover);color:var(--fg);text-decoration:none}
342.docs-nav li a.on{background:var(--accent-soft);color:var(--fg);font-weight:550}
343.docs-search{position:relative}
344.docs-results{position:absolute;z-index:30;left:0;right:-160px;top:calc(100% + 4px);background:var(--bg);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);list-style:none;margin:0;padding:4px;max-height:60vh;overflow:auto}
345.docs-results li a{display:block;padding:7px 10px;border-radius:6px;color:var(--fg)}
346.docs-results li a:hover,.docs-results li.sel a{background:var(--bg-hover);text-decoration:none}
347.docs-results .r-t{font-weight:550}.docs-results .r-s{display:block;font-size:12px;color:var(--fg2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
348.docs-results .r-none{padding:8px 10px;color:var(--fg2)}
349.docs-main{padding:28px 0 60px;min-width:0}
350.docs-main h1{font-size:30px;letter-spacing:-.02em;margin:6px 0 8px}
351.docs-crumbs{font-size:13px}
352.docs-lead{font-size:16px;color:var(--fg2);margin:0 0 14px}
353.docs-actions{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 22px;padding-bottom:18px;border-bottom:1px solid var(--border2)}
354.docs-actions .st svg{width:14px;height:14px}
355.docs-body{font-size:15px;line-height:1.7}
356.docs-body h2{font-size:21px;margin-top:34px;scroll-margin-top:64px}.docs-body h3{font-size:16.5px;margin-top:26px;scroll-margin-top:64px}.docs-body h4{scroll-margin-top:64px}
357.docs-body pre{position:relative}.docs-body pre .cp{position:absolute;top:6px;right:6px;opacity:0;transition:opacity .15s}
358.docs-body pre:hover .cp,.docs-body pre .cp:focus{opacity:1}
359.docs-body blockquote{background:var(--bg2);border-left-color:var(--accent);border-radius:0 6px 6px 0;padding:10px 14px;color:var(--fg)}
360.docs-body blockquote p:last-child{margin:0}
361.docs-body table{font-size:13.5px}.docs-body td code{white-space:nowrap}
362.docs-body p>img{display:block;width:auto;max-width:100%;max-height:640px;margin:18px auto;border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow)}
363.docs-pn{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:40px;padding-top:20px;border-top:1px solid var(--border2)}
364.docs-pn a{display:flex;flex-direction:column;gap:2px;padding:12px 14px;border:1px solid var(--border);border-radius:var(--r);color:var(--fg)}
365.docs-pn a:hover{border-color:var(--accent);text-decoration:none}.docs-pn .next{text-align:right}
366.docs-toc li a{display:block;padding:3px 0 3px 10px;border-left:2px solid var(--border2);color:var(--fg2);font-size:12.5px;line-height:1.4}
367.docs-toc li.l3 a{padding-left:22px}
368.docs-toc li a:hover{color:var(--fg);text-decoration:none}.docs-toc li a.on{color:var(--fg);border-left-color:var(--accent)}
369.docs-mnav{display:none}
370@media (max-width:1180px){.docs{grid-template-columns:232px minmax(0,1fr);gap:32px}.docs-toc{display:none}}
371@media (max-width:820px){.docs{grid-template-columns:minmax(0,1fr);gap:0}.docs-side{display:none}
372.docs-mnav{display:block;position:sticky;top:52px;z-index:15;background:var(--canvas);border-bottom:1px solid var(--border);margin:0 -12px;padding:0 12px}
373.docs-mnav summary{padding:10px 0;cursor:pointer;font-weight:550}.docs-mnav[open]{max-height:calc(100vh - 52px);overflow:auto}
374.docs-results{right:0}.docs-main{padding-top:16px}.docs-main h1{font-size:25px}}
375`;
376
377const DOCS_JS = `(()=>{
378const d=document,$=(s,r=d)=>r.querySelector(s),$$=(s,r=d)=>[...r.querySelectorAll(s)];
379// Copy buttons on code blocks.
380$$('.docs-body pre').forEach(pre=>{const b=d.createElement('button');b.type='button';b.className='btn sm cp';b.textContent='Copy';
381 b.addEventListener('click',()=>{const t=(pre.querySelector('code')||pre).innerText;navigator.clipboard.writeText(t).then(()=>{b.textContent='Copied';setTimeout(()=>{b.textContent='Copy'},1200)})});pre.appendChild(b)});
382// Copy the whole page as Markdown.
383$$('[data-copy-page]').forEach(b=>b.addEventListener('click',async()=>{const s=b.querySelector('span');
384 try{const r=await fetch(b.dataset.copyPage,{headers:{accept:'text/markdown'}});await navigator.clipboard.writeText(await r.text());s.textContent='Copied'}catch(_){s.textContent='Copy failed'}
385 setTimeout(()=>{s.textContent='Copy page'},1500)}));
386// Highlight the current section in "On this page".
387const toc=$$('.docs-toc a');if(toc.length&&'IntersectionObserver'in window){const map=new Map(toc.map(a=>[decodeURIComponent(a.hash.slice(1)),a]));
388 const io=new IntersectionObserver(es=>{es.forEach(e=>{if(e.isIntersecting){toc.forEach(a=>a.classList.remove('on'));const a=map.get(e.target.id);a&&a.classList.add('on')}})},{rootMargin:'-60px 0px -70% 0px'});
389 $$('.docs-body h2[id],.docs-body h3[id]').forEach(h=>io.observe(h))}
390// Search: a small index fetched on first use, ranked by title, heading and text matches.
391let idx=null,loading=null;const load=()=>loading||(loading=fetch('/docs/search.json').then(r=>r.json()).then(j=>{idx=j.map(p=>Object.assign(p,{tl:p.t.toLowerCase(),xl:p.x.toLowerCase(),hl:p.h.map(h=>h[1].toLowerCase())}))}));
392const esc=s=>s.replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
393function search(q){const terms=q.toLowerCase().split(/\\s+/).filter(Boolean);if(!terms.length||!idx)return[];const out=[];
394 for(const p of idx){let score=0,hit=null;for(const t of terms){let s=0;if(p.tl.includes(t))s+=10;p.hl.forEach((h,i)=>{if(h.includes(t)){s+=4;if(!hit)hit=p.h[i]}});if(p.xl.includes(t))s+=1;if(!s){score=0;break}score+=s}
395 if(score){const pos=p.xl.indexOf(terms[0]);out.push({p,score,hit,snip:pos>=0?p.x.slice(Math.max(0,pos-40),pos+90):p.d})}}
396 return out.sort((a,b)=>b.score-a.score).slice(0,12)}
397$$('[data-docs-search]').forEach(inp=>{const list=inp.parentElement.querySelector('.docs-results');let sel=-1;
398 const show=()=>{const q=inp.value.trim();if(!q){list.hidden=true;return}const rs=search(q);sel=-1;list.hidden=false;
399 list.innerHTML=rs.length?rs.map(r=>{const href=(r.p.s==='index'?'/docs':'/docs/'+r.p.s)+(r.hit?'#'+r.hit[0]:'');
400 return '<li><a href="'+esc(href)+'"><span class="r-t">'+esc(r.p.t)+(r.hit?' › '+esc(r.hit[1]):'')+'</span><span class="r-s">'+esc(r.p.c+' · '+r.snip)+'</span></a></li>'}).join(''):'<li class="r-none">No results</li>'};
401 inp.addEventListener('focus',()=>{load().then(show)});inp.addEventListener('input',()=>{load().then(show)});
402 inp.addEventListener('keydown',e=>{const items=$$('li a',list);if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();sel=(sel+(e.key==='ArrowDown'?1:-1)+items.length)%items.length;
403 $$('li',list).forEach((li,i)=>li.classList.toggle('sel',i===sel))}else if(e.key==='Enter'){const a=items[sel<0?0:sel];if(a){e.preventDefault();location.assign(a.href)}}else if(e.key==='Escape'){list.hidden=true;inp.blur()}});
404 d.addEventListener('click',e=>{if(!inp.parentElement.contains(e.target))list.hidden=true})});
405d.addEventListener('keydown',e=>{if(e.key==='/'&&!/input|textarea|select/i.test((e.target.tagName||''))&&!e.target.isContentEditable){const i=$$('[data-docs-search]').find(x=>x.offsetParent);if(i){e.preventDefault();i.focus()}}});
406})();`;