From 4680335b2a4f17f4638c2b6d7f4ca0fbd4efca8e Mon Sep 17 00:00:00 2001 From: Radek Sienkiewicz Date: Mon, 30 Mar 2026 01:21:00 +0200 Subject: [PATCH] docs: fix English link audits (#57039) Merged via squash. Prepared head SHA: d20a3b620f78881ea2175640f593929e2637f0dc Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 1 + docs/channels/feishu.md | 14 +- docs/channels/groups.md | 2 + docs/gateway/configuration-reference.md | 2 + docs/gateway/security/index.md | 2 + docs/help/faq.md | 11 +- docs/install/installer.md | 6 + docs/install/podman.md | 2 + docs/plugins/sdk-channel-plugins.md | 6 +- docs/plugins/sdk-provider-plugins.md | 3 + docs/providers/qwen.md | 4 +- docs/web/dashboard.md | 2 + scripts/docs-link-audit.mjs | 129 +++++++++++++++++-- src/scripts/docs-link-audit.test.ts | 162 +++++++++++++++++++++--- 14 files changed, 301 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7c61f5b8b..5d378bea3f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: add descriptor-backed lazy plugin CLI registration so Matrix can keep its CLI module lazy-loaded without dropping `openclaw matrix ...` from parse-time command registration. (#57165) Thanks @gumadeiras. - Plugins/CLI: collect root-help plugin descriptors through a dedicated non-activating CLI metadata path so enabled plugins keep validated config semantics without triggering runtime-only plugin registration work, while preserving runtime CLI command registration for legacy channel plugins that still wire commands from full registration. (#57294) thanks @gumadeiras. - Anthropic/OAuth: inject `/fast` `service_tier` hints for direct `sk-ant-oat-*` requests so OAuth-authenticated Anthropic runs stop missing the same overload-routing signal as API-key traffic. Fixes #55758. Thanks @Cypherm and @vincentkoc. +- Docs/anchors: fix broken English docs links and make Mint anchor audits run against the English-source docs tree. (#57039) thanks @velvet-shark. ## 2026.3.28 diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index c2a0def6af0..6e196b5bee8 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -81,7 +81,7 @@ Lark (global) tenants should use [https://open.larksuite.com/app](https://open.l 2. Fill in the app name + description 3. Choose an app icon -![Create enterprise app](../images/feishu-step2-create-app.png) +![Create enterprise app](/images/feishu-step2-create-app.png) ### 3. Copy credentials @@ -92,7 +92,7 @@ From **Credentials & Basic Info**, copy: ❗ **Important:** keep the App Secret private. -![Get credentials](../images/feishu-step3-credentials.png) +![Get credentials](/images/feishu-step3-credentials.png) ### 4. Configure permissions @@ -126,7 +126,7 @@ On **Permissions**, click **Batch import** and paste: } ``` -![Configure permissions](../images/feishu-step4-permissions.png) +![Configure permissions](/images/feishu-step4-permissions.png) ### 5. Enable bot capability @@ -135,7 +135,7 @@ In **App Capability** > **Bot**: 1. Enable bot capability 2. Set the bot name -![Enable bot capability](../images/feishu-step5-bot-capability.png) +![Enable bot capability](/images/feishu-step5-bot-capability.png) ### 6. Configure event subscription @@ -151,7 +151,7 @@ In **Event Subscription**: ⚠️ If the gateway is not running, the long-connection setup may fail to save. -![Configure event subscription](../images/feishu-step6-event-subscription.png) +![Configure event subscription](/images/feishu-step6-event-subscription.png) ### 7. Publish the app @@ -206,7 +206,7 @@ When using webhook mode, set both `channels.feishu.verificationToken` and `chann The screenshot below shows where to find the **Verification Token**. The **Encrypt Key** is listed in the same **Encryption** section. -![Verification Token location](../images/feishu-verification-token.png) +![Verification Token location](/images/feishu-verification-token.png) ### Configure via environment variables @@ -395,6 +395,8 @@ In addition to allowing the group itself, **all messages** in that group are gat --- + + ## Get group/user IDs ### Group IDs (chat_id) diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 66b8720a8c4..58490e8d247 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -54,6 +54,8 @@ If you want... - Direct chats use the main session (or per-sender if configured). - Heartbeats are skipped for group sessions. + + ## Pattern: personal DMs + public groups (single agent) Yes — this works well if your “personal” traffic is **DMs** and your “public” traffic is **groups**. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 96a547888e9..7e9486b5721 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1125,6 +1125,8 @@ See [Streaming](/concepts/streaming) for behavior + chunking details. See [Typing Indicators](/concepts/typing-indicators). + + ### `agents.defaults.sandbox` Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing) for the full guide. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index a20361a00f8..c5dc4e365e9 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -602,6 +602,8 @@ Recommendations: - When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled. - For chat-only personal assistants with trusted input and no tools, smaller models are usually fine. + + ## Reasoning & verbose output in groups `/reasoning` and `/verbose` can expose internal reasoning or tool output that diff --git a/docs/help/faq.md b/docs/help/faq.md index 1d7c164efaa..4f20649936d 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -585,11 +585,12 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - - That means your **Anthropic quota/rate limit** is exhausted for the current window. If you - use a **Claude subscription** (setup-token), wait for the window to - reset or upgrade your plan. If you use an **Anthropic API key**, check the Anthropic Console - for usage/billing and raise limits as needed. + + +That means your **Anthropic quota/rate limit** is exhausted for the current window. If you +use a **Claude subscription** (setup-token), wait for the window to +reset or upgrade your plan. If you use an **Anthropic API key**, check the Anthropic Console +for usage/billing and raise limits as needed. If the message is specifically: `Extra usage is required for long context requests`, the request is trying to use diff --git a/docs/install/installer.md b/docs/install/installer.md index dcf1ec8b759..f42fe335377 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -58,6 +58,8 @@ If install succeeds but `openclaw` is not found in a new terminal, see [Node.js --- + + ## install.sh @@ -170,6 +172,8 @@ The script exits with code `2` for invalid method selection or invalid `--instal --- + + ## install-cli.sh @@ -248,6 +252,8 @@ Designed for environments where you want everything under a local prefix (defaul --- + + ## install.ps1 ### Flow (install.ps1) diff --git a/docs/install/podman.md b/docs/install/podman.md index f99ad257fb1..decfbb390df 100644 --- a/docs/install/podman.md +++ b/docs/install/podman.md @@ -165,6 +165,8 @@ openclaw devices list \ --token "$(sed -n 's/^OPENCLAW_GATEWAY_TOKEN=//p' ~/.openclaw/.env | head -n1)" ``` + + ## Podman + Tailscale For HTTPS or remote browser access, follow the main Tailscale docs. diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 53ff0e11b49..2c4e1eb9bdd 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -37,6 +37,7 @@ dispatch. ## Walkthrough + Create the standard plugin files. The `channel` field in `package.json` is what makes this a channel plugin: @@ -298,8 +299,9 @@ dispatch. - - Write colocated tests in `src/channel.test.ts`: + + +Write colocated tests in `src/channel.test.ts`: ```typescript src/channel.test.ts import { describe, it, expect } from "vitest"; diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index e6a5aac2eb2..86a7c4f0546 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -23,6 +23,7 @@ API key auth, and dynamic model resolution. ## Walkthrough + ```json package.json @@ -319,6 +320,7 @@ API key auth, and dynamic model resolution. + A provider plugin can register speech, media understanding, image generation, and web search alongside text inference: @@ -360,6 +362,7 @@ API key auth, and dynamic model resolution. + ```typescript src/provider.test.ts import { describe, it, expect } from "vitest"; // Export your provider config object from index.ts or a dedicated file diff --git a/docs/providers/qwen.md b/docs/providers/qwen.md index 3a969a54e44..05a3576faf7 100644 --- a/docs/providers/qwen.md +++ b/docs/providers/qwen.md @@ -19,7 +19,7 @@ background. ## Recommended: Model Studio (Alibaba Cloud Coding Plan) -Use [Model Studio](/providers/modelstudio) for officially supported access to +Use [Model Studio](/providers/qwen_modelstudio) for officially supported access to Qwen models (Qwen 3.5 Plus, GLM-4.7, Kimi K2.5, and more). ```bash @@ -30,4 +30,4 @@ openclaw onboard --auth-choice modelstudio-api-key openclaw onboard --auth-choice modelstudio-api-key-cn ``` -See [Model Studio](/providers/modelstudio) for full setup details. +See [Model Studio](/providers/qwen_modelstudio) for full setup details. diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 71238e0b2bc..682c155b841 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -42,6 +42,8 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel. - If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance. - **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web). + + ## If you see "unauthorized" / 1008 - Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`). diff --git a/scripts/docs-link-audit.mjs b/scripts/docs-link-audit.mjs index ad0e40f3264..1c1e899c1ba 100644 --- a/scripts/docs-link-audit.mjs +++ b/scripts/docs-link-audit.mjs @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -67,8 +68,12 @@ for (const item of docsConfig.redirects || []) { const allFiles = walk(DOCS_DIR); const relAllFiles = new Set(allFiles.map((abs) => normalizeSlashes(path.relative(DOCS_DIR, abs)))); +function isLocalizedDocPath(p) { + return /^\/?[a-z]{2}(?:-[A-Za-z]{2,8})+\//.test(p); +} + function isGeneratedTranslatedDoc(relPath) { - return relPath.startsWith("zh-CN/"); + return isLocalizedDocPath(relPath); } const markdownFiles = allFiles.filter((abs) => { @@ -169,6 +174,95 @@ function collectNavPageEntries(node) { const markdownLinkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g; +export function sanitizeDocsConfigForEnglishOnly(value) { + if (Array.isArray(value)) { + return value + .map((item) => sanitizeDocsConfigForEnglishOnly(item)) + .filter((item) => item !== undefined); + } + + if (!value || typeof value !== "object") { + if (typeof value === "string" && isLocalizedDocPath(value)) { + return undefined; + } + return value; + } + + const record = /** @type {Record} */ (value); + if (typeof record.language === "string" && record.language !== "en") { + return undefined; + } + + /** @type {Record} */ + const sanitized = {}; + for (const [key, child] of Object.entries(record)) { + const next = sanitizeDocsConfigForEnglishOnly(child); + if (next === undefined) { + continue; + } + if (Array.isArray(next) && next.length === 0) { + continue; + } + if ( + next && + typeof next === "object" && + !Array.isArray(next) && + Object.keys(next).length === 0 + ) { + continue; + } + sanitized[key] = next; + } + + if (record.pages && !Array.isArray(sanitized.pages)) { + return undefined; + } + if (record.groups && !Array.isArray(sanitized.groups)) { + return undefined; + } + if (record.tabs && !Array.isArray(sanitized.tabs)) { + return undefined; + } + if ( + "source" in record && + typeof record.source === "string" && + typeof sanitized.source !== "string" + ) { + return undefined; + } + if ( + "destination" in record && + typeof record.destination === "string" && + typeof sanitized.destination !== "string" + ) { + return undefined; + } + + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +export function prepareAnchorAuditDocsDir(sourceDir = DOCS_DIR) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-anchor-audit-")); + fs.cpSync(sourceDir, tempDir, { recursive: true }); + + for (const entry of fs.readdirSync(tempDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + if (!isGeneratedTranslatedDoc(`${entry.name}/`)) { + continue; + } + fs.rmSync(path.join(tempDir, entry.name), { recursive: true, force: true }); + } + + const docsJsonPath = path.join(tempDir, "docs.json"); + const docsConfig = JSON.parse(fs.readFileSync(docsJsonPath, "utf8")); + const sanitized = sanitizeDocsConfigForEnglishOnly(docsConfig); + fs.writeFileSync(docsJsonPath, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8"); + + return tempDir; +} + export function auditDocsLinks() { /** @type {{file: string; line: number; link: string; reason: string}[]} */ const broken = []; @@ -293,26 +387,39 @@ export function auditDocsLinks() { * @param {{ * args?: string[]; * spawnSyncImpl?: typeof spawnSync; + * prepareAnchorAuditDocsDirImpl?: (sourceDir?: string) => string; + * cleanupAnchorAuditDocsDirImpl?: (dir: string) => void; * }} [options] */ export function runDocsLinkAuditCli(options = {}) { const args = options.args ?? process.argv.slice(2); if (args.includes("--anchors")) { const spawnSyncImpl = options.spawnSyncImpl ?? spawnSync; - const result = spawnSyncImpl("mint", ["broken-links", "--check-anchors"], { - cwd: DOCS_DIR, - stdio: "inherit", - }); + const prepareAnchorAuditDocsDirImpl = + options.prepareAnchorAuditDocsDirImpl ?? prepareAnchorAuditDocsDir; + const cleanupAnchorAuditDocsDirImpl = + options.cleanupAnchorAuditDocsDirImpl ?? + ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const anchorDocsDir = prepareAnchorAuditDocsDirImpl(DOCS_DIR); - if (result.error?.code === "ENOENT") { - const fallback = spawnSyncImpl("pnpm", ["dlx", "mint", "broken-links", "--check-anchors"], { - cwd: DOCS_DIR, + try { + const result = spawnSyncImpl("mint", ["broken-links", "--check-anchors"], { + cwd: anchorDocsDir, stdio: "inherit", }); - return fallback.status ?? 1; - } - return result.status ?? 1; + if (result.error?.code === "ENOENT") { + const fallback = spawnSyncImpl("pnpm", ["dlx", "mint", "broken-links", "--check-anchors"], { + cwd: anchorDocsDir, + stdio: "inherit", + }); + return fallback.status ?? 1; + } + + return result.status ?? 1; + } finally { + cleanupAnchorAuditDocsDirImpl(anchorDocsDir); + } } const { checked, broken } = auditDocsLinks(); diff --git a/src/scripts/docs-link-audit.test.ts b/src/scripts/docs-link-audit.test.ts index 01fb0d362f6..b87252b1e3b 100644 --- a/src/scripts/docs-link-audit.test.ts +++ b/src/scripts/docs-link-audit.test.ts @@ -1,22 +1,33 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -const { normalizeRoute, resolveRoute, runDocsLinkAuditCli } = - (await import("../../scripts/docs-link-audit.mjs")) as unknown as { - normalizeRoute: (route: string) => string; - resolveRoute: ( - route: string, - options?: { redirects?: Map; routes?: Set }, - ) => { ok: boolean; terminal: string; loop?: boolean }; - runDocsLinkAuditCli: (options?: { - args?: string[]; - spawnSyncImpl?: ( - command: string, - args: string[], - options: { cwd: string; stdio: string }, - ) => { status: number | null; error?: { code?: string } }; - }) => number; - }; +const { + normalizeRoute, + prepareAnchorAuditDocsDir, + resolveRoute, + runDocsLinkAuditCli, + sanitizeDocsConfigForEnglishOnly, +} = (await import("../../scripts/docs-link-audit.mjs")) as unknown as { + normalizeRoute: (route: string) => string; + prepareAnchorAuditDocsDir: (sourceDir?: string) => string; + resolveRoute: ( + route: string, + options?: { redirects?: Map; routes?: Set }, + ) => { ok: boolean; terminal: string; loop?: boolean }; + runDocsLinkAuditCli: (options?: { + args?: string[]; + spawnSyncImpl?: ( + command: string, + args: string[], + options: { cwd: string; stdio: string }, + ) => { status: number | null; error?: { code?: string } }; + prepareAnchorAuditDocsDirImpl?: (sourceDir?: string) => string; + cleanupAnchorAuditDocsDirImpl?: (dir: string) => void; + }) => number; + sanitizeDocsConfigForEnglishOnly: (value: unknown) => unknown; +}; describe("docs-link-audit", () => { it("normalizes route fragments away", () => { @@ -38,6 +49,101 @@ describe("docs-link-audit", () => { }); }); + it("sanitizes docs.json to English-only route targets", () => { + expect( + sanitizeDocsConfigForEnglishOnly({ + navigation: [ + { + language: "en", + tabs: [ + { + tab: "Docs", + groups: [ + { + group: "Keep", + pages: ["help/testing", "zh-CN/help/testing", "ja-JP/help/testing"], + }, + ], + }, + ], + }, + { + language: "zh-Hans", + tabs: [{ tab: "中文", groups: [{ group: "帮助", pages: ["zh-CN/help/testing"] }] }], + }, + ], + redirects: [ + { source: "/help/testing", destination: "/help/testing" }, + { source: "/zh-CN/help/testing", destination: "/help/testing" }, + { source: "/help/testing", destination: "/ja-JP/help/testing" }, + ], + }), + ).toEqual({ + navigation: [ + { + language: "en", + tabs: [ + { + tab: "Docs", + groups: [{ group: "Keep", pages: ["help/testing"] }], + }, + ], + }, + ], + redirects: [{ source: "/help/testing", destination: "/help/testing" }], + }); + }); + + it("builds an English-only docs tree for anchor audits", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "docs-link-audit-fixture-")); + const docsRoot = path.join(fixtureRoot, "docs"); + fs.mkdirSync(path.join(docsRoot, "help"), { recursive: true }); + fs.mkdirSync(path.join(docsRoot, "zh-CN", "help"), { recursive: true }); + fs.writeFileSync( + path.join(docsRoot, "docs.json"), + `${JSON.stringify( + { + navigation: [ + { + language: "en", + tabs: [{ tab: "Docs", groups: [{ group: "Help", pages: ["help/testing"] }] }], + }, + { + language: "zh-Hans", + tabs: [{ tab: "中文", groups: [{ group: "帮助", pages: ["zh-CN/help/testing"] }] }], + }, + ], + }, + null, + 2, + )}\n`, + "utf8", + ); + fs.writeFileSync(path.join(docsRoot, "help", "testing.md"), "# testing\n", "utf8"); + fs.writeFileSync(path.join(docsRoot, "zh-CN", "help", "testing.md"), "# 测试\n", "utf8"); + + const anchorDocsDir = prepareAnchorAuditDocsDir(docsRoot); + try { + expect(fs.existsSync(path.join(anchorDocsDir, "help", "testing.md"))).toBe(true); + expect(fs.existsSync(path.join(anchorDocsDir, "zh-CN"))).toBe(false); + + const sanitizedDocsJson = JSON.parse( + fs.readFileSync(path.join(anchorDocsDir, "docs.json"), "utf8"), + ); + expect(sanitizedDocsJson).toEqual({ + navigation: [ + { + language: "en", + tabs: [{ tab: "Docs", groups: [{ group: "Help", pages: ["help/testing"] }] }], + }, + ], + }); + } finally { + fs.rmSync(anchorDocsDir, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + it("prefers a local mint binary for anchor validation", () => { let invocation: | { @@ -46,9 +152,17 @@ describe("docs-link-audit", () => { options: { cwd: string; stdio: string }; } | undefined; + let cleanedDir: string | undefined; + const anchorDocsDir = path.join(os.tmpdir(), "docs-link-audit-anchor"); const exitCode = runDocsLinkAuditCli({ args: ["--anchors"], + prepareAnchorAuditDocsDirImpl() { + return anchorDocsDir; + }, + cleanupAnchorAuditDocsDirImpl(dir) { + cleanedDir = dir; + }, spawnSyncImpl(command, args, options) { invocation = { command, args, options }; return { status: 0 }; @@ -60,7 +174,8 @@ describe("docs-link-audit", () => { expect(invocation?.command).toBe("mint"); expect(invocation?.args).toEqual(["broken-links", "--check-anchors"]); expect(invocation?.options.stdio).toBe("inherit"); - expect(path.basename(invocation?.options.cwd ?? "")).toBe("docs"); + expect(invocation?.options.cwd).toBe(anchorDocsDir); + expect(cleanedDir).toBe(anchorDocsDir); }); it("falls back to pnpm dlx when mint is not on PATH", () => { @@ -69,9 +184,17 @@ describe("docs-link-audit", () => { args: string[]; options: { cwd: string; stdio: string }; }> = []; + let cleanedDir: string | undefined; + const anchorDocsDir = path.join(os.tmpdir(), "docs-link-audit-anchor"); const exitCode = runDocsLinkAuditCli({ args: ["--anchors"], + prepareAnchorAuditDocsDirImpl() { + return anchorDocsDir; + }, + cleanupAnchorAuditDocsDirImpl(dir) { + cleanedDir = dir; + }, spawnSyncImpl(command, args, options) { invocations.push({ command, args, options }); if (command === "mint") { @@ -93,7 +216,8 @@ describe("docs-link-audit", () => { args: ["dlx", "mint", "broken-links", "--check-anchors"], options: { stdio: "inherit" }, }); - expect(path.basename(invocations[0]?.options.cwd ?? "")).toBe("docs"); - expect(path.basename(invocations[1]?.options.cwd ?? "")).toBe("docs"); + expect(invocations[0]?.options.cwd).toBe(anchorDocsDir); + expect(invocations[1]?.options.cwd).toBe(anchorDocsDir); + expect(cleanedDir).toBe(anchorDocsDir); }); });