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

Merged via squash.

Prepared head SHA: 545db16f01
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
This commit is contained in:
Momo
2026-06-03 17:05:58 +08:00
committed by GitHub
parent 953358a322
commit 858a121d33
23 changed files with 1615 additions and 26 deletions
+10
View File
@@ -271,6 +271,15 @@ function buildVerification(source: SourceInfo | undefined): PackageVerificationS
scanStatus: "not-run",
};
}
// `source.path` is the package directory inside the source repo (e.g.
// "examples/openclaw-plugin"). When the package lives at the repo root the
// CLI sends "." (or empty), and there's nothing useful to serialize. Only
// promote real subpaths into `verification.sourcePath` so consumers can
// build a `raw.githubusercontent.com/<repo>/<sha>/<path>/` base URL for
// resolving relative README asset references.
const rawPath = typeof source.path === "string" ? source.path.trim() : "";
const sourcePath =
rawPath && rawPath !== "." ? rawPath.replace(/^\/+/, "").replace(/\/+$/, "") : undefined;
return {
tier: "source-linked",
scope: "artifact-only",
@@ -278,6 +287,7 @@ function buildVerification(source: SourceInfo | undefined): PackageVerificationS
sourceRepo: source.repo || source.url,
sourceCommit: source.commit,
sourceTag: source.ref,
sourcePath: sourcePath || undefined,
hasProvenance: false,
scanStatus: "not-run",
};
+56
View File
@@ -2817,6 +2817,62 @@ describe("packages public queries", () => {
});
});
it("derives missing public verification source paths from legacy release provenance", async () => {
const verification = {
tier: "source-linked",
scope: "artifact-only",
sourceRepo: "OpenViking/OpenViking",
sourceCommit: "abcdef0123456789abcdef0123456789abcdef01",
scanStatus: "clean",
};
const latestRelease = makeReleaseDoc({
verification,
source: {
kind: "github",
repo: "OpenViking/OpenViking",
path: "openclaw-plugin",
},
});
const { ctx } = makePackageCtx({
pkg: makePackageDoc({
name: "@openviking/openclaw-plugin",
normalizedName: "@openviking/openclaw-plugin",
verification,
latestVersionSummary: {
version: "1.0.0",
verification,
},
}),
latestRelease,
});
await expect(
getByNameHandler(ctx, {
name: "@openviking/openclaw-plugin",
}),
).resolves.toMatchObject({
package: {
verification: { sourcePath: "openclaw-plugin" },
},
latestRelease: {
verification: { sourcePath: "openclaw-plugin" },
},
});
await expect(
getVersionByNameHandler(ctx, {
name: "@openviking/openclaw-plugin",
version: "1.0.0",
}),
).resolves.toMatchObject({
package: {
verification: { sourcePath: "openclaw-plugin" },
},
version: {
verification: { sourcePath: "openclaw-plugin" },
},
});
});
it("does not mark owner-readable blocked public packages as public download blocked", async () => {
const { ctx } = makePackageCtx({
pkg: makePackageDoc({
+37 -8
View File
@@ -764,13 +764,29 @@ function resolvePublicPackageScanStatus(
return pkg.scanStatus;
}
function normalizePublicPackageSourcePath(sourcePath: unknown) {
if (typeof sourcePath !== "string") return undefined;
const trimmed = sourcePath.trim();
if (!trimmed || trimmed === ".") return undefined;
return trimmed.replace(/^\/+/, "").replace(/\/+$/, "") || undefined;
}
function getReleaseSourcePath(release?: Pick<Doc<"packageReleases">, "source"> | null) {
const source = release?.source;
if (!source || typeof source !== "object" || Array.isArray(source)) return undefined;
return normalizePublicPackageSourcePath((source as { path?: unknown }).path);
}
function resolvePublicPackageVerification(
pkg: Pick<Doc<"packages">, "verification" | "latestVersionSummary" | "scanStatus">,
latestRelease?: Doc<"packageReleases"> | null,
) {
const scanStatus = resolvePublicPackageScanStatus(pkg, latestRelease);
const source = pkg.verification ?? pkg.latestVersionSummary?.verification;
return source && scanStatus ? { ...source, scanStatus } : source;
if (!source) return source;
const sourcePath = source.sourcePath ?? getReleaseSourcePath(latestRelease);
const verification = sourcePath ? { ...source, sourcePath } : source;
return scanStatus ? { ...verification, scanStatus } : verification;
}
function toPublicPackage(
@@ -822,6 +838,19 @@ function omitLegacyClawScanNoteFields(release: Doc<"packageReleases">) {
return publicRelease;
}
function toPublicPackageRelease(release: Doc<"packageReleases">) {
const publicRelease = omitLegacyClawScanNoteFields(release);
const sourcePath = release.verification?.sourcePath ?? getReleaseSourcePath(release);
if (!release.verification || !sourcePath) return publicRelease;
return {
...publicRelease,
verification: {
...release.verification,
sourcePath,
},
};
}
function packageArtifactSummary(
release: Pick<
Doc<"packageReleases">,
@@ -1907,7 +1936,7 @@ export const getByName = query({
package: publicPackage,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? omitLegacyClawScanNoteFields(latestRelease)
? toPublicPackageRelease(latestRelease)
: null,
owner,
};
@@ -1948,7 +1977,7 @@ export const getManageContext = query({
return {
package: pkg,
latestRelease: omitLegacyClawScanNoteFields(latestRelease),
latestRelease: toPublicPackageRelease(latestRelease),
};
},
});
@@ -1978,7 +2007,7 @@ export const getByNameForStaff = query({
package: pkg,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? omitLegacyClawScanNoteFields(latestRelease)
? toPublicPackageRelease(latestRelease)
: null,
owner,
highlighted: highlighted
@@ -2012,7 +2041,7 @@ export const getByNameForViewerInternal = internalQuery({
package: publicPackage,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? omitLegacyClawScanNoteFields(latestRelease)
? toPublicPackageRelease(latestRelease)
: null,
owner,
};
@@ -2091,7 +2120,7 @@ export const getVersionByName = query({
if (!publicPackage) return null;
return {
package: publicPackage,
version: omitLegacyClawScanNoteFields(release),
version: toPublicPackageRelease(release),
};
},
});
@@ -2122,7 +2151,7 @@ export const getVersionByNameForViewerInternal = internalQuery({
if (!publicPackage) return null;
return {
package: publicPackage,
version: omitLegacyClawScanNoteFields(release),
version: toPublicPackageRelease(release),
};
},
});
@@ -2159,7 +2188,7 @@ export const getVersionSecurityByNameForViewerInternal = internalQuery({
...publicPackage,
publicDownloadBlocked,
},
version: omitLegacyClawScanNoteFields(release),
version: toPublicPackageRelease(release),
};
},
});
+1
View File
@@ -361,6 +361,7 @@ const packageVerificationValidator = v.optional(
sourceRepo: v.optional(v.string()),
sourceCommit: v.optional(v.string()),
sourceTag: v.optional(v.string()),
sourcePath: v.optional(v.string()),
hasProvenance: v.optional(v.boolean()),
trustedOpenClawPlugin: v.optional(v.boolean()),
scanStatus: v.optional(
+65 -2
View File
@@ -152,12 +152,75 @@ function extractLastJsonObject(output: string) {
throw new Error(`No JSON object in convex run output:\n${output}`);
}
export async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
export async function fetchWithTimeout(
input: RequestInfo | URL,
init?: RequestInit,
timeoutMs: number = REQUEST_TIMEOUT_MS,
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), REQUEST_TIMEOUT_MS);
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), timeoutMs);
try {
return await fetch(input, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
const MAX_RATE_LIMIT_WAIT_MS = 15_000;
const TRANSIENT_RETRY_DELAY_MS = 1_000;
function parsePositiveNumber(value: string | null) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function getRetryDelayMs(response: Response) {
const retryAfterSeconds = parsePositiveNumber(response.headers.get("Retry-After"));
if (retryAfterSeconds !== null) {
return Math.min(retryAfterSeconds * 1000, MAX_RATE_LIMIT_WAIT_MS);
}
const relativeResetSeconds = parsePositiveNumber(response.headers.get("RateLimit-Reset"));
if (relativeResetSeconds !== null) {
return Math.min(relativeResetSeconds * 1000, MAX_RATE_LIMIT_WAIT_MS);
}
const absoluteResetSeconds = parsePositiveNumber(response.headers.get("X-RateLimit-Reset"));
if (absoluteResetSeconds !== null) {
return Math.min(Math.max(absoluteResetSeconds * 1000 - Date.now(), 0), MAX_RATE_LIMIT_WAIT_MS);
}
return TRANSIENT_RETRY_DELAY_MS;
}
/**
* Fetch with timeout, retrying on transient failures (network abort/timeout,
* 429, and 5xx). Used for read-only verification calls against a real registry
* where occasional cold starts or rate-limit hits would otherwise flake the
* test. Non-transient HTTP responses (e.g. 401/403/404) are returned as-is.
*/
export async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
options: { maxAttempts?: number; timeoutMs?: number } = {},
) {
const maxAttempts = options.maxAttempts ?? 3;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetchWithTimeout(input, init, options.timeoutMs);
if (attempt >= maxAttempts) return response;
if (response.status === 429) {
await new Promise((resolve) => setTimeout(resolve, getRetryDelayMs(response)));
continue;
}
if (response.status >= 500) {
await new Promise((resolve) => setTimeout(resolve, TRANSIENT_RETRY_DELAY_MS * attempt));
continue;
}
return response;
} catch (error) {
lastError = error;
if (attempt >= maxAttempts) throw error;
await new Promise((resolve) => setTimeout(resolve, TRANSIENT_RETRY_DELAY_MS * attempt));
}
}
throw lastError ?? new Error("fetchWithRetry exhausted attempts");
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { readGlobalConfig } from "../packages/clawhub/src/config";
import {
allowLiveMutations,
buildE2ESkillMarkdown,
fetchWithRetry,
fetchWithTimeout,
getRegistry,
getSite,
@@ -114,7 +115,7 @@ describe("permission boundary e2e", () => {
] as const;
for (const testCase of cases) {
const response = await fetchWithTimeout(new URL(testCase.path, registry), {
const response = await fetchWithRetry(new URL(testCase.path, registry), {
method: testCase.method,
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: "body" in testCase ? JSON.stringify(testCase.body) : undefined,
+6
View File
@@ -52,6 +52,12 @@ export const PackageVerificationSummarySchema = type({
sourceRepo: "string?",
sourceCommit: "string?",
sourceTag: "string?",
// Path of the package directory inside the source repo (e.g.
// "examples/openclaw-plugin"). Forward slash separated, no leading or
// trailing slash. Used when resolving relative README asset URLs against
// raw.githubusercontent.com so that subdirectory packages render correctly.
// Absent or "." means the package lives at the repo root.
sourcePath: "string?",
hasProvenance: "boolean?",
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
});
+3
View File
@@ -50,6 +50,7 @@ export declare const PackageVerificationSummarySchema: import("arktype/internal/
sourceRepo?: string | undefined;
sourceCommit?: string | undefined;
sourceTag?: string | undefined;
sourcePath?: string | undefined;
hasProvenance?: boolean | undefined;
trustedOpenClawPlugin?: boolean | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
@@ -421,6 +422,7 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
sourceRepo?: string | undefined;
sourceCommit?: string | undefined;
sourceTag?: string | undefined;
sourcePath?: string | undefined;
hasProvenance?: boolean | undefined;
trustedOpenClawPlugin?: boolean | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
@@ -511,6 +513,7 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
sourceRepo?: string | undefined;
sourceCommit?: string | undefined;
sourceTag?: string | undefined;
sourcePath?: string | undefined;
hasProvenance?: boolean | undefined;
trustedOpenClawPlugin?: boolean | undefined;
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
+6
View File
@@ -58,6 +58,12 @@ export const PackageVerificationSummarySchema = type({
sourceRepo: "string?",
sourceCommit: "string?",
sourceTag: "string?",
// Path of the package directory inside the source repo (e.g.
// "examples/openclaw-plugin"). Forward slash separated, no leading or
// trailing slash. Used when resolving relative README asset URLs against
// raw.githubusercontent.com so that subdirectory packages render correctly.
// Absent or "." means the package lives at the repo root.
sourcePath: "string?",
hasProvenance: "boolean?",
trustedOpenClawPlugin: "boolean?",
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
File diff suppressed because one or more lines are too long
+6
View File
@@ -75,6 +75,12 @@ export const PackageVerificationSummarySchema = type({
sourceRepo: "string?",
sourceCommit: "string?",
sourceTag: "string?",
// Path of the package directory inside the source repo (e.g.
// "examples/openclaw-plugin"). Forward slash separated, no leading or
// trailing slash. Used when resolving relative README asset URLs against
// raw.githubusercontent.com so that subdirectory packages render correctly.
// Absent or "." means the package lives at the repo root.
sourcePath: "string?",
hasProvenance: "boolean?",
trustedOpenClawPlugin: "boolean?",
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
+1 -1
View File
@@ -22,7 +22,7 @@ export function parseProofPublishArgs(argv = []) {
const opts = {
artifactBranch: DEFAULT_ARTIFACT_BRANCH,
marker: DEFAULT_MARKER,
repo: process.env.GITHUB_REPOSITORY || DEFAULT_REPO,
repo: DEFAULT_REPO,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
@@ -643,4 +643,392 @@ describe("plugins publish route", () => {
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
).not.toBeNull();
});
it("warns when README references relative image paths but no source repo/commit is set", 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![diagram](./images/foo.png)\n\n<img src="./images/bar.png" alt="x"/>'],
"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(/can't resolve them to your source host/i)).toBeTruthy();
expect(screen.getByText(/\.\/images\/foo\.png/)).toBeTruthy();
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();
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\n\n![diagram](./images/foo.png)\n"], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(screen.getByText(/a package-relative image path/i)).toBeTruthy();
});
// Before source is filled, the missing-source copy is shown.
expect(screen.getByText(/Without Source repo \+ Commit SHA/i)).toBeTruthy();
fireEvent.change(screen.getByPlaceholderText("owner/repo"), {
target: { value: "openclaw/demo-plugin" },
});
// Use a real 40-hex SHA so buildReadmeAssetBaseUrl actually accepts it
// and the publish form's promise lines up with the renderer's behavior.
const validSha = "abc1234567890abcdef1234567890abcdef12345";
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: validSha },
});
// After source is filled, the missing-source copy disappears but a softer
// reminder remains, prompting the publisher to verify Package path against
// the constructed raw.githubusercontent.com URL preview.
await waitFor(() => {
expect(screen.queryByText(/Without Source repo \+ Commit SHA/i)).toBeNull();
});
expect(screen.getByText(/make sure Package path matches/i)).toBeTruthy();
expect(
screen.getByText(
new RegExp(`raw\\.githubusercontent\\.com/openclaw/demo-plugin/${validSha}/`, "i"),
),
).toBeTruthy();
});
it("keeps the missing-source warning when Source commit is not a valid 40-hex SHA, because the renderer would silently drop the rewrite", async () => {
// Regression: the form previously accepted any non-empty Commit SHA and
// promised relative README images would be served from raw.githubusercontent.com,
// but buildReadmeAssetBaseUrl (used at render time) requires a 40-hex SHA
// and silently returns undefined for shorter or otherwise malformed input.
// The result: the publisher saw a green-light reminder, shipped, and the
// detail page 404'd. The form must now hold the renderer's validation line.
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\n\n![diagram](./images/foo.png)\n"], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(screen.getByText(/Without Source repo \+ Commit SHA/i)).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText("owner/repo"), {
target: { value: "openclaw/demo-plugin" },
});
// 7-char short SHA: GitHub itself would resolve this, but our render-time
// base-URL builder rejects anything that isn't 40 hex chars, so the form
// must keep showing the missing-source copy rather than the "will be
// served from raw.githubusercontent.com" reminder.
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: "abc1234" },
});
// Give React a tick to recompute the warning useMemo.
await waitFor(() => {
expect(screen.getByText(/Without Source repo \+ Commit SHA/i)).toBeTruthy();
});
expect(screen.queryByText(/make sure Package path matches/i)).toBeNull();
expect(
screen.queryByText(/raw\.githubusercontent\.com\/openclaw\/demo-plugin\/abc1234/i),
).toBeNull();
});
it("keeps the missing-source warning when Package path cannot be resolved safely", 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\n\n![diagram](./images/foo.png)\n"], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(screen.getByText(/Without Source repo \+ Commit SHA/i)).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText("owner/repo"), {
target: { value: "openclaw/demo-plugin" },
});
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: "abc1234567890abcdef1234567890abcdef12345" },
});
fireEvent.change(screen.getByPlaceholderText("."), {
target: { value: "../demo-plugin" },
});
await waitFor(() => {
expect(screen.getByText(/Without Source repo \+ Commit SHA/i)).toBeTruthy();
});
expect(screen.queryByText(/make sure Package path matches/i)).toBeNull();
expect(screen.queryByText(/raw\.githubusercontent\.com\/openclaw\/demo-plugin/i)).toBeNull();
});
it("stops nudging about Package path once source is filled and the README has no relative images", 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\n\n![ok](https://example.com/foo.png)\n"], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText("owner/repo"), {
target: { value: "openclaw/demo-plugin" },
});
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: "abc1234567890abcdef1234567890abcdef12345" },
});
expect(screen.queryByText(/Your README references/i)).toBeNull();
expect(screen.queryByText(/make sure Package path matches/i)).toBeNull();
});
it("keeps warning about root-absolute README image paths even when Source repo and Source commit are filled", 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\n\n![logo](/static/logo.png)\n"], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(screen.getByText(/a root-absolute image path/i)).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText("owner/repo"), {
target: { value: "openclaw/demo-plugin" },
});
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
target: { value: "abc1234567890abcdef1234567890abcdef12345" },
});
// Filling in source metadata must not silence the unresolvable warning,
// because root-absolute paths are never rewritten by the renderer.
expect(screen.getByText(/a root-absolute image path/i)).toBeTruthy();
expect(screen.getByText(/\/static\/logo\.png/)).toBeTruthy();
});
it("does not warn when README only uses absolute image URLs", 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\n\n![ok](https://example.com/foo.png)\n"], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
});
expect(screen.queryByText(/Your README references/i)).toBeNull();
});
it("clears the README relative-asset warning when the user clears the selected package", 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\n\n![diagram](./images/foo.png)\n"], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(screen.getByText(/a package-relative image path/i)).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: /Clear package/i }));
// The Badge must not keep parroting the previous package's findings once
// the user has cleared the selection — otherwise the next pick window
// briefly shows stale warnings.
await waitFor(() => {
expect(screen.queryByText(/Your README references/i)).toBeNull();
});
});
});
+65
View File
@@ -64,6 +64,71 @@ describe("MarkdownPreview — raw HTML passthrough", () => {
);
});
it("leaves relative <img src> alone when no assetBaseUrl is provided", () => {
const { container } = render(
<MarkdownPreview highlight={false}>{`![diagram](./images/foo.png)`}</MarkdownPreview>,
);
const img = container.querySelector("img");
// Falls back to the legacy pass-through behavior — relative path stays as-is.
expect(img?.getAttribute("src")).toBe("./images/foo.png");
});
it("resolves relative ![](./path) images against assetBaseUrl and proxies them", () => {
const { container } = render(
<MarkdownPreview
highlight={false}
assetBaseUrl="https://raw.githubusercontent.com/owner/repo/abc123/sub/"
>{`![diagram](./images/foo.png)`}</MarkdownPreview>,
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabc123%2Fsub%2Fimages%2Ffoo.png&w=1024&q=75",
);
});
it("resolves relative <img src> in raw HTML against assetBaseUrl", () => {
const { container } = render(
<MarkdownPreview
highlight={false}
assetBaseUrl="https://raw.githubusercontent.com/owner/repo/abc123/"
>{`<img src="images/foo.png" alt="d"/>`}</MarkdownPreview>,
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabc123%2Fimages%2Ffoo.png&w=1024&q=75",
);
});
it("resolves relative <source srcset> in raw HTML picture markup against assetBaseUrl", () => {
const { container } = render(
<MarkdownPreview
highlight={false}
assetBaseUrl="https://raw.githubusercontent.com/owner/repo/abc123/docs/"
>{`<picture><source media="(prefers-color-scheme: dark)" srcset="./dark.png 1x, ./dark@2x.png 2x"/><img alt="Logo" src="./light.png"/></picture>`}</MarkdownPreview>,
);
const source = container.querySelector("picture source");
const img = container.querySelector("picture img");
expect(source?.getAttribute("srcset")).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabc123%2Fdocs%2Fdark.png&w=1024&q=75 1x, /_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabc123%2Fdocs%2Fdark%402x.png&w=1024&q=75 2x",
);
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabc123%2Fdocs%2Flight.png&w=1024&q=75",
);
});
it("does not rewrite root-absolute paths even when assetBaseUrl is set", () => {
const { container } = render(
<MarkdownPreview
highlight={false}
assetBaseUrl="https://raw.githubusercontent.com/owner/repo/abc123/"
>{`![x](/foo.png)`}</MarkdownPreview>,
);
const img = container.querySelector("img");
// Root-absolute paths are intentionally left alone — they typically point
// at the ClawHub site itself, not at a package asset.
expect(img?.getAttribute("src")).toBe("/foo.png");
});
it("renders <br/> as a real line break", () => {
const container = renderMarkdown(`line one<br/>line two`);
expect(container.querySelector("br")).not.toBeNull();
+16 -3
View File
@@ -14,6 +14,15 @@ interface MarkdownPreviewProps {
/** Enable Shiki syntax highlighting for fenced code blocks. Default: true. */
highlight?: boolean;
urlTransform?: UrlTransform;
/**
* Base URL used to resolve relative <img src> values inside the README
* (e.g. `./images/foo.png`). When set, relative sources are resolved
* against this base and then routed through the standard image proxy.
* Typical value: a `raw.githubusercontent.com/<repo>/<commit>/<dir>/` URL
* derived from the package release's `verification.sourceRepo` +
* `verification.sourceCommit`. Must end with `/`.
*/
assetBaseUrl?: string;
}
const schema = {
@@ -31,8 +40,10 @@ const schema = {
// Order matters: rehype-sanitize runs BEFORE rehype-shiki so sanitize only
// sees user-authored HTML; shiki's trusted styled output flows through after.
// rehypeProxyImages rewrites after sanitize so we rewrite only already-safe
// <img src="..."> nodes (sanitize strips event handlers, javascript: URLs).
const baseRehype: PluggableList = [rehypeRaw, [rehypeSanitize, schema], rehypeProxyImages];
// image URLs (sanitize strips event handlers, javascript: URLs).
function buildBaseRehype(assetBaseUrl: string | undefined): PluggableList {
return [rehypeRaw, [rehypeSanitize, schema], [rehypeProxyImages, { assetBaseUrl }]];
}
const SHIKI_THEME = "github-dark";
const SHIKI_LANGS = [
@@ -77,6 +88,7 @@ export function MarkdownPreview({
className,
highlight = true,
urlTransform,
assetBaseUrl,
}: MarkdownPreviewProps) {
const [highlighter, setHighlighter] = useState<unknown>(null);
@@ -97,11 +109,12 @@ export function MarkdownPreview({
}, [highlight]);
const rehypePlugins = useMemo<PluggableList>(() => {
const baseRehype = buildBaseRehype(assetBaseUrl);
if (highlight && highlighter) {
return [...baseRehype, [rehypeShikiFromHighlighter, highlighter, { theme: SHIKI_THEME }]];
}
return baseRehype;
}, [highlight, highlighter]);
}, [highlight, highlighter, assetBaseUrl]);
return (
<div className={cn("markdown", className)}>
+133
View File
@@ -0,0 +1,133 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { detectRelativeReadmeAssets } from "./detectRelativeReadmeAssets";
describe("detectRelativeReadmeAssets", () => {
it("returns nothing for empty input", () => {
expect(detectRelativeReadmeAssets("")).toEqual({
samples: [],
total: 0,
unresolvableSamples: [],
unresolvableTotal: 0,
});
});
it("flags a relative markdown image reference", () => {
const report = detectRelativeReadmeAssets("![diagram](./images/foo.png)");
expect(report.samples).toEqual(["./images/foo.png"]);
expect(report.total).toBe(1);
expect(report.unresolvableSamples).toEqual([]);
expect(report.unresolvableTotal).toBe(0);
});
it("flags relative <img src> references in raw HTML", () => {
const report = detectRelativeReadmeAssets(
`<img src="images/foo.png" alt="x"/><img src='./bar.svg'/>`,
);
expect(report.samples).toEqual(["images/foo.png", "./bar.svg"]);
expect(report.total).toBe(2);
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"]);
expect(report.total).toBe(1);
expect(report.unresolvableSamples).toEqual(["/static/logo.png"]);
expect(report.unresolvableTotal).toBe(1);
});
it("ignores absolute http(s) URLs", () => {
const report = detectRelativeReadmeAssets(
'![ok](https://example.com/foo.png)\n<img src="http://example.com/x.png"/>',
);
expect(report).toEqual({
samples: [],
total: 0,
unresolvableSamples: [],
unresolvableTotal: 0,
});
});
it("ignores protocol-relative URLs, data:, mailto:, tel:, and fragment hrefs", () => {
const report = detectRelativeReadmeAssets(
[
"![a](//cdn.example.com/x.png)",
"![b](data:image/png;base64,abc)",
"![c](#anchor)",
'<img src="mailto:x@y"/>',
].join("\n"),
);
expect(report).toEqual({
samples: [],
total: 0,
unresolvableSamples: [],
unresolvableTotal: 0,
});
});
it("deduplicates samples but counts each occurrence in total", () => {
const report = detectRelativeReadmeAssets(
'![a](./x.png)\n![a](./x.png)\n<img src="./x.png"/>\n![b](./y.png)',
);
expect(report.samples).toEqual(["./x.png", "./y.png"]);
expect(report.total).toBe(4);
});
it("counts unresolvable references in total but separates them in unresolvableSamples", () => {
const report = detectRelativeReadmeAssets(
'![rel](./images/foo.png)\n![bad](/static/logo.png)\n![bad](/static/logo.png)\n<img src="/icons/x.svg"/>',
);
expect(report.samples).toEqual(["./images/foo.png", "/static/logo.png", "/icons/x.svg"]);
expect(report.total).toBe(4);
expect(report.unresolvableSamples).toEqual(["/static/logo.png", "/icons/x.svg"]);
expect(report.unresolvableTotal).toBe(3);
});
it("caps samples at 5 distinct paths but keeps counting in total", () => {
const lines = Array.from({ length: 10 }, (_, idx) => `![n](./img-${idx}.png)`);
const report = detectRelativeReadmeAssets(lines.join("\n"));
expect(report.samples.length).toBe(5);
expect(report.samples).toEqual([
"./img-0.png",
"./img-1.png",
"./img-2.png",
"./img-3.png",
"./img-4.png",
]);
expect(report.total).toBe(10);
});
it("handles markdown image references with a title segment", () => {
const report = detectRelativeReadmeAssets(`![alt](./images/foo.png "title text")`);
expect(report.samples).toEqual(["./images/foo.png"]);
});
it("normalizes whitespace around raw HTML image src values", () => {
const report = detectRelativeReadmeAssets(
`<img src=" ./images/foo.png "/><img src=' /static/logo.png '/>`,
);
expect(report.samples).toEqual(["./images/foo.png", "/static/logo.png"]);
expect(report.unresolvableSamples).toEqual(["/static/logo.png"]);
});
});
+166
View File
@@ -0,0 +1,166 @@
/**
* Scans README markdown text for relative image references both Markdown
* `![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
* images render fine inside the package but 404 on the plugin detail page
* unless the release also carries Source repo + Source commit (which lets us
* resolve them to a stable raw.githubusercontent.com URL). We use this scanner
* to surface a non-blocking warning on the publish form so authors can either
* fill in source metadata or rewrite their README to absolute URLs before
* shipping.
*
* Two flavors of "broken on the detail page" exist and the report distinguishes
* them, because the publish form needs to behave differently:
*
* - **Resolvable**: package-relative paths like `./images/foo.png` or
* `images/foo.png`. These can be rewritten at render time to
* `raw.githubusercontent.com/<repo>/<commit>/<sourcePath>/...` once the
* publisher fills in Source repo + Commit SHA, so the warning may be
* dismissed by completing those fields.
* - **Unresolvable**: root-absolute paths like `/static/logo.png`. The
* renderer (rehypeProxyImages) intentionally never rewrites these there
* is no safe base URL that wouldn't accidentally pull random repo-root
* files so they will 404 on the plugin detail page even if source
* metadata is provided. The only fixes are to rewrite them in the README
* to a real absolute URL, or to make them package-relative.
*/
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;
if (src.startsWith("#")) return null;
if (PROTOCOL_RELATIVE.test(src)) return null;
if (ABSOLUTE_URL.test(src)) return null;
// Single leading slash (we already excluded `//...` above) means the browser
// resolves against the page origin, not the package — and the markdown
// renderer deliberately never rewrites these. Mark them out so the publish
// form can warn even when source repo + commit are both filled in.
if (src.startsWith("/")) return "root-absolute";
return "package-relative";
}
export interface RelativeReadmeAssetReport {
/** Up to MAX_REPORTED unique paths, in the order encountered. */
samples: string[];
/** Total number of relative references detected (may exceed samples.length). */
total: number;
/**
* Subset of `samples` that are root-absolute (e.g. `/static/logo.png`).
* These cannot be salvaged by Source repo + Commit SHA because the README
* renderer never rewrites root-absolute paths.
*/
unresolvableSamples: string[];
/** Total number of root-absolute references detected. */
unresolvableTotal: number;
}
export function detectRelativeReadmeAssets(readmeText: string): RelativeReadmeAssetReport {
if (!readmeText) {
return { samples: [], total: 0, unresolvableSamples: [], unresolvableTotal: 0 };
}
const seen = new Set<string>();
const samples: string[] = [];
const unresolvableSeen = new Set<string>();
const unresolvableSamples: string[] = [];
let total = 0;
let unresolvableTotal = 0;
const record = (src: string | undefined) => {
if (!src) return;
const normalizedSrc = src.trim();
const kind = classifyRelativeAsset(normalizedSrc);
if (!kind) return;
total += 1;
if (kind === "root-absolute") unresolvableTotal += 1;
if (!seen.has(normalizedSrc)) {
seen.add(normalizedSrc);
if (samples.length < MAX_REPORTED) samples.push(normalizedSrc);
}
if (kind === "root-absolute" && !unresolvableSeen.has(normalizedSrc)) {
unresolvableSeen.add(normalizedSrc);
if (unresolvableSamples.length < MAX_REPORTED) unresolvableSamples.push(normalizedSrc);
}
};
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);
match;
match = MARKDOWN_IMAGE.exec(readmeText)
) {
record(match[1]);
}
HTML_IMG_SRC.lastIndex = 0;
for (let match = HTML_IMG_SRC.exec(readmeText); match; match = HTML_IMG_SRC.exec(readmeText)) {
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 };
}
+90
View File
@@ -0,0 +1,90 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { buildReadmeAssetBaseUrl } from "./readmeAssetBaseUrl";
const SHA = "abcdef0123456789abcdef0123456789abcdef01";
describe("buildReadmeAssetBaseUrl", () => {
it("builds a raw.githubusercontent.com base from owner/repo + commit SHA", () => {
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA)).toBe(
`https://raw.githubusercontent.com/openclaw/demo/${SHA}/`,
);
});
it("normalizes a full GitHub HTTPS URL down to owner/repo", () => {
expect(buildReadmeAssetBaseUrl("https://github.com/openclaw/demo", SHA)).toBe(
`https://raw.githubusercontent.com/openclaw/demo/${SHA}/`,
);
});
it("strips a trailing .git suffix on the source repo", () => {
expect(buildReadmeAssetBaseUrl("https://github.com/openclaw/demo.git", SHA)).toBe(
`https://raw.githubusercontent.com/openclaw/demo/${SHA}/`,
);
expect(buildReadmeAssetBaseUrl("openclaw/demo.git", SHA)).toBe(
`https://raw.githubusercontent.com/openclaw/demo/${SHA}/`,
);
});
it("returns undefined for non-GitHub source URLs", () => {
expect(buildReadmeAssetBaseUrl("https://gitlab.com/openclaw/demo", SHA)).toBeUndefined();
});
it("returns undefined when sourceCommit is missing or not a 40-hex SHA", () => {
expect(buildReadmeAssetBaseUrl("openclaw/demo", undefined)).toBeUndefined();
expect(buildReadmeAssetBaseUrl("openclaw/demo", "")).toBeUndefined();
expect(buildReadmeAssetBaseUrl("openclaw/demo", "main")).toBeUndefined();
expect(buildReadmeAssetBaseUrl("openclaw/demo", "v1.2.3")).toBeUndefined();
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA.slice(0, 10))).toBeUndefined();
});
it("returns undefined when sourceRepo is missing or malformed", () => {
expect(buildReadmeAssetBaseUrl(undefined, SHA)).toBeUndefined();
expect(buildReadmeAssetBaseUrl("", SHA)).toBeUndefined();
expect(buildReadmeAssetBaseUrl("not-a-repo", SHA)).toBeUndefined();
expect(buildReadmeAssetBaseUrl("too/many/parts/here", SHA)).toBeUndefined();
});
describe("with sourcePath", () => {
it("appends a single subdirectory after the commit SHA", () => {
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA, "examples/openclaw-plugin")).toBe(
`https://raw.githubusercontent.com/openclaw/demo/${SHA}/examples/openclaw-plugin/`,
);
});
it("strips a leading or trailing slash on sourcePath", () => {
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA, "/pkg/")).toBe(
`https://raw.githubusercontent.com/openclaw/demo/${SHA}/pkg/`,
);
});
it("treats '.' and empty path as repo root (no path segment)", () => {
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA, ".")).toBe(
`https://raw.githubusercontent.com/openclaw/demo/${SHA}/`,
);
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA, "")).toBe(
`https://raw.githubusercontent.com/openclaw/demo/${SHA}/`,
);
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA, undefined)).toBe(
`https://raw.githubusercontent.com/openclaw/demo/${SHA}/`,
);
});
it("returns undefined when sourcePath contains '..' or other unsafe segments", () => {
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA, "../etc/passwd")).toBeUndefined();
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA, "pkg/../escape")).toBeUndefined();
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA, "pkg/with space")).toBeUndefined();
expect(buildReadmeAssetBaseUrl("openclaw/demo", SHA, "pkg\\windows")).toBeUndefined();
});
it("ignores sourcePath when sourceRepo or sourceCommit is invalid", () => {
expect(
buildReadmeAssetBaseUrl("not-a-repo", SHA, "examples/openclaw-plugin"),
).toBeUndefined();
expect(
buildReadmeAssetBaseUrl("openclaw/demo", "main", "examples/openclaw-plugin"),
).toBeUndefined();
});
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* Build the base URL used by MarkdownPreview to resolve relative <img src>
* values (e.g. `./images/foo.png`) inside a plugin README.
*
* Strategy: when a release has source metadata (sourceRepo + a 40-hex
* sourceCommit), point at the matching `raw.githubusercontent.com` tree.
* Using the commit SHA not a branch like `main` keeps each published
* package page stable even if the source branch later moves.
*
* When a `sourcePath` is supplied (the package directory inside the source
* repo, e.g. `examples/openclaw-plugin`), it is appended after the commit
* SHA so that relative README images resolve against the package
* subdirectory rather than the repo root. This matters for monorepo
* publishes where the README references `./images/...` next to itself.
*
* Returns `undefined` when we can't construct a safe, stable base URL; in
* that case MarkdownPreview falls back to its legacy behavior of leaving
* relative sources untouched.
*
* `sourceRepo` may be either `owner/repo` (the canonical form) or a full
* GitHub URL (older publishes stored `source.url` here when `repo` was
* empty). Both shapes are normalized; non-GitHub hosts are rejected because
* raw.githubusercontent.com is the only host this URL pattern is valid for
* and the only host beyond GitHub already in vercel.json's image
* remotePatterns allow-list relevant to this rewrite.
*
* `sourcePath` is path-segmented and per-segment validated against a
* conservative `[A-Za-z0-9._-]` whitelist. `..` segments and any
* disallowed character cause the base URL to be rejected so the publish
* form does not promise that README images will render from the wrong
* source directory. Missing, empty, or "." source paths still mean repo root.
*/
const COMMIT_SHA = /^[0-9a-f]{40}$/i;
const GITHUB_OWNER_REPO = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}[A-Za-z0-9])?)\/([A-Za-z0-9._-]+)$/;
const PATH_SEGMENT = /^[A-Za-z0-9._-]+$/;
function normalizeOwnerRepo(input: string | undefined | null): string | null {
if (!input) return null;
const trimmed = input.trim();
if (!trimmed) return null;
if (GITHUB_OWNER_REPO.test(trimmed)) {
return trimmed.replace(/\.git$/i, "");
}
// Tolerate a full GitHub URL — older publishes may have stored
// `source.url` in `verification.sourceRepo`.
try {
const url = new URL(trimmed);
if (url.hostname !== "github.com" && url.hostname !== "www.github.com") return null;
const parts = url.pathname
.replace(/^\/+/, "")
.replace(/\.git$/i, "")
.split("/");
if (parts.length < 2) return null;
const ownerRepo = `${parts[0]}/${parts[1]}`;
return GITHUB_OWNER_REPO.test(ownerRepo) ? ownerRepo : null;
} catch {
return null;
}
}
function normalizeSourcePath(input: string | undefined | null): string | null | undefined {
if (!input) return undefined;
const trimmed = input.trim();
if (!trimmed || trimmed === ".") return undefined;
// Reject anything that doesn't look like a forward-slash relative path —
// no protocol-like contents, no backslashes, no whitespace. Leading and
// trailing slashes are tolerated because `split("/").filter(Boolean)`
// collapses them; only segment-level shapes need strict validation.
if (/[\\\s]/.test(trimmed)) return null;
if (trimmed.includes("://")) return null;
const segments = trimmed.split("/").filter(Boolean);
if (segments.length === 0) return undefined;
for (const segment of segments) {
if (segment === "." || segment === "..") return null;
if (!PATH_SEGMENT.test(segment)) return null;
}
return segments.join("/");
}
export function buildReadmeAssetBaseUrl(
sourceRepo: string | undefined | null,
sourceCommit: string | undefined | null,
sourcePath?: string | null,
): string | undefined {
const ownerRepo = normalizeOwnerRepo(sourceRepo);
if (!ownerRepo) return undefined;
const commit = sourceCommit?.trim();
if (!commit || !COMMIT_SHA.test(commit)) return undefined;
const path = normalizeSourcePath(sourcePath);
if (path === null) return undefined;
// Trailing slash is required so `new URL("./images/foo.png", base)`
// resolves as a directory rather than dropping the last path segment.
return path
? `https://raw.githubusercontent.com/${ownerRepo}/${commit}/${path}/`
: `https://raw.githubusercontent.com/${ownerRepo}/${commit}/`;
}
+125
View File
@@ -0,0 +1,125 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { rehypeProxyImages } from "./rehypeProxyImages";
type ImageTree = {
type: "root";
children: Array<{
type: "element";
tagName: "img" | "source";
properties: Record<string, string>;
}>;
};
function rewriteImgSrc(src: string, assetBaseUrl?: string) {
const tree: ImageTree = {
type: "root",
children: [
{
type: "element",
tagName: "img",
properties: { src },
},
],
};
rehypeProxyImages({ assetBaseUrl })(tree);
return tree.children[0].properties.src;
}
function rewriteSourceSrcset(srcset: string, assetBaseUrl?: string, property = "srcset") {
const tree: ImageTree = {
type: "root",
children: [
{
type: "element",
tagName: "source",
properties: { [property]: srcset },
},
],
};
rehypeProxyImages({ assetBaseUrl })(tree);
return tree.children[0].properties[property];
}
describe("rehypeProxyImages", () => {
it("allows relative README assets to reference parent folders inside the same commit tree", () => {
expect(
rewriteImgSrc(
"../shared/logo.png",
"https://raw.githubusercontent.com/owner/repo/abcdef/sub/",
),
).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabcdef%2Fshared%2Flogo.png&w=1024&q=75",
);
});
it("does not rewrite relative README assets that escape above the commit root", () => {
expect(
rewriteImgSrc(
"../../../outside.png",
"https://raw.githubusercontent.com/owner/repo/abcdef/sub/dir/",
),
).toBe("../../../outside.png");
});
it("does not treat explicit non-http schemes as relative README assets", () => {
expect(
rewriteImgSrc("javascript:alert(1)", "https://raw.githubusercontent.com/owner/repo/abcdef/"),
).toBe("javascript:alert(1)");
expect(
rewriteImgSrc("ftp://example.com/image.png", "https://raw.githubusercontent.com/x/y/z/"),
).toBe("ftp://example.com/image.png");
});
it("trims incidental whitespace before resolving relative README assets", () => {
expect(
rewriteImgSrc(
" ./images/foo.png ",
"https://raw.githubusercontent.com/owner/repo/abcdef/sub/",
),
).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabcdef%2Fsub%2Fimages%2Ffoo.png&w=1024&q=75",
);
});
it("resolves relative <source srcset> candidates against assetBaseUrl and proxies them", () => {
expect(
rewriteSourceSrcset(
"./dark.png 1x, ./dark@2x.png 2x",
"https://raw.githubusercontent.com/owner/repo/abcdef/readme/",
),
).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabcdef%2Freadme%2Fdark.png&w=1024&q=75 1x, /_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabcdef%2Freadme%2Fdark%402x.png&w=1024&q=75 2x",
);
});
it("rewrites supported <source srcSet> property casing", () => {
expect(
rewriteSourceSrcset(
"wide.png 800w, wide@2x.png 1600w",
"https://raw.githubusercontent.com/owner/repo/abcdef/",
"srcSet",
),
).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabcdef%2Fwide.png&w=1024&q=75 800w, /_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabcdef%2Fwide%402x.png&w=1024&q=75 1600w",
);
});
it("preserves unsupported <source srcset> entries while rewriting proxyable entries", () => {
expect(
rewriteSourceSrcset(
"data:image/svg+xml,%3Csvg%3E 1x, /site.png 2x, https://img.shields.io/badge/x-y-blue.svg 3x, ./local.png 4x",
"https://raw.githubusercontent.com/owner/repo/abcdef/",
),
).toBe(
"data:image/svg+xml,%3Csvg%3E 1x, /site.png 2x, /_vercel/image?url=https%3A%2F%2Fimg.shields.io%2Fbadge%2Fx-y-blue.svg&w=1024&q=75 3x, /_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Fowner%2Frepo%2Fabcdef%2Flocal.png&w=1024&q=75 4x",
);
});
it("leaves relative <source srcset> candidates alone without assetBaseUrl", () => {
expect(rewriteSourceSrcset("./dark.png 1x, ./dark@2x.png 2x")).toBe(
"./dark.png 1x, ./dark@2x.png 2x",
);
});
});
+185 -9
View File
@@ -5,27 +5,203 @@ type HastElementLike = {
properties?: Record<string, unknown>;
};
interface RehypeProxyImagesOptions {
/**
* Base URL used to resolve relative <img src> values (e.g. `./images/foo.png`).
* When set, relative sources are resolved against this base into an absolute
* URL and then proxied through the same /_vercel/image path as external
* sources. When unset, relative paths pass through unchanged (legacy
* behavior).
*
* Typical value: a `raw.githubusercontent.com/<repo>/<commit>/<dir>/` URL
* built from a package release's `verification.sourceRepo` +
* `verification.sourceCommit`. Must end with `/` so it resolves as a
* directory; callers are responsible for that.
*/
assetBaseUrl?: string;
}
const DATA_OR_FRAGMENT = /^(?:data:|#|mailto:|tel:)/i;
const ABSOLUTE_HTTP = /^https?:\/\//i;
const EXPLICIT_SCHEME = /^[a-z][a-z0-9+\-.]*:/i;
const PROTOCOL_RELATIVE = /^\/\//;
const IMAGE_PROXY_WIDTH = 1024;
type SrcsetCandidate = {
url: string;
descriptors: string;
};
function getRawGitHubCommitRoot(assetBaseUrl: string): URL | null {
try {
const baseUrl = new URL(assetBaseUrl);
if (baseUrl.protocol !== "https:" || baseUrl.hostname !== "raw.githubusercontent.com") {
return null;
}
const [owner, repo, commit] = baseUrl.pathname.split("/").filter(Boolean);
if (!owner || !repo || !commit) return null;
return new URL(`/${owner}/${repo}/${commit}/`, baseUrl.origin);
} catch {
return null;
}
}
function resolveRelativeSrc(src: string, assetBaseUrl: string | undefined): string | null {
if (!assetBaseUrl) return null;
if (!src) return null;
if (ABSOLUTE_HTTP.test(src)) return null;
if (PROTOCOL_RELATIVE.test(src)) return null;
if (DATA_OR_FRAGMENT.test(src)) return null;
if (EXPLICIT_SCHEME.test(src)) return null;
// Absolute site paths (e.g. "/foo.png") are NOT package-relative — leaving
// them alone matches how npmjs.com treats them and avoids accidentally
// pulling random repo-root files.
if (src.startsWith("/")) return null;
try {
const resolved = new URL(src, assetBaseUrl);
const commitRoot = getRawGitHubCommitRoot(assetBaseUrl);
if (!commitRoot) return null;
if (resolved.origin !== commitRoot.origin) return null;
if (!resolved.pathname.startsWith(commitRoot.pathname)) return null;
return resolved.toString();
} catch {
return null;
}
}
function proxyImageSrc(src: string): string {
return `/_vercel/image?url=${encodeURIComponent(src)}&w=${IMAGE_PROXY_WIDTH}&q=75`;
}
function rewriteImageSrc(src: string, assetBaseUrl: string | undefined): string | null {
const normalizedSrc = src.trim();
let absoluteSrc: string | null = null;
if (ABSOLUTE_HTTP.test(normalizedSrc)) {
absoluteSrc = normalizedSrc;
} else {
absoluteSrc = resolveRelativeSrc(normalizedSrc, assetBaseUrl);
}
if (!absoluteSrc) return null;
return proxyImageSrc(absoluteSrc);
}
function isAsciiWhitespace(char: string): boolean {
return char === " " || char === "\n" || char === "\t" || char === "\r" || char === "\f";
}
function parseSrcset(srcset: string): SrcsetCandidate[] {
const candidates: SrcsetCandidate[] = [];
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);
}
while (index < srcset.length && isAsciiWhitespace(srcset[index])) {
index += 1;
}
let descriptors = "";
if (!endedWithComma) {
const descriptorsStart = index;
while (index < srcset.length && srcset[index] !== ",") {
index += 1;
}
descriptors = srcset.slice(descriptorsStart, index).trim();
}
candidates.push({ url, descriptors });
if (srcset[index] === ",") {
index += 1;
}
}
return candidates;
}
function rewriteSrcset(srcset: string, assetBaseUrl: string | undefined): string | null {
const candidates = parseSrcset(srcset);
if (candidates.length === 0) return null;
let didRewrite = false;
const rewritten = candidates.map((candidate) => {
const rewrittenUrl = rewriteImageSrc(candidate.url, assetBaseUrl);
if (!rewrittenUrl) {
return candidate.descriptors ? `${candidate.url} ${candidate.descriptors}` : candidate.url;
}
didRewrite = true;
return candidate.descriptors ? `${rewrittenUrl} ${candidate.descriptors}` : rewrittenUrl;
});
return didRewrite ? rewritten.join(", ") : null;
}
/**
* Routes external http(s) <img> sources through Vercel's image optimizer at
* Routes external http(s) image sources through Vercel's image optimizer at
* /_vercel/image, which enforces the allow-list, SVG rejection, and caching
* declared in vercel.json. Local paths, relative paths, and data: URIs pass
* through unchanged only external schemes are treated as untrusted.
*
* If `assetBaseUrl` is provided, relative sources are first resolved against
* that base (typically a `raw.githubusercontent.com/<repo>/<commit>/<dir>/`
* URL derived from the package release source metadata) and then routed
* through the same proxy. This fixes README images authored with relative
* paths like `./images/foo.png` or `<source srcset="./dark.png 1x">`, which
* would otherwise 404 under the ClawHub route.
*
* `w` is required by the optimizer and must match a value in the `sizes`
* array in vercel.json, so we always pass 1024. The <img width="..."> HTML
* attribute still drives layout this only controls served resolution.
*/
export function rehypeProxyImages() {
export function rehypeProxyImages(options: RehypeProxyImagesOptions = {}) {
const { assetBaseUrl } = options;
return (tree: Parameters<typeof visit>[0]) => {
visit(tree, "element", (node) => {
const element = node as HastElementLike;
if (element.tagName !== "img") return;
const src = element.properties?.src;
if (typeof src !== "string" || !/^https?:\/\//i.test(src)) return;
element.properties = {
...element.properties,
src: `/_vercel/image?url=${encodeURIComponent(src)}&w=1024&q=75`,
};
if (element.tagName === "img") {
const src = element.properties?.src;
if (typeof src === "string") {
const rewrittenSrc = rewriteImageSrc(src, assetBaseUrl);
if (rewrittenSrc) {
element.properties = {
...element.properties,
src: rewrittenSrc,
};
}
}
}
if (element.tagName === "source") {
const srcsetKey = typeof element.properties?.srcSet === "string" ? "srcSet" : "srcset";
const srcset = element.properties?.[srcsetKey];
if (typeof srcset === "string") {
const rewrittenSrcset = rewriteSrcset(srcset, assetBaseUrl);
if (rewrittenSrcset) {
element.properties = {
...element.properties,
[srcsetKey]: rewrittenSrcset,
};
}
}
}
});
};
}
+7 -1
View File
@@ -37,6 +37,7 @@ import {
buildPluginSecurityAuditHref,
parseScopedPackageName,
} from "../../lib/pluginRoutes";
import { buildReadmeAssetBaseUrl } from "../../lib/readmeAssetBaseUrl";
import { useAuthStatus } from "../../lib/useAuthStatus";
type PluginDetailRateLimitState = {
@@ -416,6 +417,11 @@ export function PluginDetailPage({
const capabilities = latestRelease?.capabilities ?? pkg.capabilities;
const compatibility = latestRelease?.compatibility ?? pkg.compatibility;
const verification = latestRelease?.verification ?? pkg.verification;
const readmeAssetBaseUrl = buildReadmeAssetBaseUrl(
verification?.sourceRepo,
verification?.sourceCommit,
verification?.sourcePath,
);
const artifact = latestRelease?.artifact ?? pkg.artifact ?? null;
const downloadPath =
pkg.latestVersion && latestRelease?.version && artifact?.kind === "npm-pack"
@@ -444,7 +450,7 @@ export function PluginDetailPage({
? Object.entries(compatibility).filter(([, v]) => v !== undefined && v !== null)
: [];
const readmePanel = readme ? (
<MarkdownPreview>{readme}</MarkdownPreview>
<MarkdownPreview assetBaseUrl={readmeAssetBaseUrl}>{readme}</MarkdownPreview>
) : (
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
<p className="empty-state-title">No README available</p>
+147
View File
@@ -26,12 +26,17 @@ import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Textarea } from "../../components/ui/textarea";
import { VersionInput } from "../../components/VersionInput";
import {
detectRelativeReadmeAssets,
type RelativeReadmeAssetReport,
} from "../../lib/detectRelativeReadmeAssets";
import {
buildPackageUploadEntries,
filterIgnoredPackageFiles,
normalizePackageUploadFiles,
} from "../../lib/packageUpload";
import { derivePluginPrefill, listPrefilledFields } from "../../lib/pluginPublishPrefill";
import { buildReadmeAssetBaseUrl } from "../../lib/readmeAssetBaseUrl";
import { expandFilesWithReport } from "../../lib/uploadFiles";
import { useAuthStatus } from "../../lib/useAuthStatus";
import { formatPublishError, hashFile, uploadFile } from "../upload/-utils";
@@ -57,6 +62,49 @@ const apiRefs = api as unknown as {
const SHOW_CLAWPACK_ONBOARDING_BANNER = false;
const PLUGIN_PUBLISHING_GUIDE_URL = "https://docs.openclaw.ai/clawhub/publishing#plugins";
function findReadmeFile(files: File[]): File | null {
// Match the same lookup the publish backend uses (readme.md / readme.mdx)
// by going through the shared upload-path normalizer so we see the exact
// path the server will see — including any shared-top-level-folder
// stripping. We pick the shallowest README so root-level READMEs win over
// ones nested in `examples/` etc.
const normalized = normalizePackageUploadFiles(files);
const candidates: Array<{ file: File; depth: number }> = [];
for (const entry of normalized) {
const lower = entry.path.toLowerCase();
if (lower === "readme.md" || lower === "readme.mdx") {
candidates.push({ file: entry.file, depth: 1 });
continue;
}
const segments = lower.split("/").filter(Boolean);
const last = segments[segments.length - 1];
if (last === "readme.md" || last === "readme.mdx") {
candidates.push({ file: entry.file, depth: segments.length });
}
}
if (candidates.length === 0) return null;
candidates.sort((a, b) => a.depth - b.depth);
return candidates[0]?.file ?? null;
}
const EMPTY_README_ASSET_REPORT: RelativeReadmeAssetReport = {
samples: [],
total: 0,
unresolvableSamples: [],
unresolvableTotal: 0,
};
async function scanReadmeRelativeAssets(files: File[]): Promise<RelativeReadmeAssetReport> {
const readme = findReadmeFile(files);
if (!readme) return EMPTY_README_ASSET_REPORT;
try {
const text = await readme.text();
return detectRelativeReadmeAssets(text);
} catch {
return EMPTY_README_ASSET_REPORT;
}
}
export function PublishPluginRoute() {
const search = useSearch({ from: "/plugins/publish" });
const { isAuthenticated, isLoading: isAuthLoading, me } = useAuthStatus();
@@ -83,6 +131,8 @@ export function PublishPluginRoute() {
const [packageSourceKind, setPackageSourceKind] = useState<PackagePickSource | null>(null);
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]);
const [readmeAssetReport, setReadmeAssetReport] =
useState<RelativeReadmeAssetReport>(EMPTY_README_ASSET_REPORT);
const [codePluginFieldIssues, setCodePluginFieldIssues] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -163,6 +213,43 @@ export function PublishPluginRoute() {
validationError,
]);
const readmeAssetWarning = useMemo(() => {
const { total, unresolvableTotal, samples, unresolvableSamples } = readmeAssetReport;
if (total === 0) return null;
const resolvableTotal = total - unresolvableTotal;
// Single source of truth: only treat the source metadata as "filled" when
// buildReadmeAssetBaseUrl — the same function the renderer uses — accepts
// it and produces a real raw.githubusercontent.com URL. This catches the
// silent-drop trap where the form previously accepted any non-empty Commit
// SHA (e.g. a 7-char short SHA, a tag like `v1.0.0`, a non-GitHub URL, or
// a `..`-laden Package path) and reassured the publisher their relative
// images would be served, while at render time COMMIT_SHA / owner-repo /
// path validation would silently drop the base URL and the detail page
// would 404. By gating on resolvedBaseUrl we keep the form's promise and
// the renderer's behavior in lock-step.
const resolvedBaseUrl = buildReadmeAssetBaseUrl(sourceRepo, sourceCommit, sourcePath);
const hasSource = Boolean(resolvedBaseUrl);
const showResolvableMissingSource = resolvableTotal > 0 && !hasSource;
const showSourcePathReminder = resolvableTotal > 0 && hasSource;
const showUnresolvable = unresolvableTotal > 0;
if (!showResolvableMissingSource && !showSourcePathReminder && !showUnresolvable) {
return null;
}
const resolvableSamples = samples.filter((sample) => !unresolvableSamples.includes(sample));
return {
total,
samples,
resolvableTotal,
resolvableSamples,
unresolvableTotal,
unresolvableSamples,
resolvedBaseUrl,
showResolvableMissingSource,
showSourcePathReminder,
showUnresolvable,
};
}, [readmeAssetReport, sourceRepo, sourceCommit, sourcePath]);
const onPickFiles = async (selected: File[], sourceKind: PackagePickSource) => {
const expanded = await expandFilesWithReport(selected, {
includeBinaryArchiveFiles: true,
@@ -177,6 +264,7 @@ export function PublishPluginRoute() {
setIgnoredPaths(nextIgnoredPaths);
setError(null);
setStatus(null);
setReadmeAssetReport(await scanReadmeRelativeAssets(filtered.files));
const prefill = await derivePluginPrefill(normalized);
setDetectedPrefillFields(listPrefilledFields(prefill));
setCodePluginFieldIssues(prefill.missingRequiredFields ?? []);
@@ -195,6 +283,12 @@ export function PublishPluginRoute() {
setIgnoredPaths([]);
setDetectedPrefillFields([]);
setCodePluginFieldIssues([]);
// Without this reset the README warning Badge keeps showing the previous
// package's relative-asset findings until the next pick's async scan
// finishes — which is misleading both while no package is selected and
// during the brief window between setFiles() and setReadmeAssetReport()
// inside onPickFiles().
setReadmeAssetReport(EMPTY_README_ASSET_REPORT);
setError(null);
setStatus(null);
};
@@ -425,6 +519,59 @@ export function PublishPluginRoute() {
disabled={metadataDisabled}
onChange={(event) => setSourceCommit(event.target.value)}
/>
{readmeAssetWarning ? (
<Badge variant="accent">
<span>
{readmeAssetWarning.showResolvableMissingSource ? (
<>
Your README references{" "}
{readmeAssetWarning.resolvableTotal === 1
? "a package-relative image path"
: `${readmeAssetWarning.resolvableTotal} package-relative image paths`}{" "}
({readmeAssetWarning.resolvableSamples.slice(0, 3).join(", ")}
{readmeAssetWarning.resolvableSamples.length > 3 ? ", \u2026" : ""}).
Without Source repo + Commit SHA the plugin detail page can't resolve
them to your source host, so they will 404. Fill in GitHub repository +
Commit SHA (and Package path if the package isn't at the repo root) to
serve them from raw.githubusercontent.com, or rewrite them to absolute
URLs in the README.
</>
) : null}
{readmeAssetWarning.showSourcePathReminder &&
readmeAssetWarning.resolvedBaseUrl ? (
<>
Your README references{" "}
{readmeAssetWarning.resolvableTotal === 1
? "a package-relative image path"
: `${readmeAssetWarning.resolvableTotal} package-relative image paths`}{" "}
({readmeAssetWarning.resolvableSamples.slice(0, 3).join(", ")}
{readmeAssetWarning.resolvableSamples.length > 3 ? ", \u2026" : ""}).
They will be served from {readmeAssetWarning.resolvedBaseUrl} make
sure Package path matches where this package lives in the repo, or the
images will 404.
</>
) : null}
{(readmeAssetWarning.showResolvableMissingSource ||
readmeAssetWarning.showSourcePathReminder) &&
readmeAssetWarning.showUnresolvable
? " "
: null}
{readmeAssetWarning.showUnresolvable ? (
<>
Your README also references{" "}
{readmeAssetWarning.unresolvableTotal === 1
? "a root-absolute image path"
: `${readmeAssetWarning.unresolvableTotal} root-absolute image paths`}{" "}
({readmeAssetWarning.unresolvableSamples.slice(0, 3).join(", ")}
{readmeAssetWarning.unresolvableSamples.length > 3 ? ", \u2026" : ""}).
These start with "/" and are resolved against the page origin, not the
package, so Source repo + Commit SHA cannot rewrite them please
replace them with absolute URLs or package-relative paths in the README.
</>
) : null}
</span>
</Badge>
) : null}
</div>
<div className="flex flex-col gap-2">
<FieldLabelWithHelp