fix(plugins): rewrite relative README image URLs to source-host raw URLs

This commit is contained in:
clawsweeper
2026-06-03 16:32:39 +08:00
committed by momothemage
parent 3f59a70d03
commit 545db16f01
3 changed files with 112 additions and 2 deletions
@@ -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<picture><source media="(prefers-color-scheme: dark)" srcset="./images/dark.png 1x, ./images/dark@2x.png 2x"><img src="https://example.com/fallback.png" alt="x"></picture>',
],
"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();
@@ -30,6 +30,25 @@ describe("detectRelativeReadmeAssets", () => {
expect(report.unresolvableSamples).toEqual([]);
});
it("flags relative <source srcset> candidates in raw HTML", () => {
const report = detectRelativeReadmeAssets(
`<picture><source media="(prefers-color-scheme: dark)" srcset="./dark.png 1x, ./dark@2x.png 2x, https://example.com/remote.png 3x"/><img src="https://example.com/fallback.png"/></picture>`,
);
expect(report.samples).toEqual(["./dark.png", "./dark@2x.png"]);
expect(report.total).toBe(2);
expect(report.unresolvableSamples).toEqual([]);
});
it("flags root-absolute <source srcset> candidates separately", () => {
const report = detectRelativeReadmeAssets(
`<source srcset="/dark.png 1x, ./light.png 2x, data:image/svg+xml,%3Csvg%3E 3x"/>`,
);
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("![logo](/static/logo.png)");
expect(report.samples).toEqual(["/static/logo.png"]);
+56 -2
View File
@@ -1,7 +1,8 @@
/**
* Scans README markdown text for relative image references — both Markdown
* `![alt](./path)` syntax and raw HTML `<img src="./path">` tags and returns
* the unique set of relative paths it finds (capped to keep UI warnings short).
* `![alt](./path)` syntax, raw HTML `<img src="./path">` tags, and
* `<source srcset="./path 1x">` 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 = /<img\b[^>]*?\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*?>/gi;
const HTML_SOURCE_SRCSET = /<source\b[^>]*?\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 };
}