Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1669,7 +1669,7 @@ Skill names must contain only alphanumeric characters, dots, hyphens, and unders
<AgentOnly variant="openclaw">

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/<name>` 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.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
281 changes: 281 additions & 0 deletions scripts/check-docs-published-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
// 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<string>;
/** Docs source path (relative to docs/) → its published route(s). */
sourceToRoutes: Map<string, string[]>;
};

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 = /(?<!!)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
let match: RegExpExecArray | null;
while ((match = linkRe.exec(scan)) !== null) {
links.push({ text: match[1], target: match[2], line: i + 1 });
}
});
return links;
}

function isRelativeLink(target: string): boolean {
if (target.startsWith("#")) return false; // same-page anchor
if (target.startsWith("/")) return false; // site-absolute route
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return false; // scheme (http:, mailto:, …)
return true;
}

export type RouteViolation = {
sourcePath: string;
fromRoute: string;
text: string;
target: string;
line: number;
resolved: string;
};

/**
* Validate every relative cross-page link on a docs source page against the
* published route map. Returns the links that resolve to no published route.
*/
export function findBrokenPublishedRoutes(
sourcePath: string,
index: PublishedRouteIndex,
docsDir: string = docsRoot,
): RouteViolation[] {
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 links = extractMarkdownLinks(body).filter((link) => 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;
}

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.
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();
}
Loading
Loading