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); } }