diff --git a/.gitignore b/.gitignore index 7e7ef9d45..f453ad120 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ tmp/ src/generated/ public/_astro/ .preview-scope.json +# Derived reference pages are regenerated from a ClickHouse snapshot. Keep the +# snapshot renderer and archive tooling in Git, but never commit its 24MB local +# preview tree. +reference-prototype/ # Local-only Mintlify leftovers package-lock.json .nimbus/ @@ -17,3 +21,4 @@ products/clickhouse-private/* # wrangler dev state and the dist symlink used for local Worker tests .wrangler/ dist +.reference-snapshots/ diff --git a/_site/customizations/webterminal.js b/_site/customizations/webterminal.js index a17845a8e..07793ad3b 100644 --- a/_site/customizations/webterminal.js +++ b/_site/customizations/webterminal.js @@ -57,9 +57,15 @@ + ''; function injectStyles() { - if (document.getElementById(STYLE_ID)) return; - var style = document.createElement('style'); - style.id = STYLE_ID; + var style = document.getElementById(STYLE_ID); + // BaseLayout supplies a persistent empty style shell so that the terminal + // does not briefly lose its fixed positioning during Astro navigation. + if (style && style.dataset.webterminalReady === 'true') return; + if (!style) { + style = document.createElement('style'); + style.id = STYLE_ID; + document.head.appendChild(style); + } style.textContent = '' // Keep the collapsed tray fixed across the viewport. The document and sidebar deliberately // keep their full height and scroll behind it; opening the panel extends the overlay upward. @@ -104,7 +110,7 @@ + '#' + ACTION_ID + ' svg { width: 16px; height: 16px; }' + '#' + PANEL_ID + '.' + OPEN_CLASS + ' #' + ACTION_ID + ' svg { transform: rotate(180deg); }' + '@media (max-width: ' + (DESKTOP_MIN_WIDTH - 1) + 'px) { #' + DOCK_ID + ' { display: none; } }'; - document.head.appendChild(style); + style.dataset.webterminalReady = 'true'; } function maxTerminalHeight() { @@ -159,8 +165,40 @@ if (panel) return; injectStyles(); - dock = document.createElement('div'); - dock.id = DOCK_ID; + // Use BaseLayout's persistent dock when available. The dynamic terminal + // panel then survives a client-side page swap instead of being rebuilt. + dock = document.getElementById(DOCK_ID); + if (!dock) { + dock = document.createElement('div'); + dock.id = DOCK_ID; + document.body.appendChild(dock); + } + + // BaseLayout renders the collapsed tray in the initial HTML. Enhance that + // stable shell instead of removing and recreating it once this deferred + // script has arrived. + panel = document.getElementById(PANEL_ID); + if (panel) { + viewport = document.getElementById(VIEWPORT_ID); + resizer = document.getElementById(RESIZER_ID); + toggle = document.getElementById(TOGGLE_ID); + action = document.getElementById(ACTION_ID); + if (!viewport || !resizer || !toggle || !action) { + panel = null; + } else { + viewport.addEventListener('wheel', function (e) { + if (!terminalOpen) return; + e.preventDefault(); + e.stopPropagation(); + }, {passive: false}); + resizer.addEventListener('pointerdown', startResize); + resizer.addEventListener('touchstart', function (e) { e.preventDefault(); }); + toggle.addEventListener('click', toggleTerminal); + action.addEventListener('click', toggleTerminal); + updateControls(); + return; + } + } panel = document.createElement('section'); panel.id = PANEL_ID; @@ -210,7 +248,6 @@ panel.appendChild(tray); dock.appendChild(panel); - document.body.appendChild(dock); updateControls(); } diff --git a/astro.config.ts b/astro.config.ts index 1f614acfc..5b4e2f287 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -7,6 +7,7 @@ import { tableScroll } from "@cloudflare/nimbus-docs/markdown"; import { satteri } from "@astrojs/markdown-satteri"; import { rebaseUrls } from "./src/plugins/satteri-rebase-urls"; import { mermaidBlocks } from "./src/plugins/satteri-mermaid"; +import { katexMathMarkers, katexMathRenderer } from "./src/plugins/satteri-katex"; import { SATTERI_FEATURES } from "./src/plugins/satteri-features"; import { readScope } from "./src/lib/scope"; import type { HastPluginDefinition } from "satteri"; @@ -120,7 +121,8 @@ export default defineConfig({ // Nimbus). Nimbus's own hast plugins must be re-added here. processor: satteri({ features: SATTERI_FEATURES, - hastPlugins: [nimbusTableScroll, rebaseUrls({ base: BASE, remoteMounts }), mermaidBlocks()], + mdastPlugins: [katexMathMarkers], + hastPlugins: [nimbusTableScroll, rebaseUrls({ base: BASE, remoteMounts }), mermaidBlocks(), katexMathRenderer], }), // The prepared Markdown surfaces are for agents rather than the web // renderer. Preserve agent-only content and reduce the path selector @@ -143,6 +145,22 @@ export default defineConfig({ }), ], vite: { + // In local development, expose the separately-running archived-artifact + // server through the same origin as the docs app. This avoids cross-origin + // browser restrictions while keeping the component contract identical to + // production, where the website worker serves this prefix from storage. + server: { + proxy: { + // Astro removes `base` before Vite evaluates the proxy matcher, so + // the browser's `/docs/reference-artifacts/...` request is seen here + // as `/reference-artifacts/...`. + "/reference-artifacts": { + target: "http://127.0.0.1:4323", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/reference-artifacts/, ""), + }, + }, + }, // The Vite dependency optimizer currently resolves React's development // JSX runtime to its production implementation in this project. The // production runtime intentionally leaves `jsxDEV` undefined, which made diff --git a/bin/archive-reference-bodies.ts b/bin/archive-reference-bodies.ts new file mode 100644 index 000000000..d53347b60 --- /dev/null +++ b/bin/archive-reference-bodies.ts @@ -0,0 +1,169 @@ +/** + * Local release-archive prototype. + * + * It reads complete, already rendered reference pages and writes only their + * document-article HTML plus navigation data and a small manifest. A release + * job supplies REFERENCE_ARCHIVE_BUILD_DIR (the completed static output); the + * HTTP input is retained solely for the fast local prototype. + */ +import fs from "node:fs"; +import path from "node:path"; + +const version = process.env.REFERENCE_ARCHIVE_VERSION ?? "26.9"; +const origin = (process.env.REFERENCE_ARCHIVE_ORIGIN ?? "http://127.0.0.1:4321/docs").replace(/\/$/, ""); +const sourceRoot = path.join(process.cwd(), "reference-prototype", version); +const finalOutputRoot = path.resolve(process.env.REFERENCE_ARCHIVE_OUTPUT_DIR ?? "/private/tmp/reference-artifacts", version, "en"); +const outputRoot = `${finalOutputRoot}.staging`; +const buildRoot = process.env.REFERENCE_ARCHIVE_BUILD_DIR + ? path.resolve(process.env.REFERENCE_ARCHIVE_BUILD_DIR) + : undefined; +const articleOpen = '
'; + +function files(directory: string): string[] { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? files(target) : entry.name.endsWith(".mdx") ? [target] : []; + }); +} + +function routeFor(file: string) { + const relative = path.relative(sourceRoot, file).replace(/\.mdx$/, "").split(path.sep).join("/"); + return relative === "index" ? "" : relative.replace(/\/index$/, ""); +} + +function titleFor(file: string) { + const source = fs.readFileSync(file, "utf8"); + const match = source.match(/^title:\s*("(?:[^"\\]|\\.)*")\s*$/m); + return match ? JSON.parse(match[1]) as string : routeFor(file) || "Reference"; +} + +const settingsExplorerRoutes: Record = { + "settings/session-settings": "session-settings", + "settings/server-settings/settings": "server-settings", + "settings/merge-tree-settings": "merge-tree-settings", +}; + +function normalizeBody(body: string, route: string) { + // The archive is a content contract, never an Astro build artifact. Strip + // component scope IDs and turn callouts into a stable semantic marker that + // the live reference shell can style in future releases. + const explorer = settingsExplorerRoutes[route]; + const normalized = body + .replace(/\sdata-astro-cid-[^\s=>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?/g, "") + // Client islands are build artifacts. A historical body is inserted into + // the current shell, where these scripts cannot (and must not) hydrate. + // Replace the settings island with a stable semantic mount point instead. + .replace(/ diff --git a/src/components/ArchivedReferenceNavigation.astro b/src/components/ArchivedReferenceNavigation.astro new file mode 100644 index 000000000..86313b84f --- /dev/null +++ b/src/components/ArchivedReferenceNavigation.astro @@ -0,0 +1,211 @@ +--- +import { SidebarFilter } from "@/components/ui/sidebar"; +import AskAiButton from "@/components/AskAiButton.astro"; +import { SearchTrigger } from "@/components/ui/search"; +const archiveOrigin = (import.meta.env.PUBLIC_REFERENCE_ARCHIVE_ORIGIN ?? "").replace(/\/$/, ""); +--- + + + + + + diff --git a/src/components/ArchivedSettingsExplorers.tsx b/src/components/ArchivedSettingsExplorers.tsx new file mode 100644 index 000000000..0eaf8e137 --- /dev/null +++ b/src/components/ArchivedSettingsExplorers.tsx @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { SettingsExplorer } from "./SettingsExplorer"; + +/** Mount the current explorer UI into versioned data placeholders in an archived body. */ +export function ArchivedSettingsExplorers() { + useEffect(() => { + const roots = new Map(); + + const mount = () => { + const placeholders = document.querySelectorAll("[data-reference-settings-explorer][data-reference-settings-index-url]"); + const active = new Set(placeholders); + roots.forEach((root, element) => { + if (active.has(element)) return; + root.unmount(); + roots.delete(element); + }); + placeholders.forEach((element) => { + if (roots.has(element)) return; + const indexUrl = element.dataset.referenceSettingsIndexUrl; + if (!indexUrl) return; + const root = createRoot(element); + roots.set(element, root); + root.render(); + }); + }; + + mount(); + document.addEventListener("reference:body-loaded", mount); + return () => { + document.removeEventListener("reference:body-loaded", mount); + roots.forEach((root) => root.unmount()); + roots.clear(); + }; + }, []); + + return null; +} diff --git a/src/components/Header.astro b/src/components/Header.astro index c91cd6b95..4bb56c266 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -10,8 +10,8 @@ import LocaleSwitcher from "./LocaleSwitcher.astro"; import AskAiButton from "./AskAiButton.astro"; import HomepageNavigation from "./HomepageNavigation.astro"; import HomepageNavbarCta from "./HomepageNavbarCta.astro"; -import HomepageLogoLight from "../../_site/logo/light.svg"; -import HomepageLogoDark from "../../_site/logo/dark.svg"; +import HomepageLogoLight from "../../_site/logo/light.svg?raw"; +import HomepageLogoDark from "../../_site/logo/dark.svg?raw"; import { uiStrings } from "@/lib/ui-strings.server"; interface Props { @@ -58,10 +58,10 @@ const messages = uiStrings(currentLocale); )}
- ClickHouse Docs - ClickHouse Docs + + - + {homepage && }
diff --git a/src/components/HomepageNavigation.astro b/src/components/HomepageNavigation.astro index 6eb40b84d..44961ff62 100644 --- a/src/components/HomepageNavigation.astro +++ b/src/components/HomepageNavigation.astro @@ -45,6 +45,10 @@ const remotePreview = readScope().remotePreview; function localHref(href: string): string { if (/^(https?:)?\/\//.test(href)) return href; + // The reference landing page moved from /reference/home to /reference. + // Keep the generated site-navigation input backwards compatible while the + // source navigation contract is migrated separately. + if (href === "/reference/home") return withBase("/reference"); if (remotePreview && (href === "/" || href === "")) return "https://clickhouse.com/docs/"; const localPath = href.startsWith("/") ? href : `/${href}`; const alreadyLocalized = localePrefix && ( @@ -60,6 +64,14 @@ function localHref(href: string): string { function hasCurrentPage(item: NavItem): boolean { if (item.link && localHref(item.link).replace(/\/+$/, "") === currentPath) return true; if (item.landing && localHref(item.landing).replace(/\/+$/, "") === currentPath) return true; + // The snapshot-driven reference microfrontend owns routes which do not + // necessarily appear as individual leaves in the authored navigation. They + // are nevertheless part of the Database section, just like the existing + // authored reference pages. + if (item.label === "Database") { + const referenceRoot = localHref("/reference").replace(/\/+$/, ""); + if (currentPath === referenceRoot || currentPath.startsWith(`${referenceRoot}/`)) return true; + } return item.items?.some(hasCurrentPage) ?? false; } diff --git a/src/components/MobileNavigation.astro b/src/components/MobileNavigation.astro index 73feecce9..add04db6f 100644 --- a/src/components/MobileNavigation.astro +++ b/src/components/MobileNavigation.astro @@ -40,10 +40,20 @@ const messages = uiStrings(Astro.props.currentLocale); function fillDrawer(dialog: HTMLDialogElement) { const target = dialog.querySelector("[data-nb-drawer-target]"); const source = document.querySelector("#desktop-sidebar nav"); - if (!target || !source || target.childElementCount > 0) return; + if (!target || !source) return; + // Astro preserves the dialog across client-side navigations. The source + // rail changes its current link and can change its shape, so never keep + // an old cloned tree in the drawer. + target.replaceChildren(); for (const child of Array.from(source.children)) { const clone = child.cloneNode(true) as HTMLElement; clone.removeAttribute("data-nb-sidebar-persist"); + // The drawer is an ephemeral clone of the desktop rail. Never copy + // Astro persistence keys into it: duplicate keys in the same page + // make the ClientRouter abort its DOM swap (including archive links). + clone.removeAttribute("data-astro-transition-persist"); + clone.querySelectorAll("[data-astro-transition-persist]") + .forEach((element) => element.removeAttribute("data-astro-transition-persist")); target.appendChild(clone); } target.querySelectorAll("[data-nb-sidebar-persist]").forEach((element) => element.removeAttribute("data-nb-sidebar-persist")); @@ -58,6 +68,30 @@ const messages = uiStrings(Astro.props.currentLocale); return () => cleanups.forEach((cleanup) => cleanup()); } + function collapseDrawerGroups(dialog: HTMLDialogElement) { + const groups = Array.from(dialog.querySelectorAll("[data-nb-sidebar-group]")).reverse(); + for (const group of groups) { + const trigger = Array.from(group.querySelectorAll("[data-nb-collapsible-trigger]")) + .find((candidate) => candidate.closest("[data-nb-sidebar-group]") === group); + // The Reference heading is a permanently expanded section wrapper, + // rather than a user-toggleable navigation tab. + if (!trigger) continue; + if (trigger?.getAttribute("data-nb-state") === "open") trigger.click(); + // An active route is rendered open by the server. Explicitly clear + // that rendered state after the disclosure callback so a filter reset + // leaves no visibly expanded sections in the drawer. + trigger.setAttribute("data-nb-state", "closed"); + trigger.setAttribute("aria-expanded", "false"); + const label = Array.from(group.querySelectorAll("[data-nb-sidebar-group-label]")) + .find((candidate) => candidate.closest("[data-nb-sidebar-group]") === group); + label?.setAttribute("data-nb-state", "closed"); + const content = Array.from(group.querySelectorAll("[data-nb-collapsible-content]")) + .find((candidate) => candidate.closest("[data-nb-sidebar-group]") === group); + content?.setAttribute("data-nb-state", "closed"); + content?.toggleAttribute("inert", true); + } + } + // mount() re-binds on every astro:page-load and tears down on // astro:before-swap, so the hamburger survives client-side navigation // (a one-shot module script would go dead after the first swap). @@ -78,7 +112,8 @@ const messages = uiStrings(Astro.props.currentLocale); } if (dialog.open) return; - disposeSidebar ??= fillDrawer(dialog); + disposeSidebar?.(); + disposeSidebar = fillDrawer(dialog); dialog.showModal(); dialog.dataset.state = "closed"; lockScroll(); @@ -131,6 +166,15 @@ const messages = uiStrings(Astro.props.currentLocale); if (e.target === dialog) closeSidebar(); }, { signal }); + // The drawer owns cloned disclosure controls. Run after its copied + // filter listener so clearing a query reliably returns this visible + // tree to its compact, all-collapsed state. + dialog.addEventListener("input", (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement) || !target.matches("[data-nb-sidebar-filter-input]") || target.value.trim()) return; + requestAnimationFrame(() => collapseDrawerGroups(dialog)); + }, { signal }); + return () => { controller.abort(); disposeSidebar?.(); diff --git a/src/components/MobileNavigationSections.astro b/src/components/MobileNavigationSections.astro index 87f8df9dc..6f7c26300 100644 --- a/src/components/MobileNavigationSections.astro +++ b/src/components/MobileNavigationSections.astro @@ -7,7 +7,13 @@ interface Props { currentLocale?: string; } const locale = localeRouteName(Astro.props.currentLocale ?? "en"); const items = loadGeneratedNavigation(locale).items; const path = Astro.url.pathname; -const menus = [sectionsFromConfig(items, path), mobileSectionsFromConfig(items, path)]; +// Keep the mobile section picker aligned with the new reference landing URL +// while the generated navigation contract still contains /reference/home. +const referenceLanding = (options: T[]) => options.map((item) => ({ + ...item, + href: item.href.replace(/\/reference\/home$/, "/reference"), +})); +const menus = [sectionsFromConfig(items, path), mobileSectionsFromConfig(items, path)].map(referenceLanding); ---
{menus.filter((options) => options.length).map((options) => ( diff --git a/src/components/ReferenceVersionPicker.astro b/src/components/ReferenceVersionPicker.astro new file mode 100644 index 000000000..9798f7773 --- /dev/null +++ b/src/components/ReferenceVersionPicker.astro @@ -0,0 +1,126 @@ +--- +/** + * Snapshot-version control for the reference explorer. + * + * The selector preserves the reference-relative route. This makes release + * comparison the default interaction: switching 26.9 → latest on an + * arithmetic-functions page opens the corresponding latest page, not home. + */ +interface Props { + currentVersion: string; +} + +const { currentVersion } = Astro.props; +// Leave this blank locally: Vite proxies the artifact store through the docs +// origin, matching the production `/docs/reference-artifacts` route. +const archiveOrigin = import.meta.env.PUBLIC_REFERENCE_ARCHIVE_ORIGIN ?? ""; +--- + +
+ + Head + + +
+ + diff --git a/src/components/SettingMetadata.astro b/src/components/SettingMetadata.astro new file mode 100644 index 000000000..34da8ca26 --- /dev/null +++ b/src/components/SettingMetadata.astro @@ -0,0 +1,18 @@ +--- +interface Props { + type?: string; + defaultValue?: string; + tier?: string; +} + +const { type, defaultValue, tier } = Astro.props; +--- + + diff --git a/src/components/SettingsExplorer.tsx b/src/components/SettingsExplorer.tsx new file mode 100644 index 000000000..24666bf95 --- /dev/null +++ b/src/components/SettingsExplorer.tsx @@ -0,0 +1,90 @@ +import { useEffect, useMemo, useState } from "react"; + +type Setting = { name: string; href: string; default?: string }; +type Group = { label: string; count: number; settings: Setting[] }; +type Index = { schemaVersion: 1; clickhouseVersion: string; displayPath: string; groups: Group[] }; + +function withDocsBase(value: string) { + if (typeof window === "undefined" || !window.location.pathname.startsWith("/docs")) return value; + // Snapshot artifacts served through the website worker already include the + // production base path. Generated Head indexes do not. + return value === "/docs" || value.startsWith("/docs/") ? value : `/docs${value}`; +} + +function terms(value: string) { + return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/) + .filter((term) => term.length > 1).map((term) => term.length > 3 && term.endsWith("s") ? term.slice(0, -1) : term); +} + +function matches(value: string, query: string, queryTerms: string[]) { + if (!query) return true; + const candidate = value.toLowerCase(); + if (!query.includes("%")) return queryTerms.every((term) => terms(value).some((candidateTerm) => candidateTerm.startsWith(term))); + const parts = query.split("%"); + let position = 0; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + if (!part) continue; + const match = candidate.indexOf(part, position); + if (match < 0 || (index === 0 && !query.startsWith("%") && match !== 0)) return false; + position = match + part.length; + } + const last = parts.at(-1); + return query.endsWith("%") || !last || position === candidate.length; +} + +export function SettingsExplorer({ indexUrl }: { indexUrl: string }) { + const [index, setIndex] = useState(); + const [failed, setFailed] = useState(false); + const [expanded, setExpanded] = useState>(() => new Set()); + const [search, setSearch] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + fetch(withDocsBase(indexUrl), { signal: controller.signal }) + .then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status)))) + .then((payload: Index) => { + if (payload.schemaVersion !== 1 || !Array.isArray(payload.groups)) throw new Error("Unsupported settings explorer index"); + setIndex(payload); + }) + .catch((reason: unknown) => { + if (!(reason instanceof DOMException && reason.name === "AbortError")) setFailed(true); + }); + return () => controller.abort(); + }, [indexUrl]); + + const normalized = search.trim().toLowerCase(); + const queryTerms = useMemo(() => terms(search), [search]); + const searching = normalized.includes("%") ? normalized.replaceAll("%", "").trim().length > 0 : queryTerms.length > 0; + const groups = useMemo(() => !index ? [] : (searching + ? index.groups.map((group) => { + const settings = group.settings.filter((setting) => matches(setting.name, normalized, queryTerms)); + return { ...group, settings, count: settings.length }; + }).filter((group) => group.count > 0) + : index.groups), [index, normalized, searching, queryTerms]); + const count = groups.reduce((total, group) => total + group.count, 0); + const allExpanded = groups.length > 0 && groups.every((group) => expanded.has(group.label)); + + if (!index) return
{failed ? "Settings explorer could not be loaded." : "Loading settings explorer…"}
; + + const toggleAll = () => setExpanded(allExpanded ? new Set() : new Set(index.groups.map((group) => group.label))); + + return
+
+ + setSearch(event.target.value)} placeholder="Search settings, e.g. parallel replicas or %materialized%" className="w-full rounded-lg border border-gray-500 bg-gray-50 py-2 pl-9 pr-3 text-sm text-gray-900 placeholder:text-gray-600 focus:border-gray-600 focus:outline-0 focus-visible:outline-0 dark:border-white/30 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-[#fdff75]" /> +
+ {searching &&
{count} matching {count === 1 ? "setting" : "settings"}
} +
+
{index.displayPath}
+ {groups.length ? groups.map((group, groupIndex) => { + const open = searching || expanded.has(group.label); + const lastGroup = groupIndex === groups.length - 1; + return
+ + {open && group.settings.map((setting, settingIndex) =>
{setting.default !== undefined && (default: {setting.default})}
)} +
; + }) :
No matching settings
} +
+
; +} diff --git a/src/components/ui/search/SearchTrigger.astro b/src/components/ui/search/SearchTrigger.astro index f5dbd60cc..249b8f5d0 100644 --- a/src/components/ui/search/SearchTrigger.astro +++ b/src/components/ui/search/SearchTrigger.astro @@ -33,16 +33,32 @@ const messages = uiStrings(currentLocale); {sidebar ? messages.search.searchShort : messages.search.search} - - CtrlK + ⌘CtrlK + + diff --git a/src/components/ui/search/search-trigger.client.ts b/src/components/ui/search/search-trigger.client.ts index 7e75f014c..6602ecdd2 100644 --- a/src/components/ui/search/search-trigger.client.ts +++ b/src/components/ui/search/search-trigger.client.ts @@ -10,8 +10,8 @@ mount("[data-search-trigger]", (btn) => { : /mac|iphone|ipod|ipad/i.test(navigator.userAgent); if (isMac) { btn.setAttribute("aria-keyshortcuts", "Meta+K"); - const key = btn.querySelector("[data-shortcut-key]"); - if (key) key.textContent = "⌘"; + } else { + btn.setAttribute("aria-keyshortcuts", "Control+K"); } return () => {}; }); diff --git a/src/components/ui/sidebar/SidebarGroup.astro b/src/components/ui/sidebar/SidebarGroup.astro index 5aa787abc..b6fa4248d 100644 --- a/src/components/ui/sidebar/SidebarGroup.astro +++ b/src/components/ui/sidebar/SidebarGroup.astro @@ -105,7 +105,9 @@ const imageIcon = icon?.startsWith("/") ? inlinePublicIcon(icon) : undefined; {...attrs} > {section ? ( -

) : ( - ))} + ))} -

+
) : indexHref ? (
; scroll: number; + /** Retain an active filter while following a result to another page. */ + filter?: string; +} + +function readStoredState(): SidebarState | null { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const value = JSON.parse(raw) as Partial; + return typeof value.hash === "string" && typeof value.open === "object" && typeof value.scroll === "number" + ? value as SidebarState + : null; + } catch { + return null; + } +} + +function saveFilter(root: HTMLElement, filter: string): void { + const hash = root.dataset.nbSidebarHash ?? ""; + if (!hash) return; + try { + const previous = readStoredState(); + const state: SidebarState = previous?.hash === hash + ? previous + : { hash, open: {}, scroll: 0 }; + state.filter = filter || undefined; + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch {} } function ownedTrigger(group: HTMLElement): HTMLElement | undefined { @@ -20,6 +48,11 @@ function ownedLabel(group: HTMLElement): HTMLElement | undefined { .find((candidate) => candidate.closest("[data-nb-sidebar-group]") === group); } +function ownedContent(group: HTMLElement): HTMLElement | undefined { + return Array.from(group.querySelectorAll("[data-nb-collapsible-content]")) + .find((candidate) => candidate.closest("[data-nb-sidebar-group]") === group); +} + function groupKey(group: HTMLElement): string { const labels: string[] = []; let current: HTMLElement | null = group; @@ -60,7 +93,9 @@ function initFilter(root: HTMLElement): (() => void) | null { if (!inputElement) return null; function handleInput() { - const query = inputElement!.value.trim().toLowerCase(); + const rawQuery = inputElement!.value.trim(); + const query = rawQuery.toLowerCase(); + saveFilter(root, rawQuery); if (!query) { resetFilter(root); return; @@ -79,27 +114,70 @@ function initFilter(root: HTMLElement): (() => void) | null { inputElement.addEventListener("input", handleInput); inputElement.addEventListener("keydown", handleKeydown); + // Astro replaces the page while following a filtered result. Restore the + // query on the replacement rail so the user stays in the same navigation + // context instead of being dropped into an unfiltered tree. + const storedFilter = readStoredState(); + const savedFilter = storedFilter && storedFilter.hash === root.dataset.nbSidebarHash + ? storedFilter.filter + : undefined; + if (savedFilter) { + inputElement.value = savedFilter; + handleInput(); + } + return () => { inputElement.removeEventListener("input", handleInput); inputElement.removeEventListener("keydown", handleKeydown); - resetFilter(root); + // Teardown also runs during Astro route swaps. A persisted reference + // explorer is deliberately retained through those swaps, so mutating its + // disclosure state here would make all of its top-level entries jump. + // `resetFilter` is reserved for an explicit user clear instead. }; } -function resetFilter(root: HTMLElement): void { - root.querySelectorAll("[data-nb-sidebar-hidden]").forEach((el) => { - el.removeAttribute("data-nb-sidebar-hidden"); - }); - // Reset groups opened by the filter back to their saved state. - root - .querySelectorAll("[data-nb-sidebar-group][data-nb-opened-by-filter]") +function collapseAllGroups(root: HTMLElement): void { + // Clearing a filter returns the explorer to a compact starting point. Do + // not retain the collection of branches that were opened only to reveal + // matches (or an active branch that was opened by a route transition). + // Descendants must close before their parents; a collapsed parent marks its + // panel inert, which would otherwise prevent a nested trigger from closing. + Array.from(root.querySelectorAll("[data-nb-sidebar-group]")) + .reverse() .forEach((group) => { const trigger = ownedTrigger(group); - trigger?.click(); + // Section headings such as "Reference" use the same container markup + // but intentionally have no trigger. They are the always-visible + // wrapper around the actual expandable tabs, not a tab themselves. + if (!trigger) return; + if (trigger?.getAttribute("data-nb-state") === "open") trigger.click(); + // Active pages initially open their ancestors on the server. Clearing a + // filter is an explicit request to leave that state, so enforce the + // collapsed DOM state after the disclosure callback has run. + group.setAttribute("data-nb-default-open", "false"); + trigger.setAttribute("data-nb-state", "closed"); + trigger.setAttribute("aria-expanded", "false"); + ownedLabel(group)?.setAttribute("data-nb-state", "closed"); + const content = ownedContent(group); + content?.setAttribute("data-nb-state", "closed"); + content?.toggleAttribute("inert", true); group.removeAttribute("data-nb-opened-by-filter"); }); } +function resetFilter(root: HTMLElement): void { + root.querySelectorAll("[data-nb-sidebar-hidden]").forEach((el) => { + el.removeAttribute("data-nb-sidebar-hidden"); + }); + collapseAllGroups(root); + // Disclosure bindings and the cloned mobile rail can finish their own + // updates later in this event turn. Make the explicit reset win after they + // have settled. + requestAnimationFrame(() => { + if (root.isConnected) collapseAllGroups(root); + }); +} + function applyFilter(root: HTMLElement, query: string): void { const links = root.querySelectorAll("[data-nb-sidebar-link]"); const groups = root.querySelectorAll("[data-nb-sidebar-group]"); @@ -144,6 +222,44 @@ function openGroup(group: HTMLElement): void { trigger.click(); } +function normalizePagePath(value: string): string { + try { + return new URL(value, window.location.origin).pathname.replace(/\/+$/, "") || "/"; + } catch { + return value.replace(/\/+$/, "") || "/"; + } +} + +/** + * A snapshot reference rail is preserved across Astro route swaps to avoid + * rebuilding its expanded branches (which visibly moves top-level rows). + * Keep its lightweight active marker current without changing disclosure + * state or the tree's dimensions. + */ +function syncPersistedSidebarCurrentPage(root: HTMLElement): void { + const currentPath = normalizePagePath(window.location.pathname); + const links = root.querySelectorAll( + "[data-nb-sidebar-link], [data-nb-sidebar-group-landing] > a", + ); + links.forEach((link) => { + const current = normalizePagePath(link.href) === currentPath; + if (current) link.setAttribute("aria-current", "page"); + else link.removeAttribute("aria-current"); + + const landing = link.closest("[data-nb-sidebar-group-landing]"); + if (landing) { + landing.classList.toggle("bg-accent", current); + landing.classList.toggle("text-foreground", current); + landing.classList.toggle("font-semibold", current); + } + }); + + root.querySelectorAll("[data-nb-sidebar-group]").forEach((group) => { + const hasCurrent = Boolean(group.querySelector("[aria-current='page']")); + ownedLabel(group)?.classList.toggle("is-active", hasCurrent); + }); +} + // --------------------------------------------------------------------------- // Persistence (open state + scroll) // --------------------------------------------------------------------------- @@ -157,6 +273,25 @@ function initPersistence(root: HTMLElement): (() => void) | null { root; const hash = root.dataset.nbSidebarHash ?? ""; + function preserveDisclosureDefault(event: MouseEvent): void { + const target = event.target; + if (!(target instanceof Element)) return; + const trigger = target.closest("[data-nb-collapsible-trigger]"); + const group = trigger?.closest("[data-nb-sidebar-group]"); + if (!trigger || !group || !root.contains(group)) return; + + // Nimbus remounts disclosure controls after an Astro navigation. Preserve + // the user's selection as their new default so a persisted tree does not + // silently collapse the branch that contains the destination page. + requestAnimationFrame(() => { + if (!group.isConnected) return; + group.setAttribute( + "data-nb-default-open", + trigger.getAttribute("data-nb-state") === "open" ? "true" : "false", + ); + }); + } + function handleLandingPageClick(event: MouseEvent): void { if ( event.defaultPrevented || @@ -183,6 +318,7 @@ function initPersistence(root: HTMLElement): (() => void) | null { } root.addEventListener("click", handleLandingPageClick); + root.addEventListener("click", preserveDisclosureDefault); function readState(): SidebarState { const groups = root.querySelectorAll("[data-nb-sidebar-group]"); @@ -193,7 +329,13 @@ function initPersistence(root: HTMLElement): (() => void) | null { // open. Recording them as open also repairs state written by older code. open[groupKey(group)] = !trigger || trigger.getAttribute("data-nb-state") === "open"; }); - return { hash, open, scroll: scrollHost.scrollTop }; + const previous = readStoredState(); + return { + hash, + open, + scroll: scrollHost.scrollTop, + filter: previous?.hash === hash ? previous.filter : undefined, + }; } function save() { @@ -228,6 +370,7 @@ function initPersistence(root: HTMLElement): (() => void) | null { return () => { observer.disconnect(); root.removeEventListener("click", handleLandingPageClick); + root.removeEventListener("click", preserveDisclosureDefault); document.removeEventListener("visibilitychange", handleVisibility); document.removeEventListener("astro:before-swap", save); window.removeEventListener("pagehide", save); @@ -265,3 +408,13 @@ function initPersistence(root: HTMLElement): (() => void) | null { })(); mount("[data-nb-sidebar]", initSidebar); + +if (!document.documentElement.hasAttribute("data-nb-sidebar-current-bound")) { + document.documentElement.setAttribute("data-nb-sidebar-current-bound", ""); + const sync = () => document + .querySelectorAll("[data-nb-sidebar-persist]") + .forEach(syncPersistedSidebarCurrentPage); + document.addEventListener("astro:after-swap", sync); + document.addEventListener("astro:page-load", sync); + sync(); +} diff --git a/src/components/ui/toc/TOC.astro b/src/components/ui/toc/TOC.astro index b38e8a00e..81d236d81 100644 --- a/src/components/ui/toc/TOC.astro +++ b/src/components/ui/toc/TOC.astro @@ -55,6 +55,7 @@ const messages = uiStrings(currentLocale); data-nb-slug={h.slug} class="no-underline" style={`--ch-toc-depth: ${indent};`} + title={h.text} > {h.text} diff --git a/src/content.config.ts b/src/content.config.ts index 367c65da6..e85f18b98 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -13,6 +13,7 @@ export const SECTIONS = [ "concepts", "guides", "reference", + "reference-prototype", "products", "clickstack", "integrations", @@ -81,7 +82,39 @@ export function pathId({ entry }: { entry: string }): string { // its sitemap canonical is `/folder`, so that is the page id here and // `/folder/index` becomes a redirect (bin/gen-redirects.ts). The root // `index.mdx` keeps the id `index`, which Nimbus expects. - const id = entry.replace(/\.(mdx?|md)$/i, ""); + const sourceId = entry.replace(/\.(mdx?|md)$/i, ""); + // The generated Head snapshot owns the public reference route whenever it + // has an equivalent source page. Keeping the authored copy in the content + // collection under that same id makes Astro choose either entry depending + // on loader order, which in turn swaps between the ordinary DocsLayout and + // the reference version shell during navigation. Retain the authored file + // for source/audit purposes, but give it a private collection id so it + // cannot compete for the public route. + const authoredReferenceRoute = sourceId.startsWith("reference/") + ? sourceId.slice("reference/".length).replace(/\/index$/, "") + : undefined; + const snapshotOwnsAuthoredReference = authoredReferenceRoute !== undefined && [ + `reference-prototype/latest/${authoredReferenceRoute}.md`, + `reference-prototype/latest/${authoredReferenceRoute}.mdx`, + `reference-prototype/latest/${authoredReferenceRoute}/index.md`, + `reference-prototype/latest/${authoredReferenceRoute}/index.mdx`, + ].some((candidate) => fs.existsSync(candidate)); + if (snapshotOwnsAuthoredReference) { + return `__snapshot-shadowed-reference__/${sourceId}`.replace(/\/index$/, ""); + } + // Keep the generated source isolated in `reference-prototype/`, while the + // scoped microfrontend preview publishes it at the real `/reference` mount. + // A prototype build excludes the authored `reference/` tree, preventing two + // sources from owning the same route. + const id = sourceId === "reference-prototype/latest" + ? "reference" + : sourceId.startsWith("reference-prototype/latest/") + ? `reference/${sourceId.slice("reference-prototype/latest/".length)}` + : sourceId === "reference-prototype" + ? "reference" + : sourceId.startsWith("reference-prototype/") + ? `reference/${sourceId.slice("reference-prototype/".length)}` + : sourceId; return id === "index" ? id : id.replace(/\/index$/, ""); } @@ -112,6 +145,13 @@ const clickhouseFields = { rss: z.any().optional(), icon: z.string().optional(), audience: z.any().optional(), + // Snapshot-rendered reference pages disclose their provenance in the layout. + generatedFromSystemTables: z.boolean().optional(), + // Identifies the generated subtree after its public route becomes `/reference`. + referenceSnapshot: z.boolean().optional(), + // Stable key used to select the matching snapshot navigation and static assets. + referenceSnapshotKey: z.string().optional(), + referenceSnapshotVersion: z.string().optional(), }; const schema = defineDocSchema({ fields: clickhouseFields, strictFrontmatter: false }); diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 000000000..287d2893a --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,9 @@ +declare namespace App { + interface Locals { + /** Original versioned route retained by the local archive rewrite. */ + archivedReference?: { + version: string; + path: string; + }; + } +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 21a7f8cb3..008bb46a8 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -109,14 +109,54 @@ const projectLogo = inlinePublicIcon("/images/logo.svg"); targetDocument.documentElement.dataset.inputModality = inputModality; }; + // The shortcut hint appears in the server-rendered sidebar. Work out + // its platform variant before the body is painted, rather than + // rendering one label and replacing it after hydration. + const applyShortcutPlatform = (targetDocument = document) => { + const platform = navigator.userAgentData?.platform ?? ""; + const isMac = platform + ? /mac/i.test(platform) + : /mac|iphone|ipod|ipad/i.test(navigator.userAgent); + targetDocument.documentElement.dataset.shortcutPlatform = isMac ? "mac" : "other"; + }; + const carrySidebarScroll = (targetDocument) => { const current = document.querySelector("#desktop-sidebar .ch-sidebar-scroll"); const incoming = targetDocument.querySelector("#desktop-sidebar .ch-sidebar-scroll"); if (current && incoming) { - incoming.setAttribute("data-nb-restore-scroll-top", String(current.scrollTop)); + // Clicking a link focuses it before Astro dispatches + // `astro:before-swap`; that native focus can already have moved + // the enclosing scroll rail. For a persisted reference rail, use + // the offset captured at pointer-down, before that focus happens. + const persisted = current.hasAttribute("data-astro-transition-persist") + || Boolean(current.closest("[data-astro-transition-persist]")); + const top = persisted + ? (current.getAttribute("data-nb-restore-scroll-top") ?? String(current.scrollTop)) + : String(current.scrollTop); + incoming.setAttribute("data-nb-restore-scroll-top", top); + // Astro retains the reference explorer's scroll host itself. + // Its incoming counterpart is discarded during the swap, so save + // the offset on the retained node too. This counteracts the + // browser's focus restoration, which otherwise scrolls the rail + // by one item after every clicked link. + if (persisted) { + current.setAttribute("data-nb-restore-scroll-top", top); + } } }; + const updatePersistedReferenceActiveLink = () => { + const sidebar = document.querySelector("[data-reference-sidebar-content]"); + if (!sidebar) return; + const currentPath = window.location.pathname.replace(/\/$/, ""); + sidebar.querySelectorAll("a[aria-current='page']").forEach((link) => { + link.removeAttribute("aria-current"); + }); + const match = Array.from(sidebar.querySelectorAll("a[href]")) + .find((link) => new URL(link.href, window.location.origin).pathname.replace(/\/$/, "") === currentPath); + match?.setAttribute("aria-current", "page"); + }; + const restoreSidebarScroll = () => { const sidebar = document.querySelector("#desktop-sidebar .ch-sidebar-scroll"); const raw = sidebar?.getAttribute("data-nb-restore-scroll-top"); @@ -132,6 +172,7 @@ const projectLogo = inlinePublicIcon("/images/logo.svg"); applyTheme(); applyInputModality(); + applyShortcutPlatform(); document.addEventListener( "pointerdown", () => { @@ -140,6 +181,21 @@ const projectLogo = inlinePublicIcon("/images/logo.svg"); }, true, ); + document.addEventListener( + "pointerdown", + (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const link = target.closest("#desktop-sidebar a"); + const scrollHost = link?.closest(".ch-sidebar-scroll"); + const persisted = scrollHost?.hasAttribute("data-astro-transition-persist") + || Boolean(scrollHost?.closest("[data-astro-transition-persist]")); + if (scrollHost && persisted) { + scrollHost.setAttribute("data-nb-restore-scroll-top", String(scrollHost.scrollTop)); + } + }, + true, + ); document.addEventListener( "keydown", (event) => { @@ -149,6 +205,26 @@ const projectLogo = inlinePublicIcon("/images/logo.svg"); }, true, ); + // Theme controls can appear inside a retained reference sidebar. Keep + // their behavior in this persistent pre-paint runtime instead of + // depending on a per-component hydration hook after every route swap. + document.addEventListener( + "click", + (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const button = target.closest("[data-theme-choice]"); + const choice = button?.getAttribute("data-theme-choice"); + if (choice !== "system" && choice !== "light" && choice !== "dark") return; + try { + localStorage.setItem(KEY, choice); + } catch { + // A restricted storage context can still apply the preference. + } + applyTheme(); + }, + true, + ); // ClientRouter replaces every attribute with the attributes from // the incoming server document. Stamp the saved theme onto that // detached document before the swap so no frame renders with the light @@ -156,11 +232,14 @@ const projectLogo = inlinePublicIcon("/images/logo.svg"); document.addEventListener("astro:before-swap", (event) => { applyTheme(event.newDocument); applyInputModality(event.newDocument); + applyShortcutPlatform(event.newDocument); carrySidebarScroll(event.newDocument); }); document.addEventListener("astro:after-swap", () => { applyTheme(); applyInputModality(); + applyShortcutPlatform(); + updatePersistedReferenceActiveLink(); restoreSidebarScroll(); }); media.addEventListener("change", () => { @@ -173,6 +252,17 @@ const projectLogo = inlinePublicIcon("/images/logo.svg"); window.__nbApplyTheme = applyTheme; })(); + + {/* Render the collapsed terminal tray here rather than inserting it after + JavaScript loads. This makes a hard reload paint the same bottom edge + that the terminal runtime subsequently enhances. */} + +
+
+
+ +
+ + +
+
+
diff --git a/src/layouts/DocsLayout.astro b/src/layouts/DocsLayout.astro index 6c4145df7..8419c1cc4 100644 --- a/src/layouts/DocsLayout.astro +++ b/src/layouts/DocsLayout.astro @@ -10,9 +10,11 @@ import BaseLayout from "./BaseLayout.astro"; import Header from "@/components/Header.astro"; import AskAiButton from "@/components/AskAiButton.astro"; import LocaleSwitcher from "@/components/LocaleSwitcher.astro"; +import { ThemeToggle } from "@/components/ui/theme-toggle"; import { Banner } from "@/components/ui/banner"; import { Sidebar, SidebarFilter } from "@/components/ui/sidebar"; import { SearchTrigger } from "@/components/ui/search"; +import ReferenceVersionPicker from "@/components/ReferenceVersionPicker.astro"; import { TOC, MobileTOC } from "@/components/ui/toc"; import { Pagination } from "@/components/ui/pagination"; import { PageActions } from "@/components/ui/page-actions"; @@ -38,9 +40,15 @@ type Props = Omit & { /** Language-switcher data for documentation routes. */ currentLocale?: string; activeLocales?: string[]; + /** Small provenance marker for pages whose body came from a binary snapshot. */ + generatedFromSystemTables?: boolean; + /** Keep the generated-reference explorer stable across client-side page swaps. */ + persistSidebar?: boolean; + /** Version displayed beside the snapshot reference-rail heading. */ + referenceVersion?: string; }; -const { showPageActions = true, title, description, sidebar, headings, breadcrumbs, prevNext, mode = "doc", banner, head = [], searchable, noindex, markdownUrl, socialImage, lastUpdated, editUrl, draft, audience, collection, entryId, lang, alternates, canonicalUrl, sections, currentLocale, activeLocales } = Astro.props; +const { showPageActions = true, title, description, sidebar, headings, breadcrumbs, prevNext, mode = "doc", banner, head = [], searchable, noindex, markdownUrl, socialImage, lastUpdated, editUrl, draft, audience, collection, entryId, lang, alternates, canonicalUrl, sections, currentLocale, activeLocales, generatedFromSystemTables = false, persistSidebar = false, referenceVersion } = Astro.props; // `searchable` derives from `noindex` when omitted: a non-crawlable page is // by default not in the site search either. Explicit `searchable: true` @@ -108,16 +116,24 @@ const messages = uiStrings(currentLocale); {hasSidebar ? ( ) : ( -