From 5f22dc4ab0f00df4984ae8030324d73205758986 Mon Sep 17 00:00:00 2001
From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>
Date: Wed, 4 Nov 2026 01:09:30 -0600
Subject: [PATCH 1/3] fix: attribute official Discord links in rendered docs
articles
---
scripts/docs-site/analytics-behavior.test.mjs | 2 +-
scripts/docs-site/build.mjs | 47 ++++++++++++++-
.../docs-site/discord-article-links.test.mjs | 60 +++++++++++++++++++
3 files changed, 107 insertions(+), 2 deletions(-)
create mode 100644 scripts/docs-site/discord-article-links.test.mjs
diff --git a/scripts/docs-site/analytics-behavior.test.mjs b/scripts/docs-site/analytics-behavior.test.mjs
index 0d9df9802a..328dca3bc2 100644
--- a/scripts/docs-site/analytics-behavior.test.mjs
+++ b/scripts/docs-site/analytics-behavior.test.mjs
@@ -155,7 +155,7 @@ test("selection placement and sanitized destination share one event without chan
for (const [selector, placement, tag] of [[".site-header", "header", "docs-header"], [".community-invite", "community_invite", "docs-community-invite"], [".site-footer", "footer", "docs-footer"]]) {
await clickAndObserve(`${selector} a[href="https://openclaw.ai/discord?from=${tag}"]`, placement, "social_link", "discord", "https://openclaw.ai/discord");
}
- await clickAndObserve(`.doc a[href="${destination}"]`, "article", "social_link", "discord", destination, true);
+ await clickAndObserve('.doc a[href="https://openclaw.ai/discord?from=docs"]', "article", "social_link", "discord", "https://openclaw.ai/discord");
await clickAndObserve('.doc a[href^="https://openclaw.ai/discord/?"]', "article", "social_link", "discord", "https://openclaw.ai/discord/");
await clickAndObserve('.doc a[href^="https://openclaw.ai/discord-extra"]', "article", "site_link", "openclaw.ai/discord-extra", "https://openclaw.ai/discord-extra");
await clickAndObserve('.doc a[href^="https://example.org/"]', "article", "public_reference", "https://example.org/guide", "https://example.org/guide");
diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs
index 9e4a0faa94..dfd29ade78 100644
--- a/scripts/docs-site/build.mjs
+++ b/scripts/docs-site/build.mjs
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import fs from "node:fs";
+import { Parser } from "htmlparser2";
import { latestReleaseAnnouncement, releaseVersionHtml, releaseBadgeHtml } from "./release-announcement.mjs";
import { homeLayoutHtml } from "./home-layout.mjs";
import { homeStringsForLocale } from "./home-strings.mjs";
@@ -302,7 +303,7 @@ function writePage(page) {
const options = { sourceFile: page.file, root: sourceRoot, pageRoute: pageRoute(page) };
const article = renderCache ? renderCache.render(page.raw, options) : renderArticle(page.raw, options);
const composed = page.slug === "index" ? homeLayoutHtml(article, icon, announcement ? { ...announcement, href: pageRoute(announcedReleasePage) } : null, page.locale) : article;
- const html = rewriteInternalUrls(composed, page.locale);
+ const html = rewriteInternalUrls(rewriteOfficialDiscordLinks(composed, page.slug), page.locale);
const toc = tableOfContents(html);
const outPath = path.join(outDir, pageRoute(page).replace(/^\//, ""), "index.html");
fs.mkdirSync(path.dirname(outPath), { recursive: true });
@@ -312,6 +313,50 @@ function writePage(page) {
fs.writeFileSync(mdPath, page.raw, "utf8");
}
+// Rewrite only rendered article anchors, after the article cache, so cached and
+// localized content share the canonical page placement without changing sources.
+function rewriteOfficialDiscordLinks(html, slug) {
+ const placement = ({
+ "help/faq": "docs-help-faq",
+ "install/updating": "docs-install-updating",
+ "plugins/building-plugins": "docs-plugins-building-plugins",
+ })[slug] ?? "docs";
+ const edits = [];
+ const ancestors = [];
+ const parser = new Parser({
+ onopentag(name, attributes) {
+ if (name === "a" && attributes.href && !ancestors.some(tag => ["pre", "code", "script", "style"].includes(tag))) {
+ let url;
+ try { url = new URL(attributes.href); } catch {}
+ // Queries/fragments may carry event-specific meaning. Keep those, other
+ // communities, service URLs, and credential-bearing URLs untouched.
+ if (url && ["http:", "https:"].includes(url.protocol) && !url.username && !url.password
+ && !url.port && !url.search && !url.hash
+ && ((url.hostname === "discord.gg" && /^\/clawd\/?$/.test(url.pathname))
+ || (["discord.com", "discordapp.com"].includes(url.hostname) && /^\/invite\/clawd\/?$/.test(url.pathname)))) {
+ const start = parser.startIndex;
+ const end = parser.endIndex + 1;
+ const opening = html.slice(start, end);
+ let replaced = false;
+ const replacement = opening.replace(/(\s+)([^\s"'<>/=]+)(\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s>]+)/g,
+ (attribute, space, name, equals) => {
+ if (replaced || name.toLowerCase() !== "href") return attribute;
+ replaced = true;
+ return `${space}${name}${equals}"https://openclaw.ai/discord?from=${placement}"`;
+ });
+ edits.push({ start, end, replacement });
+ }
+ }
+ ancestors.push(name);
+ },
+ onclosetag() { ancestors.pop(); },
+ });
+ parser.write(html);
+ parser.end();
+ for (const { start, end, replacement } of edits.reverse()) html = html.slice(0, start) + replacement + html.slice(end);
+ return html;
+}
+
function layout({ page, nav, activeTab, html, toc, prev, next }) {
const home = page.slug === "index";
const lang = page.locale;
diff --git a/scripts/docs-site/discord-article-links.test.mjs b/scripts/docs-site/discord-article-links.test.mjs
new file mode 100644
index 0000000000..4935dd9854
--- /dev/null
+++ b/scripts/docs-site/discord-article-links.test.mjs
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { test } from "node:test";
+import { DomUtils, parseDocument } from "htmlparser2";
+import { fixture, write } from "./test-helpers/redirect-fixture.mjs";
+
+const placements = {
+ "help/faq": "docs-help-faq",
+ "install/updating": "docs-install-updating",
+ "plugins/building-plugins": "docs-plugins-building-plugins",
+ "guide": "docs",
+};
+const official = ["https://discord.gg/clawd", "https://discord.com/invite/clawd", "https://discordapp.com/invite/clawd/"];
+const preserved = [
+ "https://discord.gg/other", "https://discord.com/invite/other", "https://discord.gg/clawd-extra", "https://discord.gg:8443/clawd",
+ "https://discord.com/api/v10/invites/clawd", "https://discord.gg/clawd?event=123456789012345678",
+ "https://discord.com/invite/clawd#event", "https://discord.gg/clawd?utm_source=legacy",
+ "https://discord.gg.example.org/clawd", "https://example.org/?invite=https://discord.gg/clawd",
+ "https://openclaw.ai/discord?from=docs-plugins-building-plugins", "https://community.openclaw.ai/",
+];
+
+function article(document) {
+ return DomUtils.findOne(node => node.attribs?.class?.split(" ").includes("doc"), document.children);
+}
+
+test("English and localized rendered article anchors use canonical page placements, including cached articles", t => {
+ const body = ["# Community", ...official.map((href, i) => `[Official ${i}](${href})`),
+ 'Raw HTML',
+ ...preserved.map((href, i) => `[Preserve ${i}](${href})`),
+ "`https://discord.gg/clawd`", "```html\nExample\n```",
+ "https://discord.gg/clawd", 'Code anchor',
+ ].join("\n\n");
+ const sources = {};
+ for (const locale of ["en", "fr"]) for (const slug of Object.keys(placements)) sources[`${locale === "en" ? "" : locale + "/"}${slug}.md`] = body;
+ const f = fixture(t, [], sources);
+ write(f.root, "docs/docs.json", JSON.stringify({ name: "Article placement fixture", navigation: { languages: ["en", "fr"].map(language => ({ language, tabs: [] })) } }));
+ for (let pass = 0; pass < 2; pass++) {
+ const built = f.build();
+ assert.equal(built.status, 0, built.stderr);
+ if (pass) assert.match(built.stdout, /article cache: \d+ reused, 0 rendered/);
+ for (const locale of ["en", "fr"]) for (const [slug, tag] of Object.entries(placements)) {
+ const html = fs.readFileSync(path.join(f.root, "dist/docs-site", locale === "en" ? "" : locale, slug, "index.html"), "utf8");
+ const doc = article(parseDocument(html));
+ assert.ok(doc, `${locale}/${slug}`);
+ const anchors = DomUtils.findAll(node => node.name === "a", doc.children);
+ for (let i = 0; i < official.length; i++) assert.equal(anchors.find(node => DomUtils.textContent(node) === `Official ${i}`).attribs.href, `https://openclaw.ai/discord?from=${tag}`);
+ const raw = anchors.find(node => node.attribs.class === "raw-link");
+ assert.equal(raw.attribs.href, `https://openclaw.ai/discord?from=${tag}`);
+ assert.equal(raw.attribs["data-href"], "https://example.org/unchanged"); assert.equal(raw.attribs.title, "Keep href=example title");
+ for (let i = 0; i < preserved.length; i++) assert.equal(anchors.find(node => DomUtils.textContent(node) === `Preserve ${i}`).attribs.href, preserved[i]);
+ const code = DomUtils.findAll(node => node.name === "code", doc.children).map(node => DomUtils.textContent(node));
+ assert.ok(code.some(text => text.includes('href="https://discord.gg/clawd"')), "fenced code is unchanged");
+ assert.ok(code.some(text => text === "https://discord.gg/clawd"), "inline code is unchanged");
+ assert.equal(anchors.find(node => DomUtils.textContent(node) === "Code anchor").attribs.href, "https://discord.gg/clawd");
+ assert.ok(DomUtils.textContent(doc).includes("https://discord.gg/clawd"), "display text is not rewritten");
+ assert.equal(fs.readFileSync(path.join(f.root, "docs", locale === "en" ? "" : locale, slug + ".md"), "utf8"), body);
+ }
+ }
+});
From 6bf3036c201aeb2bc6812fa357607412907625a0 Mon Sep 17 00:00:00 2001
From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>
Date: Wed, 4 Nov 2026 01:15:32 -0600
Subject: [PATCH 2/3] fix: keep fallback article attribution within registered
keys
---
scripts/docs-site/build.mjs | 11 ++++++-----
scripts/docs-site/discord-article-links.test.mjs | 1 +
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs
index dfd29ade78..588b4f6fc5 100644
--- a/scripts/docs-site/build.mjs
+++ b/scripts/docs-site/build.mjs
@@ -316,11 +316,12 @@ function writePage(page) {
// Rewrite only rendered article anchors, after the article cache, so cached and
// localized content share the canonical page placement without changing sources.
function rewriteOfficialDiscordLinks(html, slug) {
- const placement = ({
- "help/faq": "docs-help-faq",
- "install/updating": "docs-install-updating",
- "plugins/building-plugins": "docs-plugins-building-plugins",
- })[slug] ?? "docs";
+ const placements = new Map([
+ ["help/faq", "docs-help-faq"],
+ ["install/updating", "docs-install-updating"],
+ ["plugins/building-plugins", "docs-plugins-building-plugins"],
+ ]);
+ const placement = placements.get(slug) ?? "docs";
const edits = [];
const ancestors = [];
const parser = new Parser({
diff --git a/scripts/docs-site/discord-article-links.test.mjs b/scripts/docs-site/discord-article-links.test.mjs
index 4935dd9854..0623679dbf 100644
--- a/scripts/docs-site/discord-article-links.test.mjs
+++ b/scripts/docs-site/discord-article-links.test.mjs
@@ -10,6 +10,7 @@ const placements = {
"install/updating": "docs-install-updating",
"plugins/building-plugins": "docs-plugins-building-plugins",
"guide": "docs",
+ "constructor": "docs",
};
const official = ["https://discord.gg/clawd", "https://discord.com/invite/clawd", "https://discordapp.com/invite/clawd/"];
const preserved = [
From ec0324a85f0eb218243b992aacfb76d2cce8023f Mon Sep 17 00:00:00 2001
From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>
Date: Wed, 4 Nov 2026 02:09:46 -0600
Subject: [PATCH 3/3] test: assert exact preserved Discord display text
---
scripts/docs-site/discord-article-links.test.mjs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/scripts/docs-site/discord-article-links.test.mjs b/scripts/docs-site/discord-article-links.test.mjs
index 0623679dbf..bf4151a406 100644
--- a/scripts/docs-site/discord-article-links.test.mjs
+++ b/scripts/docs-site/discord-article-links.test.mjs
@@ -30,7 +30,7 @@ test("English and localized rendered article anchors use canonical page placemen
'Raw HTML',
...preserved.map((href, i) => `[Preserve ${i}](${href})`),
"`https://discord.gg/clawd`", "```html\nExample\n```",
- "https://discord.gg/clawd", 'Code anchor',
+ '
https://discord.gg/clawd
', 'Code anchor',
].join("\n\n");
const sources = {};
for (const locale of ["en", "fr"]) for (const slug of Object.keys(placements)) sources[`${locale === "en" ? "" : locale + "/"}${slug}.md`] = body;
@@ -54,7 +54,8 @@ test("English and localized rendered article anchors use canonical page placemen
assert.ok(code.some(text => text.includes('href="https://discord.gg/clawd"')), "fenced code is unchanged");
assert.ok(code.some(text => text === "https://discord.gg/clawd"), "inline code is unchanged");
assert.equal(anchors.find(node => DomUtils.textContent(node) === "Code anchor").attribs.href, "https://discord.gg/clawd");
- assert.ok(DomUtils.textContent(doc).includes("https://discord.gg/clawd"), "display text is not rewritten");
+ const display = DomUtils.findOne(node => node.attribs?.class === "display-url", doc.children);
+ assert.equal(DomUtils.textContent(display), "https://discord.gg/clawd", "display text is not rewritten");
assert.equal(fs.readFileSync(path.join(f.root, "docs", locale === "en" ? "" : locale, slug + ".md"), "utf8"), body);
}
}