mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"?',
|
||||
});
|
||||
|
||||
Vendored
+3
@@ -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;
|
||||
|
||||
Vendored
+6
@@ -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"?',
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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"?',
|
||||
|
||||
@@ -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\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\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\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\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\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\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\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\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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}>{``}</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  images against assetBaseUrl and proxies them", () => {
|
||||
const { container } = render(
|
||||
<MarkdownPreview
|
||||
highlight={false}
|
||||
assetBaseUrl="https://raw.githubusercontent.com/owner/repo/abc123/sub/"
|
||||
>{``}</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/"
|
||||
>{``}</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();
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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("");
|
||||
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("");
|
||||
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(
|
||||
'\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(
|
||||
[
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
'<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(
|
||||
'\n\n<img src="./x.png"/>\n',
|
||||
);
|
||||
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(
|
||||
'\n\n\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) => ``);
|
||||
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(``);
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Scans README markdown text for relative image references — both Markdown
|
||||
* `` 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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}/`;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user