| 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 | */ |
| 16 | import { raw } from "hono/html"; |
| 17 | import type { AppContext } from "../auth/principal.js"; |
| 18 | import type { Env } from "../env.js"; |
| 19 | import { imageProxy } from "../lib/camo.js"; |
| 20 | import { isValidLogin, isValidName } from "../lib/util.js"; |
| 21 | import { fenceLangs, renderMarkdown } from "../web/markdown.js"; |
| 22 | import { fenceHighlighter } from "../web/highlight.js"; |
| 23 | import { ICONS } from "../web/assets.js"; |
| 24 | import { render, type WebApp } from "../web/ui.js"; |
| 25 | import { pluginZipFor } from "./claude-plugins.js"; |
| 26 | import { 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"; |
| 27 | import { allPages, getPage, headings, llmsFullTxt, llmsSmallTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, sectionLlmsFullTxt, sectionLlmsTxt, SLUGS, type Page } from "./pages.js"; |
| 28 | import { docsJsonLd } from "../web/site.js"; |
| 29 | import { openApiDocument } from "./reference.js"; |
| 30 | |
| 31 | const origin = (c: AppContext) => c.env.PUBLIC_URL.replace(/\/$/, ""); |
| 32 | |
| 33 | /** Static per deploy; a short shared cache keeps them fresh after a deploy. */ |
| 34 | const PUBLIC = { "cache-control": "public, max-age=300", "access-control-allow-origin": "*", "x-content-type-options": "nosniff" }; |
| 35 | |
| 36 | const htmlCache = new Map<string, string>(); |
| 37 | async 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. */ |
| 48 | const wantsMarkdown = (c: AppContext) => /\btext\/markdown\b/i.test(c.req.header("accept") ?? ""); |
| 49 | |
| 50 | function markdownResponse(c: AppContext, body: string): Response { |
| 51 | return c.body(body, 200, { ...PUBLIC, "content-type": "text/markdown; charset=utf-8", vary: "Accept" }); |
| 52 | } |
| 53 | |
| 54 | const 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 | |
| 94 | async 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. */ |
| 197 | const 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. */ |
| 202 | export 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 | |
| 216 | export 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. */ |
| 355 | export const docsSitemapEntries = (): Array<{ path: string; updated?: string }> => SLUGS.map((slug) => ({ path: pageUrl(slug), updated: getPage(slug, "")?.updated })); |
| 356 | export { allPages }; |
| 357 | |
| 358 | const DOCS_CSS = ` |
| 359 | main.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 | |
| 401 | const DOCS_JS = `(()=>{ |
| 402 | const 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". |
| 411 | const 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. |
| 415 | let 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())}))})); |
| 416 | const esc=s=>s.replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); |
| 417 | function 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})}); |
| 429 | d.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 | })();`; |