docs: hide generated variant pages (#4724)

## Summary
Moves generated OpenClaw/Hermes lifecycle docs out of the source
directory and into the ignored docs build tree. This keeps
contributor-facing source folders free of generated page copies while
preserving Fern's native rendering for variant pages.

## Changes
- Generate variant pages under `docs/_build/` instead of beside the
original docs file.
- Rewrite relative links when generating variant pages so links resolve
from the `_build` output location.
- Point Fern navigation at the hidden generated pages and document the
variant-generation flow.
- Extend the focused variant docs test to cover rewritten relative
links.

## Type of Change
- [ ] Code change (feature, bug fix, or refactor)
- [x] Code change with doc updates
- [ ] Doc only (prose changes, no code sample modifications)
- [ ] Doc only (includes code sample changes)

## Verification
`git commit --no-verify` and `git push --no-verify` were used at the
user's request.

- [ ] `npx prek run --all-files` passes
- [ ] `npm test` passes
- [x] Tests added or updated for new or changed behavior
- [x] No secrets, API keys, or credentials committed
- [x] Docs updated for user-facing behavior changes
- [ ] `npm run docs` builds without warnings (doc changes only)
- [x] Doc pages follow the [style
guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md)
(doc changes only)
- [ ] New doc pages include SPDX header and frontmatter (new pages only)

Verified locally:
- `npm test -- test/agent-variant-docs.test.ts`
- `npm run docs` (passes with existing Fern warning)

---
Signed-off-by: Miyoung Choi <miyoungc@nvidia.com>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced agent variant documentation generation with automatic link
rewriting to ensure correct navigation in generated pages.

* **Documentation**
* Added "Agent Variant Generation" section to contributing guidelines
explaining how to create shared documentation that generates multiple
variant versions.

* **Tests**
* Updated test cases to validate variant-specific placeholder rendering
and link path rewriting functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Miyoung Choi 2026-06-03 14:28:32 -07:00 committed by GitHub
parent 93c3903cef
commit d798d5ee51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 100 additions and 12 deletions

1
.gitignore vendored
View file

@ -7,7 +7,6 @@ __pycache__/
coverage/
dist/
docs/_build/
docs/**/*.generated.mdx
node_modules/
# OS metadata

View file

@ -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 `<AgentOnly>` blocks for that content.
## Doc-Only PR Verification
Doc-only pull requests do not need the full test suite by default.

View file

@ -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

View file

@ -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 {

View file

@ -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)");
});
});