diff --git a/.gitignore b/.gitignore index 277d0421d82..ddbb67731c2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ __pycache__/ coverage/ dist/ docs/_build/ -docs/**/*.generated.mdx node_modules/ # OS metadata diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index b711f71b0a3..8ec2cad1680 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -142,6 +142,21 @@ The preview watcher uses the current Git branch name as the Fern preview ID and Fern `.mdx` pages are the source for generated user skills. Legacy `.md` pages may remain temporarily for parity checks, but release-prep skill generation should pass `--doc-platform fern-mdx`. +## Agent Variant Generation + +Some Fern pages appear in both the OpenClaw and Hermes guide variants. +When the page content is the same except for the host CLI binary, write one source page and use `$$nemoclaw` as a build-time placeholder. +Do not duplicate fenced code blocks or inline command examples only to switch between `nemoclaw` and `nemohermes`. + +The `scripts/sync-agent-variant-docs.ts` script renders variant-specific pages before Fern validates or publishes the site. +For the sandbox lifecycle guide, the source page remains at `docs/manage-sandboxes/lifecycle.mdx`. +The generated OpenClaw and Hermes pages are written under `docs/_build/agent-variants/`, which is ignored by Git. +Navigation in `docs/index.yml` points Fern at those generated pages so Fern still renders normal fenced code blocks with copy buttons and syntax highlighting. + +Run `npm run docs:sync-agent-variants` after editing a shared variant source page. +Run `npm run docs` before opening a PR to verify the generated pages, rewritten relative links, and Fern navigation. +If content differs by behavior, setup flow, state layout, or agent-specific wording, keep using `` blocks for that content. + ## Doc-Only PR Verification Doc-only pull requests do not need the full test suite by default. diff --git a/docs/index.yml b/docs/index.yml index 2e50cfa63d9..6c6e58b446d 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -71,7 +71,7 @@ navigation: collapsed: open-by-default contents: - page: "Manage Sandbox Lifecycle" - path: manage-sandboxes/lifecycle.openclaw.generated.mdx + path: _build/agent-variants/manage-sandboxes/lifecycle.openclaw.generated.mdx slug: lifecycle - page: "Runtime Controls" path: manage-sandboxes/runtime-controls.mdx @@ -222,7 +222,7 @@ navigation: collapsed: open-by-default contents: - page: "Manage Sandbox Lifecycle" - path: manage-sandboxes/lifecycle.hermes.generated.mdx + path: _build/agent-variants/manage-sandboxes/lifecycle.hermes.generated.mdx slug: lifecycle - page: "Runtime Controls" path: manage-sandboxes/runtime-controls.mdx diff --git a/scripts/sync-agent-variant-docs.ts b/scripts/sync-agent-variant-docs.ts index 335436aa5e1..0bd2ec3d984 100644 --- a/scripts/sync-agent-variant-docs.ts +++ b/scripts/sync-agent-variant-docs.ts @@ -9,6 +9,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." const sourcePath = path.join(repoRoot, "docs/reference/commands.mdx"); const targetPath = path.join(repoRoot, "docs/reference/commands-nemohermes.mdx"); const lifecyclePath = path.join(repoRoot, "docs/manage-sandboxes/lifecycle.mdx"); +const generatedDocsRoot = path.join(repoRoot, "docs/_build/agent-variants"); const agentVariants = ["openclaw", "hermes"] as const; type AgentVariant = (typeof agentVariants)[number]; @@ -16,6 +17,10 @@ type RenderedFile = { path: string; contents: string; }; +type RenderAgentVariantOptions = { + outputPath?: string; + sourcePath?: string; +}; const GENERATED_NOTICE = "{/* This file is generated from docs/reference/commands.mdx by scripts/sync-agent-variant-docs.ts. Run `npm run docs:sync-agent-variants` to regenerate it. Do not edit by hand. */}"; @@ -118,9 +123,13 @@ function stripAgentOnlyBlocksForVariant(body: string, activeVariant: AgentVarian ); } -export function renderAgentVariantPage(source: string, variant: AgentVariant): string { +export function renderAgentVariantPage( + source: string, + variant: AgentVariant, + options: RenderAgentVariantOptions = {}, +): string { const { frontmatter, body } = splitFrontmatter(source); - const renderedBody = stripAgentOnlyBlocksForVariant( + let renderedBody = stripAgentOnlyBlocksForVariant( body.replace(/^import \{ AgentOnly \} from "\.\.\/_components\/AgentGuide";\n\n?/m, ""), variant, ) @@ -128,17 +137,67 @@ export function renderAgentVariantPage(source: string, variant: AgentVariant): s .replace(/\n{3,}/g, "\n\n") .trimStart(); + if (options.sourcePath && options.outputPath) { + renderedBody = rewriteRelativeMarkdownLinks( + renderedBody, + path.dirname(options.sourcePath), + path.dirname(options.outputPath), + ); + } + return `${frontmatter}${GENERATED_VARIANT_NOTICE}\n\n${renderedBody}`.replace(/\s*$/, "\n"); } function renderGeneratedAgentVariantPages(): RenderedFile[] { const source = readFileSync(lifecyclePath, "utf8"); - const sourceDirectory = path.dirname(lifecyclePath); const basename = path.basename(lifecyclePath, ".mdx"); - return agentVariants.map((variant) => ({ - path: path.join(sourceDirectory, `${basename}.${variant}.generated.mdx`), - contents: renderAgentVariantPage(source, variant), - })); + const relativeSourceDirectory = path.relative( + path.join(repoRoot, "docs"), + path.dirname(lifecyclePath), + ); + return agentVariants.map((variant) => { + const outputPath = path.join( + generatedDocsRoot, + relativeSourceDirectory, + `${basename}.${variant}.generated.mdx`, + ); + return { + path: outputPath, + contents: renderAgentVariantPage(source, variant, { + outputPath, + sourcePath: lifecyclePath, + }), + }; + }); +} + +function rewriteRelativeMarkdownLinks( + body: string, + sourceDirectory: string, + outputDirectory: string, +): string { + return body.replace(/(!?\[[^\]]+\]\()([^)]+)(\))/g, (_match, prefix, target, suffix) => { + if (shouldKeepLinkTarget(target)) return `${prefix}${target}${suffix}`; + return `${prefix}${rewriteRelativeLinkTarget(target, sourceDirectory, outputDirectory)}${suffix}`; + }); +} + +function shouldKeepLinkTarget(target: string): boolean { + return target.startsWith("#") || target.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(target); +} + +function rewriteRelativeLinkTarget( + target: string, + sourceDirectory: string, + outputDirectory: string, +): string { + const match = target.match(/^([^?#]*)([?#].*)?$/); + if (!match || !match[1]) return target; + + const absoluteTarget = path.resolve(sourceDirectory, match[1]); + const relativeTarget = path.relative(outputDirectory, absoluteTarget).replaceAll(path.sep, "/"); + const normalizedTarget = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; + return `${normalizedTarget}${match[2] ?? ""}`; } function writeGeneratedFiles(files: RenderedFile[]): void { diff --git a/test/agent-variant-docs.test.ts b/test/agent-variant-docs.test.ts index 793556489cd..1afdbab1faf 100644 --- a/test/agent-variant-docs.test.ts +++ b/test/agent-variant-docs.test.ts @@ -23,7 +23,7 @@ $$nemoclaw list `; describe("agent variant docs", () => { - it("renders OpenClaw sentinel code and content", () => { + it("renders OpenClaw placeholder code and content", () => { const rendered = renderAgentVariantPage(source, "openclaw"); expect(rendered).toContain("OpenClaw only."); @@ -33,7 +33,7 @@ describe("agent variant docs", () => { expect(rendered).not.toContain("AgentOnly"); }); - it("renders Hermes sentinel code and content", () => { + it("renders Hermes placeholder code and content", () => { const rendered = renderAgentVariantPage(source, "hermes"); expect(rendered).not.toContain("OpenClaw only."); @@ -42,4 +42,19 @@ describe("agent variant docs", () => { expect(rendered).not.toContain("$$nemoclaw"); expect(rendered).not.toContain("AgentOnly"); }); + + it("rewrites relative links for generated build output", () => { + const rendered = renderAgentVariantPage( + `${source}\nSee [Commands](../reference/commands#$$nemoclaw-list).\nSee [Backup](backup-restore).\n`, + "hermes", + { + outputPath: + "/repo/docs/_build/agent-variants/manage-sandboxes/lifecycle.hermes.generated.mdx", + sourcePath: "/repo/docs/manage-sandboxes/lifecycle.mdx", + }, + ); + + expect(rendered).toContain("[Commands](../../../reference/commands#nemohermes-list)"); + expect(rendered).toContain("[Backup](../../../manage-sandboxes/backup-restore)"); + }); });