Skip to content

Commit dd062ee

Browse files
committed
fix(plugins): rewrite relative README image URLs to source-host raw URLs
1 parent 6adf379 commit dd062ee

10 files changed

Lines changed: 565 additions & 6 deletions

src/__tests__/plugins-publish-route.test.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,4 +648,114 @@ describe("plugins publish route", () => {
648648
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
649649
).not.toBeNull();
650650
});
651+
652+
it("warns when README references relative image paths but no source repo/commit is set", async () => {
653+
renderPublishRoute();
654+
655+
const packageJson = withRelativePath(
656+
new File(
657+
[makeCodePluginPackageJson({ name: "demo-plugin", version: "1.0.0" })],
658+
"package.json",
659+
{
660+
type: "application/json",
661+
},
662+
),
663+
"demo-plugin/package.json",
664+
);
665+
const manifest = withRelativePath(
666+
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
667+
"demo-plugin/openclaw.plugin.json",
668+
);
669+
const readme = withRelativePath(
670+
new File(
671+
['# Demo Plugin\n\n![diagram](./images/foo.png)\n\n<img src="./images/bar.png" alt="x"/>'],
672+
"README.md",
673+
{ type: "text/markdown" },
674+
),
675+
"demo-plugin/README.md",
676+
);
677+
678+
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
679+
680+
await waitFor(() => {
681+
expect(screen.getByText(/Your README references 2 relative image paths/i)).toBeTruthy();
682+
});
683+
expect(screen.getByText(/ClawHub does not\s+host package binary assets/i)).toBeTruthy();
684+
expect(screen.getByText(/\.\/images\/foo\.png/)).toBeTruthy();
685+
expect(screen.getByText(/\.\/images\/bar\.png/)).toBeTruthy();
686+
});
687+
688+
it("hides the relative-README-image warning once Source repo and Source commit are filled", async () => {
689+
renderPublishRoute();
690+
691+
const packageJson = withRelativePath(
692+
new File(
693+
[makeCodePluginPackageJson({ name: "demo-plugin", version: "1.0.0" })],
694+
"package.json",
695+
{
696+
type: "application/json",
697+
},
698+
),
699+
"demo-plugin/package.json",
700+
);
701+
const manifest = withRelativePath(
702+
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
703+
"demo-plugin/openclaw.plugin.json",
704+
);
705+
const readme = withRelativePath(
706+
new File(["# Demo\n\n![diagram](./images/foo.png)\n"], "README.md", {
707+
type: "text/markdown",
708+
}),
709+
"demo-plugin/README.md",
710+
);
711+
712+
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
713+
714+
await waitFor(() => {
715+
expect(screen.getByText(/Your README references a relative image path/i)).toBeTruthy();
716+
});
717+
718+
fireEvent.change(screen.getByPlaceholderText("Source repo (owner/repo)"), {
719+
target: { value: "openclaw/demo-plugin" },
720+
});
721+
fireEvent.change(screen.getByPlaceholderText("Source commit"), {
722+
target: { value: "abc123" },
723+
});
724+
725+
await waitFor(() => {
726+
expect(screen.queryByText(/Your README references/i)).toBeNull();
727+
});
728+
});
729+
730+
it("does not warn when README only uses absolute image URLs", async () => {
731+
renderPublishRoute();
732+
733+
const packageJson = withRelativePath(
734+
new File(
735+
[makeCodePluginPackageJson({ name: "demo-plugin", version: "1.0.0" })],
736+
"package.json",
737+
{
738+
type: "application/json",
739+
},
740+
),
741+
"demo-plugin/package.json",
742+
);
743+
const manifest = withRelativePath(
744+
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
745+
"demo-plugin/openclaw.plugin.json",
746+
);
747+
const readme = withRelativePath(
748+
new File(["# Demo\n\n![ok](https://example.com/foo.png)\n"], "README.md", {
749+
type: "text/markdown",
750+
}),
751+
"demo-plugin/README.md",
752+
);
753+
754+
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
755+
756+
await waitFor(() => {
757+
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
758+
});
759+
expect(screen.queryByText(/Your README references/i)).toBeNull();
760+
});
651761
});

src/components/MarkdownPreview.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,54 @@ describe("MarkdownPreview — raw HTML passthrough", () => {
6464
);
6565
});
6666

67+
it("leaves relative <img src> alone when no assetBaseUrl is provided", () => {
68+
const { container } = render(
69+
<MarkdownPreview highlight={false}>{`![diagram](./images/foo.png)`}</MarkdownPreview>,
70+
);
71+
const img = container.querySelector("img");
72+
// Falls back to the legacy pass-through behavior — relative path stays as-is.
73+
expect(img?.getAttribute("src")).toBe("./images/foo.png");
74+
});
75+
76+
it("resolves relative ![](./path) images against assetBaseUrl and proxies them", () => {
77+
const { container } = render(
78+
<MarkdownPreview
79+
highlight={false}
80+
assetBaseUrl="https://raw.githubusercontent.com/owner/repo/abc123/sub/"
81+
>{`![diagram](./images/foo.png)`}</MarkdownPreview>,
82+
);
83+
const img = container.querySelector("img");
84+
expect(img?.getAttribute("src")).toBe(
85+
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabc123%2Fsub%2Fimages%2Ffoo.png&w=1024&q=75",
86+
);
87+
});
88+
89+
it("resolves relative <img src> in raw HTML against assetBaseUrl", () => {
90+
const { container } = render(
91+
<MarkdownPreview
92+
highlight={false}
93+
assetBaseUrl="https://raw.githubusercontent.com/owner/repo/abc123/"
94+
>{`<img src="images/foo.png" alt="d"/>`}</MarkdownPreview>,
95+
);
96+
const img = container.querySelector("img");
97+
expect(img?.getAttribute("src")).toBe(
98+
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabc123%2Fimages%2Ffoo.png&w=1024&q=75",
99+
);
100+
});
101+
102+
it("does not rewrite root-absolute paths even when assetBaseUrl is set", () => {
103+
const { container } = render(
104+
<MarkdownPreview
105+
highlight={false}
106+
assetBaseUrl="https://raw.githubusercontent.com/owner/repo/abc123/"
107+
>{`![x](/foo.png)`}</MarkdownPreview>,
108+
);
109+
const img = container.querySelector("img");
110+
// Root-absolute paths are intentionally left alone — they typically point
111+
// at the ClawHub site itself, not at a package asset.
112+
expect(img?.getAttribute("src")).toBe("/foo.png");
113+
});
114+
67115
it("renders <br/> as a real line break", () => {
68116
const container = renderMarkdown(`line one<br/>line two`);
69117
expect(container.querySelector("br")).not.toBeNull();

src/components/MarkdownPreview.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ interface MarkdownPreviewProps {
1414
/** Enable Shiki syntax highlighting for fenced code blocks. Default: true. */
1515
highlight?: boolean;
1616
urlTransform?: UrlTransform;
17+
/**
18+
* Base URL used to resolve relative <img src> values inside the README
19+
* (e.g. `./images/foo.png`). When set, relative sources are resolved
20+
* against this base and then routed through the standard image proxy.
21+
* Typical value: a `raw.githubusercontent.com/<repo>/<commit>/<dir>/` URL
22+
* derived from the package release's `verification.sourceRepo` +
23+
* `verification.sourceCommit`. Must end with `/`.
24+
*/
25+
assetBaseUrl?: string;
1726
}
1827

1928
const schema = {
@@ -32,7 +41,9 @@ const schema = {
3241
// sees user-authored HTML; shiki's trusted styled output flows through after.
3342
// rehypeProxyImages rewrites after sanitize so we rewrite only already-safe
3443
// <img src="..."> nodes (sanitize strips event handlers, javascript: URLs).
35-
const baseRehype: PluggableList = [rehypeRaw, [rehypeSanitize, schema], rehypeProxyImages];
44+
function buildBaseRehype(assetBaseUrl: string | undefined): PluggableList {
45+
return [rehypeRaw, [rehypeSanitize, schema], [rehypeProxyImages, { assetBaseUrl }]];
46+
}
3647

3748
const SHIKI_THEME = "github-dark";
3849
const SHIKI_LANGS = [
@@ -77,6 +88,7 @@ export function MarkdownPreview({
7788
className,
7889
highlight = true,
7990
urlTransform,
91+
assetBaseUrl,
8092
}: MarkdownPreviewProps) {
8193
const [highlighter, setHighlighter] = useState<unknown>(null);
8294

@@ -97,11 +109,12 @@ export function MarkdownPreview({
97109
}, [highlight]);
98110

99111
const rehypePlugins = useMemo<PluggableList>(() => {
112+
const baseRehype = buildBaseRehype(assetBaseUrl);
100113
if (highlight && highlighter) {
101114
return [...baseRehype, [rehypeShikiFromHighlighter, highlighter, { theme: SHIKI_THEME }]];
102115
}
103116
return baseRehype;
104-
}, [highlight, highlighter]);
117+
}, [highlight, highlighter, assetBaseUrl]);
105118

106119
return (
107120
<div className={cn("markdown", className)}>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* @vitest-environment node */
2+
3+
import { describe, expect, it } from "vitest";
4+
import { detectRelativeReadmeAssets } from "./detectRelativeReadmeAssets";
5+
6+
describe("detectRelativeReadmeAssets", () => {
7+
it("returns nothing for empty input", () => {
8+
expect(detectRelativeReadmeAssets("")).toEqual({ samples: [], total: 0 });
9+
});
10+
11+
it("flags a relative markdown image reference", () => {
12+
const report = detectRelativeReadmeAssets("![diagram](./images/foo.png)");
13+
expect(report.samples).toEqual(["./images/foo.png"]);
14+
expect(report.total).toBe(1);
15+
});
16+
17+
it("flags relative <img src> references in raw HTML", () => {
18+
const report = detectRelativeReadmeAssets(
19+
`<img src="images/foo.png" alt="x"/><img src='./bar.svg'/>`,
20+
);
21+
expect(report.samples).toEqual(["images/foo.png", "./bar.svg"]);
22+
expect(report.total).toBe(2);
23+
});
24+
25+
it("flags root-absolute paths because they break on the plugin detail page", () => {
26+
const report = detectRelativeReadmeAssets("![logo](/static/logo.png)");
27+
expect(report.samples).toEqual(["/static/logo.png"]);
28+
});
29+
30+
it("ignores absolute http(s) URLs", () => {
31+
const report = detectRelativeReadmeAssets(
32+
'![ok](https://example.com/foo.png)\n<img src="http://example.com/x.png"/>',
33+
);
34+
expect(report).toEqual({ samples: [], total: 0 });
35+
});
36+
37+
it("ignores protocol-relative URLs, data:, mailto:, tel:, and fragment hrefs", () => {
38+
const report = detectRelativeReadmeAssets(
39+
[
40+
"![a](//cdn.example.com/x.png)",
41+
"![b](data:image/png;base64,abc)",
42+
"![c](#anchor)",
43+
'<img src="mailto:x@y"/>',
44+
].join("\n"),
45+
);
46+
expect(report).toEqual({ samples: [], total: 0 });
47+
});
48+
49+
it("deduplicates samples but counts each occurrence in total", () => {
50+
const report = detectRelativeReadmeAssets(
51+
'![a](./x.png)\n![a](./x.png)\n<img src="./x.png"/>\n![b](./y.png)',
52+
);
53+
expect(report.samples).toEqual(["./x.png", "./y.png"]);
54+
expect(report.total).toBe(4);
55+
});
56+
57+
it("caps samples at 5 distinct paths but keeps counting in total", () => {
58+
const lines = Array.from({ length: 10 }, (_, idx) => `![n](./img-${idx}.png)`);
59+
const report = detectRelativeReadmeAssets(lines.join("\n"));
60+
expect(report.samples.length).toBe(5);
61+
expect(report.samples).toEqual([
62+
"./img-0.png",
63+
"./img-1.png",
64+
"./img-2.png",
65+
"./img-3.png",
66+
"./img-4.png",
67+
]);
68+
expect(report.total).toBe(10);
69+
});
70+
71+
it("handles markdown image references with a title segment", () => {
72+
const report = detectRelativeReadmeAssets(`![alt](./images/foo.png "title text")`);
73+
expect(report.samples).toEqual(["./images/foo.png"]);
74+
});
75+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Scans README markdown text for relative image references — both Markdown
3+
* `![alt](./path)` syntax and raw HTML `<img src="./path">` tags — and returns
4+
* the unique set of relative paths it finds (capped to keep UI warnings short).
5+
*
6+
* Why: ClawHub does not host package binary assets. When a publisher uploads
7+
* a zip/tgz whose README references local images via relative paths, those
8+
* images render fine inside the package but 404 on the plugin detail page
9+
* unless the release also carries Source repo + Source commit (which lets us
10+
* resolve them to a stable raw.githubusercontent.com URL). We use this scanner
11+
* to surface a non-blocking warning on the publish form so authors can either
12+
* fill in source metadata or rewrite their README to absolute URLs before
13+
* shipping.
14+
*
15+
* Definition of "relative" here is intentionally narrow: anything that is not
16+
* an absolute http(s) URL, a protocol-relative URL, a data:/mailto:/tel: URI,
17+
* or a fragment. Root-absolute paths like `/foo.png` are also flagged because
18+
* on the plugin detail page the browser resolves them against clawhub.ai
19+
* itself, which is just as broken as `./foo.png`.
20+
*/
21+
22+
const MARKDOWN_IMAGE = /!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
23+
const HTML_IMG_SRC = /<img\b[^>]*?\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*?>/gi;
24+
25+
const ABSOLUTE_URL = /^[a-z][a-z0-9+\-.]*:/i;
26+
const PROTOCOL_RELATIVE = /^\/\//;
27+
28+
const MAX_REPORTED = 5;
29+
30+
function isRelativeAsset(rawSrc: string): boolean {
31+
const src = rawSrc.trim();
32+
if (!src) return false;
33+
if (src.startsWith("#")) return false;
34+
if (PROTOCOL_RELATIVE.test(src)) return false;
35+
if (ABSOLUTE_URL.test(src)) return false;
36+
return true;
37+
}
38+
39+
export interface RelativeReadmeAssetReport {
40+
/** Up to MAX_REPORTED unique paths, in the order encountered. */
41+
samples: string[];
42+
/** Total number of relative references detected (may exceed samples.length). */
43+
total: number;
44+
}
45+
46+
export function detectRelativeReadmeAssets(readmeText: string): RelativeReadmeAssetReport {
47+
if (!readmeText) return { samples: [], total: 0 };
48+
49+
const seen = new Set<string>();
50+
const samples: string[] = [];
51+
let total = 0;
52+
53+
const record = (src: string | undefined) => {
54+
if (!src) return;
55+
if (!isRelativeAsset(src)) return;
56+
total += 1;
57+
if (seen.has(src)) return;
58+
seen.add(src);
59+
if (samples.length < MAX_REPORTED) samples.push(src);
60+
};
61+
62+
MARKDOWN_IMAGE.lastIndex = 0;
63+
for (
64+
let match = MARKDOWN_IMAGE.exec(readmeText);
65+
match;
66+
match = MARKDOWN_IMAGE.exec(readmeText)
67+
) {
68+
record(match[1]);
69+
}
70+
71+
HTML_IMG_SRC.lastIndex = 0;
72+
for (let match = HTML_IMG_SRC.exec(readmeText); match; match = HTML_IMG_SRC.exec(readmeText)) {
73+
record(match[1] ?? match[2]);
74+
}
75+
76+
return { samples, total };
77+
}

0 commit comments

Comments
 (0)