diff --git a/package.json b/package.json index b8e85d816a0..cfd06ab0867 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@astrojs/rss": "4.0.18", "@astrojs/sitemap": "3.7.3", "@base-ui/react": "1.5.0", + "@cloudflare/nimbus-docs": "0.7.1", "@cloudflare/vitest-pool-workers": "0.16.15", "@cloudflare/workers-types": "4.20260615.1", "@docsearch/css": "3.9.0", @@ -112,7 +113,6 @@ "mermaid": "11.15.0", "micromark-extension-mdxjs": "3.0.0", "nanostores": "1.3.0", - "@cloudflare/nimbus-docs": "^0.6.1", "node-html-parser": "7.1.0", "openapi-types": "12.1.3", "parse-duration": "2.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d0339d74ec..65197a84e78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@cloudflare/nimbus-docs': https://registry.npmjs.org/@cloudflare/nimbus-docs/-/nimbus-docs-0.7.1.tgz + importers: .: @@ -39,8 +42,8 @@ importers: specifier: 1.5.0 version: 1.5.0(@types/react@19.0.7)(date-fns@4.4.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@cloudflare/nimbus-docs': - specifier: ^0.6.1 - version: 0.6.1(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@25.9.3)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + specifier: https://registry.npmjs.org/@cloudflare/nimbus-docs/-/nimbus-docs-0.7.1.tgz + version: https://registry.npmjs.org/@cloudflare/nimbus-docs/-/nimbus-docs-0.7.1.tgz(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@25.9.3)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@cloudflare/vitest-pool-workers': specifier: 0.16.15 version: 0.16.15(@cloudflare/workers-types@4.20260615.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@types/node@25.9.3)(happy-dom@20.10.3)(vite@8.1.5(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) @@ -779,8 +782,9 @@ packages: resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} - '@cloudflare/nimbus-docs@0.6.1': - resolution: {integrity: sha512-lqbzU/5MOGy5MvLv9uuKtKzBBA+ZQf1Od2svRE/7VLgRgqw5QVLOerNqs2sQ+at0Ipwd30IQgjj03bmVGn3C+Q==} + '@cloudflare/nimbus-docs@https://registry.npmjs.org/@cloudflare/nimbus-docs/-/nimbus-docs-0.7.1.tgz': + resolution: {integrity: sha512-SkjnlaTY1qeE4W28SbRg9mOYTEbczIrfVd0+IseIKDT7RvoR7TjzAQForR4PfIZVeCwoiqkIWqS10KeJ28Q+UQ==, tarball: https://registry.npmjs.org/@cloudflare/nimbus-docs/-/nimbus-docs-0.7.1.tgz} + version: 0.7.1 engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -6884,7 +6888,7 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/nimbus-docs@0.6.1(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@25.9.3)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + '@cloudflare/nimbus-docs@https://registry.npmjs.org/@cloudflare/nimbus-docs/-/nimbus-docs-0.7.1.tgz(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@25.9.3)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@astrojs/markdown-satteri': 0.3.4 '@astrojs/mdx': 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.3(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@25.9.3)(jiti@2.7.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index da1b4da06f8..5ccde3b6b7f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,14 @@ minimumReleaseAgeExclude: # natively (nimbus-docs pulls markdown-satteri 0.3.4, @astrojs/mdx@7 peers # ^0.3.1), so the old allowedVersions override is obsolete and has been removed. +# Resolve @cloudflare/nimbus-docs from the public npm tarball. It is published to +# public npm, but a machine-global .npmrc rule routes the whole @cloudflare scope +# to the internal registry gateway (which does not mirror it). Pinning the tarball +# URL here bypasses that routing for this one package, leaving the scope rule (and +# every other @cloudflare/* dependency) untouched. Bump this URL on each release. +overrides: + "@cloudflare/nimbus-docs": "https://registry.npmjs.org/@cloudflare/nimbus-docs/-/nimbus-docs-0.7.1.tgz" + # Prevent transitive dependencies from using exotic sources (git repos, direct tarball URLs). # Only direct dependencies may use exotic sources. blockExoticSubdeps: true diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro index 4a11d2d8c35..83e79ee19e5 100644 --- a/src/pages/[...slug].astro +++ b/src/pages/[...slug].astro @@ -14,13 +14,24 @@ import { config } from "virtual:nimbus/config"; import { docsSidebarTransform, getCfBreadcrumbs } from "../util/sidebar"; import { components } from "../mdx-components"; import { getOgImage } from "~/util/og"; +import { + pageHasRuntimeHeadings, + scrapeRenderedHeadings, +} from "../util/rendered-toc"; const NOINDEX_PRODUCTS = ["email-security"]; export const prerender = true; export const getStaticPaths = getDocsStaticPaths; -const { entry, Content, headings } = await getDocsPageProps(Astro); +const { entry, Content, headings } = await getDocsPageProps(Astro, { + partialHeadings: { + resolvePartialId: ({ file, product }) => { + if (!file) return undefined; + return product ? `${product}/${file}` : file; + }, + }, +}); const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; const sectionSegment = currentSlug.split("/").filter(Boolean)[0]; @@ -66,7 +77,20 @@ const editUrl = await getEditUrl(entry); // Frontmatter wins; git is the fallback. const lastUpdated = entry.data.lastUpdated ?? (await getLastUpdated(entry)); const tocConfig = entry.data.tableOfContents; -const tocHeadings = headings.filter((h) => h.slug !== "footnote-label"); +// Runtime (set:html) headings are missing from compile-time `headings`. +let tocSource = headings; +if ( + tocOn && + tocConfig !== false && + (await pageHasRuntimeHeadings(entry.body ?? "")) +) { + try { + tocSource = await scrapeRenderedHeadings(Content, components); + } catch (err) { + console.error(`[toc] rendered-heading scrape failed for ${entry.id}:`, err); + } +} +const tocHeadings = tocSource.filter((h) => h.slug !== "footnote-label"); const toc = tocOn && tocConfig !== false ? getTOC(tocHeadings, tocConfig) : false; const markdownPath = `/${entry.id}/index.md`; diff --git a/src/util/rendered-toc.ts b/src/util/rendered-toc.ts new file mode 100644 index 00000000000..bf281fda2fa --- /dev/null +++ b/src/util/rendered-toc.ts @@ -0,0 +1,76 @@ +import { getCollection } from "astro:content"; +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +import { loadRenderers } from "astro:container"; +import { getContainerRenderer as getMdxRenderer } from "@astrojs/mdx"; +import { getContainerRenderer as getReactRenderer } from "@astrojs/react"; +import { getHeadingsFromHtml, type Heading } from "@cloudflare/nimbus-docs"; +import type { AstroComponentFactory } from "astro/runtime/server/index.js"; + +// AnchorHeading emits its at runtime via set:html, so those headings are +// absent from compile-time `render().headings`. Pages using it (directly or +// through a partial) must read headings from rendered HTML instead. +const RENDER_MARKER = "AnchorHeading"; + +function resolvePartialId(file?: string, product?: string): string | undefined { + if (!file) return undefined; + return product ? `${product}/${file}` : file; +} + +function renderRefs(body: string): string[] { + const ids: string[] = []; + for (const match of body.matchAll(/]*>/g)) { + const tag = match[0]; + const file = /\bfile=["']([^"']+)["']/.exec(tag)?.[1]; + const product = /\bproduct=["']([^"']+)["']/.exec(tag)?.[1]; + const id = resolvePartialId(file, product); + if (id) ids.push(id); + } + return ids; +} + +let dynamicPartials: Promise> | undefined; +async function computeDynamicPartials(): Promise> { + const bodies = new Map(); + for (const partial of await getCollection("partials")) { + bodies.set(partial.id, partial.body ?? ""); + } + + const dynamic = new Set(); + for (const [id, body] of bodies) { + if (body.includes(RENDER_MARKER)) dynamic.add(id); + } + let changed = true; + while (changed) { + changed = false; + for (const [id, body] of bodies) { + if (dynamic.has(id)) continue; + if (renderRefs(body).some((ref) => dynamic.has(ref))) { + dynamic.add(id); + changed = true; + } + } + } + return dynamic; +} + +export async function pageHasRuntimeHeadings(body: string): Promise { + if (!body) return false; + if (body.includes(RENDER_MARKER)) return true; + const dynamic = await (dynamicPartials ??= computeDynamicPartials()); + return renderRefs(body).some((ref) => dynamic.has(ref)); +} + +let containerPromise: Promise | undefined; +export async function scrapeRenderedHeadings( + Content: AstroComponentFactory, + components: Record, +): Promise { + containerPromise ??= loadRenderers([ + getMdxRenderer(), + getReactRenderer(), + ]).then((renderers) => AstroContainer.create({ renderers })); + const html = await ( + await containerPromise + ).renderToString(Content, { props: { components } }); + return getHeadingsFromHtml(html); +}