openclaw/scripts/docs-sync-publish.mjs
Peter Steinberger 512d8f2ee5
chore(deps): refresh seven-day-cooled dependencies (#138199)
* chore(deps): refresh seven-day-cooled dependencies

* fix: resolve dependency refresh CI blockers

Recheck caller cancellation after the OpenAI SSE iterator ends and before
Chat Completions can promote provisional tool calls. OpenAI 7.8 may end
an aborted iterator normally; preserve the shared transport's abort contract.

Wait for the fake WebSocket receive callback before emitting replies in
three Watch journal fixtures, retaining their existing timeout and assertions.
Point the QA release-policy catalog at the repository-owned plugin guide
after main removed its duplicate ClawHub publishing page.

The existing OpenAI regression fails before the owner fix and passes after,
with 243 owner/sibling tests and both transports exercised over real loopback
HTTP. The 58-test QA catalog suite, changed gates, AI package build, and
independent scoped P0 review pass. Fresh exact-head hosted CI, including
iOS lifecycle and production advisory checks, remains required before merge.

* test(mattermost): control loopback timeout deadlines

* test(ai): cover cancellation at normal stream completion

Prove the shared Chat Completions parser rejects an abort immediately before normal iterator return and never finalizes the provisional tool call. The regression fails with only the post-loop guard removed; 312 owner and sibling tests and the changed-file gate pass with the guard intact.

* fix(ui): preserve focused popovers during sidebar updates

Keep community invitation geometry deferred while a DOM-owned popover item has keyboard focus, even when Chromium reports no sidebar focus-within. Exercise background presence updates after real menu focus.

Distinguish independent Swarm child hydration from canonical roster refreshes in the held unread acknowledgement test. Preserve immediate badge clearing and prove an extra canonical refresh still fails the assertion.

Validation: 12 Control UI E2E cases and 431 related UI tests pass; the focused invitation case fails on the previous production condition. Independent Codex P0 review is scoped-clean.

* chore(deps): upgrade CUA and preserve published docs anchors

Upgrade CUA 0.22.0 to 0.22.2 with its coordinated accepted native
artifact records, and slugify 2.2.0 to 2.2.1 under the frozen seven-day
cutoff. Preserve Mint's published heading and component anchors before
counter allocation.

Synchronize the docs publisher's independent slugify manifest and npm
lock atomically with its parser; reject unrelated dependency drift after
rebasing the publish commit. Preserve every existing product security
exception, patch, toolchain document, and public configuration contract.

209 selected owner tests, source/publisher anchor corpus comparisons,
full changed checks and build, the exact dependency age/integrity audit,
and independent managed P0 review passed. Native CUA execution and
fresh exact-head CI remain required before landing PR #138199.

* chore(deps): refresh newly cooled September 5 dependencies

Advance the frozen seven-day selection to 2026-08-29T18:40:39Z. Update AWS, ACP, TUI, Discord types, Matrix WASM and duration formatting; align standalone broker Node types. Preserve main security exceptions and hold incompatible direct Zod upgrades.

* fix: align dependency refresh CI fixtures

Model the atomic publisher manifest/lock handoff in the process-fault fixture and execute its real validator before push. Preserve strict command matching, drain ordering and terminal rejection semantics. Inline the single-use reasoning-effort resolver to keep the shared stream below its existing line limit without changing cancellation or reasoning behavior.

* test(ui): await settled skill-menu geometry

Wait for the existing semantic and animation readiness boundary before comparing list and action widths. Preserve exact width tolerance, viewport bounds and read-only pin assertions; do not fast-forward animation or alter production styling.

* test(ui): bind live browser disclosures to their owners

Adapt the run/tool identity repair from 90c51add82 while preserving the existing 15-second overall observation budget. Remove live page-wide positional disclosure polling; keep history and inert-route assertions, and capture optional synthetic proof.
2026-09-05 22:59:41 -07:00

967 lines
28 KiB
JavaScript

#!/usr/bin/env node
// Syncs source docs into the generated publish tree.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { isDeepStrictEqual } from "node:util";
import { renderDocsHeadingMap } from "./docs-list.js";
import { requireOptionArgument } from "./lib/arg-utils.runtime.mjs";
import { repairMintlifyAccordionIndentation } from "./lib/mintlify-accordion.mjs";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
const ROOT = resolveRepoRoot(import.meta.url);
const SOURCE_DOCS_DIR = path.join(ROOT, "docs");
const SOURCE_CONFIG_PATH = path.join(SOURCE_DOCS_DIR, "docs.json");
const SLUGIFY_PACKAGE = "@sindresorhus/slugify";
const INTERNAL_DOCS_DIRS = ["internal"];
const DEFAULT_CLAWHUB_SOURCE_REPO = "openclaw/clawhub";
const CLAWHUB_DOCS_TARGET_DIR = "clawhub";
const CLAWHUB_REPO_ENV = "OPENCLAW_DOCS_SYNC_CLAWHUB_REPO";
const DEFAULT_CLAWHUB_REPO_CANDIDATES = [
path.resolve(ROOT, "..", "clawhub-docs-clawhub"),
path.resolve(ROOT, "..", "clawhub"),
];
const SYNC_SUPPORT_FILES = [
// File URLs declare the copied runtime closure without executing modules:
// source sync runs before parser dependencies are installed.
{
source: new URL("./lib/docs-markdown.mjs", import.meta.url),
target: path.join(".openclaw-sync", "lib", "docs-markdown.mjs"),
},
{
source: new URL("./lib/docs-redirects.mjs", import.meta.url),
target: path.join(".openclaw-sync", "lib", "docs-redirects.mjs"),
},
{
source: path.join(ROOT, "scripts", "check-docs-mdx.mjs"),
target: path.join(".openclaw-sync", "check-docs-mdx.mjs"),
},
{
source: path.join(ROOT, "scripts", "check-docs-mdx.mts"),
target: path.join(".openclaw-sync", "check-docs-mdx.mts"),
},
{
source: path.join(ROOT, "scripts", "lib", "arg-utils.runtime.mjs"),
target: path.join(".openclaw-sync", "lib", "arg-utils.runtime.mjs"),
},
{
source: path.join(ROOT, "scripts", "lib", "tsx-cli-shim.mjs"),
target: path.join(".openclaw-sync", "lib", "tsx-cli-shim.mjs"),
},
{
source: path.join(ROOT, "scripts", "lib", "local-check-runtime.mts"),
target: path.join(".openclaw-sync", "lib", "local-check-runtime.mts"),
},
{
source: path.join(ROOT, "scripts", "tsx.mjs"),
target: path.join(".openclaw-sync", "tsx.mjs"),
},
{
source: path.join(ROOT, "scripts", "lib", "mintlify-accordion.mjs"),
target: path.join(".openclaw-sync", "lib", "mintlify-accordion.mjs"),
},
{
source: path.join(ROOT, ".github", "codex", "prompts", "docs-mdx-repair.md"),
target: path.join(".openclaw-sync", "docs-mdx-repair.md"),
},
];
const GENERATED_LOCALES = [
{
language: "zh-Hans",
dir: "zh-CN",
navFile: "zh-Hans-navigation.json",
tmFile: "zh-CN.tm.jsonl",
navMode: "overlay",
},
{
language: "zh-Hant",
dir: "zh-TW",
navFile: "zh-Hant-navigation.json",
tmFile: "zh-TW.tm.jsonl",
navMode: "clone-en",
},
{
language: "ja",
dir: "ja-JP",
navFile: "ja-navigation.json",
tmFile: "ja-JP.tm.jsonl",
navMode: "clone-en",
},
{
language: "es",
dir: "es",
navFile: "es-navigation.json",
tmFile: "es.tm.jsonl",
navMode: "clone-en",
},
{
language: "pt-BR",
dir: "pt-BR",
navFile: "pt-BR-navigation.json",
tmFile: "pt-BR.tm.jsonl",
navMode: "clone-en",
},
{
language: "ko",
dir: "ko",
navFile: "ko-navigation.json",
tmFile: "ko.tm.jsonl",
navMode: "clone-en",
},
{
language: "de",
dir: "de",
navFile: "de-navigation.json",
tmFile: "de.tm.jsonl",
navMode: "clone-en",
},
{
language: "fr",
dir: "fr",
navFile: "fr-navigation.json",
tmFile: "fr.tm.jsonl",
navMode: "clone-en",
},
{
language: "hi",
dir: "hi",
navFile: "hi-navigation.json",
tmFile: "hi.tm.jsonl",
navMode: "clone-en",
},
{
language: "ar",
dir: "ar",
navFile: "ar-navigation.json",
tmFile: "ar.tm.jsonl",
navMode: "clone-en",
},
{
language: "it",
dir: "it",
navFile: "it-navigation.json",
tmFile: "it.tm.jsonl",
navMode: "clone-en",
},
{
language: "vi",
dir: "vi",
navFile: "vi-navigation.json",
tmFile: "vi.tm.jsonl",
navMode: "clone-en",
},
{
language: "nl",
dir: "nl",
navFile: "nl-navigation.json",
tmFile: "nl.tm.jsonl",
navMode: "clone-en",
},
{
language: "fa",
dir: "fa",
navFile: "fa-navigation.json",
tmFile: "fa.tm.jsonl",
navMode: "clone-en",
// Mintlify does not currently accept `fa` in navigation.languages.
// Keep generated docs and translation memory so the locale stays available
// once the docs host accepts it.
navigation: false,
},
{
language: "tr",
dir: "tr",
navFile: "tr-navigation.json",
tmFile: "tr.tm.jsonl",
navMode: "clone-en",
},
{
language: "uk",
dir: "uk",
navFile: "uk-navigation.json",
tmFile: "uk.tm.jsonl",
navMode: "clone-en",
},
{
language: "id",
dir: "id",
navFile: "id-navigation.json",
tmFile: "id.tm.jsonl",
navMode: "clone-en",
},
{
language: "pl",
dir: "pl",
navFile: "pl-navigation.json",
tmFile: "pl.tm.jsonl",
navMode: "clone-en",
},
{
language: "th",
dir: "th",
navFile: "th-navigation.json",
tmFile: "th.tm.jsonl",
navMode: "clone-en",
// Mintlify does not currently accept `th` in navigation.languages.
// Keep generated docs and translation memory so the locale stays available
// once the docs host accepts it.
navigation: false,
},
{
language: "ru",
dir: "ru",
navFile: "ru-navigation.json",
tmFile: "ru.tm.jsonl",
navMode: "clone-en",
},
];
export function parseArgs(argv) {
const args = {
target: "",
sourceRepo: "",
sourceSha: "",
clawhubRepo: process.env[CLAWHUB_REPO_ENV] || "",
clawhubSourceRepo:
process.env.OPENCLAW_DOCS_SYNC_CLAWHUB_SOURCE_REPO || DEFAULT_CLAWHUB_SOURCE_REPO,
clawhubSourceSha: process.env.OPENCLAW_DOCS_SYNC_CLAWHUB_SOURCE_SHA || "",
};
for (let index = 0; index < argv.length; index += 1) {
const part = argv[index];
switch (part) {
case "--target":
args.target = requireOptionArgument(argv, index, part);
index += 1;
break;
case "--source-repo":
args.sourceRepo = requireOptionArgument(argv, index, part);
index += 1;
break;
case "--source-sha":
args.sourceSha = requireOptionArgument(argv, index, part);
index += 1;
break;
case "--clawhub-repo":
args.clawhubRepo = requireOptionArgument(argv, index, part);
index += 1;
break;
case "--clawhub-source-repo":
args.clawhubSourceRepo = requireOptionArgument(argv, index, part);
index += 1;
break;
case "--clawhub-source-sha":
args.clawhubSourceSha = requireOptionArgument(argv, index, part);
index += 1;
break;
default:
throw new Error(`unknown arg: ${part}`);
}
}
if (!args.target) {
throw new Error("missing --target");
}
return args;
}
function run(command, args, options = {}) {
execFileSync(command, args, {
cwd: ROOT,
stdio: "inherit",
...options,
});
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function normalizeSlashes(value) {
return value.replace(/\\/g, "/");
}
function walkFiles(entryPath, out = []) {
if (!fs.existsSync(entryPath)) {
return out;
}
const stat = fs.statSync(entryPath);
if (stat.isFile()) {
out.push(entryPath);
return out;
}
for (const entry of fs.readdirSync(entryPath, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === ".git") {
continue;
}
walkFiles(path.join(entryPath, entry.name), out);
}
return out;
}
function walkMarkdownFiles(entryPath, out = []) {
if (!fs.existsSync(entryPath)) {
return out;
}
const stat = fs.statSync(entryPath);
if (stat.isFile()) {
if (/\.mdx?$/i.test(entryPath)) {
out.push(entryPath);
}
return out;
}
for (const entry of fs.readdirSync(entryPath, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name === ".git") {
continue;
}
walkMarkdownFiles(path.join(entryPath, entry.name), out);
}
return out;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJson(filePath, value) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function getGitHeadSha(repoPath) {
try {
return execFileSync("git", ["-C", repoPath, "rev-parse", "HEAD"], {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return "";
}
}
/**
* Resolves the local ClawHub repository path used for docs mirroring.
*/
export function resolveClawHubRepoPath(value = "", options = {}) {
const required = options.required !== false;
const candidates = [
value,
process.env[CLAWHUB_REPO_ENV] || "",
...DEFAULT_CLAWHUB_REPO_CANDIDATES,
].filter((candidate) => candidate.trim().length > 0);
for (const candidate of candidates) {
const repoPath = path.resolve(candidate);
if (fs.existsSync(path.join(repoPath, "docs"))) {
return repoPath;
}
}
if (required) {
throw new Error(`missing ClawHub docs source; pass --clawhub-repo or set ${CLAWHUB_REPO_ENV}`);
}
return "";
}
function prefixLocalePage(entry, localeDir) {
if (typeof entry === "string") {
return `${localeDir}/${entry}`;
}
if (Array.isArray(entry)) {
return entry.map((item) => prefixLocalePage(item, localeDir));
}
if (!entry || typeof entry !== "object") {
return entry;
}
const clone = { ...entry };
if (typeof clone.page === "string") {
clone.page = `${localeDir}/${clone.page}`;
}
if (Array.isArray(clone.pages)) {
clone.pages = clone.pages.map((item) => prefixLocalePage(item, localeDir));
}
return clone;
}
function prefixLocaleNavGroup(group, localeDir) {
const clone = { ...group };
if (Array.isArray(clone.pages)) {
clone.pages = clone.pages.map((entry) => prefixLocalePage(entry, localeDir));
}
return clone;
}
function prefixLocaleNavTab(tab, localeDir) {
const clone = { ...tab };
if (Array.isArray(clone.pages)) {
clone.pages = clone.pages.map((entry) => prefixLocalePage(entry, localeDir));
}
if (Array.isArray(clone.groups)) {
clone.groups = clone.groups.map((group) => prefixLocaleNavGroup(group, localeDir));
}
return clone;
}
function cloneEnglishLanguageNav(englishNav, locale) {
if (!englishNav) {
throw new Error("docs/docs.json is missing navigation.languages.en");
}
return {
...englishNav,
language: locale.language,
tabs: Array.isArray(englishNav.tabs)
? englishNav.tabs
.filter((tab) => tab?.tab !== "ClawHub")
.map((tab) => prefixLocaleNavTab(tab, locale.dir))
: englishNav.tabs,
};
}
function collectNavPages(entry, pages = new Set()) {
if (typeof entry === "string") {
pages.add(entry);
return pages;
}
if (Array.isArray(entry)) {
for (const item of entry) {
collectNavPages(item, pages);
}
return pages;
}
if (!entry || typeof entry !== "object") {
return pages;
}
if (typeof entry.page === "string") {
pages.add(entry.page);
}
collectNavPages(entry.pages, pages);
collectNavPages(entry.groups, pages);
collectNavPages(entry.tabs, pages);
return pages;
}
function findBestNavMatchIndex(candidates, overlayEntry, excludedIndexes = new Set()) {
const overlayPages = collectNavPages(overlayEntry);
let bestIndex = -1;
let bestScore = 0;
for (const [index, candidate] of candidates.entries()) {
if (excludedIndexes.has(index)) {
continue;
}
const candidatePages = collectNavPages(candidate);
let score = 0;
for (const page of overlayPages) {
if (candidatePages.has(page)) {
score += 1;
}
}
if (score > bestScore) {
bestIndex = index;
bestScore = score;
}
}
return bestIndex;
}
export function applyLocaleNavLabelOverlay(fullNav, labelOverlay) {
const tabs = Array.isArray(fullNav.tabs)
? fullNav.tabs.map((tab) => ({
...tab,
groups: Array.isArray(tab.groups) ? tab.groups.map((group) => ({ ...group })) : tab.groups,
}))
: fullNav.tabs;
const composed = { ...fullNav, tabs };
if (!Array.isArray(tabs) || !Array.isArray(labelOverlay?.tabs)) {
return composed;
}
for (const overlayTab of labelOverlay.tabs) {
const tabIndex = findBestNavMatchIndex(tabs, overlayTab);
if (tabIndex < 0) {
continue;
}
const tab = tabs[tabIndex];
if (typeof overlayTab.tab === "string") {
tab.tab = overlayTab.tab;
}
if (!Array.isArray(tab.groups) || !Array.isArray(overlayTab.groups)) {
continue;
}
const matchedGroupIndexes = new Set();
for (const overlayGroup of overlayTab.groups) {
const groupIndex = findBestNavMatchIndex(tab.groups, overlayGroup, matchedGroupIndexes);
if (groupIndex >= 0 && typeof overlayGroup.group === "string") {
tab.groups[groupIndex].group = overlayGroup.group;
matchedGroupIndexes.add(groupIndex);
}
}
}
return composed;
}
function composeLocaleNav(locale, englishNav) {
const cloned = cloneEnglishLanguageNav(englishNav, locale);
if (!locale.navFile) {
return cloned;
}
const overlayPath = path.join(SOURCE_DOCS_DIR, ".i18n", locale.navFile);
if (!fs.existsSync(overlayPath)) {
return cloned;
}
return applyLocaleNavLabelOverlay(cloned, readJson(overlayPath));
}
export function composeDocsConfig() {
const sourceConfig = readJson(SOURCE_CONFIG_PATH);
const languages = sourceConfig?.navigation?.languages;
if (!Array.isArray(languages)) {
throw new Error("docs/docs.json is missing navigation.languages");
}
const englishNav = languages.find((entry) => entry?.language === "en");
const generatedLanguageSet = new Set(
GENERATED_LOCALES.filter((entry) => entry.navigation !== false).map((entry) => entry.language),
);
const withoutGenerated = languages.filter((entry) => !generatedLanguageSet.has(entry?.language));
const enIndex = withoutGenerated.findIndex((entry) => entry?.language === "en");
const generated = GENERATED_LOCALES.filter((entry) => entry.navigation !== false).map((entry) =>
composeLocaleNav(entry, englishNav),
);
if (enIndex === -1) {
withoutGenerated.push(...generated);
} else {
withoutGenerated.splice(enIndex + 1, 0, ...generated);
}
return {
...sourceConfig,
navigation: {
...sourceConfig.navigation,
languages: withoutGenerated,
},
};
}
export function reportOrphanLocaleDocs(targetDocsDir) {
let orphaned = 0;
for (const locale of GENERATED_LOCALES) {
const localeDir = path.join(targetDocsDir, locale.dir);
if (!fs.existsSync(localeDir)) {
continue;
}
for (const filePath of walkMarkdownFiles(localeDir)) {
const relativePath = path.relative(localeDir, filePath);
// Check the assembled publish tree so externally mirrored docs, such as
// ClawHub pages, count as valid English sources too.
const englishBase = path.join(targetDocsDir, relativePath);
const englishMd = englishBase.replace(/\.mdx?$/i, ".md");
const englishMdx = englishBase.replace(/\.mdx?$/i, ".mdx");
if (fs.existsSync(englishMd) || fs.existsSync(englishMdx)) {
continue;
}
orphaned += 1;
}
}
if (orphaned > 0) {
// Translation artifacts update inbound links and delete their old target
// together. Docs sync must not publish the deletion ahead of that step.
console.log(`Deferred ${orphaned} orphan localized doc(s) to translation finalization.`);
}
return orphaned;
}
function repairGeneratedLocaleDocs(targetDocsDir) {
let repaired = 0;
for (const locale of GENERATED_LOCALES) {
const localeDir = path.join(targetDocsDir, locale.dir);
for (const filePath of walkMarkdownFiles(localeDir)) {
const raw = fs.readFileSync(filePath, "utf8");
const repairedRaw = repairMintlifyAccordionIndentation(raw);
if (repairedRaw === raw) {
continue;
}
fs.writeFileSync(filePath, repairedRaw);
repaired += 1;
}
}
if (repaired > 0) {
console.log(`Repaired Mintlify accordion indentation in ${repaired} generated locale doc(s).`);
}
}
function pruneInternalDocs(targetDocsDir) {
let pruned = 0;
for (const relativeDir of INTERNAL_DOCS_DIRS) {
const dirPath = path.join(targetDocsDir, relativeDir);
if (!fs.existsSync(dirPath)) {
continue;
}
fs.rmSync(dirPath, { recursive: true, force: true });
pruned += 1;
}
if (pruned > 0) {
console.log(`Pruned ${pruned} internal-only docs director${pruned === 1 ? "y" : "ies"}.`);
}
}
function shouldExcludeClawHubDocsPath(relativePath) {
const normalized = normalizeSlashes(relativePath);
return (
normalized === "specs" || normalized.startsWith("specs/") || normalized.includes("/specs/")
);
}
function toClawHubTargetRelativePath(relativePath) {
const normalized = normalizeSlashes(relativePath);
if (normalized === "README.md") {
return "";
}
if (normalized === "clawhub.md") {
return "index.md";
}
return normalized.replace(/\/README\.md$/iu, "/index.md");
}
function toClawHubDocsRoute(relativePath) {
const targetRelativePath = toClawHubTargetRelativePath(relativePath);
if (!targetRelativePath) {
return "";
}
const normalized = targetRelativePath.replace(/\.mdx?$/iu, "");
if (normalized === "index") {
return `/${CLAWHUB_DOCS_TARGET_DIR}`;
}
if (normalized.endsWith("/index")) {
return `/${CLAWHUB_DOCS_TARGET_DIR}/${normalized.slice(0, -"/index".length)}`;
}
return `/${CLAWHUB_DOCS_TARGET_DIR}/${normalized}`;
}
function splitLinkTarget(value) {
const match = /^(\S+)(.*)$/su.exec(value);
return {
target: match?.[1] ?? value,
suffix: match?.[2] ?? "",
};
}
function splitTargetParts(value) {
const hashIndex = value.indexOf("#");
const queryIndex = value.indexOf("?");
const splitIndexes = [hashIndex, queryIndex].filter((index) => index >= 0);
const splitIndex = splitIndexes.length > 0 ? Math.min(...splitIndexes) : -1;
if (splitIndex === -1) {
return { pathPart: value, rest: "" };
}
return {
pathPart: value.slice(0, splitIndex),
rest: value.slice(splitIndex),
};
}
function rewriteClawHubMarkdownLinkTarget(rawTarget, relativeSourceDir, source) {
const { target, suffix } = splitLinkTarget(rawTarget);
if (/^(?:https?:|mailto:|tel:|data:|#)/iu.test(target) || target.startsWith("/")) {
return rawTarget;
}
const { pathPart, rest } = splitTargetParts(target);
if (!pathPart) {
return rawTarget;
}
let normalizedRelative;
if (pathPart.startsWith("docs/")) {
normalizedRelative = normalizeSlashes(pathPart.slice("docs/".length));
} else if (
pathPart.startsWith("./") ||
pathPart.startsWith("../") ||
/\.mdx?$/iu.test(pathPart)
) {
normalizedRelative = normalizeSlashes(path.normalize(path.join(relativeSourceDir, pathPart)));
} else {
return rawTarget;
}
if (normalizedRelative.startsWith("../")) {
const sourceRef = source.sha || "main";
const repoRelative = normalizeSlashes(
path.normalize(path.join("docs", relativeSourceDir, pathPart)),
).replace(/^(?:\.\.\/)+/u, "");
return `https://github.com/${source.repository}/blob/${sourceRef}/${repoRelative}${rest}${suffix}`;
}
if (!/\.mdx?$/iu.test(normalizedRelative)) {
return rawTarget;
}
const route = toClawHubDocsRoute(normalizedRelative);
return route ? `${route}${rest}${suffix}` : rawTarget;
}
function rewriteClawHubMarkdownLinks(raw, relativeSourcePath, source) {
const relativeSourceDir = normalizeSlashes(path.dirname(relativeSourcePath));
const baseDir = relativeSourceDir === "." ? "" : relativeSourceDir;
return raw.replace(/(!?\[[^\]]*\]\()([^)]+)(\))/gu, (_match, prefix, target, suffix) => {
return `${prefix}${rewriteClawHubMarkdownLinkTarget(target, baseDir, source)}${suffix}`;
});
}
/**
* Mirrors ClawHub docs into the target docs tree.
*/
export function syncClawHubDocsTree(targetDocsDir, options = {}) {
const repoPath = resolveClawHubRepoPath(options.repoPath || "", {
required: options.required !== false,
});
if (!repoPath) {
return {
repository: options.sourceRepo || DEFAULT_CLAWHUB_SOURCE_REPO,
sha: options.sourceSha || "",
path: "",
files: 0,
};
}
const sourceDocsDir = path.join(repoPath, "docs");
const targetDir = path.join(targetDocsDir, CLAWHUB_DOCS_TARGET_DIR);
const source = {
repository: options.sourceRepo || DEFAULT_CLAWHUB_SOURCE_REPO,
sha: options.sourceSha || getGitHeadSha(repoPath),
};
fs.rmSync(targetDir, { recursive: true, force: true });
ensureDir(targetDir);
let copied = 0;
for (const sourcePath of walkFiles(sourceDocsDir)) {
const relativeSourcePath = normalizeSlashes(path.relative(sourceDocsDir, sourcePath));
if (shouldExcludeClawHubDocsPath(relativeSourcePath)) {
continue;
}
const targetRelativePath = toClawHubTargetRelativePath(relativeSourcePath);
if (!targetRelativePath) {
continue;
}
const targetPath = path.join(targetDir, targetRelativePath);
ensureDir(path.dirname(targetPath));
if (/\.mdx?$/iu.test(sourcePath)) {
const raw = fs.readFileSync(sourcePath, "utf8");
fs.writeFileSync(
targetPath,
rewriteClawHubMarkdownLinks(raw, relativeSourcePath, source),
"utf8",
);
} else {
fs.copyFileSync(sourcePath, targetPath);
}
copied += 1;
}
console.log(`Synced ${copied} ClawHub doc asset(s) from ${repoPath}.`);
return {
...source,
path: repoPath,
files: copied,
};
}
function syncDocsTree(targetRoot, options = {}) {
const targetDocsDir = path.join(targetRoot, "docs");
ensureDir(targetDocsDir);
const localeFilters = GENERATED_LOCALES.flatMap((entry) => [
"--filter",
`P ${entry.dir}/`,
"--filter",
`P .i18n/${entry.tmFile}`,
"--exclude",
`${entry.dir}/`,
"--exclude",
`.i18n/${entry.tmFile}`,
]);
run("rsync", [
"-a",
"--delete",
"--filter",
"P .i18n/README.md",
"--exclude",
".i18n/README.md",
...INTERNAL_DOCS_DIRS.flatMap((dir) => ["--exclude", `${dir}/`]),
...localeFilters,
`${SOURCE_DOCS_DIR}/`,
`${targetDocsDir}/`,
]);
pruneInternalDocs(targetDocsDir);
writePublishedDocsMap(targetDocsDir);
for (const locale of GENERATED_LOCALES) {
const sourceTmPath = path.join(SOURCE_DOCS_DIR, ".i18n", locale.tmFile);
const targetTmPath = path.join(targetDocsDir, ".i18n", locale.tmFile);
if (!fs.existsSync(targetTmPath) && fs.existsSync(sourceTmPath)) {
ensureDir(path.dirname(targetTmPath));
fs.copyFileSync(sourceTmPath, targetTmPath);
}
}
const clawhubSource = syncClawHubDocsTree(targetDocsDir, {
repoPath: options.clawhubRepo,
sourceRepo: options.clawhubSourceRepo,
sourceSha: options.clawhubSourceSha,
});
reportOrphanLocaleDocs(targetDocsDir);
repairGeneratedLocaleDocs(targetDocsDir);
writeJson(path.join(targetDocsDir, "docs.json"), composeDocsConfig());
return { clawhub: clawhubSource };
}
/** Writes the public heading map into the publish tree without committing an expanded mirror. */
export function writePublishedDocsMap(targetDocsDir) {
const outputPath = path.join(targetDocsDir, "docs_map.md");
fs.writeFileSync(outputPath, renderDocsHeadingMap(SOURCE_DOCS_DIR), "utf8");
return outputPath;
}
function writeSyncMetadata(targetRoot, args, sources) {
const metadata = {
repository: args.sourceRepo || "",
sha: args.sourceSha || "",
sources: {
openclaw: {
repository: args.sourceRepo || "",
sha: args.sourceSha || "",
},
clawhub: {
repository:
sources.clawhub.repository || args.clawhubSourceRepo || DEFAULT_CLAWHUB_SOURCE_REPO,
sha: sources.clawhub.sha || args.clawhubSourceSha || "",
},
},
syncedAt: new Date().toISOString(),
};
writeJson(path.join(targetRoot, ".openclaw-sync", "source.json"), metadata);
}
function sourceSlugifyVersion() {
const version = readJson(path.join(ROOT, "package.json")).devDependencies[SLUGIFY_PACKAGE];
if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error("docs sync requires an exact source slugify version");
}
return version;
}
function readPublishDependencies(targetRoot) {
return {
packageJson: readJson(path.join(targetRoot, "package.json")),
packageLock: readJson(path.join(targetRoot, "package-lock.json")),
};
}
function matchesSlugifyVersion({ packageJson, packageLock }, version) {
return (
packageJson.devDependencies?.[SLUGIFY_PACKAGE] === version &&
packageLock.lockfileVersion === 3 &&
packageLock.packages?.[""]?.devDependencies?.[SLUGIFY_PACKAGE] === version &&
packageLock.packages?.[`node_modules/${SLUGIFY_PACKAGE}`]?.version === version
);
}
/** Checks sync output here and against fresh publisher main after the workflow rebases. */
export function validateDocsSyncDependencies(targetRoot, baseline) {
const current = readPublishDependencies(targetRoot);
const version = sourceSlugifyVersion();
if (!matchesSlugifyVersion(current, version)) {
throw new Error(`docs sync publisher manifest and lock must both pin slugify ${version}`);
}
// Only this dependency's declaration and resolved row belong to source sync.
// Compare objects, not JSON formatting; never absorb unrelated npm or rebase churn.
const expected = structuredClone(baseline);
expected.packageJson.devDependencies[SLUGIFY_PACKAGE] = version;
expected.packageLock.packages[""].devDependencies[SLUGIFY_PACKAGE] = version;
const slugifyPath = `node_modules/${SLUGIFY_PACKAGE}`;
expected.packageLock.packages[slugifyPath] = current.packageLock.packages[slugifyPath];
if (!isDeepStrictEqual(current, expected)) {
throw new Error(
"docs sync changed unrelated publisher dependencies; reconcile the lock before retrying",
);
}
for (const entry of SYNC_SUPPORT_FILES) {
const copied = fs.readFileSync(path.join(targetRoot, entry.target));
if (!fs.readFileSync(entry.source).equals(copied)) {
throw new Error(`docs sync support file differs from source: ${entry.target}`);
}
}
}
function syncSupportFiles(targetRoot) {
const baseline = readPublishDependencies(targetRoot);
const version = sourceSlugifyVersion();
if (!matchesSlugifyVersion(baseline, version)) {
// Generate the lock from the publisher's existing graph, without installing
// packages. Parser, manifest and lock are committed together by the workflow.
run(
"npm",
[
"install",
"--package-lock-only",
"--ignore-scripts",
"--no-audit",
"--no-fund",
"--save-dev",
"--save-exact",
`${SLUGIFY_PACKAGE}@${version}`,
],
{ cwd: targetRoot, timeout: 120_000 },
);
}
for (const entry of SYNC_SUPPORT_FILES) {
const targetPath = path.join(targetRoot, entry.target);
ensureDir(path.dirname(targetPath));
fs.copyFileSync(entry.source, targetPath);
}
validateDocsSyncDependencies(targetRoot, baseline);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const targetRoot = path.resolve(args.target);
if (!fs.existsSync(targetRoot)) {
throw new Error(`target does not exist: ${targetRoot}`);
}
const clawhubRepo = resolveClawHubRepoPath(args.clawhubRepo);
const sources = syncDocsTree(targetRoot, {
clawhubRepo,
clawhubSourceRepo: args.clawhubSourceRepo,
clawhubSourceSha: args.clawhubSourceSha,
});
syncSupportFiles(targetRoot);
writeSyncMetadata(targetRoot, args, sources);
}
function isCliEntry() {
const cliArg = process.argv[1];
return cliArg ? import.meta.url === pathToFileURL(cliArg).href : false;
}
if (isCliEntry()) {
main();
}