Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/docs-site/analytics-behavior.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
48 changes: 47 additions & 1 deletion scripts/docs-site/build.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
Expand All @@ -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;
Expand Down
62 changes: 62 additions & 0 deletions scripts/docs-site/discord-article-links.test.mjs
Original file line number Diff line number Diff line change
@@ -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})`),
'<a class="raw-link" data-href="https://example.org/unchanged" href=\'https://discord.gg/clawd\' title="Keep href=example title">Raw HTML</a>',
...preserved.map((href, i) => `[Preserve ${i}](${href})`),
"`https://discord.gg/clawd`", "```html\n<a href=\"https://discord.gg/clawd\">Example</a>\n```",
'<p class="display-url">https://discord.gg/clawd</p>', '<code><a href="https://discord.gg/clawd">Code anchor</a></code>',
].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);
}
}
});
Loading