diff --git a/.changeset/honest-published-paths.md b/.changeset/honest-published-paths.md new file mode 100644 index 00000000..3f9f941b --- /dev/null +++ b/.changeset/honest-published-paths.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/nimbus-docs": patch +--- + +Harden registry resolution and diagnostics, follow local imported Nimbus configs during checks, reject stale route manifests, report partial Cloudflare server setup, and preserve authored API union titles. diff --git a/.changeset/safe-empty-scaffolds.md b/.changeset/safe-empty-scaffolds.md new file mode 100644 index 00000000..9dc0783a --- /dev/null +++ b/.changeset/safe-empty-scaffolds.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/create-nimbus-docs": patch +--- + +Support transactional scaffolding into an empty current directory and refresh generated project guidance and Nimbus build-output ignores. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 691d5a17..811c4cac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,11 @@ name: CI -# Red/green signal on every PR: typecheck (whole workspace) + test + audit. on: pull_request: branches: [main] push: branches: [main] -# Default-deny; each job opts back into the minimum it needs. permissions: {} concurrency: @@ -32,7 +30,6 @@ jobs: - run: pnpm install --frozen-lockfile - # Root script builds nimbus-docs, then runs `pnpm -r typecheck`. - run: pnpm typecheck test: @@ -94,9 +91,6 @@ jobs: - run: pnpm --filter @nimbus/www generate-registry - # Registry payloads must import the scoped package. A bare - # `grep 'from "nimbus-docs'` false-passes because the JSON escapes the - # quote — match the escaped form / parse `.files[].content`. - name: Assert no unscoped nimbus-docs imports run: | hits=$(grep -rl 'from \\"nimbus-docs' apps/www/public/registry/components || true) @@ -121,13 +115,8 @@ jobs: node-version: 24 cache: pnpm - # `pnpm audit` needs the resolved tree to populate advisory paths; without - # an install it returns advisories with empty `paths` and the fail-closed - # shape guard rejects the run. - run: pnpm install --frozen-lockfile - # Fail closed: a non-JSON or unsupported audit response means this gate - # cannot classify published-package risk reliably. - name: Audit published package prod deps run: pnpm audit:published-prod diff --git a/.github/workflows/templates.yml b/.github/workflows/templates.yml index 8122ffc4..7d6e032b 100644 --- a/.github/workflows/templates.yml +++ b/.github/workflows/templates.yml @@ -11,6 +11,7 @@ on: - "packages/create-nimbus-docs/**" - "packages/nimbus-docs/**" - "scripts/templates-check.mjs" + - "package.json" - ".github/workflows/templates.yml" pull_request: branches: [main] @@ -19,6 +20,7 @@ on: - "packages/create-nimbus-docs/**" - "packages/nimbus-docs/**" - "scripts/templates-check.mjs" + - "package.json" - ".github/workflows/templates.yml" permissions: @@ -47,34 +49,3 @@ jobs: env: TEMPLATES_CHECK_LANE: ${{ matrix.lane }} run: node scripts/templates-check.mjs - - # Scaffold + install a variant under pnpm@latest, so a build-scripts gate - # regression fails a PR instead of a user. The monorepo's pin to pnpm 9 - # predates the gate, so the pnpm-9 checks above can't catch it. - templates-modern-pnpm: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - lane: [static, cloudflare] - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24 - cache: pnpm - - - run: pnpm install --frozen-lockfile - - - name: Enable corepack (for pnpm@latest) - run: corepack enable - - - name: Scaffold + install + build a template under pnpm@latest - env: - SCAFFOLD_PNPM: pnpm@latest - TEMPLATES_CHECK_LANE: ${{ matrix.lane }} - COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" - run: node scripts/templates-check.mjs diff --git a/.npmrc b/.npmrc deleted file mode 100644 index bfb08bd0..00000000 --- a/.npmrc +++ /dev/null @@ -1,12 +0,0 @@ -# Pin npm-style pre/post lifecycle hooks ON. Under this repo's toolchain -# (pnpm 9 and 10) they already run by DEFAULT — verified: `pnpm run deploy` -# fires `predeploy` with no .npmrc present, and only `enable-pre-post-scripts=false` -# suppresses it. This line is therefore defensive, not corrective: it guarantees -# the behavior against a global `enable-pre-post-scripts=false` or a future -# default flip. What relies on it: apps/www#predeploy — the pre-hook of the -# custom `deploy` script builds nimbus-docs before `astro build`, so a standalone -# `pnpm --filter @nimbus/www deploy` ships the registry files. If a hook ever -# silently no-ops, a Cloudflare deploy goes out without the registry and -# `nimbus-docs add ` 404s. (npm-native hooks like prepack/prepare run -# regardless of this setting.) -enable-pre-post-scripts=true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ad796cf..9e0a1e9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ Before you open a PR: ### Local development -Requires **Node ≥ 22.12.0** and **pnpm 9** (pinned via `packageManager`, so Corepack fetches it for you). +Requires **Node ≥ 22.12.0** and **pnpm 11**, pinned via `packageManager`. On Node 22–24, enable Corepack; on Node 25+, install Corepack or pnpm separately. ```sh pnpm install diff --git a/apps/www/registry/features/api-reference.md b/apps/www/registry/features/api-reference.md index 3568d233..1fdc7ba2 100644 --- a/apps/www/registry/features/api-reference.md +++ b/apps/www/registry/features/api-reference.md @@ -77,6 +77,9 @@ Print a short, exact plan to the user **before** writing anything: so `src/content.config.ts` can derive the collection from the same list — no second spec declaration. - Create `src/pages/api/[...slug].astro` and `src/pages/api/[...slug]/index.md.ts`. +- Add desktop product navigation to `src/components/Header.astro`; keep it out of + mobile navigation for now so the existing sidebar button remains the only + mobile menu. - Resulting URLs: `/api` (overview), `/api/` (each page), the matching `/api//index.md` twins, and `/api/llms.txt`. @@ -150,8 +153,9 @@ import nimbusConfig from "./nimbus.config"; `spec` is the path from Q1, resolved from the project root (not the current working directory — builds from a monorepo root or `--root` resolve correctly). `spec` may also be an inline OpenAPI object. Add a `label` for a friendlier name -in build diagnostics; it defaults to the collection name. To mount more than one -spec, add more entries to the array and explicitly register each one in 4c. +in navigation and build diagnostics; it defaults to the collection name. To +mount more than one spec, add more entries to the array and explicitly register +each one in 4c. > **Why the `/config` entry?** `@cloudflare/nimbus-docs/config` exports only the > identity `defineConfig` with no side effects, so a `nimbus.config.ts` imported @@ -238,7 +242,9 @@ export async function GET({ props }: { props: SlugProps }) { const { item } = props; const { title, description, markdownUrl, sourceUrl, version } = item; - const markdown = await renderIndexedEntryMarkdown(item); + const markdown = await renderIndexedEntryMarkdown(item, { + base: import.meta.env.BASE_URL, + }); const body = [ "---", @@ -312,10 +318,11 @@ const result = await getApiRoute(Astro); if (result instanceof Response) return result; const { page, nav, collection, version, coordinate } = result; const socialImage = `/og${page.href.replace(/\/$/, "")}.png`; +const title = page.kind === "api" ? page.title : `${page.title} · API`; --- +```ts +import { + getProductSections, + getSidebarSections, +} from "@cloudflare/nimbus-docs/runtime"; +``` + +After the existing `sections` derivation, derive the product links. Preserve the +Header's explicit `sections` override as a complete automatic-navigation bypass: + + +```ts +const productSections = + sectionsProp === undefined + ? await getProductSections(currentSlug, { + collection, + base: import.meta.env.BASE_URL, + }) + : []; +const showProductSections = productSections.length >= 2; +``` + +Inside the Header's left-hand group, after the brand and before the prose +`Sections` nav, render: + + +```astro +{showProductSections && ( + +)} +``` + +Keep this nav desktop-only. Do not add another mobile trigger; the existing +sidebar hamburger remains the sole mobile menu until product switching has a +single consolidated mobile design. + ## 5. Optional — add to the sidebar Sidebar layout is taste-laden; ask before editing. If the user wants an "API" @@ -354,6 +423,8 @@ After writing all files, run the user's build command and confirm: 4. `dist/api/llms.txt` lists every API page, and the root `dist/llms.txt` includes `api` as a top-level section. 5. `dist/llms-full.txt` (if the site emits a corpus) embeds the API markdown. +6. The desktop Header shows separate Docs and API product links; both are hidden + on mobile, where only the existing sidebar hamburger remains. Then tell the user the URLs to visit: `/api`, `/api/`, `/api//index.md`, `/api/llms.txt`. diff --git a/apps/www/registry/features/changelog.md b/apps/www/registry/features/changelog.md index 9fa4241c..891edf28 100644 --- a/apps/www/registry/features/changelog.md +++ b/apps/www/registry/features/changelog.md @@ -977,7 +977,7 @@ export async function GET({ props }: { props: SlugProps }) { const socialImage = typeof rawImage === "string" && rawImage.length > 0 ? rawImage : config.socialImage; - const markdown = renderEntryAsMarkdown(entry); + const markdown = renderEntryAsMarkdown(entry, { base: import.meta.env.BASE_URL }); const body = [ "---", diff --git a/apps/www/registry/features/new-collection.md b/apps/www/registry/features/new-collection.md index da2a515e..1c7ec5b8 100644 --- a/apps/www/registry/features/new-collection.md +++ b/apps/www/registry/features/new-collection.md @@ -317,7 +317,7 @@ export async function GET({ props }: { props: SlugProps }) { ? rawImage : config.socialImage; - const markdown = renderEntryAsMarkdown(entry); + const markdown = renderEntryAsMarkdown(entry, { base: import.meta.env.BASE_URL }); const body = [ "---", diff --git a/apps/www/registry/features/new-version.md b/apps/www/registry/features/new-version.md index 4515260a..94e8d5d3 100644 --- a/apps/www/registry/features/new-version.md +++ b/apps/www/registry/features/new-version.md @@ -485,7 +485,7 @@ export async function GET({ props }: { props: SlugProps }) { ? rawImage : config.socialImage; - const markdown = renderEntryAsMarkdown(entry); + const markdown = renderEntryAsMarkdown(entry, { base: import.meta.env.BASE_URL }); const body = [ "---", diff --git a/apps/www/src/components/ui/search/providers/pagefind.ts b/apps/www/src/components/ui/search/providers/pagefind.ts index 934dcbcc..d50ee9f0 100644 --- a/apps/www/src/components/ui/search/providers/pagefind.ts +++ b/apps/www/src/components/ui/search/providers/pagefind.ts @@ -60,8 +60,7 @@ const defaultFilters: PagefindFilters | undefined = export const provider: SearchProvider = { async init() { if (pagefind) return; - const baseUrl = new URL(import.meta.env.BASE_URL ?? "/", window.location.origin); - const pagefindUrl = new URL("pagefind/pagefind.js", baseUrl); + const pagefindUrl = new URL(withBase("/pagefind/pagefind.js"), window.location.origin); pagefind = (await import(/* @vite-ignore */ pagefindUrl.href)) as PagefindApi; await pagefind.init(); }, diff --git a/apps/www/src/pages/[...slug]/index.md.ts b/apps/www/src/pages/[...slug]/index.md.ts index 9b416c92..e6cf5c5f 100644 --- a/apps/www/src/pages/[...slug]/index.md.ts +++ b/apps/www/src/pages/[...slug]/index.md.ts @@ -53,7 +53,7 @@ export async function GET({ props }: { props: SlugProps }) { ? rawImage : config.socialImage; - const markdown = renderEntryAsMarkdown(entry); + const markdown = renderEntryAsMarkdown(entry, { base: import.meta.env.BASE_URL }); const body = [ "---", diff --git a/package.json b/package.json index 45e11c6b..6bd613ef 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "packageManager": "pnpm@9.0.0", + "packageManager": "pnpm@11.25.0", "scripts": { "build": "pnpm -r build", "test": "pnpm -r test", diff --git a/packages/create-nimbus-docs/src/scaffold.ts b/packages/create-nimbus-docs/src/scaffold.ts index c94f05f8..1cfef71f 100644 --- a/packages/create-nimbus-docs/src/scaffold.ts +++ b/packages/create-nimbus-docs/src/scaffold.ts @@ -1,16 +1,30 @@ import * as p from "@clack/prompts"; import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { cpSync, existsSync, + linkSync, lstatSync, + mkdirSync, + mkdtempSync, realpathSync, readdirSync, renameSync, + rmdirSync, rmSync, + readFileSync, writeFileSync, } from "node:fs"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + basename as pathBasename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; import { fileURLToPath } from "node:url"; import { downloadTemplate } from "giget"; import type { AdapterId } from "@cloudflare/nimbus-docs/adapters"; @@ -158,6 +172,9 @@ export interface ScaffoldInternals { * giget (network) or the local `--template-dir` copy. */ fetchTemplate?: (target: string, options: ScaffoldOptions) => Promise; + beforeCommit?: () => void; + beforeCommitEntry?: (entry: string, index: number) => void; + afterCommitEntry?: (entry: string, index: number) => void; } interface PreviewProvenance { @@ -171,6 +188,7 @@ export async function scaffold( ) { const { dir, packageManager, git, skipInstall } = options; const cwd = internals.cwd ?? process.cwd(); + const scaffoldInCwd = dir === "." || dir === "./"; // Validate everything up front — before the spinner starts and before any // filesystem writes — so a bad target fails fast and clean. @@ -194,36 +212,44 @@ export async function scaffold( `Pick a path inside ${cwd}.`, ); } - if (target === cwd) { + if (target === cwd && !scaffoldInCwd) { throw new ScaffoldError( `Directory "${dir}" resolves to the current directory. Choose a new subdirectory name.`, ); } const canonicalCwd = realpathSync(cwd); - const existingParent = closestExistingPath(dirname(target)); - let canonicalParent: string; - try { - canonicalParent = realpathSync(existingParent); - } catch { + if (scaffoldInCwd && readdirSync(canonicalCwd).length > 0) { throw new ScaffoldError( - `Directory "${dir}" passes through a dangling symlink. Pick a path inside ${cwd}.`, - ); - } - if (!lstatSync(canonicalParent).isDirectory()) { - throw new ScaffoldError( - `Directory "${dir}" passes through a non-directory path at ${existingParent}.`, - ); - } - if (!isContainedBy(canonicalCwd, canonicalParent)) { - throw new ScaffoldError( - `Directory "${dir}" resolves outside the current directory through ${existingParent}. ` + - `Pick a path inside ${cwd}.`, + `Current directory ${canonicalCwd} is not empty. Use "." only in an empty directory.`, ); } - if (pathEntryExists(target)) { - throw new ScaffoldError(`Directory "${dir}" already exists.`); + if (!scaffoldInCwd) { + const existingParent = closestExistingPath(dirname(target)); + let canonicalParent: string; + try { + canonicalParent = realpathSync(existingParent); + } catch { + throw new ScaffoldError( + `Directory "${dir}" passes through a dangling symlink. Pick a path inside ${cwd}.`, + ); + } + if (!lstatSync(canonicalParent).isDirectory()) { + throw new ScaffoldError( + `Directory "${dir}" passes through a non-directory path at ${existingParent}.`, + ); + } + if (!isContainedBy(canonicalCwd, canonicalParent)) { + throw new ScaffoldError( + `Directory "${dir}" resolves outside the current directory through ${existingParent}. ` + + `Pick a path inside ${cwd}.`, + ); + } + + if (pathEntryExists(target)) { + throw new ScaffoldError(`Directory "${dir}" already exists.`); + } } const fetchTemplate = @@ -233,6 +259,8 @@ export async function scaffold( const preview = previewProvenance(internals); const s = p.spinner(); + let workTarget = target; + let staging: string | undefined; // Fetch + transform. If anything throws mid-way (network, EACCES, disk full, // a malformed template package.json), roll back the partial target dir — we @@ -241,40 +269,59 @@ export async function scaffold( // blocks re-running (the existence check above hard-fails on it). s.start("Fetching template…"); try { - await fetchTemplate(target, options); - assertNoTemplateSymlinks(target); + if (scaffoldInCwd) { + staging = mkdtempSync( + join(dirname(canonicalCwd), `.${pathBasename(canonicalCwd)}-nimbus-`), + ); + workTarget = staging; + } + await fetchTemplate(workTarget, options); + assertNoTemplateSymlinks(workTarget); s.stop("Template ready."); s.start("Configuring project…"); - normalizePackageManagerFiles(target, packageManager); + normalizePackageManagerFiles(workTarget, packageManager); + const projectName = scaffoldInCwd + ? pathBasename(canonicalCwd) + : basename(dir); if (options.output === "server") { - await applyAdapter(target, options.adapter); - writeServerWrangler(target, options.adapter); - appendAdapterIgnoreEntries(target, options.adapter); + await applyAdapter(workTarget, options.adapter); + writeServerWrangler(workTarget, options.adapter); + appendAdapterIgnoreEntries(workTarget, options.adapter); if (options.adapter === "cloudflare") { // wrangler (added by updatePackageJson) pulls workerd; decline its // build script so pnpm install doesn't trip the build-scripts gate — // same as the static Cloudflare path. - declineBuildScript(target, "workerd"); + declineBuildScript(workTarget, "workerd"); } - await updatePackageJson(target, { - name: basename(dir), + await updatePackageJson(workTarget, { + name: projectName, output: "server", adapter: options.adapter, }); } else { - await applyDeployTarget(target, options.deploy); - await updatePackageJson(target, { - name: basename(dir), + await applyDeployTarget(workTarget, options.deploy); + await updatePackageJson(workTarget, { + name: projectName, output: "static", deploy: options.deploy, }); } - writeNimbusJson(target, options, preview); + writeNimbusJson(workTarget, options, preview); + if (scaffoldInCwd) { + commitStagedProject(workTarget, canonicalCwd, internals); + rmSync(workTarget, { recursive: true, force: true }); + staging = undefined; + workTarget = canonicalCwd; + } s.stop("Project configured."); } catch (err) { s.stop("Failed."); - rmSync(target, { recursive: true, force: true }); + if (scaffoldInCwd) { + if (staging) rmSync(staging, { recursive: true, force: true }); + } else { + rmSync(target, { recursive: true, force: true }); + } // A ScaffoldError already carries an actionable message (missing tag, // offline, rate-limited, bad --template-dir). Pass it through untouched; // only wrap genuinely unexpected failures. @@ -285,11 +332,13 @@ export async function scaffold( ); } + const commandTarget = scaffoldInCwd ? canonicalCwd : target; + // 3. Git init if (git) { s.start("Initializing git repository…"); try { - await runCommand("git", ["init"], target); + await runCommand("git", ["init"], commandTarget); s.stop("Git repository initialized."); } catch { s.stop("Skipped git initialization."); @@ -307,7 +356,7 @@ export async function scaffold( try { const cmd = packageManager === "yarn" ? "yarn" : `${packageManager} install`; const [bin = packageManager, ...args] = cmd.split(" "); - await runCommand(bin, args, target); + await runCommand(bin, args, commandTarget); s.stop("Dependencies installed."); } catch { s.stop("Failed to install dependencies."); @@ -317,6 +366,196 @@ export async function scaffold( } } +interface OwnedPath { + path: string; + dev: number; + ino: number; + directory: boolean; + mode: number; + mtimeMs: number; + digest?: string; +} + +interface CommitLedgerEntry { + name: string; + root?: OwnedPath; + paths: OwnedPath[]; +} + +function commitStagedProject( + staging: string, + cwd: string, + internals: ScaffoldInternals, +): void { + const entries = readdirSync(staging); + const ledger: CommitLedgerEntry[] = []; + + try { + internals.beforeCommit?.(); + assertNoForeignEntries(cwd, ledger); + + for (const [index, entry] of entries.entries()) { + assertNoForeignEntries(cwd, ledger); + internals.beforeCommitEntry?.(entry, index); + const committed: CommitLedgerEntry = { name: entry, paths: [] }; + ledger.push(committed); + installStagedPath( + join(staging, entry), + join(cwd, entry), + committed.paths, + ); + committed.root = committed.paths[0]; + internals.afterCommitEntry?.(entry, index); + } + + assertNoForeignEntries(cwd, ledger); + } catch (err) { + rollbackCommit(ledger); + throw err; + } +} + +function assertNoForeignEntries( + cwd: string, + ledger: CommitLedgerEntry[], +): void { + const owned = new Map( + ledger.flatMap((entry) => (entry.root ? [[entry.name, entry.root]] : [])), + ); + const names = readdirSync(cwd); + for (const name of names) { + const expected = owned.get(name); + if (!expected || !isUnchangedEntry(join(cwd, name), expected)) { + throw new ScaffoldError( + `Current directory changed while the project was being prepared. Preserved concurrent entry "${name}" and aborted.`, + ); + } + } + for (const name of owned.keys()) { + if (!names.includes(name)) { + throw new ScaffoldError( + `Current directory changed while the project was being prepared. Entry "${name}" was removed, so the scaffold was aborted.`, + ); + } + } + for (const entry of ledger) { + if ( + entry.paths.some((ownedPath) => + !isUnchangedEntry(ownedPath.path, ownedPath), + ) + ) { + throw new ScaffoldError( + `Current directory changed while the project was being prepared. Preserved concurrent changes under "${entry.name}" and aborted.`, + ); + } + } +} + +function installStagedPath( + source: string, + destination: string, + ledger: OwnedPath[], +): void { + const sourceStat = lstatSync(source); + if (sourceStat.isDirectory()) { + mkdirSync(destination); + const ledgerIndex = ledger.push(snapshotOwnedPath(destination)) - 1; + for (const entry of readdirSync(source)) { + installStagedPath(join(source, entry), join(destination, entry), ledger); + } + ledger[ledgerIndex] = snapshotOwnedPath(destination); + return; + } + linkSync(source, destination); + ledger.push(snapshotOwnedPath(destination)); +} + +function snapshotOwnedPath(path: string): OwnedPath { + const stat = lstatSync(path); + const directory = stat.isDirectory(); + return { + path, + dev: stat.dev, + ino: stat.ino, + directory, + mode: stat.mode, + mtimeMs: stat.mtimeMs, + ...(directory + ? {} + : { + digest: createHash("sha256").update(readFileSync(path)).digest("hex"), + }), + }; +} + +function rollbackCommit(ledger: CommitLedgerEntry[]): void { + for (const entry of [...ledger].reverse()) { + for (const owned of [...entry.paths].reverse()) { + if (owned.directory) { + if (!isOwnedDirectory(owned.path, owned)) continue; + } else if (!isUnchangedEntry(owned.path, owned)) { + continue; + } + if (!owned.directory) { + rmSync(owned.path, { force: true }); + continue; + } + try { + rmdirSync(owned.path); + } catch (err) { + if ( + !["ENOENT", "ENOTEMPTY"].includes( + (err as NodeJS.ErrnoException).code ?? "", + ) + ) { + throw err; + } + } + } + } +} + +function isOwnedDirectory(path: string, expected: OwnedPath): boolean { + try { + const stat = lstatSync(path); + return ( + stat.isDirectory() && + stat.dev === expected.dev && + stat.ino === expected.ino && + stat.mode === expected.mode + ); + } catch (err) { + if (["ENOENT", "ENOTDIR"].includes((err as NodeJS.ErrnoException).code ?? "")) { + return false; + } + throw err; + } +} + +function isUnchangedEntry(path: string, expected: OwnedPath): boolean { + try { + const stat = lstatSync(path); + if ( + stat.dev !== expected.dev || + stat.ino !== expected.ino || + stat.mode !== expected.mode || + stat.mtimeMs !== expected.mtimeMs + ) { + return false; + } + return ( + expected.directory || + createHash("sha256").update(readFileSync(path)).digest("hex") === + expected.digest + ); + } catch (err) { + if (["ENOENT", "ENOTDIR"].includes((err as NodeJS.ErrnoException).code ?? "")) { + return false; + } + throw err; + } +} + function assertNoTemplateSymlinks(target: string): void { const visit = (path: string): void => { const stat = lstatSync(path); @@ -337,7 +576,9 @@ function pathEntryExists(path: string): boolean { lstatSync(path); return true; } catch (err) { - if (["ENOENT", "ENOTDIR"].includes((err as NodeJS.ErrnoException).code ?? "")) { + if ( + ["ENOENT", "ENOTDIR"].includes((err as NodeJS.ErrnoException).code ?? "") + ) { return false; } throw err; diff --git a/packages/create-nimbus-docs/test/generate-templates.test.ts b/packages/create-nimbus-docs/test/generate-templates.test.ts index 54ad908f..209a9ecf 100644 --- a/packages/create-nimbus-docs/test/generate-templates.test.ts +++ b/packages/create-nimbus-docs/test/generate-templates.test.ts @@ -31,6 +31,14 @@ test("every generated variant ships the adapter marker and implicit build defaul assert.doesNotMatch(cfg, /nimbus:rendering/); assert.doesNotMatch(cfg, /rendering:\s*\{\s*default:\s*["']request["']/); assert.doesNotMatch(cfg, /rendering:\s*\{/); + assert.match( + fs.readFileSync(path.join(dir, "gitignore"), "utf8"), + /^\.nimbus\/$/m, + ); + const agent = fs.readFileSync(path.join(dir, "AGENT.md"), "utf8"); + assert.match(agent, /@cloudflare\/nimbus-docs\/components\/Icon\.astro/); + assert.doesNotMatch(agent, /astro-icon\/components/); + assert.match(agent, /`nimbus-docs` CLI/); } } finally { fs.rmSync(out, { recursive: true, force: true }); diff --git a/packages/create-nimbus-docs/test/scaffold.test.ts b/packages/create-nimbus-docs/test/scaffold.test.ts index 7c8ca80f..cbb319f7 100644 --- a/packages/create-nimbus-docs/test/scaffold.test.ts +++ b/packages/create-nimbus-docs/test/scaffold.test.ts @@ -101,6 +101,13 @@ function cleanup(...dirs: string[]) { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); } +function stagingEntries(cwd: string): string[] { + const prefix = `.${path.basename(fs.realpathSync(cwd))}-nimbus-`; + return fs + .readdirSync(path.dirname(cwd)) + .filter((entry) => entry.startsWith(prefix)); +} + test("happy path writes and transforms the project", async () => { const cwd = makeCwd(); const tmpl = makeTemplate(); @@ -139,6 +146,268 @@ test("happy path writes and transforms the project", async () => { } }); +test("scaffolds . and ./ through a sibling staging directory", async (t) => { + for (const dir of [".", "./"]) { + await t.test(dir, async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + let staging = ""; + try { + await scaffold( + { ...BASE_OPTIONS, dir }, + { + cwd, + fetchTemplate: async (target) => { + staging = target; + fs.cpSync(tmpl, target, { recursive: true }); + }, + beforeCommit: () => { + assert.deepEqual(fs.readdirSync(cwd), []); + const stagedPkg = JSON.parse( + fs.readFileSync(path.join(staging, "package.json"), "utf8"), + ); + assert.equal( + stagedPkg.name, + path.basename(fs.realpathSync(cwd)).toLowerCase(), + ); + }, + }, + ); + + const pkg = JSON.parse( + fs.readFileSync(path.join(cwd, "package.json"), "utf8"), + ); + assert.notEqual(staging, cwd); + assert.equal(path.dirname(staging), path.dirname(fs.realpathSync(cwd))); + assert.equal( + pkg.name, + path.basename(fs.realpathSync(cwd)).toLowerCase(), + ); + assert.deepEqual(stagingEntries(cwd), []); + } finally { + cleanup(cwd, tmpl); + } + }); + } +}); + +test("rejects . in a non-empty cwd before fetching", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + fs.writeFileSync(path.join(cwd, "keep.txt"), "precious"); + let fetched = false; + try { + await assert.rejects( + scaffold( + { ...BASE_OPTIONS, dir: "." }, + { + cwd, + fetchTemplate: async () => { + fetched = true; + }, + }, + ), + (err: unknown) => + err instanceof ScaffoldError && /not empty/.test(err.message), + ); + assert.equal(fetched, false); + assert.equal( + fs.readFileSync(path.join(cwd, "keep.txt"), "utf8"), + "precious", + ); + } finally { + cleanup(cwd, tmpl); + } +}); + +test("rolls back staging when cwd commit fails before its final check", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + try { + await assert.rejects( + scaffold( + { ...BASE_OPTIONS, dir: "." }, + { + ...internals(cwd, tmpl), + beforeCommit: () => { + throw new Error("injected before commit"); + }, + }, + ), + (err: unknown) => + err instanceof ScaffoldError && + /injected before commit/.test(err.message), + ); + assert.deepEqual(fs.readdirSync(cwd), []); + assert.deepEqual(stagingEntries(cwd), []); + } finally { + cleanup(cwd, tmpl); + } +}); + +test("rolls back ledger-owned entries when cwd commit fails mid-rename", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + try { + await assert.rejects( + scaffold( + { ...BASE_OPTIONS, dir: "." }, + { + ...internals(cwd, tmpl), + afterCommitEntry: () => { + throw new Error("injected during commit"); + }, + }, + ), + (err: unknown) => + err instanceof ScaffoldError && + /injected during commit/.test(err.message), + ); + assert.deepEqual(fs.readdirSync(cwd), []); + } finally { + cleanup(cwd, tmpl); + } +}); + +test("rollback removes a populated scaffold-owned directory", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + fs.mkdirSync(path.join(tmpl, "src")); + fs.writeFileSync(path.join(tmpl, "src/index.ts"), "export {};\n"); + try { + await assert.rejects( + scaffold( + { ...BASE_OPTIONS, dir: "." }, + { + ...internals(cwd, tmpl), + afterCommitEntry: (entry) => { + if (fs.lstatSync(path.join(cwd, entry)).isDirectory()) { + throw new Error("abort after directory commit"); + } + }, + }, + ), + (err: unknown) => + err instanceof ScaffoldError && /directory commit/.test(err.message), + ); + assert.deepEqual(fs.readdirSync(cwd), []); + } finally { + cleanup(cwd, tmpl); + } +}); + +test("aborts cwd commit and preserves an entry created before commit", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + try { + await assert.rejects( + scaffold( + { ...BASE_OPTIONS, dir: "." }, + { + ...internals(cwd, tmpl), + beforeCommit: () => { + fs.writeFileSync(path.join(cwd, "foreign.txt"), "keep me"); + }, + }, + ), + (err: unknown) => + err instanceof ScaffoldError && /changed while/.test(err.message), + ); + assert.deepEqual(fs.readdirSync(cwd), ["foreign.txt"]); + assert.equal( + fs.readFileSync(path.join(cwd, "foreign.txt"), "utf8"), + "keep me", + ); + } finally { + cleanup(cwd, tmpl); + } +}); + +test("preserves a concurrent replacement of a ledger-owned entry", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + let replaced = ""; + try { + await assert.rejects( + scaffold( + { ...BASE_OPTIONS, dir: "." }, + { + ...internals(cwd, tmpl), + afterCommitEntry: (entry) => { + replaced = entry; + fs.rmSync(path.join(cwd, entry), { recursive: true, force: true }); + fs.writeFileSync(path.join(cwd, entry), "foreign replacement"); + }, + }, + ), + (err: unknown) => + err instanceof ScaffoldError && /changed while/.test(err.message), + ); + assert.deepEqual(fs.readdirSync(cwd), [replaced]); + assert.equal( + fs.readFileSync(path.join(cwd, replaced), "utf8"), + "foreign replacement", + ); + } finally { + cleanup(cwd, tmpl); + } +}); + +test("never replaces an entry created immediately before its commit", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + let raced = ""; + try { + await assert.rejects( + scaffold( + { ...BASE_OPTIONS, dir: "." }, + { + ...internals(cwd, tmpl), + beforeCommitEntry: (entry) => { + if (raced) return; + raced = entry; + fs.writeFileSync(path.join(cwd, entry), "foreign entry"); + }, + }, + ), + (err: unknown) => + err instanceof ScaffoldError && /Could not scaffold/.test(err.message), + ); + assert.deepEqual(fs.readdirSync(cwd), [raced]); + assert.equal(fs.readFileSync(path.join(cwd, raced), "utf8"), "foreign entry"); + } finally { + cleanup(cwd, tmpl); + } +}); + +test("rollback preserves an in-place edit to a committed file", async () => { + const cwd = makeCwd(); + const tmpl = makeTemplate(); + let edited = ""; + try { + await assert.rejects( + scaffold( + { ...BASE_OPTIONS, dir: "." }, + { + ...internals(cwd, tmpl), + afterCommitEntry: (entry) => { + const destination = path.join(cwd, entry); + if (edited || !fs.lstatSync(destination).isFile()) return; + edited = entry; + fs.writeFileSync(destination, "concurrent edit"); + throw new Error("abort after concurrent edit"); + }, + }, + ), + (err: unknown) => + err instanceof ScaffoldError && /concurrent edit/.test(err.message), + ); + assert.equal(fs.readFileSync(path.join(cwd, edited), "utf8"), "concurrent edit"); + } finally { + cleanup(cwd, tmpl); + } +}); + test("strips stale .nimbus build output so it never reaches the project", async () => { const cwd = makeCwd(); const tmpl = makeTemplate(); diff --git a/packages/nimbus-docs/src/_internal/api/markdown.ts b/packages/nimbus-docs/src/_internal/api/markdown.ts index c7b5b4ba..65f47c21 100644 --- a/packages/nimbus-docs/src/_internal/api/markdown.ts +++ b/packages/nimbus-docs/src/_internal/api/markdown.ts @@ -21,6 +21,7 @@ import type { ApiUnionView, JsonValue, } from "./view-model.js"; +import { withBase } from "../url.js"; function inlineCode(value: string): string { // Inline code is single-line by nature; a newline would let the block parser @@ -71,11 +72,12 @@ function safeHref(href: string): string { .replace(/`/g, "%60"); } -function link(label: string, href: string): string { +function link(label: string, href: string, base: string): string { // Escape backslash first-class (a trailing `\` would escape the closing `]`), // brackets (break the label span), and backticks (an odd count opens a code // span whose precedence swallows the trailing `](href)`). - return `[${inlineText(label).replace(/[\\`[\]]/g, "\\$&")}](${safeHref(href)})`; + const target = href.startsWith("/") && !href.startsWith("//") ? withBase(href, base) : href; + return `[${inlineText(label).replace(/[\\`[\]]/g, "\\$&")}](${safeHref(target)})`; } /** Neutralize block prose so spec CommonMark can't forge document structure: @@ -96,10 +98,10 @@ function safeBlock(text: string): string { .join("\n"); } -function typeLabel(field: ApiFieldView): string { - if (field.typeRef) return link(field.type, field.typeRef.href); +function typeLabel(field: ApiFieldView, base: string): string { + if (field.typeRef) return link(field.type, field.typeRef.href, base); if (field.typeRefs && field.typeRefs.length > 0) { - return field.typeRefs.map((r) => link(r.label, r.href)).join(" | "); + return field.typeRefs.map((r) => link(r.label, r.href, base)).join(" | "); } return inlineText(field.type); } @@ -130,9 +132,9 @@ function detailParts(d: { return parts; } -function renderField(field: ApiFieldView, depth: number, out: string[]): void { +function renderField(field: ApiFieldView, depth: number, out: string[], base: string): void { const pad = " ".repeat(depth); - const flags: string[] = [typeLabel(field)]; + const flags: string[] = [typeLabel(field, base)]; flags.push(field.required ? "required" : "optional"); if (field.nullable) flags.push("nullable"); if (field.deprecated) flags.push("deprecated"); @@ -141,7 +143,7 @@ function renderField(field: ApiFieldView, depth: number, out: string[]): void { // the corpus. Indentation still conveys nesting; the leaf is the suffix. let head = `${pad}- ${inlineCode(field.coordinate)} (${flags.join(", ")})`; if (field.description) head += ` — ${inlineText(field.description)}`; - if (field.link) head += ` (${link("details", field.link.href)})`; + if (field.link) head += ` (${link("details", field.link.href, base)})`; out.push(head); const detail = detailParts(field); @@ -149,14 +151,14 @@ function renderField(field: ApiFieldView, depth: number, out: string[]): void { if (field.union) { const label = field.union.kind === "oneOf" ? "one of" : "any of"; - out.push(`${pad} - ${label}: ${field.union.variants.map(variantLabel).join(", ")}`); + out.push(`${pad} - ${label}: ${field.union.variants.map((variant) => variantLabel(variant, base)).join(", ")}`); if (field.union.discriminator) { out.push(`${pad} - discriminator: ${inlineCode(field.union.discriminator)}`); } return; } - for (const child of field.children) renderField(child, depth + 1, out); + for (const child of field.children) renderField(child, depth + 1, out, base); if (field.truncated) renderOmitted(field.childCount, field.children.length, out, `${pad} `); } @@ -176,18 +178,18 @@ function renderScalar(scalar: ApiScalarView, out: string[]): void { if (detail.length > 0) out.push(`- ${detail.join("; ")}`, ""); } -function variantLabel(v: { label: string; href?: string }): string { - return v.href ? link(v.label, v.href) : inlineCode(v.label); +function variantLabel(v: { label: string; href?: string }, base: string): string { + return v.href ? link(v.label, v.href, base) : inlineCode(v.label); } -function renderUnion(union: ApiUnionView, out: string[]): void { +function renderUnion(union: ApiUnionView, out: string[], base: string): void { out.push(union.kind === "oneOf" ? "One of:" : "Any of:", ""); - for (const v of union.variants) out.push(`- ${variantLabel(v)}`); + for (const v of union.variants) out.push(`- ${variantLabel(v, base)}`); out.push(""); if (union.discriminator) { out.push(`Discriminator: ${inlineCode(union.discriminator)}`, ""); for (const m of union.mapping ?? []) { - out.push(`- ${inlineCode(m.value)} → ${variantLabel(m.variant)}`); + out.push(`- ${inlineCode(m.value)} → ${variantLabel(m.variant, base)}`); } if (union.mapping && union.mapping.length > 0) out.push(""); } @@ -197,11 +199,12 @@ function renderFieldSection( title: string, fields: ApiFieldView[], out: string[], + base: string, truncated?: { total: number }, ): void { if (fields.length === 0) return; out.push(`## ${title}`, ""); - for (const field of fields) renderField(field, 0, out); + for (const field of fields) renderField(field, 0, out, base); if (truncated) renderOmitted(truncated.total, fields.length, out); out.push(""); } @@ -245,7 +248,7 @@ function renderExample(heading: string, example: ApiExampleView, out: string[]): fenced(isJson ? "json" : "text", body, out); } -function renderResponses(responses: ApiResponseView[], out: string[]): void { +function renderResponses(responses: ApiResponseView[], out: string[], base: string): void { if (responses.length === 0) return; out.push("## Responses", ""); for (const response of responses) { @@ -254,23 +257,23 @@ function renderResponses(responses: ApiResponseView[], out: string[]): void { if (response.example) renderExample("#### Example", response.example, out); if (response.headers && response.headers.length > 0) { out.push("Headers:", ""); - for (const header of response.headers) renderField(header, 0, out); + for (const header of response.headers) renderField(header, 0, out, base); out.push(""); } if (response.bodyUnion) { - renderUnion(response.bodyUnion, out); + renderUnion(response.bodyUnion, out, base); } else { - for (const field of response.fields) renderField(field, 0, out); + for (const field of response.fields) renderField(field, 0, out, base); if (response.truncated) renderOmitted(response.truncated.total, response.fields.length, out); if (response.fields.length > 0) out.push(""); } } } -function renderRefs(title: string, refs: ApiRef[], out: string[]): void { +function renderRefs(title: string, refs: ApiRef[], out: string[], base: string): void { if (refs.length === 0) return; out.push(`## ${title}`, ""); - for (const ref of refs) out.push(`- ${link(ref.label, ref.href)}`); + for (const ref of refs) out.push(`- ${link(ref.label, ref.href, base)}`); out.push(""); } @@ -280,15 +283,19 @@ function renderRefs(title: string, refs: ApiRef[], out: string[]): void { * timestamps or build metadata. Consumes only the frozen view-model, never the * spine, and never the parser. Every page emits a non-empty body. */ -export function renderApiPageMarkdown(props: ApiPageProps): string { +export function renderApiPageMarkdown( + props: ApiPageProps, + options: { base?: string } = {}, +): string { const out: string[] = []; + const base = options.base ?? "/"; if (props.deprecated) { const bits = ["> **Deprecated.**"]; const successor = props.deprecation?.successor; - if (successor) bits.push(`Use ${link(successor.label, successor.href)} instead.`); + if (successor) bits.push(`Use ${link(successor.label, successor.href, base)} instead.`); if (props.deprecation?.migrationHref) { - bits.push(`See the ${link("migration guide", props.deprecation.migrationHref)}.`); + bits.push(`See the ${link("migration guide", props.deprecation.migrationHref, base)}.`); } out.push(bits.join(" "), ""); } @@ -304,7 +311,7 @@ export function renderApiPageMarkdown(props: ApiPageProps): string { if (props.description) out.push(safeBlock(props.description), ""); renderAuth(props.auth, out); for (const group of props.parameters) { - renderFieldSection(group.label, group.fields, out, group.truncated); + renderFieldSection(group.label, group.fields, out, base, group.truncated); } const hasAdditional = (props.additionalBodies?.length ?? 0) > 0; const bodyHeading = @@ -313,17 +320,17 @@ export function renderApiPageMarkdown(props: ApiPageProps): string { : "Request body"; if (props.bodyUnion) { out.push(`## ${bodyHeading}`, ""); - renderUnion(props.bodyUnion, out); + renderUnion(props.bodyUnion, out, base); } else { - renderFieldSection(bodyHeading, props.body, out, props.bodyTruncated); + renderFieldSection(bodyHeading, props.body, out, base, props.bodyTruncated); } if (props.example) renderExample("## Example request", props.example, out); for (const body of props.additionalBodies ?? []) { out.push(`## Request body (${body.mediaType})`, ""); if (body.union) { - renderUnion(body.union, out); + renderUnion(body.union, out, base); } else { - for (const field of body.fields) renderField(field, 0, out); + for (const field of body.fields) renderField(field, 0, out, base); if (body.truncated) renderOmitted(body.truncated.total, body.fields.length, out); if (body.fields.length > 0) out.push(""); } @@ -336,15 +343,15 @@ export function renderApiPageMarkdown(props: ApiPageProps): string { fenced(sample.lang, sample.source, out); } } - renderResponses(props.responses, out); + renderResponses(props.responses, out, base); break; } case "schema": { if (props.description) out.push(safeBlock(props.description), ""); if (props.scalar) renderScalar(props.scalar, out); - if (props.union) renderUnion(props.union, out); + if (props.union) renderUnion(props.union, out, base); if (props.fields.length > 0) { - renderFieldSection("Fields", props.fields, out, props.truncated); + renderFieldSection("Fields", props.fields, out, base, props.truncated); } else if (!props.description && !props.scalar && !props.union) { out.push("_No fields documented._", ""); } @@ -352,7 +359,7 @@ export function renderApiPageMarkdown(props: ApiPageProps): string { } case "section": { if (props.description) out.push(safeBlock(props.description), ""); - if (props.operations.length > 0) renderRefs("Operations", props.operations, out); + if (props.operations.length > 0) renderRefs("Operations", props.operations, out, base); else if (!props.description) out.push("_No operations._", ""); break; } @@ -364,7 +371,7 @@ export function renderApiPageMarkdown(props: ApiPageProps): string { for (const server of props.servers) out.push(`- ${inlineCode(server)}`); out.push(""); } - if (props.sections.length > 0) renderRefs("Sections", props.sections, out); + if (props.sections.length > 0) renderRefs("Sections", props.sections, out, base); else if (!props.description && props.servers.length === 0) { out.push("_No sections documented._", ""); } diff --git a/packages/nimbus-docs/src/_internal/api/schema-resolver.ts b/packages/nimbus-docs/src/_internal/api/schema-resolver.ts index 623b00c1..bac3bbb3 100644 --- a/packages/nimbus-docs/src/_internal/api/schema-resolver.ts +++ b/packages/nimbus-docs/src/_internal/api/schema-resolver.ts @@ -333,7 +333,8 @@ export class SchemaResolver { ? { label: name, coordinate: schemaCoordinate(name) } : { label: name }; } - return { label: typeLabel(branch) }; + const title = typeof branch.title === "string" ? branch.title.trim() : ""; + return { label: title || typeLabel(branch) }; } private knownSchema(name: string): boolean { diff --git a/packages/nimbus-docs/src/_internal/authored-links.ts b/packages/nimbus-docs/src/_internal/authored-links.ts new file mode 100644 index 00000000..90b3051e --- /dev/null +++ b/packages/nimbus-docs/src/_internal/authored-links.ts @@ -0,0 +1,264 @@ +import path from "node:path"; +import { realpathSync } from "node:fs"; +import { mdxToMdast } from "satteri"; +import { withBase } from "./url.js"; + +interface MdNode { + type?: string; + name?: unknown; + url?: unknown; + children?: unknown; + attributes?: unknown; + position?: { + start?: { offset?: number }; + end?: { offset?: number }; + }; +} + +function destinationOffset(source: string, node: MdNode, offsets: number[]): number | null { + const codePointStart = node.position?.start?.offset; + const codePointEnd = node.position?.end?.offset; + if (typeof codePointStart !== "number" || typeof codePointEnd !== "number") return null; + const start = offsets[codePointStart]; + const end = offsets[codePointEnd]; + if (start === undefined || end === undefined) return null; + + const raw = source.slice(start, end); + let offset = 0; + if (node.type === "link") { + const children = Array.isArray(node.children) ? node.children : []; + const lastChild = children.at(-1) as MdNode | undefined; + const childEnd = lastChild?.position?.end?.offset; + const childUtf16End = typeof childEnd === "number" ? offsets[childEnd] : undefined; + offset = (childUtf16End ?? start) - start; + while (offset < raw.length) { + if (raw[offset] === "]") { + let opening = offset + 1; + while (/\s/.test(raw[opening] ?? "")) opening++; + if (raw[opening] === "(") { + offset = opening + 1; + break; + } + } + offset++; + } + } else { + while (offset < raw.length) { + if (raw[offset] === "]") { + let backslashes = 0; + for (let index = offset - 1; index >= 0 && raw[index] === "\\"; index--) backslashes++; + if (backslashes % 2 === 1) { + offset++; + continue; + } + let colon = offset + 1; + while (/\s/.test(raw[colon] ?? "")) colon++; + if (raw[colon] === ":") { + offset = colon + 1; + break; + } + } + offset++; + } + } + if (offset >= raw.length) return null; + while (/\s/.test(raw[offset] ?? "")) offset++; + if (raw[offset] === "<") offset++; + return start + offset; +} + +function buildOffsetMap(source: string): number[] { + const offsets = [0]; + let index = 0; + while (index < source.length) { + const codePoint = source.codePointAt(index); + index += codePoint !== undefined && codePoint > 0xffff ? 2 : 1; + offsets.push(index); + } + return offsets; +} + +function visit(node: MdNode, callback: (node: MdNode) => void): void { + callback(node); + if (!Array.isArray(node.children)) return; + for (const child of node.children) { + if (child && typeof child === "object") visit(child as MdNode, callback); + } +} + +function expressionEnd( + raw: string, + start: number, + spread: boolean, + nextAttribute: unknown, +): number | null { + let candidate = raw.indexOf("}", start + 1); + let attempts = 0; + while (candidate !== -1 && attempts < 32) { + let next = candidate + 1; + while (/\s/.test(raw[next] ?? "")) next++; + const expected = + nextAttribute && typeof nextAttribute === "object" + ? (nextAttribute as { type?: string; name?: unknown }) + : undefined; + const atAttributeBoundary = expected + ? expected.type === "mdxJsxExpressionAttribute" + ? raw[next] === "{" + : typeof expected.name === "string" && raw.startsWith(expected.name, next) + : raw[next] === "/" || raw[next] === ">"; + if (atAttributeBoundary) { + attempts++; + const expression = raw.slice(start, candidate + 1); + const fixture = spread ? `` : ``; + try { + mdxToMdast(fixture); + return candidate + 1; + } catch { + // Try the next syntactically plausible closing brace. + } + } + candidate = raw.indexOf("}", candidate + 1); + } + return null; +} + +function staticHrefOffsets(raw: string, node: MdNode, base: string): number[] { + if (!Array.isArray(node.attributes)) return []; + const offsets: number[] = []; + const attributes = node.attributes; + let cursor = 1; + while (cursor < raw.length && !/[\s/>]/.test(raw[cursor] ?? "")) cursor++; + + for (const [index, value] of attributes.entries()) { + if (!value || typeof value !== "object") return []; + const attribute = value as { type?: string; name?: unknown; value?: unknown }; + while (/\s/.test(raw[cursor] ?? "")) cursor++; + + if (attribute.type === "mdxJsxExpressionAttribute") { + const end = expressionEnd(raw, cursor, true, attributes[index + 1]); + if (end === null) return []; + cursor = end; + continue; + } + + if (attribute.type !== "mdxJsxAttribute" || typeof attribute.name !== "string") return []; + if (!raw.startsWith(attribute.name, cursor)) return []; + cursor += attribute.name.length; + if (attribute.value === null) continue; + while (/\s/.test(raw[cursor] ?? "")) cursor++; + if (raw[cursor] !== "=") return []; + cursor++; + while (/\s/.test(raw[cursor] ?? "")) cursor++; + + if (typeof attribute.value === "object") { + const end = expressionEnd(raw, cursor, false, attributes[index + 1]); + if (end === null) return []; + cursor = end; + continue; + } + + if (typeof attribute.value !== "string") return []; + const delimiter = raw[cursor]; + if (delimiter !== '"' && delimiter !== "'") return []; + const valueStart = ++cursor; + while (cursor < raw.length) { + if (raw[cursor] === delimiter) { + let backslashes = 0; + for (let index = cursor - 1; index >= valueStart && raw[index] === "\\"; index--) { + backslashes++; + } + if (backslashes % 2 === 0) break; + } + cursor++; + } + if (cursor >= raw.length) return []; + const href = attribute.value; + if ( + (attribute.name === "href" || + (node.name === "a" && attribute.name.toLowerCase() === "href")) && + href.startsWith("/") && + !href.startsWith("//") && + withBase(href, base) !== href + ) { + offsets.push(valueStart); + } + cursor++; + } + return offsets; +} + +function canonicalPath(filePath: string): string { + try { + return realpathSync(filePath); + } catch { + return path.resolve(filePath); + } +} + +export function transformAuthoredLinks(source: string, base: string): string { + const basedRoot = withBase("/", base); + const prefix = basedRoot === "/" ? "" : basedRoot.replace(/\/$/, ""); + if (!prefix) return source; + + let tree: MdNode; + try { + tree = mdxToMdast(source) as MdNode; + } catch { + return source; + } + + const offsets = buildOffsetMap(source); + const insertions = new Set(); + visit(tree, (node) => { + if ( + (node.type === "link" || node.type === "definition") && + typeof node.url === "string" && + node.url.startsWith("/") && + !node.url.startsWith("//") && + withBase(node.url, base) !== node.url + ) { + const offset = destinationOffset(source, node, offsets); + if (offset !== null) insertions.add(offset); + return; + } + + if (node.type !== "mdxJsxFlowElement" && node.type !== "mdxJsxTextElement") return; + const codePointStart = node.position?.start?.offset; + const codePointEnd = node.position?.end?.offset; + if (typeof codePointStart !== "number" || typeof codePointEnd !== "number") return; + const start = offsets[codePointStart]; + const end = offsets[codePointEnd]; + if (start === undefined || end === undefined) return; + const raw = source.slice(start, end); + for (const offset of staticHrefOffsets(raw, node, base)) insertions.add(start + offset); + }); + + let transformed = source; + for (const offset of [...insertions].sort((a, b) => b - a)) { + transformed = `${transformed.slice(0, offset)}${prefix}${transformed.slice(offset)}`; + } + return transformed; +} + +export function authoredLinksPlugin(options: { + base: string; + contentDirs: ReadonlyArray; +}) { + const normalizedDirs = options.contentDirs.map(canonicalPath); + return { + name: "nimbus-docs:authored-links", + enforce: "pre" as const, + transform(code: string, id: string) { + const [pathOnly] = id.split("?", 1); + if (!pathOnly || (!pathOnly.endsWith(".mdx") && !pathOnly.endsWith(".md"))) return null; + const absolute = canonicalPath(pathOnly); + if (absolute.split(path.sep).includes("node_modules")) return null; + const inScope = normalizedDirs.some( + (dir) => absolute === dir || absolute.startsWith(`${dir}${path.sep}`), + ); + if (!inScope) return null; + const transformed = transformAuthoredLinks(code, options.base); + return transformed === code ? null : { code: transformed, map: null }; + }, + }; +} diff --git a/packages/nimbus-docs/src/_internal/parse-nimbus-config.ts b/packages/nimbus-docs/src/_internal/parse-nimbus-config.ts index 29961ab1..261e5b8a 100644 --- a/packages/nimbus-docs/src/_internal/parse-nimbus-config.ts +++ b/packages/nimbus-docs/src/_internal/parse-nimbus-config.ts @@ -19,6 +19,8 @@ const CONFIG_FILENAMES = [ "astro.config.js", ] as const; +const IMPORT_EXTENSIONS = [".ts", ".mts", ".cts", ".mjs", ".cjs", ".js"] as const; + const NIMBUS_PACKAGE = "@cloudflare/nimbus-docs"; export interface FieldSpan { @@ -93,7 +95,15 @@ export function parseNimbusConfig(cwd: string): ConfigParseResult { }; } - const objectStart = locateConfigObject(masked, argText); + let owner = found; + let objectStart = locateConfigObject(masked, argText); + if (objectStart === -1) { + const imported = resolveImportedConfig(cwd, source, masked, argText); + if (imported) { + owner = imported; + objectStart = imported.objectStart; + } + } if (objectStart === -1) { return { ok: false, @@ -102,23 +112,35 @@ export function parseNimbusConfig(cwd: string): ConfigParseResult { file, }; } - const objectEnd = findMatchingBrace(masked, objectStart); + const ownerMasked = owner === found ? masked : maskSource(owner.source); + const objectEnd = findMatchingBrace(ownerMasked, objectStart); if (objectEnd === -1) { return { ok: false, reason: "syntax", - detail: `Unbalanced braces in the config object in ${path.basename(file)}.`, - file, + detail: `Unbalanced braces in the config object in ${path.basename(owner.file)}.`, + file: owner.file, }; } - const { fields, config, unresolved } = readFields(source, masked, objectStart, objectEnd); + const { fields, config, unresolved } = readFields( + owner.source, + ownerMasked, + objectStart, + objectEnd, + ); return { ok: true, config, unresolved, - location: { file, source, objectStart, objectEnd, fields }, + location: { + file: owner.file, + source: owner.source, + objectStart, + objectEnd, + fields, + }, }; } @@ -306,6 +328,267 @@ function findDeclarationValueOffset(masked: string, identifier: string): number return -1; } +interface ImportedConfig { + file: string; + source: string; + objectStart: number; +} + +function resolveImportedConfig( + cwd: string, + source: string, + masked: string, + argText: string, +): ImportedConfig | null { + const identifier = argText.trim(); + if (!/^[A-Za-z_$][\w$]*$/.test(identifier)) return null; + + const specifiers = findExactDefaultImportSpecifiers(source, masked, identifier); + if (specifiers.length !== 1) return null; + const file = resolveProjectImport(cwd, specifiers[0]!); + if (!file) return null; + + let importedSource: string; + try { + importedSource = fs.readFileSync(file, "utf8"); + } catch { + return null; + } + const importedMasked = maskSource(importedSource); + const objectStart = findImportedDefaultObject(importedSource, importedMasked); + return objectStart === -1 ? null : { file, source: importedSource, objectStart }; +} + +function findExactDefaultImportSpecifiers( + source: string, + masked: string, + identifier: string, +): string[] { + const found: string[] = []; + const importPositions = [...masked.matchAll(/\bimport\b/g)].map((match) => match.index!); + const fromRe = /\bfrom\s+(["'])([^"'\\\r\n]+)\1/g; + let match: RegExpExecArray | null; + + while ((match = fromRe.exec(source)) !== null) { + let importIdx = -1; + for (const idx of importPositions) { + if (idx < match.index) importIdx = idx; + else break; + } + if (importIdx === -1) continue; + const clause = masked.slice(importIdx + "import".length, match.index).trim(); + if (/\bimport\b/.test(clause) || clause !== identifier) continue; + found.push(match[2]!); + } + return found; +} + +function resolveProjectImport(cwd: string, specifier: string): string | null { + if (!/^\.\.?\//.test(specifier)) return null; + const root = path.resolve(cwd); + const unresolved = path.resolve(root, specifier); + if (!isContainedPath(root, unresolved)) return null; + + const extension = path.extname(unresolved); + const hasSupportedExtension = IMPORT_EXTENSIONS.includes( + extension as (typeof IMPORT_EXTENSIONS)[number], + ); + const candidates = hasSupportedExtension + ? [unresolved] + : IMPORT_EXTENSIONS.map((candidateExtension) => unresolved + candidateExtension); + const existing: Array<{ file: string; stat: fs.Stats }> = []; + + for (const file of candidates) { + try { + existing.push({ file, stat: fs.lstatSync(file) }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return null; + } + } + if (existing.length !== 1) return null; + const target = existing[0]!; + if (target.stat.isSymbolicLink() || !target.stat.isFile()) return null; + + try { + return isContainedPath(fs.realpathSync(root), fs.realpathSync(target.file)) + ? target.file + : null; + } catch { + return null; + } +} + +function isContainedPath(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..") + ); +} + +function findImportedDefaultObject(source: string, masked: string): number { + const exports = [...masked.matchAll(/\bexport\s+default\b/g)].filter( + (match) => nestingDepthAt(masked, match.index!) === 0, + ); + if (exports.length !== 1) return -1; + const exportMatch = exports[0]!; + const from = exportMatch.index! + exportMatch[0].length; + const resolved = resolveRestrictedExpression( + masked, + from, + hasRecognizedDefineConfigImport(source, masked), + new Set(), + ); + if (!resolved || !endsStatement(masked, resolved.end)) return -1; + return resolved.objectStart; +} + +function hasRecognizedDefineConfigImport(source: string, masked: string): boolean { + const safePackage = `${NIMBUS_PACKAGE}/config`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const fromRe = new RegExp(`\\bfrom\\s+(["'])${safePackage}\\1`, "g"); + const importPositions = [...masked.matchAll(/\bimport\b/g)].map((match) => match.index!); + let match: RegExpExecArray | null; + + while ((match = fromRe.exec(source)) !== null) { + let importIdx = -1; + for (const idx of importPositions) { + if (idx < match.index) importIdx = idx; + else break; + } + if (importIdx === -1) continue; + const clause = masked.slice(importIdx + "import".length, match.index).trim(); + if (/\bimport\b/.test(clause)) continue; + const named = /^\{([^}]*)\}$/.exec(clause); + if (named?.[1]?.split(",").some((entry) => entry.trim() === "defineConfig")) return true; + } + return false; +} + +interface RestrictedExpression { + objectStart: number; + end: number; +} + +function resolveRestrictedExpression( + masked: string, + from: number, + allowWrapper: boolean, + seen: Set, +): RestrictedExpression | null { + const start = skipWs(masked, from); + if (masked[start] === "{") { + const objectEnd = findMatchingBrace(masked, start); + return objectEnd === -1 ? null : { objectStart: start, end: objectEnd + 1 }; + } + + const identifierMatch = /^[A-Za-z_$][\w$]*/.exec(masked.slice(start)); + if (!identifierMatch) return null; + const identifier = identifierMatch[0]; + const identifierEnd = start + identifier.length; + const afterIdentifier = skipWs(masked, identifierEnd); + + if (identifier === "defineConfig" && masked[afterIdentifier] === "(") { + if (!allowWrapper) return null; + const inner = resolveRestrictedExpression( + masked, + afterIdentifier + 1, + false, + seen, + ); + if (!inner) return null; + const close = skipWs(masked, inner.end); + return masked[close] === ")" + ? { objectStart: inner.objectStart, end: close + 1 } + : null; + } + + if (seen.has(identifier)) return null; + const declarations = findRestrictedDeclarations(masked, identifier); + if (declarations.length !== 1) return null; + const nextSeen = new Set(seen).add(identifier); + const value = resolveRestrictedExpression( + masked, + declarations[0]!.value, + allowWrapper, + nextSeen, + ); + if ( + !value || + !endsStatement(masked, value.end) || + hasUnsupportedBindingUse( + masked, + identifier, + declarations[0]!.binding, + value.objectStart, + value.end, + start, + ) + ) return null; + return { objectStart: value.objectStart, end: identifierEnd }; +} + +function findRestrictedDeclarations( + masked: string, + identifier: string, +): Array<{ value: number; binding: number }> { + const safeIdentifier = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const declaration = new RegExp(`\\bconst\\s+${safeIdentifier}\\b`, "g"); + const declarations: Array<{ value: number; binding: number }> = []; + let match: RegExpExecArray | null; + while ((match = declaration.exec(masked)) !== null) { + if (nestingDepthAt(masked, match.index) !== 0) continue; + const equals = skipWs(masked, match.index + match[0].length); + if (masked[equals] === "=" && masked[equals + 1] !== "=" && masked[equals + 1] !== ">") { + declarations.push({ + value: equals + 1, + binding: match.index + match[0].lastIndexOf(identifier), + }); + } + } + return declarations; +} + +function nestingDepthAt(masked: string, end: number): number { + let depth = 0; + for (let i = 0; i < end; i++) { + if (masked[i] === "{" || masked[i] === "[" || masked[i] === "(") depth++; + else if (masked[i] === "}" || masked[i] === "]" || masked[i] === ")") depth--; + } + return depth; +} + +function endsStatement(masked: string, from: number): boolean { + const end = skipWs(masked, from); + return ( + masked[end] === ";" || + end === masked.length || + /[\r\n]/.test(masked.slice(from, end)) + ); +} + +function hasUnsupportedBindingUse( + masked: string, + identifier: string, + declarationBinding: number, + initializerStart: number, + initializerEnd: number, + allowedUse: number, +): boolean { + const safeIdentifier = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const uses = new RegExp(`\\b${safeIdentifier}\\b`, "g"); + let match: RegExpExecArray | null; + while ((match = uses.exec(masked)) !== null) { + const insideInitializer = + match.index >= initializerStart && match.index < initializerEnd; + if ( + match.index !== declarationBinding && + match.index !== allowedUse && + !insideInitializer + ) return true; + } + return false; +} + // First `=` that's an assignment (skips `==`, `===`, `=>`, `<=`, `>=`, `!=`). function findAssignmentEquals(source: string, from: number): number { for (let i = from; i < source.length; i++) { diff --git a/packages/nimbus-docs/src/_internal/route-manifest.ts b/packages/nimbus-docs/src/_internal/route-manifest.ts new file mode 100644 index 00000000..5ee603ba --- /dev/null +++ b/packages/nimbus-docs/src/_internal/route-manifest.ts @@ -0,0 +1,199 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +export const ROUTE_MANIFEST_VERSION = 2; +export const ROUTE_SOURCE_FINGERPRINT_VERSION = 1; + +export interface RouteTruth { + version: typeof ROUTE_MANIFEST_VERSION; + sourceFingerprint: { + version: typeof ROUTE_SOURCE_FINGERPRINT_VERSION; + algorithm: "sha256"; + digest: string; + }; + base: string; + knownRoutes: string[]; + opaqueNamespaces: string[]; +} + +export type RouteManifestStatus = + "fresh" | "missing" | "legacy" | "malformed" | "stale" | "unreadable"; + +export type RouteManifestInspection = + | { status: "fresh"; truth: RouteTruth } + | { status: Exclude; truth: null }; + +const CONFIG_FILE = /^(?:astro|nimbus|content)\.config\.(?:[cm]?[jt]s)$/; + +export function computeRouteSourceFingerprint(projectRoot: string): string { + const files = collectRouteSourceFiles(projectRoot); + const hash = createHash("sha256"); + hash.update(`nimbus-route-sources-v${ROUTE_SOURCE_FINGERPRINT_VERSION}\0`); + for (const { relative, absolute } of files) { + const bytes = fs.readFileSync(absolute); + hash.update( + `${Buffer.byteLength(relative)}\0${relative}\0${bytes.length}\0`, + ); + hash.update(bytes); + hash.update("\0"); + } + return hash.digest("hex"); +} + +export function inspectRouteManifest( + projectRoot: string, +): RouteManifestInspection { + const manifestPath = path.join(projectRoot, ".nimbus", "routes.json"); + let raw: string; + try { + raw = fs.readFileSync(manifestPath, "utf8"); + } catch (error) { + return { + status: + (error as NodeJS.ErrnoException).code === "ENOENT" + ? "missing" + : "unreadable", + truth: null, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { status: "malformed", truth: null }; + } + if ( + isRecord(parsed) && + typeof parsed.version === "number" && + parsed.version < ROUTE_MANIFEST_VERSION + ) { + return { status: "legacy", truth: null }; + } + if (!isRouteTruth(parsed)) return { status: "malformed", truth: null }; + + let current: string; + try { + current = computeRouteSourceFingerprint(projectRoot); + } catch { + return { status: "unreadable", truth: null }; + } + if (current !== parsed.sourceFingerprint.digest) { + return { status: "stale", truth: null }; + } + return { status: "fresh", truth: parsed }; +} + +export function routeManifestCoverageReason( + status: Exclude, +): string { + const detail: Record, string> = { + missing: "is missing", + legacy: "uses a legacy schema", + malformed: "is malformed", + stale: "does not match the current route-producing sources", + unreadable: "or its route-producing sources could not be read", + }; + return `link checking skipped — \`.nimbus/routes.json\` ${detail[status]}. Run \`astro build\` to regenerate fresh route truth. Other authoring rules still ran.`; +} + +function collectRouteSourceFiles( + projectRoot: string, +): Array<{ relative: string; absolute: string }> { + const byRelative = new Map(); + for (const relativeDir of ["src", "config"]) { + collectDirectory( + path.join(projectRoot, relativeDir), + projectRoot, + byRelative, + ); + } + for (const relativeDir of [".", "src"]) { + const absoluteDir = path.join(projectRoot, relativeDir); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(absoluteDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + for (const entry of entries) { + if (!entry.isFile() || !CONFIG_FILE.test(entry.name)) continue; + addFile(path.join(absoluteDir, entry.name), projectRoot, byRelative); + } + } + return [...byRelative.entries()] + .sort(([a], [b]) => compareStrings(a, b)) + .map(([relative, absolute]) => ({ relative, absolute })); +} + +function collectDirectory( + dir: string, + projectRoot: string, + files: Map, +): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + entries.sort((a, b) => compareStrings(a.name, b.name)); + for (const entry of entries) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) collectDirectory(absolute, projectRoot, files); + else if (entry.isFile()) addFile(absolute, projectRoot, files); + else throw new Error(`unsupported route source: ${absolute}`); + } +} + +function addFile( + absolute: string, + projectRoot: string, + files: Map, +): void { + const relative = path + .relative(projectRoot, absolute) + .split(path.sep) + .join("/") + .normalize("NFC"); + if (relative.startsWith("../") || path.isAbsolute(relative)) { + throw new Error(`route source escaped project root: ${absolute}`); + } + const existing = files.get(relative); + if (existing && existing !== absolute) + throw new Error(`duplicate normalized route source: ${relative}`); + files.set(relative, absolute); +} + +function isRouteTruth(value: unknown): value is RouteTruth { + if (!isRecord(value) || value.version !== ROUTE_MANIFEST_VERSION) + return false; + const fingerprint = value.sourceFingerprint; + return ( + isRecord(fingerprint) && + fingerprint.version === ROUTE_SOURCE_FINGERPRINT_VERSION && + fingerprint.algorithm === "sha256" && + typeof fingerprint.digest === "string" && + /^[a-f0-9]{64}$/.test(fingerprint.digest) && + typeof value.base === "string" && + isStringArray(value.knownRoutes) && + isStringArray(value.opaqueNamespaces) + ); +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === "string") + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/packages/nimbus-docs/src/_internal/transform.ts b/packages/nimbus-docs/src/_internal/transform.ts index 3a02c2c1..cb91601a 100644 --- a/packages/nimbus-docs/src/_internal/transform.ts +++ b/packages/nimbus-docs/src/_internal/transform.ts @@ -13,6 +13,7 @@ import { resolveCitations, type CitationIndex, } from "./api/citations.js"; +import { transformAuthoredLinks } from "./authored-links.js"; export interface MarkdownComponentRenderContext { name: string; @@ -25,6 +26,8 @@ export type MarkdownComponentRenderer = ( ) => string; export interface RenderEntryAsMarkdownOptions { + /** Astro base path applied to site-root-relative authored links. */ + base?: string; /** * Override how specific MDX components are rendered. Keys are component * names (e.g. `Aside`, `Tabs`, `PackageManagers`). @@ -265,6 +268,8 @@ export function renderEntryAsMarkdown( }).code; } + markdown = transformAuthoredLinks(markdown, options.base ?? "/"); + const protectedCode = protectCode(markdown); markdown = protectedCode.markdown; diff --git a/packages/nimbus-docs/src/check/authoring.ts b/packages/nimbus-docs/src/check/authoring.ts index e19c191a..933efc8d 100644 --- a/packages/nimbus-docs/src/check/authoring.ts +++ b/packages/nimbus-docs/src/check/authoring.ts @@ -12,6 +12,10 @@ import fs from "node:fs"; import path from "node:path"; +import { + inspectRouteManifest, + routeManifestCoverageReason, +} from "../_internal/route-manifest.js"; import { findMdxFiles, @@ -53,20 +57,18 @@ export function checkAuthoring(cwd: string): ScopeReport { "opt-in authoring rules not evaluated — their enablement is materialized by `astro build` into `.nimbus/lint.json`, which doesn't exist yet. Core rules still ran.", requiresBuild: true, }); - } else if ( - !error && - internalLinkEnabled(config) && - !fs.existsSync(path.join(cwd, ".nimbus", "routes.json")) - ) { - notes.push({ - code: "nimbus/internal-link-skipped", - reason: - "link checking skipped — `nimbus/internal-link` resolves against the route map `astro build` materializes into `.nimbus/routes.json`, which doesn't exist yet. Other authoring rules still ran.", - requiresBuild: true, - }); - // Noted structurally above — disable the rule so it doesn't also run and - // write its own skip warning to stderr. - effective = withRuleDisabled(config, "nimbus/internal-link"); + } else if (!error && internalLinkEnabled(config)) { + const routeManifest = inspectRouteManifest(cwd); + if (routeManifest.status !== "fresh") { + notes.push({ + code: "nimbus/internal-link-skipped", + reason: routeManifestCoverageReason(routeManifest.status), + requiresBuild: true, + }); + // Noted structurally above — disable the rule so it doesn't also run and + // write its own skip warning to stderr. + effective = withRuleDisabled(config, "nimbus/internal-link"); + } } for (const d of lintPaths(files, cwd, effective)) findings.push(fromDiagnostic(d)); diff --git a/packages/nimbus-docs/src/cli/adapter.ts b/packages/nimbus-docs/src/cli/adapter.ts index b9dcf852..bf477564 100644 --- a/packages/nimbus-docs/src/cli/adapter.ts +++ b/packages/nimbus-docs/src/cli/adapter.ts @@ -615,6 +615,7 @@ function manageServerWrangler( } } const settings = mergeSettingsFrom(existing); + const navigationWarnings = foreignNavigationWarnings(existing); const guidance = activeConfig.name === "wrangler.toml" ? `Add \`nodejs_compat\` to the top-level \`compatibility_flags\`, remove ` + @@ -630,6 +631,7 @@ function manageServerWrangler( `untouched. The Cloudflare build resolves ${activeConfig.display}; ` + `${activeIsSymlink ? "Nimbus does not rewrite symlinked configs." : "Nimbus only manages the project's wrangler.jsonc automatically."} Adapt ` + `${activeConfig.display} for server output. ${guidance}`, + ...navigationWarnings, ...redirectBaseWarnings(cwd), ], }; @@ -671,6 +673,7 @@ function manageServerWrangler( const cfg = mergeSettingsFrom( typeof parsed === "object" && parsed ? (parsed as Record) : undefined, ); + const navigationWarning = foreignNavigationWarnings(parsed); return { result: { action: "skipped-foreign", path }, warnings: [ @@ -678,11 +681,30 @@ function manageServerWrangler( `static config, and a server deploy needs a different shape (no static ` + `\`assets.directory\`, plus \`compatibility_flags: ["nodejs_compat"]\`). ` + `Merge this in by hand without replacing other settings:\n${serialize(cfg)}`, + ...navigationWarning, ...inactiveWarnings, ], }; } +function foreignNavigationWarnings(parsed: unknown): string[] { + const assets = + typeof parsed === "object" && parsed && !Array.isArray(parsed) && + typeof (parsed as Record).assets === "object" && + (parsed as Record).assets !== null && + !Array.isArray((parsed as Record).assets) + ? ((parsed as Record).assets as Record) + : undefined; + return assets?.not_found_handling === "404-page" && + assets.run_worker_first !== true + ? [ + '`assets.not_found_handling = "404-page"` can serve the static 404 before ' + + 'Astro handles browser navigation to request-rendered routes. Set it to ' + + '`"none"`; use a scoped `run_worker_first` only when Worker-first routing is intentional.', + ] + : []; +} + function isValidExistingWorkerName( config: Record | undefined, ): config is Record & { name: string } { diff --git a/packages/nimbus-docs/src/cli/index.ts b/packages/nimbus-docs/src/cli/index.ts index 33b79047..c9d6ad47 100644 --- a/packages/nimbus-docs/src/cli/index.ts +++ b/packages/nimbus-docs/src/cli/index.ts @@ -52,9 +52,9 @@ import { quoteForDisplay, } from "./pm.js"; import { - getIndexEntry, listEntries, registrySource, + resolveIndexEntryWithSnapshot, resolveComponentTree, type ComponentItem, } from "./resolver.js"; @@ -110,6 +110,11 @@ interface CliArgs { color?: boolean; } +function logError(message: string): void { + if (process.argv.includes("--print")) process.stderr.write(`${message}\n`); + else p.log.error(message); +} + const HELP = ` Commands: list [--type ui|lib|feature] List available registry items @@ -258,7 +263,7 @@ async function main(): Promise { return; } - p.log.error(`Unknown command: \`${command}\`. Try \`${invocation("--help")}\`.`); + logError(`Unknown command: \`${command}\`. Try \`${invocation("--help")}\`.`); process.exit(1); } @@ -279,7 +284,7 @@ function listCommand(typeFilter: string | undefined): void { : undefined; if (typeFilter && !(typeFilter in typeMap)) { - p.log.error( + logError( `Unknown --type "${typeFilter}". Valid: ui, lib, feature.`, ); process.exit(1); @@ -345,7 +350,7 @@ async function addCommand( if (slug === "server-output") { const adapterId = parseAdapterFlag(flags.adapter); if (!adapterId) { - p.log.error( + logError( `\`server-output\` requires \`--adapter <${ADAPTER_IDS.join("|")}>\`. ` + `Example: \`${invocation("add server-output --adapter vercel")}\``, ); @@ -365,13 +370,8 @@ async function addCommand( return; } - const entry = getIndexEntry(slug); - if (!entry) { - p.log.error( - `Unknown registry item: \`${slug}\`. Try \`${invocation("list")}\` to see what's available.`, - ); - process.exit(1); - } + const resolvedEntry = await resolveIndexEntryWithSnapshot(slug); + const entry = resolvedEntry.entry; if (entry.type === "registry:feature") { await installFeature(slug, { print: flags.print }); @@ -379,7 +379,7 @@ async function addCommand( } if (flags.print) { - p.log.error("`--print` is only available for features and the Cloudflare adapter."); + logError("`--print` is only available for features and the Cloudflare adapter."); process.exit(1); } @@ -398,13 +398,13 @@ async function addCommand( spinner.start("Resolving dependencies"); let items; try { - items = await resolveComponentTree(slug); + items = await resolveComponentTree(slug, entry, resolvedEntry.liveIndex); spinner.stop( `Resolved ${items.length} item${items.length === 1 ? "" : "s"}.`, ); } catch (err) { spinner.stop("Failed to resolve."); - p.log.error((err as Error).message); + logError((err as Error).message); process.exit(1); } @@ -501,7 +501,7 @@ async function runAdapterInstall( return; } if (printRecipe) { - p.log.error("`--print` is only available for the Cloudflare adapter."); + logError("`--print` is only available for the Cloudflare adapter."); process.exit(1); } @@ -531,7 +531,7 @@ async function runAdapterInstall( }); if (outcome.status === "error") { - p.log.error(outcome.message); + logError(outcome.message); process.exit(1); } @@ -593,7 +593,10 @@ function appendWranglerWriteLine( lines.push("+ Wrote wrangler.jsonc (server)"); } else if (wrangler?.action === "rewritten") { lines.push("~ Updated wrangler.jsonc for server output"); - } else if (wrangler?.action === "write-failed") { + } else if ( + wrangler?.action === "write-failed" || + wrangler?.action === "skipped-foreign" + ) { lines.push("! Cloudflare server deployment is only partially configured"); } } @@ -613,6 +616,6 @@ function spawnInstall(bin: string, args: string[], cwd: string): Promise { // --------------------------------------------------------------------------- main().catch((err) => { - p.log.error(`${(err as Error).message}`); + logError(`${(err as Error).message}`); process.exit(1); }); diff --git a/packages/nimbus-docs/src/cli/resolver.ts b/packages/nimbus-docs/src/cli/resolver.ts index 9d7d5911..71d5a8b3 100644 --- a/packages/nimbus-docs/src/cli/resolver.ts +++ b/packages/nimbus-docs/src/cli/resolver.ts @@ -58,6 +58,13 @@ const NPM_NAME_RE = // Registry slug (`card-grid`, `404-page`), interpolated into the fetch URL. const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; +const REQUEST_TIMEOUT_MS = 10_000; +const MAX_REDIRECTS = 5; +const INDEX_MAX_BYTES = 1024 * 1024; +const COMPONENT_MAX_BYTES = 10 * 1024 * 1024; +const FEATURE_MAX_BYTES = 2 * 1024 * 1024; +const SINGLE_LINE_DISPLAY_RE = /^[^\u0000-\u001f\u007f-\u009f]*$/; + // Not strict: unknown keys strip (not reject) so the wire format can grow without // breaking installed CLIs. Safety is per-field (shell/URL/fs constraints), below. const registryFileSchema = z.object({ @@ -68,8 +75,8 @@ const registryFileSchema = z.object({ const componentItemSchema = z.object({ name: z.string().min(1), type: z.enum(["registry:ui", "registry:lib"]), - title: z.string(), - description: z.string(), + title: z.string().regex(SINGLE_LINE_DISPLAY_RE), + description: z.string().regex(SINGLE_LINE_DISPLAY_RE), version: z.string().optional(), dependencies: z.array( z.string().regex(NPM_NAME_RE, "is not a valid npm package name"), @@ -80,6 +87,45 @@ const componentItemSchema = z.object({ files: z.array(registryFileSchema), }); +const registryIndexEntrySchema = z.object({ + name: z.string().regex(SLUG_RE, "is not a valid registry slug").max(100), + type: z.enum(["registry:ui", "registry:lib", "registry:feature"]), + title: z.string().max(200).regex(SINGLE_LINE_DISPLAY_RE), + description: z.string().max(1_000).regex(SINGLE_LINE_DISPLAY_RE), +}); + +const registryIndexSchema = z + .object({ + version: z.literal(1), + registryVersion: z.string().min(1).max(100), + items: z.record(z.string(), registryIndexEntrySchema), + }) + .superRefine((index, context) => { + const entries = Object.entries(index.items); + if (entries.length > 2_000) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["items"], + message: "contains more than 2000 entries", + }); + } + for (const [slug, entry] of entries) { + if (!SLUG_RE.test(slug) || slug.length > 100) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["items", slug], + message: "key is not a valid registry slug", + }); + } else if (entry.name !== slug) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["items", slug, "name"], + message: `must match its key "${slug}"`, + }); + } + } + }); + function formatZodIssues(error: z.ZodError): string { return error.issues .map((issue) => { @@ -142,7 +188,9 @@ function maybeWarnOverride(): void { // --------------------------------------------------------------------------- export function getIndexEntry(slug: string): RegistryIndexEntry | undefined { - return BUNDLED_INDEX.items[slug]; + return Object.hasOwn(BUNDLED_INDEX.items, slug) + ? BUNDLED_INDEX.items[slug] + : undefined; } export function listEntries(filter?: { @@ -153,73 +201,249 @@ export function listEntries(filter?: { return all.filter((e) => e.type === filter.type); } +export async function resolveIndexEntry( + slug: string, +): Promise { + return (await resolveIndexEntryWithSnapshot(slug)).entry; +} + +export async function resolveIndexEntryWithSnapshot(slug: string): Promise<{ + entry: RegistryIndexEntry; + liveIndex?: Record; +}> { + if (!SLUG_RE.test(slug) || slug.length > 100) { + throw new Error( + `Invalid registry item: \`${slug}\`. Names use lowercase letters, numbers, and hyphens.`, + ); + } + const bundled = getIndexEntry(slug); + if (bundled) return { entry: bundled }; + + const liveIndex = await fetchLiveIndexItems(); + const entry = liveIndex[slug]; + if (!entry) { + throw unknownRegistryItemError(slug); + } + return { entry, liveIndex }; +} + +async function fetchLiveIndexItems(): Promise> { + const url = `${getBaseUrl()}/registry.json`; + const data = await fetchJson(url, "registry index", INDEX_MAX_BYTES); + const parsed = registryIndexSchema.safeParse(data); + if (!parsed.success) { + throw new Error( + `Live registry index at ${url} failed validation:\n` + + formatZodIssues(parsed.error), + ); + } + return parsed.data.items; +} + +function unknownRegistryItemError(slug: string): Error { + const url = `${getBaseUrl()}/registry.json`; + return new Error( + `Unknown registry item: \`${slug}\`. The live registry index at ${url} was checked successfully; verify the spelling or browse the registry for current names.`, + ); +} + // --------------------------------------------------------------------------- // Network: component JSON + feature markdown // --------------------------------------------------------------------------- -async function httpGet(url: string, accept: string): Promise { - maybeWarnOverride(); +function errorChain(error: unknown): string { + const messages: string[] = []; + const seen = new Set(); + let current: unknown = error; + while (current && !seen.has(current)) { + seen.add(current); + if (current instanceof Error) messages.push(current.message || current.name); + else messages.push(String(current)); + current = + typeof current === "object" && current !== null && "cause" in current + ? (current as { cause?: unknown }).cause + : undefined; + } + return messages.filter(Boolean).join(" -> "); +} - let res: Response; - try { - res = await fetch(url, { headers: { accept } }); - } catch (err) { - const cause = (err as Error).message; - throw new Error( - `Could not reach the registry at ${url}.\n` + - ` Underlying error: ${cause}\n\n` + - ` Things to try:\n` + - ` - Set the registry URL: NIMBUS_REGISTRY_URL=https://example.com ${invocation("add ")}\n` + - ` - Check the value in your project's .env file.\n` + - ` - Working in the Nimbus monorepo? Start the local registry with \`pnpm local\`.`, +function transportError(url: string, error: unknown): Error { + const detail = errorChain(error); + if ( + (error instanceof Error && error.name === "TimeoutError") || + /timed?\s*out|timeout|abort due to timeout/i.test(detail) + ) { + return new Error( + `Registry request for ${url} timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds. Check your connection or registry URL and try again.`, ); } - // Refuse cross-origin redirects: fetch follows redirects by default, and a - // redirect onto another origin means we're no longer talking to the registry. - const requestedOrigin = new URL(url).origin; - const finalOrigin = new URL(res.url || url).origin; - if (finalOrigin !== requestedOrigin) { - throw new Error( - `Registry request for ${url} was redirected across origins ` + - `(${requestedOrigin} → ${finalOrigin}). Refusing to follow for safety. ` + - `If the redirect is legitimate, point NIMBUS_REGISTRY_URL at the final host directly.`, + const proxy = /proxy|connect|tunnel|econnrefused|enotfound|eai_again/i.test(detail); + return new Error( + `Could not reach the registry at ${url}.\n` + + ` ${proxy ? "Proxy/connection error" : "Underlying error"}: ${detail || "Unknown transport failure"}\n\n` + + ` Things to try:\n` + + ` - ${proxy ? "Check HTTPS_PROXY/HTTP_PROXY and whether the proxy permits CONNECT to the registry host.\n - " : ""}Set the registry URL: NIMBUS_REGISTRY_URL=https://example.com ${invocation("add ")}\n` + + ` - Check the value in your project's .env file.\n` + + ` - Working in the Nimbus monorepo? Start the local registry with \`pnpm local\`.`, + ); +} + +function statusError(url: string, res: Response): Error { + const retryAfter = res.headers.get("retry-after"); + if (res.status === 401 || res.status === 403) { + return new Error( + `Registry access was denied (${res.status}) for ${url}. Check registry authentication and proxy credentials.`, ); } - - if (!res.ok) { - throw new Error( - `Registry returned ${res.status} ${res.statusText} for ${url}. ` + - `The server is up but doesn't know about this slug — check \`${invocation("list")}\` for valid names.`, + if (res.status === 404) { + return new Error( + `Registry resource was not found (404) at ${url}. Check the registry URL and requested slug.`, ); } - return res; + if (res.status === 429) { + return new Error( + `Registry rate limit exceeded (429) for ${url}.${retryAfter ? ` Retry after ${retryAfter}.` : " Wait and try again."}`, + ); + } + if (res.status >= 500) { + return new Error( + `Registry server is unavailable (${res.status}) for ${url}. Try again later or use another registry host.`, + ); + } + return new Error( + `Registry returned ${res.status} ${res.statusText || "HTTP error"} for ${url}.`, + ); +} + +async function httpGet(url: string, accept: string): Promise { + maybeWarnOverride(); + const requestedOrigin = new URL(url).origin; + const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + let currentUrl = url; + + for (let redirects = 0; ; redirects += 1) { + let res: Response; + try { + res = await fetch(currentUrl, { + headers: { accept }, + redirect: "manual", + signal, + }); + } catch (error) { + throw transportError(url, error); + } + + const finalOrigin = new URL(res.url || currentUrl).origin; + if (finalOrigin !== requestedOrigin) { + throw new Error( + `Registry request for ${url} was redirected across origins ` + + `(${requestedOrigin} → ${finalOrigin}). Refusing to follow for safety. ` + + `If the redirect is legitimate, point NIMBUS_REGISTRY_URL at the final host directly.`, + ); + } + + if (![301, 302, 303, 307, 308].includes(res.status)) { + if (!res.ok) throw statusError(currentUrl, res); + return res; + } + + if (redirects >= MAX_REDIRECTS) { + await res.body?.cancel(); + throw new Error( + `Registry request for ${url} exceeded the limit of ${MAX_REDIRECTS} redirects.`, + ); + } + const location = res.headers.get("location"); + if (!location) throw statusError(currentUrl, res); + const nextUrl = new URL(location, currentUrl); + if (nextUrl.origin !== requestedOrigin) { + await res.body?.cancel(); + throw new Error( + `Registry request for ${url} was redirected across origins ` + + `(${requestedOrigin} → ${nextUrl.origin}). Refusing to follow for safety. ` + + `If the redirect is legitimate, point NIMBUS_REGISTRY_URL at the final host directly.`, + ); + } + await res.body?.cancel(); + currentUrl = nextUrl.href; + } } function contentType(res: Response): string { return (res.headers.get("content-type") ?? "").toLowerCase(); } -export async function fetchComponent(slug: string): Promise { - const url = `${getBaseUrl()}/components/${slug}.json`; - const res = await httpGet(url, "application/json"); - - // A 200 HTML error/fallback page is the usual "not JSON"; name it clearly. - if (contentType(res).includes("text/html")) { +async function readText( + res: Response, + url: string, + label: string, + maxBytes: number, +): Promise { + const declared = res.headers.get("content-length"); + if (declared !== null && /^\d+$/.test(declared) && Number(declared) > maxBytes) { + await res.body?.cancel(); throw new Error( - `Expected JSON for "${slug}" from ${url} but the server returned HTML. ` + - `The registry host is likely serving an error or fallback page.`, + `Registry ${label} at ${url} exceeds the ${maxBytes} byte size limit (Content-Length: ${declared}).`, ); } - let data: unknown; + const reader = res.body?.getReader(); + if (!reader) return ""; + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; try { - data = await res.json(); - } catch { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > maxBytes) { + await reader.cancel(); + throw new Error( + `Registry ${label} at ${url} exceeds the ${maxBytes} byte size limit.`, + ); + } + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); + } catch (error) { + if (error instanceof Error && error.message.includes("byte size limit")) { + throw error; + } + throw transportError(url, error); + } +} + +async function fetchJson( + url: string, + label: string, + maxBytes: number, +): Promise { + const res = await httpGet(url, "application/json"); + if (contentType(res).includes("text/html")) { throw new Error( - `Registry response for "${slug}" (${url}) was not valid JSON.`, + `Expected JSON ${label} from ${url} but the server returned HTML. The registry host is likely serving an error or fallback page.`, ); } + const text = await readText(res, url, label, maxBytes); + try { + return JSON.parse(text); + } catch { + throw new Error(`Registry ${label} at ${url} was not valid JSON.`); + } +} + +export async function fetchComponent( + slug: string, + expected?: RegistryIndexEntry, +): Promise { + const url = `${getBaseUrl()}/components/${slug}.json`; + const data = await fetchJson( + url, + `component response for "${slug}"`, + COMPONENT_MAX_BYTES, + ); const parsed = componentItemSchema.safeParse(data); if (!parsed.success) { @@ -228,6 +452,16 @@ export async function fetchComponent(slug: string): Promise { formatZodIssues(parsed.error), ); } + if (parsed.data.name !== slug) { + throw new Error( + `Registry payload name "${parsed.data.name}" does not match requested slug "${slug}".`, + ); + } + if (expected && parsed.data.type !== expected.type) { + throw new Error( + `Registry payload type "${parsed.data.type}" for "${slug}" does not match index type "${expected.type}".`, + ); + } return parsed.data; } @@ -244,7 +478,7 @@ export async function fetchFeatureMarkdown(slug: string): Promise { ); } - return await res.text(); + return await readText(res, url, `feature markdown for "${slug}"`, FEATURE_MAX_BYTES); } // --------------------------------------------------------------------------- @@ -257,15 +491,37 @@ export async function fetchFeatureMarkdown(slug: string): Promise { */ export async function resolveComponentTree( rootSlug: string, + rootEntry?: RegistryIndexEntry, + initialIndex?: Record, ): Promise { const visited = new Set(); const ordered: ComponentItem[] = []; + const entries = new Map(); + let liveIndexItems: Promise> | undefined = + initialIndex ? Promise.resolve(initialIndex) : undefined; + if (rootEntry) entries.set(rootSlug, rootEntry); + + async function entryFor(slug: string): Promise { + const cached = entries.get(slug) ?? getIndexEntry(slug); + if (cached) return cached; + liveIndexItems ??= fetchLiveIndexItems(); + const entry = (await liveIndexItems)[slug]; + if (!entry) throw unknownRegistryItemError(slug); + return entry; + } async function visit(slug: string): Promise { if (visited.has(slug)) return; visited.add(slug); - const item = await fetchComponent(slug); + const entry = await entryFor(slug); + entries.set(slug, entry); + if (entry.type === "registry:feature") { + throw new Error( + `Registry component "${slug}" cannot depend on feature "${entry.name}".`, + ); + } + const item = await fetchComponent(slug, entry); // Walk deps first so they're earlier in the install order. for (const dep of item.registryDependencies) { diff --git a/packages/nimbus-docs/src/components/NimbusHead.astro b/packages/nimbus-docs/src/components/NimbusHead.astro index c2564c29..bea91cb2 100644 --- a/packages/nimbus-docs/src/components/NimbusHead.astro +++ b/packages/nimbus-docs/src/components/NimbusHead.astro @@ -159,13 +159,13 @@ const fullTitle = title !== config.title ? `${title} | ${config.title}` : title; const resolvedDescription = description ?? config.description; const lang = config.locale ?? "en"; -const isHomePage = Astro.url.pathname === "/"; +const isHomePage = Astro.url.pathname === withBase("/", baseUrl); const ogType = isHomePage ? "website" : "article"; // Favicon precedence: svg > ico > png. The first file that exists wins; // when none exists we still emit the svg link so users who drop one in // later don't need to touch the layout. -const faviconHref = `${import.meta.env.BASE_URL}${headDefaults.favicon.file}`; +const faviconHref = withBase(`/${headDefaults.favicon.file}`, baseUrl); const faviconType = headDefaults.favicon.type; // Social image precedence: page prop > config > `public/opengraph.png` > @@ -205,7 +205,10 @@ const structuredData = canonical // Per-HTML last-element-wins semantics: page extras come after config // extras so per-page overrides win for any duplicated single-value tag. const mergedHead = [...(config.head ?? []), ...pageHead]; -const shikiCssHref = `${import.meta.env.BASE_URL}_nimbus/shiki.css${import.meta.env.DEV ? `?v=${Date.now()}` : ""}`; +const shikiCssHref = withBase( + `/_nimbus/shiki.css${import.meta.env.DEV ? `?v=${Date.now()}` : ""}`, + baseUrl, +); --- {fullTitle} diff --git a/packages/nimbus-docs/src/integration.ts b/packages/nimbus-docs/src/integration.ts index 4e62baf0..b36d55cc 100644 --- a/packages/nimbus-docs/src/integration.ts +++ b/packages/nimbus-docs/src/integration.ts @@ -30,6 +30,7 @@ */ import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -46,6 +47,7 @@ import { import { deriveFootprint, footprintRoutes } from "./_internal/footprint.js"; import { readDependencyNames } from "./check/probe.js"; import { parseComponentsRegistry } from "./_internal/parse-components-registry.js"; +import { authoredLinksPlugin } from "./_internal/authored-links.js"; import { validateLintOptions, type CollectionsConfig, @@ -60,8 +62,13 @@ import { formatDuplicateRoutes, formatShadowedRoutes, type RouteOwner, - type RouteTruth, } from "./lint/site-model.js"; +import { + computeRouteSourceFingerprint, + ROUTE_MANIFEST_VERSION, + ROUTE_SOURCE_FINGERPRINT_VERSION, + type RouteTruth, +} from "./_internal/route-manifest.js"; import { filterIndexableCollections, parseCollectionBases, @@ -338,6 +345,7 @@ export function nimbus( let sitemapExcludedPaths = new Set(); let sitemapTrailingSlash: "always" | "never" | "ignore" = "ignore"; let building = false; + let routeSourceFingerprintAtBuildStart: string | null = null; // Built eagerly at config:setup, reassigned by the dev re-bake; both the // citation plugin and virtual:nimbus/coordinates read it through a getter. @@ -364,6 +372,7 @@ export function nimbus( // content/assets stay root-relative via their collection bases. const srcDir = fileURLToPath(astroConfig.srcDir); const projectRoot = fileURLToPath(astroConfig.root); + if (building) invalidateRouteTruth(projectRoot); const publicDir = astroConfig.publicDir ? fileURLToPath(astroConfig.publicDir) : path.join(projectRoot, "public"); @@ -971,6 +980,10 @@ export function nimbus( contentDirs: citationContentDirs, getCitationIndex: () => citationIndex, }), + authoredLinksPlugin({ + base: astroConfig.base ?? "/", + contentDirs: [projectRoot], + }), virtualCoordinatesPlugin(() => ({ coordinates: Object.fromEntries(citationIndex), manifest: coordinatesManifest, @@ -1182,6 +1195,11 @@ export function nimbus( } }, "astro:build:start": async () => { + if (building) { + invalidateRouteTruth(projectRootForBuild); + routeSourceFingerprintAtBuildStart = + computeRouteSourceFingerprint(projectRootForBuild); + } const { clearNavCaches } = await import("./index.js"); clearNavCaches(); }, @@ -1253,14 +1271,6 @@ export function nimbus( // Duplicate-slug detection happens in `astro:config:setup`, not // here: Astro silently dedupes colliding routes before this hook // fires, so the collisions are invisible post-build. - materializeRouteTruthFromPages( - projectRootForBuild, - astroBaseForBuild, - publicPages, - requestRoutes, - logger, - ); - // Filled by `astro:routes:resolved`; reset at the next build's // `config:setup`, so a build whose `routes:resolved` never fires trips // the empty-routes guard instead of reusing stale routes. @@ -1318,6 +1328,15 @@ export function nimbus( ), ); } + + materializeRouteTruthFromPages( + projectRootForBuild, + astroBaseForBuild, + publicPages, + requestRoutes, + routeSourceFingerprintAtBuildStart, + logger, + ); }, }, }; @@ -1371,6 +1390,7 @@ function materializeRouteTruthFromPages( base: string, pages: readonly { pathname: string }[], requestRoutes: readonly string[], + sourceFingerprintAtBuildStart: string | null, logger: { warn: (msg: string) => void; debug?: (msg: string) => void }, ): void { // Normalize and dedupe pathnames into the canonical `/foo` form used by @@ -1386,22 +1406,35 @@ function materializeRouteTruthFromPages( canonical.add(canonicalizePathname(pathname)); } - const truth: RouteTruth = { - version: 1, - base, - knownRoutes: [...canonical].sort(), - // Nimbus collections remain enumerable even when their HTML is rendered - // on request, so broad opaque namespaces would only hide broken links. - opaqueNamespaces: [], - }; - try { + const sourceFingerprint = computeRouteSourceFingerprint(projectRoot); + if ( + sourceFingerprintAtBuildStart !== null && + sourceFingerprint !== sourceFingerprintAtBuildStart + ) { + logger.debug?.( + "route-producing sources changed during the build — internal-link will skip until the next build", + ); + return; + } + const truth: RouteTruth = { + version: ROUTE_MANIFEST_VERSION, + sourceFingerprint: { + version: ROUTE_SOURCE_FINGERPRINT_VERSION, + algorithm: "sha256", + digest: sourceFingerprint, + }, + base, + knownRoutes: [...canonical].sort(), + // Nimbus collections remain enumerable even when their HTML is rendered + // on request, so broad opaque namespaces would only hide broken links. + opaqueNamespaces: [], + }; const dir = path.join(projectRoot, ".nimbus"); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( + writeFileAtomicSync( path.join(dir, "routes.json"), JSON.stringify(truth, null, 2) + "\n", - "utf8", ); } catch (err) { logger.debug?.( @@ -1410,6 +1443,33 @@ function materializeRouteTruthFromPages( } } +function invalidateRouteTruth(projectRoot: string): void { + try { + fs.unlinkSync(path.join(projectRoot, ".nimbus", "routes.json")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +function writeFileAtomicSync(file: string, content: string): void { + const tmp = `${file}.tmp-${process.pid}-${randomUUID()}`; + try { + const handle = fs.openSync(tmp, "wx", 0o600); + try { + fs.writeFileSync(handle, content, "utf8"); + fs.fsyncSync(handle); + } finally { + fs.closeSync(handle); + } + fs.renameSync(tmp, file); + } catch (error) { + try { + fs.unlinkSync(tmp); + } catch {} + throw error; + } +} + function isRequestRouteInventoryPath(pathname: string, base: string): boolean { const canonical = canonicalizePathname(pathname); const normalizedBase = canonicalizePathname(base); diff --git a/packages/nimbus-docs/src/lint/rules/internal-link.ts b/packages/nimbus-docs/src/lint/rules/internal-link.ts index a88ba385..fd072d5f 100644 --- a/packages/nimbus-docs/src/lint/rules/internal-link.ts +++ b/packages/nimbus-docs/src/lint/rules/internal-link.ts @@ -35,11 +35,14 @@ * silences them for projects that want to use them. */ -import fs from "node:fs"; import path from "node:path"; import { matchesAnyIgnore } from "../../_internal/ignore-glob.js"; import { suggest } from "../../_internal/levenshtein.js"; +import { + inspectRouteManifest, + type RouteTruth, +} from "../../_internal/route-manifest.js"; import { collect, startOf, @@ -48,34 +51,18 @@ import { type ParsedFile, } from "../parse.js"; import type { Rule } from "../rule.js"; -import type { RouteTruth } from "../site-model.js"; // Process-level cache: read `routes.json` once per CLI invocation, not // once per file. The rule itself is stateless; the cache lives in the // module scope. let cached: { root: string; truth: RouteTruth | null } | null = null; -let missingWarned = false; function loadRouteTruth(file: ParsedFile): RouteTruth | null { const root = inferProjectRoot(file.absPath); if (cached && cached.root === root) return cached.truth; - let truth: RouteTruth | null = null; - try { - const raw = fs.readFileSync( - path.join(root, ".nimbus", "routes.json"), - "utf8", - ); - const parsed = JSON.parse(raw) as RouteTruth; - if (parsed.version === 1) truth = parsed; - } catch { - if (!missingWarned) { - process.stderr.write( - "nimbus/internal-link: skipped — `.nimbus/routes.json` is missing. Run `astro build` first; the route truth is materialized at `astro:build:done`.\n", - ); - missingWarned = true; - } - } + const inspected = inspectRouteManifest(root); + const truth = inspected.status === "fresh" ? inspected.truth : null; cached = { root, truth }; return truth; } @@ -354,5 +341,4 @@ function isUnderOpaqueNamespace( // load per CLI run; tests want isolation between cases. export function _resetInternalLinkCacheForTests(): void { cached = null; - missingWarned = false; } diff --git a/packages/nimbus-docs/src/lint/site-model.ts b/packages/nimbus-docs/src/lint/site-model.ts index 69545973..eaff19f9 100644 --- a/packages/nimbus-docs/src/lint/site-model.ts +++ b/packages/nimbus-docs/src/lint/site-model.ts @@ -4,8 +4,8 @@ * Route truth for `nimbus/internal-link` comes from Astro itself * (`astro:build:done` hands us the emitted `pages` array — the single * source of truth for served URLs). The integration writes that to - * `.nimbus/routes.json`; the type lives here only so the rule and the - * writer agree on the shape. + * `.nimbus/routes.json`; its schema and freshness validation live in + * `_internal/route-manifest.ts`. * * The duplicate-slug validator runs *before* the build because Astro * silently dedupes colliding routes — by the time `astro:build:done` @@ -294,26 +294,3 @@ export function contentEntryUrl( const prefix = collectionMountPrefix(entry.collection, versions); return canonicalEntryUrl(prefix, entry.id); } - -// --------------------------------------------------------------------------- -// Route truth — shape only. The integration's `astro:build:done` hook -// constructs and writes this; `internal-link.ts` reads it. -// --------------------------------------------------------------------------- - -export interface RouteTruth { - /** Schema version. Bump if the shape changes. */ - version: 1; - /** Astro `base` config (`"/docs"`, `""`). Empty string when unset. */ - base: string; - /** - * Every URL Astro emitted during the last build, canonicalized to - * `/foo` form (no trailing slash unless root). The lint rule resolves - * internal links against this set. - */ - knownRoutes: string[]; - /** - * Reserved for future SSR-route handling — URL prefixes that can't be - * statically enumerated. Empty in the current all-prerendered path. - */ - opaqueNamespaces: string[]; -} diff --git a/packages/nimbus-docs/src/runtime.ts b/packages/nimbus-docs/src/runtime.ts index 08ac557e..c7933301 100644 --- a/packages/nimbus-docs/src/runtime.ts +++ b/packages/nimbus-docs/src/runtime.ts @@ -43,7 +43,7 @@ import { sidebarHash, } from "./_internal/sidebar.js"; import { entryRouteUrl } from "./_internal/astro-slug.js"; -import { toBrowserHref, withBase } from "./_internal/url.js"; +import { toBrowserHref, toRouteKey, withBase } from "./_internal/url.js"; import { PRIMARY_COLLECTION, collectionLabel as resolveCollectionSlug, @@ -499,10 +499,11 @@ export async function getIndexedTopLevel(): Promise { */ export async function renderIndexedEntryMarkdown( item: IndexedEntry, + options?: { base?: string }, ): Promise { const apiCollections = await loadApiCollections(); if (!apiCollections.includes(item.collection)) { - return getEntryMarkdown(item.entry); + return getEntryMarkdown(item.entry, { base: options?.base }); } const { renderApiPageMarkdown } = await import("./_internal/api/markdown.js"); const { isPreparedApiPage } = await import("./_internal/api/prepared.js"); @@ -523,7 +524,7 @@ export async function renderIndexedEntryMarkdown( "is missing its prepared page data — rebuild the apiCollection() index.", ); } - return renderApiPageMarkdown(apiData.prepared.page); + return renderApiPageMarkdown(apiData.prepared.page, { base: options?.base }); } /** @@ -571,7 +572,7 @@ export async function renderCorpusMarkdown(options?: { description: item.description, url: item.url, markdownUrl: item.markdownUrl, - markdown: await renderIndexedEntryMarkdown(item), + markdown: await renderIndexedEntryMarkdown(item, { base: options?.base }), })), ); @@ -681,6 +682,46 @@ export async function getSidebarSections( return deriveSidebarSections(tree, currentSlug); } +/** + * Derive product-level header navigation from the docs home and configured API + * families. Returned internal hrefs include the deployment base path. + */ +export async function getProductSections( + currentSlug: string, + options?: { collection?: string; base?: string }, +): Promise { + const base = options?.base ?? "/"; + const baseKey = toRouteKey(base); + const withoutBase = (href: string) => { + const key = toRouteKey(href); + if (baseKey === "/") return key; + if (key === baseKey) return "/"; + return key.startsWith(`${baseKey}/`) ? key.slice(baseKey.length) : key; + }; + const route = withoutBase(currentSlug); + const config = await loadNimbusConfig(); + const apiSections = (config.api ?? []).map((api) => { + const mount = `/${api.collection}`; + return { + label: api.label ?? api.collection, + href: withBase(toBrowserHref(mount), base), + isActive: + options?.collection === api.collection || + route === mount || + route.startsWith(`${mount}/`), + }; + }); + + return [ + { + label: "Docs", + href: withBase("/", base), + isActive: !apiSections.some((section) => section.isActive), + }, + ...apiSections, + ]; +} + // A path that matches no real href, so the cached tree is built with every // active flag inert; flags are stamped per page by `markActiveState`. const NO_ACTIVE_PATH = "\u0000__nimbus_structural__"; diff --git a/packages/nimbus-docs/src/types.ts b/packages/nimbus-docs/src/types.ts index da18cdd0..d9005966 100644 --- a/packages/nimbus-docs/src/types.ts +++ b/packages/nimbus-docs/src/types.ts @@ -204,7 +204,7 @@ export interface ApiSpec { * document object. Not a remote URL in v1. Omit when `versions` is set. */ spec?: string | Record; - /** Human label for build diagnostics (falls back to `collection`). */ + /** Human label for navigation and build diagnostics (falls back to `collection`). */ label?: string; /** * Declares this collection as a version family. Pages are linked across @@ -268,7 +268,7 @@ export interface ApiVersionSpec { * policy; a shared route map may be imported into several versions, but every * override key is validated against the concrete version receiving it. */ - routes?: ApiRoutePolicy; + routes?: ApiRoutePolicy; } /** @@ -390,7 +390,10 @@ export interface SearchResult { export interface SearchProvider { /** Optional lazy setup hook, called before the first search. */ init?(): Promise; - search(query: string, opts?: { signal?: AbortSignal }): Promise; + search( + query: string, + opts?: { signal?: AbortSignal }, + ): Promise; } export interface HeadElement { @@ -639,9 +642,7 @@ export interface SidebarGroupItem { } export type SidebarItem = - | SidebarLinkItem - | SidebarExternalLinkItem - | SidebarGroupItem; + SidebarLinkItem | SidebarExternalLinkItem | SidebarGroupItem; /** * A pass over the final sidebar tree (after scope and isolate), returning diff --git a/packages/nimbus-docs/test/adapter-cli-output.test.ts b/packages/nimbus-docs/test/adapter-cli-output.test.ts index f69d15cc..49bea6fe 100644 --- a/packages/nimbus-docs/test/adapter-cli-output.test.ts +++ b/packages/nimbus-docs/test/adapter-cli-output.test.ts @@ -105,7 +105,8 @@ test("non-Cloudflare adapter --print fails without editing", () => { { cwd: dir, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } }, ); assert.equal(result.status, 1); - assert.match(`${result.stdout}\n${result.stderr}`, /only available for the Cloudflare adapter/); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /only available for the Cloudflare adapter/); assert.equal(fs.readFileSync(path.join(dir, "astro.config.ts"), "utf8"), config); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -122,13 +123,39 @@ test("component --print fails before writing", () => { { cwd: dir, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } }, ); assert.equal(result.status, 1); - assert.match(`${result.stdout}\n${result.stderr}`, /only available for features and the Cloudflare adapter/); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /only available for features and the Cloudflare adapter/); assert.deepEqual(fs.readdirSync(dir), []); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); +test("registry resolution failure with --print keeps stdout empty", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-adapter-cli-")); + + try { + const result = spawnSync( + process.execPath, + ["--import", TSX, CLI, "add", "not-bundled", "--print"], + { + cwd: dir, + encoding: "utf8", + env: { + ...process.env, + NIMBUS_REGISTRY_URL: "http://127.0.0.1:1", + NO_COLOR: "1", + }, + }, + ); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /Could not reach the registry/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test("adapter install reports partial success when wrangler cannot be written", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-adapter-cli-")); fs.writeFileSync( @@ -178,3 +205,48 @@ export default { fs.rmSync(dir, { recursive: true, force: true }); } }); + +test("adapter install reports partial success for a retained foreign wrangler", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-adapter-cli-")); + fs.writeFileSync( + path.join(dir, "astro.config.ts"), + `import cloudflare from "@astrojs/cloudflare"; +export default { + // nimbus:adapter + output: "server", + adapter: cloudflare({ prerenderEnvironment: "node" }), +}; +`, + ); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ + dependencies: { astro: "7.0.9", "@astrojs/cloudflare": "14.1.7" }, + }), + ); + const wrangler = JSON.stringify({ + name: "docs", + compatibility_date: "2025-01-01", + assets: { + directory: "./dist", + not_found_handling: "404-page", + binding: "ASSETS", + }, + }); + fs.writeFileSync(path.join(dir, "wrangler.jsonc"), wrangler); + + try { + const result = spawnSync( + process.execPath, + ["--import", TSX, CLI, "add", "adapter-cloudflare"], + { cwd: dir, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } }, + ); + const output = `${result.stdout}\n${result.stderr}`; + assert.equal(result.status, 0, output); + assert.equal(fs.readFileSync(path.join(dir, "wrangler.jsonc"), "utf8"), wrangler); + assert.match(output, /only partially configured/); + assert.match(output, /static 404 before Astro/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/nimbus-docs/test/adapter-wrangler.test.ts b/packages/nimbus-docs/test/adapter-wrangler.test.ts index 7ceb674d..f17f9406 100644 --- a/packages/nimbus-docs/test/adapter-wrangler.test.ts +++ b/packages/nimbus-docs/test/adapter-wrangler.test.ts @@ -500,6 +500,49 @@ test("cloudflare refuses to clobber a hand-edited wrangler and prints the merge" const merge = JSON.parse(warning.slice(warning.indexOf("{"))); assert.deepEqual(merge.compatibility_flags, ["python_workers", "nodejs_compat"]); assert.deepEqual(merge.assets, { binding: "ASSETS", not_found_handling: "none" }); + assert.ok(res.warnings.some((value) => /static 404 before Astro/.test(value))); + assert.ok(res.warnings.some((value) => /scoped `run_worker_first`/.test(value))); +}); + +test("cloudflare does not warn about static 404 routing when the Worker runs first", async () => { + const dir = scratch(); + project(dir); + writeFileSync( + join(dir, "wrangler.jsonc"), + JSON.stringify({ + ...STATIC_WRANGLER, + compatibility_flags: ["python_workers"], + assets: { + ...STATIC_WRANGLER.assets, + binding: "ASSETS", + run_worker_first: true, + }, + }), + ); + const res = await installAdapter("cloudflare", { cwd: dir, installDeps: okInstaller }); + assert.equal(res.status, "applied"); + if (res.status !== "applied") return; + assert.equal(res.wrangler?.action, "skipped-foreign"); + assert.equal(res.warnings.some((value) => /static 404 before Astro/.test(value)), false); +}); + +test("cloudflare warns about browser navigation in a foreign wrangler.json", async () => { + const dir = scratch(); + project(dir); + writeFileSync( + join(dir, "wrangler.json"), + JSON.stringify({ + name: "foreign-worker", + compatibility_date: "2025-01-01", + compatibility_flags: ["python_workers"], + assets: { directory: "./dist", not_found_handling: "404-page" }, + }), + ); + const res = await installAdapter("cloudflare", { cwd: dir, installDeps: okInstaller }); + assert.equal(res.status, "applied"); + if (res.status !== "applied") return; + assert.equal(res.wrangler?.action, "skipped-foreign"); + assert.ok(res.warnings.some((value) => /static 404 before Astro/.test(value))); }); test("cloudflare sanitizes invalid identity values in the printed merge", async () => { diff --git a/packages/nimbus-docs/test/api-markdown.test.ts b/packages/nimbus-docs/test/api-markdown.test.ts index 0c98468d..14304d16 100644 --- a/packages/nimbus-docs/test/api-markdown.test.ts +++ b/packages/nimbus-docs/test/api-markdown.test.ts @@ -111,6 +111,20 @@ describe("api markdown emitter", () => { } }); + test("applies the configured base to generated internal links", () => { + let links = 0; + for (const { coordinate } of getApiPageSlugs(smallco)) { + const md = renderApiPageMarkdown(getApiPageProps(smallco, coordinate), { + base: "/docs", + }); + for (const match of md.matchAll(/\]\((\/[^)]*)\)/g)) { + links++; + assert.match(match[1] ?? "", /^\/docs(?:\/|$)/); + } + } + assert.ok(links > 0); + }); + test("operation page carries method + path and its facts", () => { const props = getApiPageProps(smallco, "create"); assert.equal(props.kind, "operation"); @@ -173,6 +187,32 @@ describe("api markdown emitter", () => { assert.match(md, /- `string`/); }); + test("inline union titles render trimmed before the type fallback", async () => { + const model = await buildApiModel({ + collection: "titles", + spec: { + openapi: "3.1.0", + info: { title: "T", version: "1" }, + paths: {}, + components: { + schemas: { + Choice: { + anyOf: [ + { title: " Existing customer ", type: "object" }, + { title: "\t", type: "integer" }, + ], + }, + }, + }, + }, + }); + const md = renderApiPageMarkdown(getApiPageProps(model, "Choice")); + + assert.match(md, /- `Existing customer`/); + assert.match(md, /- `integer`/); + assert.doesNotMatch(md, / Existing customer /); + }); + test("discriminated union renders the value→variant mapping", async () => { const model = await buildApiModel({ collection: "disc", diff --git a/packages/nimbus-docs/test/api-view-model.test.ts b/packages/nimbus-docs/test/api-view-model.test.ts index 9931bb6d..e9ca136f 100644 --- a/packages/nimbus-docs/test/api-view-model.test.ts +++ b/packages/nimbus-docs/test/api-view-model.test.ts @@ -284,6 +284,36 @@ describe("nesting: children, childCount, required-first", () => { }); describe("unions: enrichment edge cases", () => { + test("inline branch titles project trimmed while blank titles fall back and `$ref` links stay unchanged", async () => { + const model = await buildApiModel({ + collection: "titles", + spec: { + openapi: "3.1.0", + info: { title: "T", version: "1" }, + paths: {}, + components: { + schemas: { + Referenced: { type: "object", properties: { id: { type: "string" } } }, + Choice: { + oneOf: [ + { title: " Customer record ", type: "object" }, + { title: " ", type: "string" }, + { $ref: "#/components/schemas/Referenced", title: "Ignored sibling title" }, + ], + }, + }, + }, + }, + }); + const page = getApiPageProps(model, "Choice") as ApiSchemaPage; + + assert.deepEqual(page.union!.variants, [ + { label: "Customer record" }, + { label: "string" }, + { label: "Referenced", href: "/titles/schemas/Referenced" }, + ]); + }); + test("a top-level `oneOf` RESPONSE projects onto response.bodyUnion (symmetric with the request)", async () => { const model = await buildApiModel({ collection: "u", diff --git a/packages/nimbus-docs/test/authored-links.test.ts b/packages/nimbus-docs/test/authored-links.test.ts new file mode 100644 index 00000000..45847a14 --- /dev/null +++ b/packages/nimbus-docs/test/authored-links.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { + authoredLinksPlugin, + transformAuthoredLinks, +} from "../src/_internal/authored-links.js"; + +test("transformAuthoredLinks applies base to authored internal links", () => { + const source = `[Root](/) +[Components](/components?view=all#cards) +[Already based](/docs/components) +[External](https://example.com/components) +[Protocol relative](//cdn.example.com/file.js) +[Relative](../components) +[Anchor](#cards) + +[Reference][components] + +[components]: /components "Components" +[x][foo\\\\] + +[foo\\\\]: /escaped-definition +[Trap](/trap "title ]( example") +[Escaped](\\/escaped) +[Entity](/entity) + +Plain +Uppercase +Entity attribute +Already based entity + + + +\`[Inline code](/unchanged)\` + +\`\`\`md +[Fenced code](/unchanged) +\`\`\` +`; + + const transformed = transformAuthoredLinks(source, "/docs/"); + assert.match(transformed, /\[Root\]\(\/docs\/\)/); + assert.match(transformed, /\[Components\]\(\/docs\/components\?view=all#cards\)/); + assert.match(transformed, /\[Already based\]\(\/docs\/components\)/); + assert.match(transformed, /\[components\]: \/docs\/components "Components"/); + assert.match(transformed, /\[foo\\\\\]: \/docs\/escaped-definition/); + assert.match(transformed, /\[Trap\]\(\/docs\/trap "title \]\( example"\)/); + assert.match(transformed, /\[Escaped\]\(\/docs\\\/escaped\)/); + assert.match(transformed, /\[Entity\]\(\/docs/entity\)/); + assert.match(transformed, /\[Relative\]\(\.\.\/components\)/); + assert.match(transformed, /href="\/docs\/plain"/); + assert.match(transformed, /HREF="\/docs\/uppercase"/); + assert.match(transformed, /href="\/docs/entity-attribute"/); + assert.match(transformed, /href="\/docs\/already-based"/); + assert.match(transformed, /href='\/docs\/card'/); + assert.match(transformed, /href=\{target\}/); + assert.match(transformed, /\[Inline code\]\(\/unchanged\)/); + assert.match(transformed, /\[Fenced code\]\(\/unchanged\)/); + assert.equal(transformAuthoredLinks(transformed, "/docs/"), transformed); +}); + +test("transformAuthoredLinks is unchanged at the root base", () => { + const source = "[Components](/components)"; + assert.equal(transformAuthoredLinks(source, "/"), source); +}); + +test("transformAuthoredLinks handles astral characters before links and JSX", () => { + const source = `😀😀 [Link](/link) + +😀 `; + assert.equal( + transformAuthoredLinks(source, "/docs"), + `😀😀 [Link](/docs/link) + +😀 `, + ); +}); + +test("transformAuthoredLinks does not rewrite code nested inside JSX", () => { + const source = ` + +\`\` + +\`\`\`html +example +\`\`\` + +inner +`; + + const transformed = transformAuthoredLinks(source, "/docs"); + assert.match(transformed, //); + assert.match(transformed, /``/); + assert.match(transformed, /example<\/a>/); + assert.match(transformed, /inner<\/a>/); +}); + +test("transformAuthoredLinks handles JSX expressions before static hrefs", () => { + const source = ` 0} label={'href="/example"'} value={/* } href } href */ target} href="/components/card" />`; + assert.equal( + transformAuthoredLinks(source, "/docs"), + ` 0} label={'href="/example"'} value={/* } href } href */ target} href="/docs/components/card" />`, + ); +}); + +test("transformAuthoredLinks ignores href-like text inside expressions", () => { + const source = ''; + assert.equal(transformAuthoredLinks(source, "/docs"), source); +}); + +test("transformAuthoredLinks handles static hrefs around multiline expressions", () => { + const source = ` 0 + } bar={/}/.test(value)} quote={/"/.test(value)} slash={/[//]/.test(value)} trap={/} href/.test(value)} keyword={(() => { return /"/.test(value); })()} href="/after" />`; + assert.equal( + transformAuthoredLinks(source, "/docs"), + ` 0 + } bar={/}/.test(value)} quote={/"/.test(value)} slash={/[//]/.test(value)} trap={/} href/.test(value)} keyword={(() => { return /"/.test(value); })()} href="/docs/after" />`, + ); +}); + +test("authoredLinksPlugin only transforms project content", () => { + const plugin = authoredLinksPlugin({ + base: "/docs", + contentDirs: ["/project/src/content"], + }); + assert.deepEqual(plugin.transform("[Components](/components)", "/project/src/content/a.mdx"), { + code: "[Components](/docs/components)", + map: null, + }); + assert.equal(plugin.transform("[Components](/components)", "/project/README.md"), null); + assert.equal( + plugin.transform("[Components](/components)", "/project/src/content/node_modules/pkg/readme.md"), + null, + ); +}); + +test("authoredLinksPlugin rejects content symlinked outside the project", () => { + const root = mkdtempSync(path.join(tmpdir(), "nimbus-authored-links-")); + const project = path.join(root, "project"); + const external = path.join(root, "external"); + mkdirSync(project); + mkdirSync(external); + const externalFile = path.join(external, "guide.mdx"); + writeFileSync(externalFile, "[Components](/components)"); + const linkedFile = path.join(project, "guide.mdx"); + symlinkSync(externalFile, linkedFile); + + try { + const plugin = authoredLinksPlugin({ base: "/docs", contentDirs: [project] }); + assert.equal(plugin.transform("[Components](/components)", linkedFile), null); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/nimbus-docs/test/check/authoring-coverage.test.ts b/packages/nimbus-docs/test/check/authoring-coverage.test.ts index c6b64f89..ac43743c 100644 --- a/packages/nimbus-docs/test/check/authoring-coverage.test.ts +++ b/packages/nimbus-docs/test/check/authoring-coverage.test.ts @@ -6,6 +6,7 @@ import { test } from "node:test"; import { checkAuthoring } from "../../src/check/authoring.js"; import { _resetInternalLinkCacheForTests } from "../../src/lint/rules/internal-link.js"; +import { computeRouteSourceFingerprint } from "../../src/_internal/route-manifest.js"; interface ProjectOpts { body?: string; @@ -33,7 +34,15 @@ function project(opts: ProjectOpts = {}): string { if (opts.routesJson) { fs.writeFileSync( path.join(dir, ".nimbus", "routes.json"), - JSON.stringify({ version: 1, ...opts.routesJson }), + JSON.stringify({ + version: 2, + sourceFingerprint: { + version: 1, + algorithm: "sha256", + digest: computeRouteSourceFingerprint(dir), + }, + ...opts.routesJson, + }), ); } return dir; @@ -66,8 +75,14 @@ test("no .nimbus → authoring-optin-skipped note, evaluated true, never in find const skip = note(r, "nimbus/authoring-optin-skipped"); assert.ok(skip, "opt-in skip is surfaced as a note"); assert.equal(skip.requiresBuild, true); - assert.ok(!hasFinding(r, "nimbus/authoring-optin-skipped"), "never a finding"); - assert.ok(!hasFinding(r, "nimbus/authoring-not-evaluated"), "old code is gone"); + assert.ok( + !hasFinding(r, "nimbus/authoring-optin-skipped"), + "never a finding", + ); + assert.ok( + !hasFinding(r, "nimbus/authoring-not-evaluated"), + "old code is gone", + ); assert.ok(!hasFinding(r, "nimbus/internal-link")); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -84,7 +99,10 @@ test("lint.json enables internal-link but no routes.json → internal-link-skipp assert.equal(skipped.requiresBuild, true); assert.ok(!hasFinding(r, "nimbus/internal-link-skipped")); assert.ok(!hasFinding(r, "nimbus/internal-link")); - assert.ok(!note(r, "nimbus/authoring-optin-skipped"), "opt-in ran, so no opt-in note"); + assert.ok( + !note(r, "nimbus/authoring-optin-skipped"), + "opt-in ran, so no opt-in note", + ); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -99,7 +117,10 @@ test("lint.json + routes.json present → broken link is actually caught", () => try { const r = checkAuthoring(dir); const broken = r.findings.find((f) => f.code === "nimbus/internal-link"); - assert.ok(broken, "the broken link must be flagged when route truth exists"); + assert.ok( + broken, + "the broken link must be flagged when route truth exists", + ); assert.equal(broken.severity, "error"); assert.ok(!note(r, "nimbus/internal-link-skipped")); assert.ok(!note(r, "nimbus/authoring-optin-skipped")); @@ -108,6 +129,52 @@ test("lint.json + routes.json present → broken link is actually caught", () => } }); +test("stale route truth produces one structured coverage note and no broken-link findings", () => { + _resetInternalLinkCacheForTests(); + const dir = project({ + lintJson: INTERNAL_LINK_ON, + routesJson: { base: "/", knownRoutes: ["/"], opaqueNamespaces: [] }, + }); + try { + fs.appendFileSync( + path.join(dir, "src/content/docs/page.mdx"), + "\nroute change\n", + ); + const r = checkAuthoring(dir); + const skipped = r.notes.filter( + (item) => item.code === "nimbus/internal-link-skipped", + ); + assert.equal(skipped.length, 1); + assert.match( + skipped[0]!.reason, + /does not match the current route-producing sources/, + ); + assert.ok(!hasFinding(r, "nimbus/internal-link")); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("legacy and malformed route truth each produce one coverage note", () => { + for (const body of [JSON.stringify({ version: 1 }), "{"]) { + _resetInternalLinkCacheForTests(); + const dir = project({ lintJson: INTERNAL_LINK_ON }); + try { + fs.mkdirSync(path.join(dir, ".nimbus"), { recursive: true }); + fs.writeFileSync(path.join(dir, ".nimbus/routes.json"), body); + const r = checkAuthoring(dir); + assert.equal( + r.notes.filter((item) => item.code === "nimbus/internal-link-skipped") + .length, + 1, + ); + assert.ok(!hasFinding(r, "nimbus/internal-link")); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + test("internal-link disabled + no routes.json → no skip note (nothing to skip)", () => { _resetInternalLinkCacheForTests(); const dir = project({ @@ -128,10 +195,15 @@ test("always-on validators still fire without a lint.json (mdx-syntax not skippe try { const r = checkAuthoring(dir); assert.ok( - r.findings.some((f) => f.code === "nimbus/mdx-syntax" && f.severity === "error"), + r.findings.some( + (f) => f.code === "nimbus/mdx-syntax" && f.severity === "error", + ), "mdx-syntax must fire even when no lint config is materialized", ); - assert.ok(note(r, "nimbus/authoring-optin-skipped"), "opt-in skip still noted"); + assert.ok( + note(r, "nimbus/authoring-optin-skipped"), + "opt-in skip still noted", + ); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -148,7 +220,10 @@ test("internal-link skip is noted structurally, never leaked to stderr", () => { }) as typeof process.stderr.write; try { const r = checkAuthoring(dir); - assert.ok(note(r, "nimbus/internal-link-skipped"), "the skip is a structured note"); + assert.ok( + note(r, "nimbus/internal-link-skipped"), + "the skip is a structured note", + ); assert.doesNotMatch(captured, /nimbus\/internal-link: skipped/); } finally { process.stderr.write = original; diff --git a/packages/nimbus-docs/test/check/parse-nimbus-config.test.ts b/packages/nimbus-docs/test/check/parse-nimbus-config.test.ts index 151cc199..f8922d0a 100644 --- a/packages/nimbus-docs/test/check/parse-nimbus-config.test.ts +++ b/packages/nimbus-docs/test/check/parse-nimbus-config.test.ts @@ -29,6 +29,20 @@ function parse(source: string, filename?: string): ConfigParseResult { return withConfig(source, (dir) => parseNimbusConfig(dir), filename); } +function withProject(files: Record, body: (dir: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-cfg-")); + for (const [name, source] of Object.entries(files)) { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, source); + } + try { + return body(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + function ok(result: ConfigParseResult) { assert.ok(result.ok, `expected ok, got ${result.ok ? "" : result.reason}`); return result as Extract; @@ -66,6 +80,156 @@ export default { integrations: [nimbus(cfg)] };`), assert.equal(r.config.title, "X"); }); +test("follows one relative default import and owns the imported spans", () => { + withProject( + { + "astro.config.ts": `${IMPORT} +import nimbusConfig from "./config/nimbus.config"; +export default { integrations: [nimbus(nimbusConfig)] };`, + "config/nimbus.config.ts": `import { defineConfig } from "@cloudflare/nimbus-docs/config"; +const config = { site: "https://x.dev", title: "X" }; +export default defineConfig(config);`, + }, + (dir) => { + const r = ok(parseNimbusConfig(dir)); + assert.equal(r.config.site, "https://x.dev"); + assert.equal(r.location.file, path.join(dir, "config/nimbus.config.ts")); + assert.match(r.location.source, /const config/); + assert.equal(r.location.source.slice(r.location.objectStart, r.location.objectEnd + 1), `{ site: "https://x.dev", title: "X" }`); + }, + ); +}); + +test("follows a semicolonless imported const config", () => { + withProject( + { + "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config.ts"\nexport default { integrations: [nimbus(config)] }`, + "nimbus.config.ts": `const config = { site: "https://x.dev" }\nexport default config`, + }, + (dir) => assert.equal(ok(parseNimbusConfig(dir)).config.site, "https://x.dev"), + ); +}); + +test("rejects mutable and subsequently mutated imported bindings", async (t) => { + for (const [name, imported] of [ + ["let binding", `let config = { site: "x" }; export default config;`], + ["direct reassignment", `const config = { site: "x" }; config = { site: "y" }; export default config;`], + ["property assignment", `const config = { site: "x" }; config.site = "y"; export default config;`], + ["object assign", `const config = { site: "x" }; Object.assign(config, { site: "y" }); export default config;`], + ["delete", `const config = { site: "x" }; delete config.site; export default config;`], + ["increment", `const config = { retries: 1 }; config.retries++; export default config;`], + ["hoisted mutator", `function mutate() { config.site = "y"; } const config = { site: "x" }; mutate(); export default config;`], + ] as const) { + await t.test(name, () => + withProject( + { + "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, + "nimbus.config.ts": imported, + }, + (dir) => assert.equal(parseNimbusConfig(dir).ok, false), + ), + ); + } +}); + +test("supports explicit JS/TS extensions and the restricted default export forms", async (t) => { + const cases = [ + ["./nimbus.config.ts", `export default { site: "https://x.dev" };`], + ["./nimbus.config.js", `const config = { site: "https://x.dev" }; export default config;`], + [ + "./nimbus.config.mts", + `import { defineConfig } from "@cloudflare/nimbus-docs/config"; export default defineConfig({ site: "https://x.dev" });`, + ], + ] as const; + for (const [specifier, imported] of cases) { + await t.test(specifier, () => { + withProject( + { + "astro.config.ts": `${IMPORT}\nimport config from "${specifier}";\nexport default { integrations: [nimbus(config)] };`, + [specifier.slice(2)]: imported, + }, + (dir) => assert.equal(ok(parseNimbusConfig(dir)).config.site, "https://x.dev"), + ); + }); + } +}); + +test("rejects ambiguous extensionless imports", () => { + withProject( + { + "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config";\nexport default { integrations: [nimbus(config)] };`, + "nimbus.config.ts": `export default { site: "https://x.dev" };`, + "nimbus.config.js": `export default { site: "https://other.dev" };`, + }, + (dir) => assert.equal(parseNimbusConfig(dir).ok, false), + ); +}); + +test("does not execute an unsupported imported expression", () => { + withProject( + { + "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, + "nimbus.config.ts": `import fs from "node:fs"; export default (fs.writeFileSync(new URL("./executed", import.meta.url), ""), { site: "x" });`, + }, + (dir) => { + assert.equal(parseNimbusConfig(dir).ok, false); + assert.equal(fs.existsSync(path.join(dir, "executed")), false); + }, + ); +}); + +test("rejects package, named, aliased, re-exported, multi-hop, and unsupported imports", async (t) => { + const cases: Array<[string, Record]> = [ + ["package", { "astro.config.ts": `${IMPORT}\nimport config from "some-package";\nexport default { integrations: [nimbus(config)] };` }], + ["named", { "astro.config.ts": `${IMPORT}\nimport { config } from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, "nimbus.config.ts": `export const config = { site: "x" };` }], + ["aliased default", { "astro.config.ts": `${IMPORT}\nimport { default as config } from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, "nimbus.config.ts": `export default { site: "x" };` }], + ["re-export", { "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, "nimbus.config.ts": `export { default } from "./other.ts";`, "other.ts": `export default { site: "x" };` }], + ["multi-hop", { "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, "nimbus.config.ts": `import config from "./other.ts"; export default config;`, "other.ts": `export default { site: "x" };` }], + ["nested binding", { "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, "nimbus.config.ts": `function build() { const config = { site: "x" }; return config; } export default config;` }], + ["unsupported expression", { "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, "nimbus.config.ts": `export default makeConfig({ site: "x" });` }], + ["aliased wrapper", { "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, "nimbus.config.ts": `import { defineConfig as wrap } from "@cloudflare/nimbus-docs/config"; export default wrap({ site: "x" });` }], + ]; + for (const [name, files] of cases) { + await t.test(name, () => withProject(files, (dir) => assert.equal(parseNimbusConfig(dir).ok, false))); + } +}); + +test("rejects outside-root files, directory indexes, and symlink targets", async (t) => { + await t.test("outside root", () => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-cfg-parent-")); + const dir = path.join(parent, "project"); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(parent, "outside.ts"), `export default { site: "x" };`); + fs.writeFileSync(path.join(dir, "astro.config.ts"), `${IMPORT}\nimport config from "../outside.ts";\nexport default { integrations: [nimbus(config)] };`); + try { + assert.equal(parseNimbusConfig(dir).ok, false); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + } + }); + await t.test("directory index", () => { + withProject( + { + "astro.config.ts": `${IMPORT}\nimport config from "./config";\nexport default { integrations: [nimbus(config)] };`, + "config/index.ts": `export default { site: "x" };`, + }, + (dir) => assert.equal(parseNimbusConfig(dir).ok, false), + ); + }); + await t.test("symlink", () => { + withProject( + { + "astro.config.ts": `${IMPORT}\nimport config from "./nimbus.config.ts";\nexport default { integrations: [nimbus(config)] };`, + "real.ts": `export default { site: "x" };`, + }, + (dir) => { + fs.symlinkSync(path.join(dir, "real.ts"), path.join(dir, "nimbus.config.ts")); + assert.equal(parseNimbusConfig(dir).ok, false); + }, + ); + }); +}); + test("aliased default import is followed", () => { const r = ok( parse(`import nb, { defineConfig as d } from "@cloudflare/nimbus-docs"; diff --git a/packages/nimbus-docs/test/check/run.test.ts b/packages/nimbus-docs/test/check/run.test.ts index 63075406..5e8cfe85 100644 --- a/packages/nimbus-docs/test/check/run.test.ts +++ b/packages/nimbus-docs/test/check/run.test.ts @@ -4,8 +4,9 @@ import os from "node:os"; import path from "node:path"; import { test } from "node:test"; -import { runChecks } from "../../src/check/run.js"; +import { rewriteConfigField } from "../../src/_internal/parse-nimbus-config.js"; import { exitCodeFor } from "../../src/check/finding.js"; +import { runChecks } from "../../src/check/run.js"; function project(config: string): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-run-")); @@ -48,6 +49,32 @@ test("real site with search disabled passes env with exit 0", async () => { } }); +test("runChecks reports and rewrites an imported Nimbus config", async () => { + const dir = project(`nimbusConfig`); + fs.appendFileSync( + path.join(dir, "astro.config.ts"), + `\nimport nimbusConfig from "./nimbus.config";`, + ); + const importedFile = path.join(dir, "nimbus.config.ts"); + fs.writeFileSync( + importedFile, + `import { defineConfig } from "@cloudflare/nimbus-docs/config";\nexport default defineConfig({ site: "https://example.com", title: "X", search: false });`, + ); + try { + const r = await runChecks(dir, { env: true, structure: false, authoring: false, types: false }); + assert.ok(r.findings.some((finding) => finding.code === "nimbus/site-placeholder")); + assert.equal(r.location?.file, importedFile); + assert.ok(r.location); + const next = rewriteConfigField(r.location, "site", "https://docs.example.com"); + fs.writeFileSync(r.location.file, next); + const reparsed = await runChecks(dir, { env: true, structure: false, authoring: false, types: false }); + assert.equal(reparsed.findings.some((finding) => finding.code === "nimbus/site-placeholder"), false); + assert.match(fs.readFileSync(importedFile, "utf8"), /site: "https:\/\/docs\.example\.com"/); + } finally { + cleanup(dir); + } +}); + test("wrangler config without wrangler installed is a warning, not an error", async () => { const dir = project(`{ site: "https://docs.example.com", title: "X", search: false }`); fs.writeFileSync(path.join(dir, "wrangler.jsonc"), `{ "name": "x" }`); diff --git a/packages/nimbus-docs/test/lint/internal-link.test.ts b/packages/nimbus-docs/test/lint/internal-link.test.ts index c6a286d3..e3c601e4 100644 --- a/packages/nimbus-docs/test/lint/internal-link.test.ts +++ b/packages/nimbus-docs/test/lint/internal-link.test.ts @@ -19,21 +19,35 @@ import { test } from "node:test"; import { lintFile } from "../../src/lint/engine.js"; import { parseSource } from "../../src/lint/parse.js"; import { _resetInternalLinkCacheForTests } from "../../src/lint/rules/internal-link.js"; -import type { RouteTruth } from "../../src/lint/site-model.js"; +import { + computeRouteSourceFingerprint, + type RouteTruth, +} from "../../src/_internal/route-manifest.js"; interface Setup { root: string; pagePath: string; } -function setupProject(truth: RouteTruth): Setup { +type RouteTruthInput = Omit; + +function setupProject(truth: RouteTruthInput): Setup { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-il-")); fs.mkdirSync(path.join(root, ".nimbus"), { recursive: true }); + fs.mkdirSync(path.join(root, "src/content/docs"), { recursive: true }); + const manifest: RouteTruth = { + version: 2, + sourceFingerprint: { + version: 1, + algorithm: "sha256", + digest: computeRouteSourceFingerprint(root), + }, + ...truth, + }; fs.writeFileSync( path.join(root, ".nimbus", "routes.json"), - JSON.stringify(truth), + JSON.stringify(manifest), ); - fs.mkdirSync(path.join(root, "src/content/docs"), { recursive: true }); return { root, pagePath: path.join(root, "src/content/docs/page.mdx") }; } @@ -61,9 +75,8 @@ function lint(setup: Setup, mdx: string) { }).filter((d) => d.code === "nimbus/internal-link"); } -function baseTruth(overrides: Partial = {}): RouteTruth { +function baseTruth(overrides: Partial = {}): RouteTruthInput { return { - version: 1, base: "", knownRoutes: ["/", "/workers", "/r2", "/guides/setup", "/search"], opaqueNamespaces: [], @@ -252,9 +265,7 @@ See [./other](./other). test("internal-link stays silent under an opaque namespace", () => { // A non-framework dynamic route under `/dashboard/**` makes the namespace // opaque. Links into it must NOT be flagged. - const setup = setupProject( - baseTruth({ opaqueNamespaces: ["/dashboard"] }), - ); + const setup = setupProject(baseTruth({ opaqueNamespaces: ["/dashboard"] })); try { const diags = lint( setup, @@ -271,9 +282,7 @@ See [the dashboard](/dashboard/anything/at/all). }); test("internal-link still checks links outside an opaque namespace", () => { - const setup = setupProject( - baseTruth({ opaqueNamespaces: ["/dashboard"] }), - ); + const setup = setupProject(baseTruth({ opaqueNamespaces: ["/dashboard"] })); try { const diags = lint( setup, @@ -293,9 +302,7 @@ test("internal-link still checks links outside an opaque namespace", () => { test("internal-link only resolves links that appear in knownRoutes", () => { // A URL not in knownRoutes and not under an opaque namespace is broken, // regardless of how the truth got populated. - const setup = setupProject( - baseTruth({ knownRoutes: ["/workers"] }), - ); + const setup = setupProject(baseTruth({ knownRoutes: ["/workers"] })); try { const diags = lint(setup, `${FM}\n# x\n\n[broken](/missing)`); assert.equal(diags.length, 1); @@ -350,10 +357,7 @@ test("internal-link respects the ignore glob list", () => { ); const diags = lintFile(parsed, { rules: { - "nimbus/internal-link": [ - "error", - { ignore: ["/api/**", "/api"] }, - ], + "nimbus/internal-link": ["error", { ignore: ["/api/**", "/api"] }], }, }).filter((d) => d.code === "nimbus/internal-link"); assert.equal(diags.length, 1); @@ -537,6 +541,22 @@ test("internal-link skips silently when routes.json is missing", () => { } }); +test("internal-link never emits broken links from stale or malformed route truth", () => { + const setup = setupProject(baseTruth({ knownRoutes: ["/"] })); + try { + fs.appendFileSync( + path.join(setup.root, "src/content/docs/new.mdx"), + "# New\n", + ); + assert.deepEqual(lint(setup, `${FM}\n# x\n\n[anything](/nope)`), []); + + fs.writeFileSync(path.join(setup.root, ".nimbus/routes.json"), "{"); + assert.deepEqual(lint(setup, `${FM}\n# x\n\n[anything](/nope)`), []); + } finally { + cleanup(setup.root); + } +}); + test("internal-link skips draft sources entirely — frontmatter draft: true short-circuits the rule", () => { // Drafts are excluded from `routes.json` (Nimbus filters them from // content queries, sidebar, alternates). Linting a draft would surface @@ -691,7 +711,15 @@ test("internal-link infers the project root from the last /src/ in the path", () fs.mkdirSync(path.join(root, ".nimbus"), { recursive: true }); fs.writeFileSync( path.join(root, ".nimbus", "routes.json"), - JSON.stringify(baseTruth()), + JSON.stringify({ + version: 2, + sourceFingerprint: { + version: 1, + algorithm: "sha256", + digest: computeRouteSourceFingerprint(root), + }, + ...baseTruth(), + }), ); fs.mkdirSync(path.join(root, "src/content/docs"), { recursive: true }); _resetInternalLinkCacheForTests(); diff --git a/packages/nimbus-docs/test/registry-trust-boundary.test.ts b/packages/nimbus-docs/test/registry-trust-boundary.test.ts index 25d0fdbb..359cc01c 100644 --- a/packages/nimbus-docs/test/registry-trust-boundary.test.ts +++ b/packages/nimbus-docs/test/registry-trust-boundary.test.ts @@ -8,6 +8,9 @@ import { test, beforeEach, afterEach } from "node:test"; import { fetchComponent, fetchFeatureMarkdown, + resolveIndexEntry, + resolveIndexEntryWithSnapshot, + resolveComponentTree, registryOverrideWarning, } from "../src/cli/resolver.js"; import { REGISTRY_BASE_URL } from "../src/cli/_registry.generated.js"; @@ -18,6 +21,8 @@ interface ResSpec { status?: number; url?: string; contentType?: string; + contentLength?: number; + location?: string; json?: unknown; jsonThrows?: boolean; text?: string; @@ -27,28 +32,30 @@ function makeRes(reqUrl: string, spec: ResSpec): Response { const status = spec.status ?? 200; const headers = new Headers(); if (spec.contentType) headers.set("content-type", spec.contentType); - return { - ok: status >= 200 && status < 300, - status, - statusText: "", - url: spec.url ?? reqUrl, - headers, - async json() { - if (spec.jsonThrows) throw new SyntaxError("Unexpected token < in JSON"); - return spec.json; - }, - async text() { - return spec.text ?? ""; - }, - } as unknown as Response; + if (spec.contentLength !== undefined) { + headers.set("content-length", String(spec.contentLength)); + } + if (spec.location) headers.set("location", spec.location); + const body = spec.jsonThrows + ? "{invalid" + : spec.text ?? (spec.json === undefined ? "" : JSON.stringify(spec.json)); + const response = new Response(body, { status, headers }); + Object.defineProperty(response, "url", { value: spec.url ?? reqUrl }); + return response; } -function stubFetch(spec: ResSpec): void { +function stubFetch(spec: ResSpec | ResSpec[]): string[] { + const requests: string[] = []; + const specs = Array.isArray(spec) ? [...spec] : [spec]; globalThis.fetch = (async (input: unknown) => { const url = typeof input === "string" ? input : (input as { url: string }).url; - return makeRes(url, spec); + requests.push(url); + const next = specs.shift(); + if (!next) throw new Error(`Unexpected request: ${url}`); + return makeRes(url, next); }) as typeof fetch; + return requests; } const validPayload = { @@ -85,6 +92,27 @@ test("valid payload parses to a typed ComponentItem", async () => { assert.deepEqual(item.dependencies, ["clsx", "@astrojs/react"]); }); +test("component payload name must agree with the requested slug", async () => { + stubFetch({ + contentType: "application/json", + json: { ...validPayload, name: "button" }, + }); + await assert.rejects(fetchComponent("dialog"), /does not match requested slug/); +}); + +test("component payload type must agree with its index entry", async () => { + stubFetch({ contentType: "application/json", json: validPayload }); + await assert.rejects( + fetchComponent("dialog", { + name: "dialog", + type: "registry:lib", + title: "Dialog", + description: "A dialog.", + }), + /does not match index type/, + ); +}); + test("exact dependency versions pass payload validation", async () => { const dependencies = [ "@scalar/openapi-parser@0.28.12", @@ -154,8 +182,213 @@ test("traversal-shaped registry slug is rejected", async () => { await assert.rejects(fetchComponent("dialog"), /valid registry slug/); }); +test("remote display fields reject terminal control characters", async () => { + stubFetch({ + contentType: "application/json", + json: { ...validPayload, title: "Dialog\u001b]8;;https://evil.test\u0007click" }, + }); + await assert.rejects(fetchComponent("dialog"), /failed validation/); +}); + +test("transitive components must exist in the registry index", async () => { + stubFetch([ + { + contentType: "application/json", + json: { ...validPayload, registryDependencies: ["not-indexed"] }, + }, + { + contentType: "application/json", + json: { version: 1, registryVersion: "next", items: {} }, + }, + ]); + await assert.rejects( + resolveComponentTree("dialog", { + name: "dialog", + type: "registry:ui", + title: "Dialog", + description: "A dialog.", + }), + /Unknown registry item/, + ); +}); + +test("components cannot depend on registry features", async () => { + stubFetch([ + { + contentType: "application/json", + json: { ...validPayload, registryDependencies: ["agent-guide"] }, + }, + { + contentType: "application/json", + json: { + version: 1, + registryVersion: "next", + items: { + "agent-guide": { + name: "agent-guide", + type: "registry:feature", + title: "Agent guide", + description: "Instructions.", + }, + }, + }, + }, + ]); + await assert.rejects( + resolveComponentTree("dialog", { + name: "dialog", + type: "registry:ui", + title: "Dialog", + description: "A dialog.", + }), + /cannot depend on feature/, + ); +}); + +test("a component tree uses one consistent live index snapshot", async () => { + const requests = stubFetch([ + { + contentType: "application/json", + json: { + version: 1, + registryVersion: "next", + items: { + "new-dialog": { + name: "new-dialog", + type: "registry:ui", + title: "Dialog", + description: "A dialog.", + }, + "dep-a": { + name: "dep-a", + type: "registry:lib", + title: "Dependency A", + description: "First dependency.", + }, + "dep-b": { + name: "dep-b", + type: "registry:lib", + title: "Dependency B", + description: "Second dependency.", + }, + }, + }, + }, + { + contentType: "application/json", + json: { + ...validPayload, + name: "new-dialog", + registryDependencies: ["dep-a", "dep-b"], + }, + }, + { + contentType: "application/json", + json: { + ...validPayload, + name: "dep-a", + type: "registry:lib", + registryDependencies: [], + }, + }, + { + contentType: "application/json", + json: { + ...validPayload, + name: "dep-b", + type: "registry:lib", + registryDependencies: [], + }, + }, + ]); + const resolved = await resolveIndexEntryWithSnapshot("new-dialog"); + const items = await resolveComponentTree( + "new-dialog", + resolved.entry, + resolved.liveIndex, + ); + assert.deepEqual(items.map((item) => item.name), ["dep-a", "dep-b", "new-dialog"]); + assert.equal( + requests.filter((url) => url.endsWith("/registry.json")).length, + 1, + ); +}); + // ---- Transport guards ----------------------------------------------------- +test("bundled index hits remain offline", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + throw new Error("must stay offline"); + }) as typeof fetch; + const entry = await resolveIndexEntry("dialog"); + assert.equal(entry.name, "dialog"); + assert.equal(calls, 0); +}); + +test("invalid requested slugs are rejected before lookup or fetch", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + throw new Error("must not fetch"); + }) as typeof fetch; + await assert.rejects(resolveIndexEntry("__proto__"), /Invalid registry item/); + await assert.rejects(resolveIndexEntry("../dialog"), /Invalid registry item/); + assert.equal(calls, 0); +}); + +test("a bundled miss resolves from a valid live index", async () => { + const requests = stubFetch({ + contentType: "application/json", + json: { + version: 1, + registryVersion: "next", + items: { + "new-feature": { + name: "new-feature", + type: "registry:feature", + title: "New feature", + description: "Newly published.", + }, + }, + }, + }); + const entry = await resolveIndexEntry("new-feature"); + assert.equal(entry.type, "registry:feature"); + assert.deepEqual(requests, [`${TEST_ORIGIN}/registry.json`]); +}); + +test("live index rejects key/name disagreement", async () => { + stubFetch({ + contentType: "application/json", + json: { + version: 1, + registryVersion: "next", + items: { + "new-feature": { + name: "other-feature", + type: "registry:feature", + title: "New feature", + description: "Newly published.", + }, + }, + }, + }); + await assert.rejects(resolveIndexEntry("new-feature"), /must match its key/); +}); + +test("unknown item after a successful live index fetch is actionable", async () => { + stubFetch({ + contentType: "application/json", + json: { version: 1, registryVersion: "next", items: {} }, + }); + await assert.rejects( + resolveIndexEntry("not-published"), + /checked successfully; verify the spelling/, + ); +}); + test("cross-origin redirect is refused", async () => { stubFetch({ contentType: "application/json", @@ -175,6 +408,29 @@ test("same-origin redirect (e.g. trailing slash / path change) is allowed", asyn assert.equal(item.name, "dialog"); }); +test("same-origin redirects are followed manually", async () => { + const requests = stubFetch([ + { status: 302, location: "/moved/dialog.json" }, + { contentType: "application/json", json: validPayload }, + ]); + const item = await fetchComponent("dialog"); + assert.equal(item.name, "dialog"); + assert.deepEqual(requests, [ + `${TEST_ORIGIN}/components/dialog.json`, + `${TEST_ORIGIN}/moved/dialog.json`, + ]); +}); + +test("more than five redirects are refused", async () => { + stubFetch( + Array.from({ length: 6 }, (_, index) => ({ + status: 302, + location: `/redirect-${index + 1}`, + })), + ); + await assert.rejects(fetchComponent("dialog"), /limit of 5 redirects/); +}); + test("HTML response for a component fetch fails cleanly", async () => { stubFetch({ contentType: "text/html", text: "" }); await assert.rejects(fetchComponent("dialog"), /returned HTML/); @@ -199,6 +455,55 @@ test("feature fetch returns markdown on a clean response", async () => { assert.equal(md, "# Hello\n"); }); +test("content-length is rejected before reading oversized responses", async () => { + stubFetch({ + contentType: "text/markdown", + contentLength: 2 * 1024 * 1024 + 1, + text: "small body", + }); + await assert.rejects(fetchFeatureMarkdown("404-page"), /Content-Length/); +}); + +test("streamed response size is bounded after decompression", async () => { + stubFetch({ + contentType: "application/json", + text: "x".repeat(1024 * 1024 + 1), + }); + await assert.rejects(resolveIndexEntry("new-feature"), /byte size limit/); +}); + +for (const [status, expected] of [ + [401, /access was denied \(401\)/], + [403, /access was denied \(403\)/], + [404, /not found \(404\)/], + [429, /rate limit exceeded \(429\)/], + [503, /server is unavailable \(503\)/], +] as const) { + test(`HTTP ${status} is classified`, async () => { + stubFetch({ status }); + await assert.rejects(fetchComponent("dialog"), expected); + }); +} + +test("nested proxy CONNECT failures retain their actionable cause", async () => { + globalThis.fetch = (async () => { + throw new TypeError("fetch failed", { + cause: new Error("proxy CONNECT tunnel refused"), + }); + }) as typeof fetch; + await assert.rejects( + fetchComponent("dialog"), + /Proxy\/connection error: fetch failed -> proxy CONNECT tunnel refused/, + ); +}); + +test("timeouts are classified separately from transport failures", async () => { + globalThis.fetch = (async () => { + throw new DOMException("The operation timed out", "TimeoutError"); + }) as typeof fetch; + await assert.rejects(fetchComponent("dialog"), /timed out after 10 seconds/); +}); + // ---- Override warning (pure) ---------------------------------------------- test("registryOverrideWarning names a non-default host", () => { diff --git a/packages/nimbus-docs/test/rendering-policy.test.ts b/packages/nimbus-docs/test/rendering-policy.test.ts index 377fd692..43a55f62 100644 --- a/packages/nimbus-docs/test/rendering-policy.test.ts +++ b/packages/nimbus-docs/test/rendering-policy.test.ts @@ -47,23 +47,26 @@ test("request inventory preserves prose ids and only collapses the API root", () requestInventoryEntryUrl("", "guides/index", false), "/guides/index", ); - assert.equal(requestInventoryEntryUrl("/blog", "index", false), "/blog/index"); + assert.equal( + requestInventoryEntryUrl("/blog", "index", false), + "/blog/index", + ); assert.equal(requestInventoryEntryUrl("/api", "index", true), "/api"); assert.equal( requestInventoryEntryUrl("/api", "guides/index", true), "/api/guides/index", ); - assert.equal(requestInventoryVersionStatusKey("docs-v1", false, "v1"), "docs-v1"); + assert.equal( + requestInventoryVersionStatusKey("docs-v1", false, "v1"), + "docs-v1", + ); assert.equal(requestInventoryVersionStatusKey("api", true, "v1"), "api@v1"); }); test("request inventory reader removes root and base-prefixed candidates", async (t) => { const root = await mkdtemp(path.join(tmpdir(), "nimbus-request-inventory-")); t.after(() => rm(root, { recursive: true, force: true })); - const rootInventory = path.join( - root, - "_nimbus/request-route-inventory.json", - ); + const rootInventory = path.join(root, "_nimbus/request-route-inventory.json"); const basedInventory = path.join( root, "docs/_nimbus/request-route-inventory.json", @@ -392,10 +395,12 @@ async function generateRequestSitemap( const sitemapIntegration = integration.configUpdates .flatMap( (update) => - (update.integrations as Array<{ - name: string; - hooks: Record unknown>; - }> | undefined) ?? [], + (update.integrations as + | Array<{ + name: string; + hooks: Record unknown>; + }> + | undefined) ?? [], ) .find((candidate) => candidate.name === "@astrojs/sitemap"); assert.ok(sitemapIntegration); @@ -530,7 +535,10 @@ test("upstream sitemap chunks request pages read from a mutable customPages arra 5, ); assert.ok(chunks.every((xml) => (xml.match(//g)?.length ?? 0) <= 2)); - assert.match(await readFile(path.join(root, "sitemap-index.xml"), "utf8"), /sitemap-2\.xml/); + assert.match( + await readFile(path.join(root, "sitemap-index.xml"), "utf8"), + /sitemap-2\.xml/, + ); }); test("request inventory is removed before downstream build failures", async (t) => { @@ -828,24 +836,22 @@ test("configured request routes are explained to the build invariant", async (t) assert.ok( infos.some((message) => /docs prerendered=2\/3 \(1 moved\)/.test(message)), ); - assert.deepEqual( - JSON.parse( - await readFile( - path.join(integration.root, ".nimbus/routes.json"), - "utf8", - ), - ), - { - version: 1, - base: "", - knownRoutes: [ - "/built", - "/foo/_nimbus/request-route-inventory.json", - "/guide", - ], - opaqueNamespaces: [], - }, + const routeTruth = JSON.parse( + await readFile(path.join(integration.root, ".nimbus/routes.json"), "utf8"), ); + assert.equal(routeTruth.version, 2); + assert.deepEqual(routeTruth.sourceFingerprint, { + version: 1, + algorithm: "sha256", + digest: routeTruth.sourceFingerprint.digest, + }); + assert.match(routeTruth.sourceFingerprint.digest, /^[a-f0-9]{64}$/); + assert.deepEqual(routeTruth.knownRoutes, [ + "/built", + "/foo/_nimbus/request-route-inventory.json", + "/guide", + ]); + assert.deepEqual(routeTruth.opaqueNamespaces, []); assert.equal( integration.injectedRoutes.some( (candidate) => @@ -863,6 +869,71 @@ test("configured request routes are explained to the build invariant", async (t) ); }); +test("production build start invalidates old route truth and failed builds do not restore it", async (t) => { + const integration = await setupIntegration(t, undefined, "build"); + integration.configDone({ + injectTypes: () => new URL("file:///noop"), + config: { output: "server", adapter: { name: "node" } }, + buildOutput: "server", + } as never); + integration.routesResolved({ routes: [] } as never); + + const manifest = path.join(integration.root, ".nimbus/routes.json"); + await mkdir(path.dirname(manifest), { recursive: true }); + await writeFile(manifest, '{"version":1}\n', "utf8"); + await integration.buildStart({} as never); + await assert.rejects(() => readFile(manifest, "utf8"), /ENOENT/); + + await assert.rejects( + () => + integration.buildDone({ + dir: pathToFileURL(`${path.join(integration.root, "dist")}${path.sep}`), + pages: [{ pathname: "/" }], + logger: buildLogger, + } as never), + /CANNOT BE VERIFIED/, + ); + await assert.rejects(() => readFile(manifest, "utf8"), /ENOENT/); +}); + +test("a source change during a production build leaves route truth invalidated", async (t) => { + const integration = await setupIntegration(t, undefined, "build"); + integration.configDone({ + injectTypes: () => new URL("file:///noop"), + config: { output: "static", adapter: undefined }, + buildOutput: "static", + } as never); + integration.routesResolved({ + routes: [ + { + pattern: "/", + entrypoint: "src/pages/[...slug].astro", + type: "page", + isPrerendered: true, + origin: "project", + }, + ], + } as never); + await integration.buildStart({} as never); + await mkdir(path.join(integration.root, "src/data"), { recursive: true }); + await writeFile( + path.join(integration.root, "src/data/routes.json"), + '["/new"]\n', + "utf8", + ); + const dist = path.join(integration.root, "dist"); + await mkdir(dist, { recursive: true }); + await integration.buildDone({ + dir: pathToFileURL(`${dist}${path.sep}`), + pages: [{ pathname: "/" }], + logger: buildLogger, + } as never); + await assert.rejects( + () => readFile(path.join(integration.root, ".nimbus/routes.json"), "utf8"), + /ENOENT/, + ); +}); + test("dev setup preserves pre-registered request styles", async (t) => { const integration = await setupIntegration(t, { collections: { docs: "request" }, diff --git a/packages/nimbus-docs/test/route-manifest.test.ts b/packages/nimbus-docs/test/route-manifest.test.ts new file mode 100644 index 00000000..374b5fa1 --- /dev/null +++ b/packages/nimbus-docs/test/route-manifest.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { + computeRouteSourceFingerprint, + inspectRouteManifest, + type RouteTruth, +} from "../src/_internal/route-manifest.js"; + +function project(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nimbus-routes-")); + fs.mkdirSync(path.join(root, "src/content/docs"), { recursive: true }); + fs.mkdirSync(path.join(root, "src/pages"), { recursive: true }); + fs.writeFileSync(path.join(root, "src/content/docs/index.mdx"), "# Home\n"); + fs.writeFileSync(path.join(root, "src/pages/search.astro"), "---\n---\n"); + fs.writeFileSync(path.join(root, "astro.config.ts"), "export default {};\n"); + fs.writeFileSync( + path.join(root, "src/content.config.ts"), + "export const collections = {};\n", + ); + return root; +} + +function writeFreshManifest(root: string): void { + const truth: RouteTruth = { + version: 2, + sourceFingerprint: { + version: 1, + algorithm: "sha256", + digest: computeRouteSourceFingerprint(root), + }, + base: "", + knownRoutes: ["/", "/search"], + opaqueNamespaces: [], + }; + fs.mkdirSync(path.join(root, ".nimbus"), { recursive: true }); + fs.writeFileSync( + path.join(root, ".nimbus/routes.json"), + JSON.stringify(truth), + ); +} + +test("route source fingerprints are deterministic and bounded to route inputs", () => { + const root = project(); + try { + const initial = computeRouteSourceFingerprint(root); + assert.equal(computeRouteSourceFingerprint(root), initial); + + fs.writeFileSync(path.join(root, "README.md"), "irrelevant\n"); + assert.equal(computeRouteSourceFingerprint(root), initial); + + for (const relative of [ + "src/content/docs/index.mdx", + "src/pages/search.astro", + "astro.config.ts", + "src/content.config.ts", + ]) { + const before = computeRouteSourceFingerprint(root); + fs.appendFileSync(path.join(root, relative), "changed\n"); + assert.notEqual(computeRouteSourceFingerprint(root), before, relative); + } + + fs.mkdirSync(path.join(root, "src/data")); + fs.writeFileSync(path.join(root, "src/data/routes.json"), "[]\n"); + const withData = computeRouteSourceFingerprint(root); + fs.writeFileSync(path.join(root, "src/data/routes.json"), '["/new"]\n'); + assert.notEqual(computeRouteSourceFingerprint(root), withData); + + fs.mkdirSync(path.join(root, "config")); + fs.writeFileSync(path.join(root, "config/routes.ts"), "export default [];\n"); + const withConfigHelper = computeRouteSourceFingerprint(root); + fs.writeFileSync(path.join(root, "config/routes.ts"), "export default ['/new'];\n"); + assert.notEqual(computeRouteSourceFingerprint(root), withConfigHelper); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("route manifest inspection rejects missing, legacy, malformed, stale, and unreadable truth", () => { + const root = project(); + try { + assert.equal(inspectRouteManifest(root).status, "missing"); + + fs.mkdirSync(path.join(root, ".nimbus")); + fs.writeFileSync( + path.join(root, ".nimbus/routes.json"), + JSON.stringify({ version: 1 }), + ); + assert.equal(inspectRouteManifest(root).status, "legacy"); + + fs.writeFileSync(path.join(root, ".nimbus/routes.json"), "{"); + assert.equal(inspectRouteManifest(root).status, "malformed"); + + writeFreshManifest(root); + assert.equal(inspectRouteManifest(root).status, "fresh"); + fs.appendFileSync(path.join(root, "src/content/docs/index.mdx"), "stale\n"); + assert.equal(inspectRouteManifest(root).status, "stale"); + + fs.rmSync(path.join(root, ".nimbus/routes.json")); + fs.mkdirSync(path.join(root, ".nimbus/routes.json")); + assert.equal(inspectRouteManifest(root).status, "unreadable"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("unsupported route-source entries make otherwise valid truth unreadable", () => { + const root = project(); + try { + writeFreshManifest(root); + fs.symlinkSync( + path.join(root, "README.md"), + path.join(root, "src/pages/linked.astro"), + ); + assert.equal(inspectRouteManifest(root).status, "unreadable"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/nimbus-docs/test/transform-citations.test.ts b/packages/nimbus-docs/test/transform-citations.test.ts index 42760919..809b54dc 100644 --- a/packages/nimbus-docs/test/transform-citations.test.ts +++ b/packages/nimbus-docs/test/transform-citations.test.ts @@ -23,6 +23,14 @@ describe("renderEntryAsMarkdown: coordinate citations", () => { assert.doesNotMatch(out, /api\.ref:/); }); + test("applies the configured base to resolved citations", () => { + const out = renderEntryAsMarkdown( + { body: "See [create a zone](api.ref:zones:createZone) first." }, + { citationIndex: index, base: "/docs" }, + ); + assert.match(out, /\[create a zone\]\(\/docs\/api\/zones\/create-zone\)/); + }); + test("derived mode: an unresolved citation degrades to # without throwing", () => { const out = renderEntryAsMarkdown( { body: "See [gone](api.ref:zones:removedOp)." }, diff --git a/packages/nimbus-starter-source/AGENT.md b/packages/nimbus-starter-source/AGENT.md index 2e0df159..8986e14a 100644 --- a/packages/nimbus-starter-source/AGENT.md +++ b/packages/nimbus-starter-source/AGENT.md @@ -1,6 +1,6 @@ # This Nimbus docs site -Astro-based docs. The `nimbus-docs` package handles content schemas, sidebar/TOC, MDX→markdown, build hooks, and the `nimbus` CLI. Everything in `src/` is yours to edit. +Astro-based docs. The `@cloudflare/nimbus-docs` package handles content schemas, sidebar/TOC, MDX→markdown, build hooks, and the `nimbus-docs` CLI. Everything in `src/` is yours to edit. ## File layout @@ -31,7 +31,7 @@ Cloudflare deploys also have `wrangler.jsonc` at the project root. ## Writing docs -Frontmatter validates against `docsSchema` (`nimbus-docs/schemas`). Required: `title`. +Frontmatter validates against `docsSchema` (`@cloudflare/nimbus-docs/schemas`). Required: `title`. ```mdx --- @@ -48,7 +48,7 @@ Rules: - **Components must be PascalCase and registered in `src/components.ts`.** A pre-build validator catches typos with a "did you mean" hint. - **Partials use ``.** Don't import `.mdx` directly. Shared content lives in `src/content/partials/.mdx`. -- **Icons use `astro-icon` + Phosphor.** `` from `astro-icon/components`. Glyphs: [phosphoricons.com](https://phosphoricons.com). +- **Icons use Nimbus + Phosphor.** Import `Icon` from `@cloudflare/nimbus-docs/components/Icon.astro`, then use ``. Glyphs: [phosphoricons.com](https://phosphoricons.com). - **Don't remove `` from `BaseLayout.astro`.** It points agents at `/llms.txt`. ## Adding things diff --git a/packages/nimbus-starter-source/gitignore b/packages/nimbus-starter-source/gitignore index 695a043a..46f044c7 100644 --- a/packages/nimbus-starter-source/gitignore +++ b/packages/nimbus-starter-source/gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ .astro/ .wrangler/ +.nimbus/ .env .env.local diff --git a/packages/nimbus-starter-source/src/components/Header.astro b/packages/nimbus-starter-source/src/components/Header.astro index 33d2bf15..c04017ed 100644 --- a/packages/nimbus-starter-source/src/components/Header.astro +++ b/packages/nimbus-starter-source/src/components/Header.astro @@ -7,6 +7,7 @@ import { ThemeToggle } from "./ui/theme-toggle"; import SearchTrigger from "./ui/search/SearchTrigger.astro"; import { config } from "virtual:nimbus/config"; import { getSidebarSections } from "@cloudflare/nimbus-docs/runtime"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; interface Props { /** Astro collection id for the current page, forwarded from DocsLayout. */ @@ -17,8 +18,9 @@ interface Props { * Whether the page has a sidebar to open. When `false`, the mobile * menu button is hidden — pages that opted out via `sidebar: false` * shouldn't show a button that opens an empty dialog. Default `true`. - */ + */ showSidebar?: boolean; + /** Override prose section links. */ sections?: SidebarSection[]; } @@ -27,29 +29,38 @@ interface Props { // destructure to avoid an unused-var warning. const { collection, showSidebar = true, sections: sectionsProp } = Astro.props; -// Normalize trailing slash so isActive matches sidebar hrefs. -const currentSlug = Astro.url.pathname.replace(/\/$/, "") || "/"; +const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); +const pathname = Astro.url.pathname; +const unbasedPathname = basePath && (pathname === basePath || pathname.startsWith(`${basePath}/`)) + ? pathname.slice(basePath.length) || "/" + : pathname; +const currentSlug = unbasedPathname.replace(/\/$/, "") || "/"; const sections = sectionsProp ?? (await getSidebarSections(currentSlug, { collection })); const showSections = sections.length >= 2; +const homeHref = withBase("/", import.meta.env.BASE_URL); +const resolveSectionHref = (href: string): string => + sectionsProp === undefined + ? withBase(href, import.meta.env.BASE_URL) + : href; ---
- - + + {config.title} {showSections && ( -
diff --git a/packages/nimbus-starter-source/src/components/ui/breadcrumbs/Breadcrumbs.astro b/packages/nimbus-starter-source/src/components/ui/breadcrumbs/Breadcrumbs.astro index d27dc6f0..9b981df8 100644 --- a/packages/nimbus-starter-source/src/components/ui/breadcrumbs/Breadcrumbs.astro +++ b/packages/nimbus-starter-source/src/components/ui/breadcrumbs/Breadcrumbs.astro @@ -2,6 +2,7 @@ import { cn } from "@/lib/cn"; import type { HTMLAttributes } from "astro/types"; import type { Breadcrumb } from "@cloudflare/nimbus-docs/types"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; interface Props extends HTMLAttributes<"nav"> { items: Breadcrumb[]; @@ -18,6 +19,8 @@ interface Props extends HTMLAttributes<"nav"> { const { items, maxVisible = 4, endsAtCurrentPage = true, class: className, ...attrs } = Astro.props; const isCurrent = (isLast: boolean): boolean => endsAtCurrentPage && isLast; +const resolveHref = (href: string): string => + href.startsWith("/") ? withBase(href, import.meta.env.BASE_URL) : href; const shouldCollapse = items.length > maxVisible; // When collapsed: Home + first segment + ... + last 2 (parent + current) @@ -37,7 +40,7 @@ const tailItems = shouldCollapse ? items.slice(items.length - tailCount) : []; {isCurrent(!shouldCollapse && i === items.length - 1) || !crumb.href ? ( {crumb.label} ) : ( - + {crumb.label} )} @@ -58,7 +61,7 @@ const tailItems = shouldCollapse ? items.slice(items.length - tailCount) : []; {collapsedItems.map((crumb) => ( crumb.href ? ( {crumb.label} @@ -80,7 +83,7 @@ const tailItems = shouldCollapse ? items.slice(items.length - tailCount) : []; {isCurrent(i === tailItems.length - 1) || !crumb.href ? ( {crumb.label} ) : ( - + {crumb.label} )} diff --git a/packages/nimbus-starter-source/src/components/ui/pagination/Pagination.astro b/packages/nimbus-starter-source/src/components/ui/pagination/Pagination.astro index 40d78528..0a1f088e 100644 --- a/packages/nimbus-starter-source/src/components/ui/pagination/Pagination.astro +++ b/packages/nimbus-starter-source/src/components/ui/pagination/Pagination.astro @@ -3,6 +3,7 @@ import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; import { cn } from "@/lib/cn"; import type { HTMLAttributes } from "astro/types"; import type { PrevNext } from "@cloudflare/nimbus-docs/types"; +import { withBase } from "@cloudflare/nimbus-docs/runtime"; interface Props extends HTMLAttributes<"nav"> { prevNext: PrevNext; @@ -10,12 +11,14 @@ interface Props extends HTMLAttributes<"nav"> { const { prevNext, class: className, ...attrs } = Astro.props; const { prev, next } = prevNext; +const prevHref = prev ? withBase(prev.href, import.meta.env.BASE_URL) : undefined; +const nextHref = next ? withBase(next.href, import.meta.env.BASE_URL) : undefined; --- {(prev || next) && (