From 545db16f01f5f06d34be4586d0696dd9034740eb Mon Sep 17 00:00:00 2001
From: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Date: Wed, 3 Jun 2026 03:16:00 +0000
Subject: [PATCH] fix(plugins): rewrite relative README image URLs to
source-host raw URLs
---
src/__tests__/plugins-publish-route.test.tsx | 37 +++++++++++++
src/lib/detectRelativeReadmeAssets.test.ts | 19 +++++++
src/lib/detectRelativeReadmeAssets.ts | 58 +++++++++++++++++++-
3 files changed, 112 insertions(+), 2 deletions(-)
diff --git a/src/__tests__/plugins-publish-route.test.tsx b/src/__tests__/plugins-publish-route.test.tsx
index e5314f66..49472b4e 100644
--- a/src/__tests__/plugins-publish-route.test.tsx
+++ b/src/__tests__/plugins-publish-route.test.tsx
@@ -680,6 +680,43 @@ describe("plugins publish route", () => {
expect(screen.getByText(/\.\/images\/bar\.png/)).toBeTruthy();
});
+ it("warns when README picture source srcset references relative image paths", async () => {
+ renderPublishRoute();
+
+ const packageJson = withRelativePath(
+ new File(
+ [makeCodePluginPackageJson({ name: "demo-plugin", version: "1.0.0" })],
+ "package.json",
+ {
+ type: "application/json",
+ },
+ ),
+ "demo-plugin/package.json",
+ );
+ const manifest = withRelativePath(
+ new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
+ "demo-plugin/openclaw.plugin.json",
+ );
+ const readme = withRelativePath(
+ new File(
+ [
+ '# Demo Plugin\n\n
',
+ ],
+ "README.md",
+ { type: "text/markdown" },
+ ),
+ "demo-plugin/README.md",
+ );
+
+ fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
+
+ await waitFor(() => {
+ expect(screen.getByText(/2 package-relative image paths/i)).toBeTruthy();
+ });
+ expect(screen.getByText(/\.\/images\/dark\.png/)).toBeTruthy();
+ expect(screen.getByText(/\.\/images\/dark@2x\.png/)).toBeTruthy();
+ });
+
it("swaps the missing-source warning for a Package-path reminder once Source repo and a valid 40-hex Source commit are filled", async () => {
renderPublishRoute();
diff --git a/src/lib/detectRelativeReadmeAssets.test.ts b/src/lib/detectRelativeReadmeAssets.test.ts
index 65802744..41779240 100644
--- a/src/lib/detectRelativeReadmeAssets.test.ts
+++ b/src/lib/detectRelativeReadmeAssets.test.ts
@@ -30,6 +30,25 @@ describe("detectRelativeReadmeAssets", () => {
expect(report.unresolvableSamples).toEqual([]);
});
+ it("flags relative candidates in raw HTML", () => {
+ const report = detectRelativeReadmeAssets(
+ `
`,
+ );
+ expect(report.samples).toEqual(["./dark.png", "./dark@2x.png"]);
+ expect(report.total).toBe(2);
+ expect(report.unresolvableSamples).toEqual([]);
+ });
+
+ it("flags root-absolute candidates separately", () => {
+ const report = detectRelativeReadmeAssets(
+ ``,
+ );
+ expect(report.samples).toEqual(["/dark.png", "./light.png"]);
+ expect(report.total).toBe(2);
+ expect(report.unresolvableSamples).toEqual(["/dark.png"]);
+ expect(report.unresolvableTotal).toBe(1);
+ });
+
it("flags root-absolute paths separately as unresolvable", () => {
const report = detectRelativeReadmeAssets("");
expect(report.samples).toEqual(["/static/logo.png"]);
diff --git a/src/lib/detectRelativeReadmeAssets.ts b/src/lib/detectRelativeReadmeAssets.ts
index fb27b61f..005a75a0 100644
--- a/src/lib/detectRelativeReadmeAssets.ts
+++ b/src/lib/detectRelativeReadmeAssets.ts
@@ -1,7 +1,8 @@
/**
* Scans README markdown text for relative image references — both Markdown
- * `` syntax and raw HTML `
` tags — and returns
- * the unique set of relative paths it finds (capped to keep UI warnings short).
+ * `` syntax, raw HTML `
` tags, and
+ * `` candidates — and returns the unique set of
+ * relative paths it finds (capped to keep UI warnings short).
*
* Why: ClawHub does not host package binary assets. When a publisher uploads
* a zip/tgz whose README references local images via relative paths, those
@@ -30,12 +31,17 @@
const MARKDOWN_IMAGE = /!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
const HTML_IMG_SRC = /
]*?\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*?>/gi;
+const HTML_SOURCE_SRCSET = /]*?\bsrcset\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*?>/gi;
const ABSOLUTE_URL = /^[a-z][a-z0-9+\-.]*:/i;
const PROTOCOL_RELATIVE = /^\/\//;
const MAX_REPORTED = 5;
+function isAsciiWhitespace(char: string): boolean {
+ return char === " " || char === "\n" || char === "\t" || char === "\r" || char === "\f";
+}
+
function classifyRelativeAsset(rawSrc: string): "package-relative" | "root-absolute" | null {
const src = rawSrc.trim();
if (!src) return null;
@@ -94,6 +100,45 @@ export function detectRelativeReadmeAssets(readmeText: string): RelativeReadmeAs
}
};
+ const recordSrcset = (srcset: string | undefined) => {
+ if (!srcset) return;
+ let index = 0;
+ while (index < srcset.length) {
+ while (index < srcset.length) {
+ const char = srcset[index];
+ if (isAsciiWhitespace(char) || char === ",") {
+ index += 1;
+ continue;
+ }
+ break;
+ }
+ if (index >= srcset.length) break;
+
+ const urlStart = index;
+ while (index < srcset.length && !isAsciiWhitespace(srcset[index])) {
+ index += 1;
+ }
+ let url = srcset.slice(urlStart, index);
+ const endedWithComma = url.endsWith(",");
+ if (endedWithComma) {
+ url = url.slice(0, -1);
+ }
+ record(url);
+
+ if (!endedWithComma) {
+ while (index < srcset.length && isAsciiWhitespace(srcset[index])) {
+ index += 1;
+ }
+ while (index < srcset.length && srcset[index] !== ",") {
+ index += 1;
+ }
+ }
+ if (srcset[index] === ",") {
+ index += 1;
+ }
+ }
+ };
+
MARKDOWN_IMAGE.lastIndex = 0;
for (
let match = MARKDOWN_IMAGE.exec(readmeText);
@@ -108,5 +153,14 @@ export function detectRelativeReadmeAssets(readmeText: string): RelativeReadmeAs
record(match[1] ?? match[2]);
}
+ HTML_SOURCE_SRCSET.lastIndex = 0;
+ for (
+ let match = HTML_SOURCE_SRCSET.exec(readmeText);
+ match;
+ match = HTML_SOURCE_SRCSET.exec(readmeText)
+ ) {
+ recordSrcset(match[1] ?? match[2]);
+ }
+
return { samples, total, unresolvableSamples, unresolvableTotal };
}