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.

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