From ebe8ec8667b8805e76e89b35a045b6b367ceadfe Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Tue, 7 Jul 2026 08:38:21 +0000 Subject: [PATCH 1/2] docs(commands): route Install OpenClaw Plugins link to published section The OpenClaw commands reference linked to ../deployment/install-openclaw-plugins, mirroring the target's source directory. Fern publishes that page under the manage-sandboxes nav section (docs/index.yml), so the link 404s on the live site even though the source file exists on disk. PR #6290 reverted a previously correct link because fern check and source-path checks both pass on it. Restore the published-route link and add a route-level regression guard: scripts/check-docs-published-routes.ts derives the published route map from docs/index.yml and resolves the commands page's relative links route-relative (as Fern serves them), failing if any resolves to no published route. It is wired into 'npm run docs:strict' and covered by a Vitest regression that fails on upstream/main and passes after the fix. Verified against Fern's own 'fern docs broken-links': the commands-page error is gone (26 -> 24), none added. Fixes #5445 Signed-off-by: Yimo Jiang --- docs/reference/commands.mdx | 2 +- package.json | 3 +- scripts/check-docs-published-routes.ts | 248 +++++++++++++++++++ test/repro-5445-docs-published-route.test.ts | 110 ++++++++ 4 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 scripts/check-docs-published-routes.ts create mode 100644 test/repro-5445-docs-published-route.test.ts diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d6356c5816c..4930992cc36 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1669,7 +1669,7 @@ Skill names must contain only alphanumeric characters, dots, hyphens, and unders OpenClaw plugins are a different kind of extension. -To install an OpenClaw plugin, refer to [Install OpenClaw Plugins](../deployment/install-openclaw-plugins). +To install an OpenClaw plugin, refer to [Install OpenClaw Plugins](../manage-sandboxes/install-openclaw-plugins). For OpenClaw, the command uploads the skill to the OpenClaw state directory and mirrors it into `$HOME/.openclaw/skills/` when the agent home directory differs from the state directory. That mirror makes skills listed by `openclaw skills list` available at session startup. If mirror creation fails, NemoClaw prints a warning so you can reinstall or inspect the home directory permissions. diff --git a/package.json b/package.json index 1665e6131e6..86df0f0dd81 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,8 @@ "docs:deps": "node -p \"require('./fern/fern.config.json').version\" | xargs -I {} npx --yes fern-api@{} --version", "docs:sync-agent-variants": "tsx scripts/sync-agent-variant-docs.ts", "docs:check-agent-variants": "tsx scripts/sync-agent-variant-docs.ts --check", - "docs:strict": "npm run docs:check-agent-variants && FERN_VERSION=$(node -p \"require('./fern/fern.config.json').version\") && cd fern && npx --yes \"fern-api@${FERN_VERSION}\" check", + "docs:check-routes": "tsx scripts/check-docs-published-routes.ts", + "docs:strict": "npm run docs:check-agent-variants && npm run docs:check-routes && FERN_VERSION=$(node -p \"require('./fern/fern.config.json').version\") && cd fern && npx --yes \"fern-api@${FERN_VERSION}\" check", "docs:live": "FERN_VERSION=$(node -p \"require('./fern/fern.config.json').version\") && cd fern && npx --yes \"fern-api@${FERN_VERSION}\" docs dev", "docs:preview:watch": "tsx scripts/watch-fern-preview.ts", "docs:clean": "rm -rf .fern-cache fern/.fern-cache docs/_build", diff --git a/scripts/check-docs-published-routes.ts b/scripts/check-docs-published-routes.ts new file mode 100644 index 00000000000..6823009f337 --- /dev/null +++ b/scripts/check-docs-published-routes.ts @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Validate that relative cross-page links on drift-prone docs pages resolve to +// real *published* Fern routes, not merely to source files that exist on disk. +// +// Background (NemoClaw#5445): Fern publishes a page at a route built from its +// navigation section slugs (docs/index.yml), which can differ from the source +// file's directory. `docs/deployment/install-openclaw-plugins.mdx` is published +// under the `manage-sandboxes` section, so its route is +// `/user-guide/openclaw/manage-sandboxes/install-openclaw-plugins`. A link that +// mirrors the *source directory* (`../deployment/install-openclaw-plugins`) +// points at a route that does not exist and 404s on the live site even though +// the source file resolves on disk. PR #6290 made exactly that mistake because +// `fern check` and source-path checks both passed. This checker resolves links +// route-relative against the published route map so the drift cannot recur on +// the commands reference page that has regressed repeatedly. + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parse } from "yaml"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const docsRoot = path.join(repoRoot, "docs"); + +export type PublishedRouteIndex = { + /** Every published page route, e.g. `/user-guide/openclaw/reference/commands`. */ + routes: Set; + /** Docs source path (relative to docs/) → its published route(s). */ + sourceToRoutes: Map; +}; + +type NavNode = { + page?: string; + section?: string; + link?: string; + title?: string; + slug?: string; + path?: string; + contents?: NavNode[]; + layout?: NavNode[]; + variants?: NavNode[]; +}; + +// A generated agent-variant page (`_build/agent-variants/foo.openclaw.generated.mdx`) +// is rendered from the shared source `foo.mdx`; links live in that source, so map +// both paths to the same route. +function agentVariantSourcePath(navPath: string): string | null { + const match = navPath.match( + /^_build\/agent-variants\/(.+)\.(?:openclaw|hermes)\.generated\.mdx$/, + ); + return match ? `${match[1]}.mdx` : null; +} + +function walkLayout( + nodes: NavNode[] | undefined, + variant: string, + parents: string[], + index: PublishedRouteIndex, +): void { + for (const node of nodes ?? []) { + // Fail loud rather than silently corrupt the route map: this repo always + // declares explicit slugs, and Fern auto-derives a slug from the title when + // one is omitted, so a slugless page/section would shift every downstream + // route. If that convention ever changes, update this checker deliberately. + if (node.path && !node.slug) { + throw new Error(`docs/index.yml page '${node.path}' has no slug; route checker needs it`); + } + if (node.contents && node.section !== undefined && !node.slug) { + throw new Error( + `docs/index.yml section '${node.section}' has no slug; route checker needs it`, + ); + } + if (node.path && node.slug) { + const route = `/${["user-guide", variant, ...parents, node.slug].join("/")}`; + index.routes.add(route); + for (const source of [node.path, agentVariantSourcePath(node.path)]) { + if (!source) continue; + const existing = index.sourceToRoutes.get(source) ?? []; + if (!existing.includes(route)) existing.push(route); + index.sourceToRoutes.set(source, existing); + } + } + if (node.contents) { + const childParents = node.slug ? [...parents, node.slug] : parents; + walkLayout(node.contents, variant, childParents, index); + } + } +} + +export function buildPublishedRouteIndex( + navYaml: string = readFileSync(path.join(docsRoot, "index.yml"), "utf8"), +): PublishedRouteIndex { + const doc = parse(navYaml) as { navigation?: NavNode[] }; + const userGuide = doc.navigation?.find((item) => Array.isArray(item.variants)); + if (!userGuide?.variants) { + throw new Error("docs/index.yml must define navigation variants"); + } + const index: PublishedRouteIndex = { routes: new Set(), sourceToRoutes: new Map() }; + for (const variant of userGuide.variants) { + if (!variant.slug) continue; + walkLayout(variant.layout, variant.slug, [], index); + } + if (index.routes.size === 0) { + throw new Error("no published routes derived from docs/index.yml"); + } + return index; +} + +/** + * Resolve a relative link the way Fern serves it: relative to the linking + * page's published route (the page slug is dropped, then `..`/`.`/segments are + * applied), NOT relative to the source file's directory. + */ +export function resolvePublishedRoute(fromRoute: string, target: string): string { + // Drop the query/fragment, then the .md/.mdx extension: Fern serves pages + // extensionless, so `../foo/bar.mdx` and `../foo/bar` reach the same route. + const cleanTarget = target.replace(/[?#].*$/, "").replace(/\.mdx?$/, ""); + const parts = fromRoute.replace(/^\//, "").split("/"); + parts.pop(); // drop the linking page's own slug + for (const segment of cleanTarget.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (parts.length > 0) parts.pop(); + } else { + parts.push(segment); + } + } + return `/${parts.join("/")}`; +} + +export type MarkdownLink = { text: string; target: string; line: number }; + +/** Extract markdown links, skipping fenced code blocks and inline code spans. */ +export function extractMarkdownLinks(body: string): MarkdownLink[] { + const links: MarkdownLink[] = []; + const lines = body.split(/\r?\n/); + // Track the opening fence char and length: a fence closes only on the same + // char with length >= the opener (CommonMark), so a 3-backtick line inside a + // 4-backtick or ~~~ block does not prematurely flip state. + let fenceChar = ""; + let fenceLen = 0; + let inFence = false; + lines.forEach((rawLine, i) => { + const fenceMatch = rawLine.match(/^\s*(`{3,}|~{3,})(.*)$/); + if (fenceMatch) { + const marker = fenceMatch[1]; + const [char, len, rest] = [marker[0], marker.length, fenceMatch[2]]; + if (!inFence) { + [inFence, fenceChar, fenceLen] = [true, char, len]; + } else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(rest)) { + [inFence, fenceChar, fenceLen] = [false, "", 0]; + } + return; + } + if (inFence) return; + // Blank out inline code spans so a `[x](y)` inside backticks is ignored, but + // keep an empty link-text group (`[]`) matchable so links whose text is + // entirely an inline-code span (e.g. [`nemoclaw list`](...)) are still seen. + const scan = rawLine.replace(/`[^`]*`/g, ""); + // Tolerate an optional CommonMark link title: [text](target "title"). + const linkRe = /(? isRelativeLink(link.target)); + const violations: RouteViolation[] = []; + for (const link of links) { + for (const fromRoute of routes) { + const resolved = resolvePublishedRoute(fromRoute, link.target); + if (!index.routes.has(resolved)) { + violations.push({ sourcePath, fromRoute, ...link, resolved }); + } + } + } + return violations; +} + +// Pages that have repeatedly regressed on source-path-vs-published-route drift +// (NemoClaw#5445, #6290, #5465, #5460). Scoped intentionally: the wider docs +// tree has unrelated pre-existing broken links tracked separately. +const GUARDED_SOURCE_PAGES = ["reference/commands.mdx"]; + +function main(): void { + const index = buildPublishedRouteIndex(); + const violations = GUARDED_SOURCE_PAGES.flatMap((source) => + findBrokenPublishedRoutes(source, index), + ); + if (violations.length > 0) { + console.error( + "check-docs-published-routes: relative links resolve to no published Fern route.", + ); + console.error( + "Link by the target page's navigation section slug (docs/index.yml), not its source directory.\n", + ); + for (const v of violations) { + console.error( + ` docs/${v.sourcePath}:${v.line} [${v.text}](${v.target})\n` + + ` from route ${v.fromRoute}\n` + + ` resolves to ${v.resolved} — not a published route`, + ); + } + process.exit(1); + } + console.log( + `check-docs-published-routes: OK — ${GUARDED_SOURCE_PAGES.length} guarded page(s), all relative links resolve to published routes`, + ); +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + main(); +} diff --git a/test/repro-5445-docs-published-route.test.ts b/test/repro-5445-docs-published-route.test.ts new file mode 100644 index 00000000000..a3a6006ba3a --- /dev/null +++ b/test/repro-5445-docs-published-route.test.ts @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Route-level regression for NemoClaw#5445: the OpenClaw commands reference page +// linked to `../deployment/install-openclaw-plugins`, which mirrors the target's +// SOURCE directory (`docs/deployment/install-openclaw-plugins.mdx`) rather than +// its PUBLISHED nav section. Fern serves that page under the `manage-sandboxes` +// section, so the source-directory link 404s on the live site even though the +// file exists on disk. `fern check` and source-path checks (PR #6290) missed it; +// these assertions derive the published route from docs/index.yml and check the +// route the reader actually navigates to. + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildPublishedRouteIndex, + extractMarkdownLinks, + findBrokenPublishedRoutes, + resolvePublishedRoute, +} from "../scripts/check-docs-published-routes.ts"; + +const REPO_ROOT = path.dirname(import.meta.dirname); +const COMMANDS_SOURCE = "reference/commands.mdx"; +const CORRECT_ROUTE = "/user-guide/openclaw/manage-sandboxes/install-openclaw-plugins"; +const WRONG_ROUTE = "/user-guide/openclaw/deployment/install-openclaw-plugins"; + +const index = buildPublishedRouteIndex(); +const commandsBody = fs.readFileSync(path.join(REPO_ROOT, "docs", COMMANDS_SOURCE), "utf8"); +const installLink = extractMarkdownLinks(commandsBody).find( + (link) => link.text === "Install OpenClaw Plugins", +); +const commandsRoutes = index.sourceToRoutes.get(COMMANDS_SOURCE) ?? []; + +describe("docs published-route map derived from docs/index.yml (#5445)", () => { + it("publishes Install OpenClaw Plugins under the manage-sandboxes section (#5445)", () => { + expect(index.routes.has(CORRECT_ROUTE)).toBe(true); + }); + + it("does not publish the plugins page under a deployment route (#5445)", () => { + expect(index.routes.has(WRONG_ROUTE)).toBe(false); + }); + + it("maps the commands source to the published OpenClaw commands route (#5445)", () => { + expect(commandsRoutes).toContain("/user-guide/openclaw/reference/commands"); + }); +}); + +describe("OpenClaw commands page Install OpenClaw Plugins link (#5445)", () => { + it("still contains the Install OpenClaw Plugins link (#5445)", () => { + expect(installLink).toBeDefined(); + }); + + it("resolves to the published manage-sandboxes route, not a source-path route (#5445)", () => { + expect(installLink).toBeDefined(); + const resolved = resolvePublishedRoute( + "/user-guide/openclaw/reference/commands", + installLink?.target ?? "", + ); + // Pre-fix (../deployment/install-openclaw-plugins) this resolved to + // WRONG_ROUTE and this assertion failed on upstream/main. + expect(resolved).toBe(CORRECT_ROUTE); + expect(resolved).not.toBe(WRONG_ROUTE); + expect(index.routes.has(resolved)).toBe(true); + }); +}); + +describe("commands reference relative links resolve to published routes (#5445)", () => { + it("has no relative link that resolves to a nonexistent published route (#5445)", () => { + const violations = findBrokenPublishedRoutes(COMMANDS_SOURCE, index); + expect(violations).toEqual([]); + }); +}); + +describe("route resolver and link extractor robustness (#5445)", () => { + it("resolves route-relative links the way Fern serves them (#5445)", () => { + const from = "/user-guide/openclaw/reference/commands"; + expect(resolvePublishedRoute(from, "../manage-sandboxes/install-openclaw-plugins")).toBe( + CORRECT_ROUTE, + ); + expect(resolvePublishedRoute(from, "../deployment/install-openclaw-plugins")).toBe(WRONG_ROUTE); + // Fern serves extensionless routes; a stray .mdx suffix resolves the same. + expect(resolvePublishedRoute(from, "../manage-sandboxes/install-openclaw-plugins.mdx")).toBe( + CORRECT_ROUTE, + ); + // Fragments and queries do not change the target route. + expect(resolvePublishedRoute(from, "../reference/network-policies#policy-tiers")).toBe( + "/user-guide/openclaw/reference/network-policies", + ); + }); + + it("extracts links with code-span text, titles, and skips code fences (#5445)", () => { + const body = [ + "[Install OpenClaw Plugins](../manage-sandboxes/install-openclaw-plugins)", + '[`nemoclaw list`](../reference/commands "List sandboxes")', + "````md", + "```", + "[fenced](../should/be/ignored)", + "````", + "`[inline code](../also/ignored)`", + ].join("\n"); + const targets = extractMarkdownLinks(body).map((link) => link.target); + expect(targets).toContain("../manage-sandboxes/install-openclaw-plugins"); + // Code-span link text is still captured; the title suffix is stripped. + expect(targets).toContain("../reference/commands"); + // A 3-backtick line inside a 4-backtick block must not end the fence. + expect(targets).not.toContain("../should/be/ignored"); + expect(targets).not.toContain("../also/ignored"); + }); +}); From 400d50d6a9755e10b649d8945b8cafcfa5ea49f9 Mon Sep 17 00:00:00 2001 From: Yimo Jiang Date: Tue, 7 Jul 2026 08:48:34 +0000 Subject: [PATCH 2/2] test(docs): assert route via checker return values, not source-shape The route regression read docs/reference/commands.mdx directly in the test and asserted on the extracted link, which the source-shape budget gate (scripts/find-source-shape-tests.ts) flags as source-text coupling (budget 0). Move the docs read into an exported resolvePageLinkByText() in the checker and assert on its returned resolved route + published flag instead. Behavior is unchanged: the test still fails on upstream/main (link resolves to the deployment route) and passes after the fix. Signed-off-by: Yimo Jiang --- scripts/check-docs-published-routes.ts | 33 ++++++++++++++ test/repro-5445-docs-published-route.test.ts | 45 ++++++++------------ 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/scripts/check-docs-published-routes.ts b/scripts/check-docs-published-routes.ts index 6823009f337..874495b5593 100644 --- a/scripts/check-docs-published-routes.ts +++ b/scripts/check-docs-published-routes.ts @@ -212,6 +212,39 @@ export function findBrokenPublishedRoutes( return violations; } +export type ResolvedPageLink = { + /** The raw link target as written in the source, e.g. `../deployment/x`. */ + target: string; + /** The published route of the linking page. */ + fromRoute: string; + /** The route the link resolves to, the way Fern serves it. */ + resolved: string; + /** Whether `resolved` is an actual published route (false ⇒ 404 on the site). */ + published: boolean; +}; + +/** + * Resolve a single named link on a published docs page to the route a reader + * navigates to. Returns null if the page has no link with that display text. + */ +export function resolvePageLinkByText( + sourcePath: string, + linkText: string, + index: PublishedRouteIndex, + docsDir: string = docsRoot, +): ResolvedPageLink | null { + const routes = index.sourceToRoutes.get(sourcePath); + if (!routes || routes.length === 0) { + throw new Error(`${sourcePath} is not a published navigation page in docs/index.yml`); + } + const body = readFileSync(path.join(docsDir, sourcePath), "utf8"); + const link = extractMarkdownLinks(body).find((entry) => entry.text === linkText); + if (!link) return null; + const fromRoute = routes[0]; + const resolved = resolvePublishedRoute(fromRoute, link.target); + return { target: link.target, fromRoute, resolved, published: index.routes.has(resolved) }; +} + // Pages that have repeatedly regressed on source-path-vs-published-route drift // (NemoClaw#5445, #6290, #5465, #5460). Scoped intentionally: the wider docs // tree has unrelated pre-existing broken links tracked separately. diff --git a/test/repro-5445-docs-published-route.test.ts b/test/repro-5445-docs-published-route.test.ts index a3a6006ba3a..d43c3362582 100644 --- a/test/repro-5445-docs-published-route.test.ts +++ b/test/repro-5445-docs-published-route.test.ts @@ -6,31 +6,27 @@ // SOURCE directory (`docs/deployment/install-openclaw-plugins.mdx`) rather than // its PUBLISHED nav section. Fern serves that page under the `manage-sandboxes` // section, so the source-directory link 404s on the live site even though the -// file exists on disk. `fern check` and source-path checks (PR #6290) missed it; -// these assertions derive the published route from docs/index.yml and check the -// route the reader actually navigates to. +// file exists on disk. `fern check` and source-path checks (PR #6290) missed it. +// +// These assertions exercise behavior: the route map is derived from +// docs/index.yml and the link is resolved the way Fern serves it, both inside +// the checker under test (docs page reads happen there, not here). -import fs from "node:fs"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildPublishedRouteIndex, extractMarkdownLinks, findBrokenPublishedRoutes, + resolvePageLinkByText, resolvePublishedRoute, } from "../scripts/check-docs-published-routes.ts"; -const REPO_ROOT = path.dirname(import.meta.dirname); const COMMANDS_SOURCE = "reference/commands.mdx"; const CORRECT_ROUTE = "/user-guide/openclaw/manage-sandboxes/install-openclaw-plugins"; const WRONG_ROUTE = "/user-guide/openclaw/deployment/install-openclaw-plugins"; const index = buildPublishedRouteIndex(); -const commandsBody = fs.readFileSync(path.join(REPO_ROOT, "docs", COMMANDS_SOURCE), "utf8"); -const installLink = extractMarkdownLinks(commandsBody).find( - (link) => link.text === "Install OpenClaw Plugins", -); -const commandsRoutes = index.sourceToRoutes.get(COMMANDS_SOURCE) ?? []; +const installLink = resolvePageLinkByText(COMMANDS_SOURCE, "Install OpenClaw Plugins", index); describe("docs published-route map derived from docs/index.yml (#5445)", () => { it("publishes Install OpenClaw Plugins under the manage-sandboxes section (#5445)", () => { @@ -42,33 +38,28 @@ describe("docs published-route map derived from docs/index.yml (#5445)", () => { }); it("maps the commands source to the published OpenClaw commands route (#5445)", () => { - expect(commandsRoutes).toContain("/user-guide/openclaw/reference/commands"); + expect(index.sourceToRoutes.get(COMMANDS_SOURCE)).toContain( + "/user-guide/openclaw/reference/commands", + ); }); }); describe("OpenClaw commands page Install OpenClaw Plugins link (#5445)", () => { - it("still contains the Install OpenClaw Plugins link (#5445)", () => { - expect(installLink).toBeDefined(); + it("still links to Install OpenClaw Plugins from the commands page (#5445)", () => { + expect(installLink).not.toBeNull(); }); it("resolves to the published manage-sandboxes route, not a source-path route (#5445)", () => { - expect(installLink).toBeDefined(); - const resolved = resolvePublishedRoute( - "/user-guide/openclaw/reference/commands", - installLink?.target ?? "", - ); // Pre-fix (../deployment/install-openclaw-plugins) this resolved to - // WRONG_ROUTE and this assertion failed on upstream/main. - expect(resolved).toBe(CORRECT_ROUTE); - expect(resolved).not.toBe(WRONG_ROUTE); - expect(index.routes.has(resolved)).toBe(true); + // WRONG_ROUTE (not a published route), so these assertions failed on + // upstream/main and pass only after the link is corrected. + expect(installLink?.resolved).toBe(CORRECT_ROUTE); + expect(installLink?.resolved).not.toBe(WRONG_ROUTE); + expect(installLink?.published).toBe(true); }); -}); -describe("commands reference relative links resolve to published routes (#5445)", () => { it("has no relative link that resolves to a nonexistent published route (#5445)", () => { - const violations = findBrokenPublishedRoutes(COMMANDS_SOURCE, index); - expect(violations).toEqual([]); + expect(findBrokenPublishedRoutes(COMMANDS_SOURCE, index)).toEqual([]); }); });