docs: generate docs map at publish time (#117398)

* docs: generate docs map at publish time

* fix: canonicalize Docker pack entry path

* fix: preserve package lifecycle ownership
This commit is contained in:
Peter Steinberger
2026-08-01 07:01:43 -07:00
committed by GitHub
parent e4d1b7e0d3
commit 385f1dee16
27 changed files with 962 additions and 11411 deletions

View File

@@ -1,8 +1,9 @@
// docs-list tests cover source docs metadata discovery for docs-aware tooling.
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { renderDocsHeadingMap } from "../../scripts/docs-list.js";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
const tempDirs: string[] = [];
@@ -13,8 +14,8 @@ function makeTempRepoRoot(prefix: string): string {
return makeTempDir(tempDirs, prefix);
}
function runDocsList(cwd: string): string {
return execFileSync(process.execPath, [docsListScriptPath], {
function runDocsList(cwd: string, args: string[] = []): string {
return execFileSync(process.execPath, [docsListScriptPath, ...args], {
cwd,
encoding: "utf8",
});
@@ -25,6 +26,17 @@ afterEach(() => {
});
describe("docs-list", () => {
it("reports a concise error outside a source checkout", () => {
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-list-missing-");
const result = spawnSync(process.execPath, [docsListScriptPath], {
cwd: tempRepoRoot,
encoding: "utf8",
});
expect(result.status).toBe(1);
expect(result.stderr).toBe("docs:list: missing docs directory. Run from repo root.\n");
});
it("prints single-line read_when strings as read hints", () => {
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-list-");
mkdirSync(path.join(tempRepoRoot, "docs"), { recursive: true });
@@ -43,4 +55,58 @@ read_when: "Read this page when the hint is inline."
expect(output).toContain("page.md - Single-line read_when page");
expect(output).toContain("Read when: Read this page when the hint is inline.");
});
it("renders the publish docs map on demand without creating a mirror", () => {
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-headings-");
mkdirSync(path.join(tempRepoRoot, "docs", "nested"), { recursive: true });
writeFileSync(
path.join(tempRepoRoot, "docs", "page.md"),
`---
summary: "Page"
---
# Visible title
## \`API[*]\` <script>alert(1)</script>
### <scr<script>ipt>alert(1)</script>
#### \`![label](https://example.test/image)\`
\`\`\`md
### Hidden fenced heading
\`\`\`
`,
"utf8",
);
writeFileSync(path.join(tempRepoRoot, "docs", "nested", "index.mdx"), "# Nested\n");
writeFileSync(path.join(tempRepoRoot, "docs", "AGENTS.md"), "# Instructions\n");
const output = runDocsList(tempRepoRoot, ["--headings"]);
expect(output).toContain("## page.md\n\n- Route: /page");
expect(output).toContain(" - H1: Visible title");
expect(output).toContain(" - H2: `API[*]` &lt;script&gt;alert(1)&lt;/script&gt;");
expect(output).toContain(" - H3: &lt;scr&lt;script&gt;ipt&gt;alert(1)&lt;/script&gt;");
expect(output).toContain(" - H4: `![label](https://example.test/image)`");
expect(output).toContain("## nested/index.mdx\n\n- Route: /nested");
expect(output).not.toContain("Hidden fenced heading");
expect(output).not.toContain("AGENTS.md");
expect(existsSync(path.join(tempRepoRoot, "docs", "docs_map.md"))).toBe(false);
});
it("normalizes injected Windows paths for nested page routes", () => {
const tempRepoRoot = makeTempRepoRoot("openclaw-docs-headings-windows-");
const docsDir = path.join(tempRepoRoot, "docs");
mkdirSync(path.join(docsDir, "nested"), { recursive: true });
writeFileSync(path.join(docsDir, "nested", "index.mdx"), "# Nested index\n");
writeFileSync(path.join(docsDir, "nested", "page.md"), "# Nested page\n");
const output = renderDocsHeadingMap(docsDir, {
relativePath: (base, fullPath) => path.relative(base, fullPath).replace(/[\\/]+/gu, "\\"),
});
expect(output).toContain("## nested/index.mdx\n\n- Route: /nested");
expect(output).toContain("## nested/page.md\n\n- Route: /nested/page");
expect(output).not.toContain("\\");
});
});

View File

@@ -2,10 +2,12 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { renderDocsHeadingMap } from "../../scripts/docs-list.js";
import {
composeDocsConfig,
parseArgs,
reportOrphanLocaleDocs,
writePublishedDocsMap,
} from "../../scripts/docs-sync-publish.mjs";
function collectPages(entry: unknown, pages: string[] = []): string[] {
@@ -33,6 +35,18 @@ function collectPages(entry: unknown, pages: string[] = []): string[] {
}
describe("docs-sync-publish", () => {
it("materializes the public docs map only in the publish tree", () => {
const targetDocsDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-map-publish-"));
try {
const outputPath = writePublishedDocsMap(targetDocsDir);
expect(fs.readFileSync(outputPath, "utf8")).toBe(
renderDocsHeadingMap(path.resolve(import.meta.dirname, "../../docs")),
);
} finally {
fs.rmSync(targetDocsDir, { recursive: true, force: true });
}
});
it("parses docs sync provenance args", () => {
expect(
parseArgs([

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import { testing } from "../../scripts/generate-docs-map.mjs";
describe("generate docs map", () => {
it("renders heading HTML as text", () => {
expect(testing.cleanHeadingText("`API` <script>alert(1)</script>")).toBe(
"API &lt;script&gt;alert(1)&lt;/script&gt;",
);
expect(testing.cleanHeadingText("<scr<script>ipt>alert(1)</script>")).toBe(
"&lt;scr&lt;script&gt;ipt&gt;alert(1)&lt;/script&gt;",
);
});
it("preserves Markdown syntax inside inline code", () => {
expect(testing.cleanHeadingText("`agents.entries.*.contextLimits` and *defaults*")).toBe(
"`agents.entries.*.contextLimits` and defaults",
);
expect(testing.cleanHeadingText("`![label](https://example.test/image)`")).toBe(
"`![label](https://example.test/image)`",
);
});
});

View File

@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -11,12 +11,24 @@ import {
resolveRuntimePackEnvironment,
resolveRuntimePackPlan,
resolveWorkspaceInstallPlan,
restoreRuntimePack,
rewriteWorkspaceDependencyVersions,
runPreparedRuntimePack,
} from "../../scripts/ocm-npm-workspace-deps.mjs";
import { restorePrepackArtifacts } from "../../scripts/openclaw-postpack.mjs";
import { preparePackageChangelog } from "../../scripts/package-changelog.mjs";
import { preparePackageDocsMap } from "../../scripts/package-docs-map.mjs";
const adapterPath = fileURLToPath(
new URL("../../scripts/ocm-npm-workspace-deps.mjs", import.meta.url),
);
const packageDocsMapPath = fileURLToPath(
new URL("../../scripts/package-docs-map.mjs", import.meta.url),
);
const packageChangelogPath = fileURLToPath(
new URL("../../scripts/package-changelog.mjs", import.meta.url),
);
const postpackPath = fileURLToPath(new URL("../../scripts/openclaw-postpack.mjs", import.meta.url));
describe("OCM npm workspace dependency adapter", () => {
it("allows Unreleased notes only for non-publishing pack commands", () => {
@@ -81,6 +93,106 @@ describe("OCM npm workspace dependency adapter", () => {
).toThrow("runtime pack commit must be a full 40-character hexadecimal SHA");
});
it("does not let a failed concurrent owner restore the active pack lifecycle", async () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-ocm-pack-owner-"));
const docsDir = join(root, "docs");
const mapPath = join(docsDir, "docs_map.md");
const receiptPath = join(root, ".artifacts", "package-docs-map", "receipt.json");
const changelogBackupPath = join(
root,
".artifacts",
"package-changelog",
"CHANGELOG.md.prepack-backup",
);
const sourceMap = "# Docs map source\n";
const sourceChangelog = `# Changelog
## 2026.8.1
- Current release notes with enough detail for package validation.
## 2026.7.1
- Previous release notes with enough detail for package validation.
`;
mkdirSync(docsDir, { recursive: true });
writeFileSync(join(docsDir, "page.md"), "# Package docs\n");
writeFileSync(mapPath, sourceMap);
writeFileSync(join(root, "package.json"), '{"name":"openclaw","version":"2026.8.1"}\n');
writeFileSync(join(root, "CHANGELOG.md"), sourceChangelog);
try {
await preparePackageDocsMap(root);
await preparePackageChangelog(root);
const activeMap = readFileSync(mapPath, "utf8");
const activeChangelog = readFileSync(join(root, "CHANGELOG.md"), "utf8");
let packCalled = false;
expect(() =>
runPreparedRuntimePack(
() =>
execFileSync(process.execPath, [packageDocsMapPath, "prepare"], {
cwd: root,
stdio: "pipe",
}),
() => {
packCalled = true;
return 0;
},
() => execFileSync(process.execPath, [postpackPath], { cwd: root, stdio: "pipe" }),
),
).toThrow();
expect(packCalled).toBe(false);
expect(existsSync(receiptPath)).toBe(true);
expect(existsSync(changelogBackupPath)).toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(activeMap);
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toBe(activeChangelog);
await restorePrepackArtifacts(root);
expect(readFileSync(mapPath, "utf8")).toBe(sourceMap);
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("restores a prepared legacy source fixture through its changelog owner", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-ocm-historical-pack-"));
const scriptsDir = join(root, "scripts");
const sourceChangelog = `# Changelog
## 2026.8.1
- Current release notes with enough detail for package validation.
## 2026.7.1
- Previous release notes with enough detail for package validation.
`;
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(root, "package.json"), '{"name":"openclaw","version":"2026.8.1"}\n');
writeFileSync(join(root, "CHANGELOG.md"), sourceChangelog);
try {
writeFileSync(
join(scriptsDir, "package-changelog.mjs"),
readFileSync(packageChangelogPath, "utf8"),
);
execFileSync(process.execPath, ["scripts/package-changelog.mjs", "prepare"], {
cwd: root,
stdio: "pipe",
});
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).not.toBe(sourceChangelog);
expect(existsSync(join(root, "scripts", "openclaw-postpack.mjs"))).toBe(false);
restoreRuntimePack(process.env, root);
expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
expect(
existsSync(join(root, ".artifacts", "package-changelog", "CHANGELOG.md.prepack-backup")),
).toBe(false);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("resolves workspace package directories", () => {
expect(
parseWorkspaceDependencyDirs(["packages/ai", "extensions/example"].join(delimiter), "/repo"),

View File

@@ -0,0 +1,131 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { renderDocsHeadingMap } from "../../scripts/docs-list.js";
import { restorePrepackArtifacts } from "../../scripts/openclaw-postpack.mjs";
import { preparePackageChangelog } from "../../scripts/package-changelog.mjs";
import { preparePackageDocsMap, restorePackageDocsMap } from "../../scripts/package-docs-map.mjs";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
const tempDirs: string[] = [];
const sourceChangelog = `# Changelog
## 2026.8.1
- Current release notes with enough detail for package validation.
## 2026.7.1
- Previous release notes with enough detail for package validation.
`;
function makePackageRoot(): string {
const root = makeTempDir(tempDirs, "openclaw-package-docs-map-");
mkdirSync(path.join(root, "docs"), { recursive: true });
writeFileSync(path.join(root, "docs", "page.md"), "# Package docs\n", "utf8");
writeFileSync(path.join(root, "package.json"), '{"name":"openclaw","version":"2026.8.1"}\n');
writeFileSync(path.join(root, "CHANGELOG.md"), sourceChangelog);
return root;
}
afterEach(() => {
cleanupTempDirs(tempDirs);
});
describe("package docs map", () => {
it("materializes exact generated bytes and removes only its transient copy", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
await expect(preparePackageDocsMap(root)).resolves.toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
await expect(restorePackageDocsMap(root)).resolves.toBe(true);
expect(existsSync(mapPath)).toBe(false);
});
it("preserves an identical map that existed before packaging", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
const content = renderDocsHeadingMap(path.join(root, "docs"));
writeFileSync(mapPath, content, "utf8");
await expect(preparePackageDocsMap(root)).resolves.toBe(false);
await expect(restorePackageDocsMap(root)).resolves.toBe(false);
expect(readFileSync(mapPath, "utf8")).toBe(content);
});
it("restores a tracked source stub after packaging", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
const stub = "# Docs map source\n";
writeFileSync(mapPath, stub, "utf8");
await expect(preparePackageDocsMap(root)).resolves.toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
await expect(restorePackageDocsMap(root)).resolves.toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(stub);
});
it("serializes concurrent package preparations with the receipt", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
const stub = "# Docs map source\n";
writeFileSync(mapPath, stub, "utf8");
const results = await Promise.allSettled([
preparePackageDocsMap(root),
preparePackageDocsMap(root),
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
const rejected = results.find((result) => result.status === "rejected");
expect(rejected).toMatchObject({
reason: expect.objectContaining({
code: "PACKAGE_DOCS_MAP_ACTIVE",
message: expect.stringContaining("node scripts/openclaw-postpack.mjs"),
}),
});
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
await expect(restorePackageDocsMap(root)).resolves.toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(stub);
});
it("refuses to remove a transient map changed after prepack", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
await preparePackageDocsMap(root);
writeFileSync(mapPath, "operator change\n", "utf8");
await expect(restorePackageDocsMap(root)).rejects.toThrow("changed after prepack");
expect(readFileSync(mapPath, "utf8")).toBe("operator change\n");
});
it("keeps the lifecycle lock until interrupted changelog state is restored", async () => {
const root = makePackageRoot();
const mapPath = path.join(root, "docs", "docs_map.md");
const receiptPath = path.join(root, ".artifacts", "package-docs-map", "receipt.json");
const changelogBackupPath = path.join(
root,
".artifacts",
"package-changelog",
"CHANGELOG.md.prepack-backup",
);
const stub = "# Docs map source\n";
writeFileSync(mapPath, stub);
await preparePackageDocsMap(root);
await preparePackageChangelog(root);
const packagedChangelog = readFileSync(path.join(root, "CHANGELOG.md"), "utf8");
writeFileSync(path.join(root, "CHANGELOG.md"), "operator change\n");
await expect(restorePrepackArtifacts(root)).rejects.toThrow("changed since the backup");
expect(existsSync(receiptPath)).toBe(true);
expect(existsSync(changelogBackupPath)).toBe(true);
expect(readFileSync(mapPath, "utf8")).toBe(renderDocsHeadingMap(path.join(root, "docs")));
writeFileSync(path.join(root, "CHANGELOG.md"), packagedChangelog);
await expect(restorePrepackArtifacts(root)).resolves.toBeUndefined();
expect(readFileSync(path.join(root, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog);
expect(readFileSync(mapPath, "utf8")).toBe(stub);
expect(existsSync(changelogBackupPath)).toBe(false);
expect(existsSync(receiptPath)).toBe(false);
});
});