mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
Consolidate repeated CLI parsing, scanning, benchmark and release projections through their existing owners. Preserve script and CI contracts. Fix malformed installer version diagnostics, flat ClawHub artifact sealing, completed polling sleep listener retention, and metadata output symlink confinement.
777 lines
23 KiB
JavaScript
777 lines
23 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 { parseFlagArgs, stringFlag } from "./lib/arg-utils.runtime.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 AUTHORING_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
const DEFAULT_CLAWHUB_SOURCE_REPO = "openclaw/clawhub";
|
|
const CLAWHUB_DOCS_TARGET_DIR = "clawhub";
|
|
export const CLAWHUB_REPO_ENV = "OPENCLAW_DOCS_SYNC_CLAWHUB_REPO";
|
|
const DEFAULT_CLAWHUB_REPO_CANDIDATES = [
|
|
path.resolve(ROOT, "..", "clawhub-docs-clawhub"),
|
|
path.resolve(ROOT, "..", "clawhub"),
|
|
];
|
|
// File URLs declare the copied runtime closure without executing modules:
|
|
// source sync runs before parser dependencies are installed.
|
|
const SYNC_SUPPORT_FILES = [
|
|
...[
|
|
"lib/docs-markdown.mjs",
|
|
"lib/docs-redirects.mjs",
|
|
"check-docs-mdx.mjs",
|
|
"check-docs-mdx.mts",
|
|
"lib/arg-utils.runtime.mjs",
|
|
"lib/tsx-cli-shim.mjs",
|
|
"lib/local-check-runtime.mts",
|
|
"tsx.mjs",
|
|
].map((file) => ({
|
|
source: new URL(`./${file}`, import.meta.url),
|
|
target: path.join(".openclaw-sync", file),
|
|
})),
|
|
{
|
|
source: path.join(ROOT, ".github", "codex", "prompts", "docs-mdx-repair.md"),
|
|
target: path.join(".openclaw-sync", "docs-mdx-repair.md"),
|
|
},
|
|
];
|
|
const GENERATED_LOCALES = [
|
|
["zh-Hans", "zh-CN"],
|
|
["zh-Hant", "zh-TW"],
|
|
["ja", "ja-JP"],
|
|
["es"],
|
|
["pt-BR"],
|
|
["ko"],
|
|
["de"],
|
|
["fr"],
|
|
["hi"],
|
|
["ar"],
|
|
["it"],
|
|
["vi"],
|
|
["nl"],
|
|
["fa"],
|
|
["tr"],
|
|
["uk"],
|
|
["id"],
|
|
["pl"],
|
|
["th"],
|
|
["ru"],
|
|
].map(([language, dir = language]) => ({
|
|
language,
|
|
dir,
|
|
navFile: `${language}-navigation.json`,
|
|
tmFile: `${dir}.tm.jsonl`,
|
|
}));
|
|
|
|
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 || "",
|
|
};
|
|
|
|
const flags = [
|
|
["--target", "target"],
|
|
["--source-repo", "sourceRepo"],
|
|
["--source-sha", "sourceSha"],
|
|
["--clawhub-repo", "clawhubRepo"],
|
|
["--clawhub-source-repo", "clawhubSourceRepo"],
|
|
["--clawhub-source-sha", "clawhubSourceSha"],
|
|
];
|
|
parseFlagArgs(
|
|
argv,
|
|
args,
|
|
flags.map(([flag, key]) =>
|
|
stringFlag(flag, key, { allowInline: false, repeatable: true, rejectShortOptions: true }),
|
|
),
|
|
{
|
|
ignoreDoubleDash: false,
|
|
onUnhandledArg(part) {
|
|
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 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)) {
|
|
console.warn(
|
|
`docs-sync-publish: missing navigation overlay ${locale.navFile} for locale ${locale.language}; publishing the English sidebar labels for that locale.`,
|
|
);
|
|
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.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.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 walkFiles(localeDir).filter((file) => /\.mdx?$/i.test(file))) {
|
|
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 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 pruneAuthoringInstructions(targetDocsDir) {
|
|
// Root repository instructions are not public pages. Locale orphans remain
|
|
// translation-finalizer owned so their inbound links are repaired together.
|
|
for (const file of AUTHORING_INSTRUCTION_FILES) {
|
|
fs.rmSync(path.join(targetDocsDir, file), { force: true });
|
|
}
|
|
}
|
|
|
|
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}/`]),
|
|
...AUTHORING_INSTRUCTION_FILES.flatMap((file) => ["--exclude", `/${file}`]),
|
|
...localeFilters,
|
|
`${SOURCE_DOCS_DIR}/`,
|
|
`${targetDocsDir}/`,
|
|
]);
|
|
pruneInternalDocs(targetDocsDir);
|
|
pruneAuthoringInstructions(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);
|
|
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();
|
|
}
|