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..588b4f6fc5 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,51 @@ 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 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({ + 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..bf4151a406 --- /dev/null +++ b/scripts/docs-site/discord-article-links.test.mjs @@ -0,0 +1,62 @@ +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", + "constructor": "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");
+ 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);
+ }
+ }
+});