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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ __pycache__/
coverage/
dist/
docs/_build/
docs/**/*.generated.mdx
node_modules/

# OS metadata
Expand Down
15 changes: 15 additions & 0 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
73 changes: 66 additions & 7 deletions scripts/sync-agent-variant-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ 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];
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. */}";
Expand Down Expand Up @@ -118,27 +123,81 @@ 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,
)
.replaceAll(CLI_SENTINEL, variant === "hermes" ? "nemohermes" : "nemoclaw")
.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 {
Expand Down
19 changes: 17 additions & 2 deletions test/agent-variant-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -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.");
Expand All @@ -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)");
});
});
Loading