Skip to content

Commit bfe1eec

Browse files
committed
UI: send docs markdown links to public docs
1 parent 00d846d commit bfe1eec

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

ui/src/ui/markdown.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,45 @@ PY
582582
expect(html).toBe("<p><a>report.docx</a></p>\n");
583583
});
584584

585+
it("rewrites docs-root markdown links to the public docs host", () => {
586+
const html = toSanitizedMarkdownHtml(
587+
"[Workspace concept](/concepts/agent-workspace#agent-workspace)",
588+
);
589+
expect(html).toBe(
590+
'<p><a href="https://docs.openclaw.ai/concepts/agent-workspace#agent-workspace" rel="noreferrer noopener" target="_blank">Workspace concept</a></p>\n',
591+
);
592+
});
593+
594+
it("rewrites nested docs links while preserving Control UI app routes", () => {
595+
const html = toSanitizedMarkdownHtml(
596+
"[Discord docs](/channels/discord) [usage](/usage) [chat](/chat) [media](/api/chat/media/outgoing/file)",
597+
);
598+
expect(html).toBe(
599+
'<p><a href="https://docs.openclaw.ai/channels/discord" rel="noreferrer noopener" target="_blank">Discord docs</a> <a href="/usage" rel="noreferrer noopener" target="_blank">usage</a> <a href="/chat" rel="noreferrer noopener" target="_blank">chat</a> <a href="/api/chat/media/outgoing/file" rel="noreferrer noopener" target="_blank">media</a></p>\n',
600+
);
601+
});
602+
603+
it("rewrites docs links rendered in markdown sidebars", () => {
604+
const container = document.createElement("div");
605+
606+
render(
607+
renderMarkdownSidebar({
608+
content: {
609+
kind: "markdown",
610+
content: "Read the [agent workspace](/concepts/agent-workspace) docs.",
611+
},
612+
error: null,
613+
onClose: () => undefined,
614+
onViewRawText: () => undefined,
615+
}),
616+
container,
617+
);
618+
619+
expect(container.querySelector(".sidebar-markdown a")?.getAttribute("href")).toBe(
620+
"https://docs.openclaw.ai/concepts/agent-workspace",
621+
);
622+
});
623+
585624
it("keeps app-relative links navigable", () => {
586625
const html = toSanitizedMarkdownHtml("[usage](/usage)");
587626
expect(html).toBe(

ui/src/ui/markdown.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,64 @@ const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i;
8787
const HOST_LOCAL_FILE_HREF_RE =
8888
/^(?:~\/|\/(?:Users|home|tmp|private\/tmp|var\/folders|private\/var\/folders)\/|\/[A-Za-z]:\/|[A-Za-z]:[\\/])/;
8989
const markdownCache = new Map<string, string>();
90+
const DOCS_BASE_URL = "https://docs.openclaw.ai";
91+
const DOCS_ROOT_PATH_SEGMENTS = new Set([
92+
"agent-runtime-architecture",
93+
"announcements",
94+
"auth-credential-semantics",
95+
"automation",
96+
"channels",
97+
"ci",
98+
"clawhub",
99+
"cli",
100+
"concepts",
101+
"date-time",
102+
"debug",
103+
"diagnostics",
104+
"gateway",
105+
"help",
106+
"install",
107+
"logging",
108+
"network",
109+
"nodes",
110+
"openclaw-agent-runtime",
111+
"platforms",
112+
"plugins",
113+
"prose",
114+
"providers",
115+
"reference",
116+
"security",
117+
"start",
118+
"tools",
119+
"vps",
120+
"web",
121+
]);
122+
const CONTROL_UI_APP_PATHS = new Set([
123+
"/activity",
124+
"/agents",
125+
"/ai-agents",
126+
"/appearance",
127+
"/automation",
128+
"/channels",
129+
"/chat",
130+
"/communications",
131+
"/config",
132+
"/cron",
133+
"/debug",
134+
"/dreaming",
135+
"/dreams",
136+
"/infrastructure",
137+
"/instances",
138+
"/logs",
139+
"/mcp",
140+
"/nodes",
141+
"/overview",
142+
"/sessions",
143+
"/skills",
144+
"/skills/workshop",
145+
"/usage",
146+
"/workboard",
147+
]);
90148
const TAIL_LINK_BLUR_CLASS = "chat-link-tail-blur";
91149
const FENCE_OPEN_RE = /^[ \t]{0,3}(`{3,}|~{3,})/;
92150
const FENCE_CONTAINER_PREFIX_RE = /^[ \t]{0,3}(?:(?:>\s?)|(?:(?:[-+*]|\d{1,9}[.)])[ \t]+))/;
@@ -143,6 +201,22 @@ function isHostLocalFileHref(href: string): boolean {
143201
return HOST_LOCAL_FILE_HREF_RE.test(href.trim());
144202
}
145203

204+
function normalizeDocsRootHref(href: string): string | null {
205+
if (!href.startsWith("/") || href.startsWith("//")) {
206+
return null;
207+
}
208+
const parsed = new URL(href, DOCS_BASE_URL);
209+
const normalizedPath = parsed.pathname.replace(/\/+$/, "") || "/";
210+
if (CONTROL_UI_APP_PATHS.has(normalizedPath) || normalizedPath.startsWith("/api/")) {
211+
return null;
212+
}
213+
const firstSegment = normalizedPath.split("/").find(Boolean);
214+
if (!firstSegment || !DOCS_ROOT_PATH_SEGMENTS.has(firstSegment)) {
215+
return null;
216+
}
217+
return `${DOCS_BASE_URL}${parsed.pathname}${parsed.search}${parsed.hash}`;
218+
}
219+
146220
function installHooks() {
147221
if (hooksInstalled) {
148222
return;
@@ -176,6 +250,11 @@ function installHooks() {
176250
// javascript: by default. This is defense-in-depth.
177251
}
178252

253+
const normalizedDocsHref = normalizeDocsRootHref(href);
254+
if (normalizedDocsHref) {
255+
node.setAttribute("href", normalizedDocsHref);
256+
}
257+
179258
node.setAttribute("rel", "noreferrer noopener");
180259
node.setAttribute("target", "_blank");
181260
if (normalizeLowercaseStringOrEmpty(href).includes("tail")) {

0 commit comments

Comments
 (0)