Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions BOTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ and the need to ask a maintainer for a rerun. Network, tooling, and registry
failures keep their diagnostics in the workflow log and job summary and add
`needs-maintainer`; they do not post an error comment on the author's issue.
Inspect the failed run, resolve its cause, rerun that issue, and remove the label
when handled. Theme submissions currently need a maintainer to supply image
media in the record: the form has no media input. Manifest metadata ingestion
is a separate registry change; this workflow does not ask for a listing file.
when handled. Submission reads metadata from the pinned `paseo-plugin.json`
and preserves record overrides. Categories do not trigger content requirements;
the PR reviewer decides which screenshots the plugin needs under REVIEW.md.

npm package files come from one cached tarball per version, verified against
npm's SHA-512 before reading. No package code runs, and files are read to stdout
Expand Down
52 changes: 44 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ pin; `repository` is the browseable source and optional proven source commit.

Humans edit categories and optional `listing` overrides (`name`, HTTPS PNG `icon`,
HTTPS `media`). The bot writes artifact pins and review dates. The published
index combines records with metadata from their pinned artifacts.
index combines records with metadata from their pinned manifests. See
[plugin metadata](#plugin-metadata) for fields and override precedence.

Authors must keep `OVERVIEW.md` beside `paseo-plugin.json` in the repository at the
pinned source commit. Git monorepos use `artifact.pluginPath`; npm monorepos use
Expand Down Expand Up @@ -98,21 +99,56 @@ contract is reviewed by a person.

The detail document keeps its existing `readme` field. It publishes the pinned
author `OVERVIEW.md`, or the registry import stopgap when author content is absent.
With neither source it fails. `README.md`, `readme.md`, and `paseo-listing.json`
readme overrides are never used for overview content.
With neither source it fails. `README.md` and `readme.md` are never used for
overview content.

A plugin can ship a separate `paseo-listing.json` next to its strict manifest:
## Plugin metadata

Declare your display name, icon, screenshots, and demo videos in `paseo-plugin.json`:

```json
{
"id": "example",
"name": "Example",
"icon": "icon.png",
"media": ["https://example.com/demo.mp4", "https://example.com/screen.png"]
"description": "A short description of what the plugin does.",
"icon": "assets/icon.png",
"media": ["assets/screenshot.png", "https://example.com/demo.mp4"],
"requirements": { "paseo": ">=0.11.0" }
}
```

Relative icons resolve to the pinned artifact. Media entries are HTTPS image or video URLs;
record overrides win over this file. Cards use the first image in media order.
`name`, `icon`, and `media` are optional manifest fields. Include only assets
that exist in your release. `icon` is a package-relative PNG path. Each `media`
entry is a package-relative path or an HTTPS URL with an image extension
(`png`, `jpg`, `jpeg`, `webp`, `gif`) or video extension (`mp4`, `webm`).
SVG and URLs without a supported extension are not accepted by the registry.
Paths use forward slashes and stay inside the plugin directory. For npm,
include local asset files in the published package's `files` list.

The registry reads the manifest from the pinned npm tarball or Git commit.
Relative assets become URLs pointing to that version, under `pluginPath` for
Git monorepos. Media keep their declared order; cards use the first image.
Manifests declaring these fields require Paseo 0.11.0 or later.

The existing record's `listing` values override the manifest **per field**:

| Field | First choice | Otherwise |
| --- | --- | --- |
| Name | `listing.name` | Manifest `name`, then a humanized registry id |
| Icon | `listing.icon` | Manifest `icon`, or no icon |
| Media | `listing.media` | Manifest `media`, or an empty array |

An explicit `listing.media: []` replaces all manifest media. The submission
bot saves the issue title as `listing.name`. Maintainers can keep using the
same overrides without changing existing records.

The registry does not read `paseo-listing.json`. Move its metadata into the
manifest when publishing a new release.

Categories describe where a plugin is listed. Selecting **Themes** does not
identify the plugin as a theme or trigger a screenshot requirement in the
submission bot. The PR reviewer determines which screenshots are needed from
what the plugin does, following [the review policy](REVIEW.md#content).

## Maintainers

Expand Down
8 changes: 6 additions & 2 deletions REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,16 @@ what each is judged against.
- Source match: when the declared repository holds the source at the pinned commit, the
artifact's code matches it. Extra files, changed logic, or a dependency the source does
not declare is a mismatch, and a mismatch is rejected.
- Listing media: entries are HTTPS image (`png`, `jpg`, `jpeg`, `webp`, `gif`) or video (`mp4`,
- Listing media: review the manifest metadata after applying any registry `listing`
overrides. Entries are HTTPS image (`png`, `jpg`, `jpeg`, `webp`, `gif`) or video (`mp4`,
`webm`) URLs, with case-insensitive extensions. Each URL returns HTTP 200 and an
`image/*` or `video/*` content type. SVG is not accepted. The card thumbnail is the
first image in media order.
- Visible surfaces: every theme and any plugin that adds a panel or other UI lists at
least one image of that surface. A theme without an image is not listed.
least one image of that surface. A theme without an image is not listed. Determine
what the plugin does from the artifact; categories are organizational labels. A
plugin categorized as Themes can be a tool for creating themes. The PR reviewer
applies this requirement; submission does not infer it from a category.
- Scope: a pull request changes one record and its overview. Anything touching `.github/`, `featured.json`,
`scripts/`, `categories.json`, this file, or more than one record is a maintainer change
and is never merged by the bot.
Expand Down
73 changes: 23 additions & 50 deletions scripts/lib/listing.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mergeListing, parseListingFile, resolvePlugin } from "./listing.ts";
import type { VersionDoc } from "./npm.ts";
import { resolvePlugin } from "./listing.ts";
import type { NpmClient, VersionDoc } from "./npm.ts";
import type { PluginRecord } from "./record.ts";

const doc: VersionDoc = {
Expand Down Expand Up @@ -36,58 +36,23 @@ const record: PluginRecord = {
reviewedAt: "2026-10-03",
};

test("assets in the package resolve to the pinned version on the CDN", () => {
const plugin = mergeListing({
record,
doc,
listingFile: parseListingFile(
JSON.stringify({
name: "Dracula",
icon: "icon.png",
media: ["https://x.test/a.mp4", "https://x.test/b.png"],
}),
),
readme: "# Dracula\n",
installs: 42,
});
test("publication preserves author, overview, and review dates", async () => {
const client: NpmClient = {
async packument() { return { name: doc.name, "dist-tags": { latest: doc.version }, versions: { [doc.version]: doc }, time: {} }; },
async file() { return '{"name":"Dracula"}'; },
async provenance() { return null; },
async tarball() { throw new Error("Not needed"); },
};
const plugin = await resolvePlugin(client, { ...record, repository: { url: record.repository.url } }, "Overview.");
assert.equal(plugin.name, "Dracula");
assert.equal(plugin.icon, "https://cdn.jsdelivr.net/npm/@omercnet/paseo-dracula@1.2.0/icon.png");
assert.deepEqual(plugin.media, [
"https://x.test/a.mp4",
"https://x.test/b.png",
]);
assert.equal(plugin.description, doc.description);
assert.deepEqual(plugin.author, { npm: "omercnet", name: "Omer Cohen", github: "omercnet" });
assert.equal(plugin.repository?.commit, record.repository?.commit);
assert.equal(plugin.readme, "# Dracula\n");
});

test("record overrides win, and a package without a listing file gets a humanized name", () => {
const plugin = mergeListing({
record: { ...record, listing: { media: ["https://x.test/override.png"] } },
doc: { ...doc, author: undefined, repository: undefined },
listingFile: parseListingFile(null),
readme: null,
});
assert.equal(plugin.name, "Dracula");
assert.deepEqual(plugin.media, ["https://x.test/override.png"]);
assert.equal(plugin.icon, undefined);
assert.deepEqual(plugin.author, { npm: "omercnet", name: "omercnet", github: "omercnet" });
assert.equal(plugin.readme, "");
assert.equal(plugin.installs, undefined);
});

test("publication date comes from the record review date, not the npm release", () => {
const plugin = mergeListing({
record,
doc,
listingFile: {},
readme: "Overview.",
});
assert.equal(plugin.readme, "Overview.");
assert.equal(plugin.publishedAt, "2026-10-03T00:00:00.000Z");
assert.equal(plugin.updatedAt, "2026-10-03T00:00:00.000Z");
assert.equal(plugin.installs, undefined);
});


test("a tagged monorepo artifact is pinned, validated and built through both submission syntaxes", async () => {
const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, cpSync, rmSync } =
await import("node:fs");
Expand All @@ -104,8 +69,10 @@ test("a tagged monorepo artifact is pinned, validated and built through both sub
mkdirSync(join(repository, pluginPath), { recursive: true });
writeFileSync(
join(repository, pluginPath, "paseo-plugin.json"),
JSON.stringify({ id: "example", description: "Pinned monorepo example" }),
JSON.stringify({ id: "example", name: "Manifest name", description: "Pinned monorepo example", icon: "assets/icon.png", media: ["assets/demo.mp4", "assets/screen.png"] }),
);
mkdirSync(join(repository, pluginPath, "assets"));
for (const asset of ["icon.png", "demo.mp4", "screen.png"]) writeFileSync(join(repository, pluginPath, "assets", asset), "fixture");
writeFileSync(join(repository, pluginPath, "OVERVIEW.md"), "Monorepo example overview.\n");
writeFileSync(join(repository, pluginPath, "README.md"), "Wrong README");
writeFileSync(
Expand Down Expand Up @@ -189,7 +156,13 @@ test("a tagged monorepo artifact is pinned, validated and built through both sub
assert.equal(detail.description, "Pinned monorepo example");
assert.equal(detail.artifact.pluginPath, pluginPath);
assert.equal(detail.artifact.commit, commit);
assert.deepEqual(detail.media, []);
const base = `https://github.com/acme/plugins/raw/${commit}/${pluginPath}/`;
assert.equal(detail.name, "Manifest name");
assert.equal(detail.icon, `${base}assets/icon.png`);
assert.deepEqual(detail.media, [`${base}assets/demo.mp4`, `${base}assets/screen.png`]);
const summary = index.plugins.find((item: { id: string }) => item.id === detail.id);
const { readme, ...expectedSummary } = detail;
assert.deepEqual(summary, expectedSummary);
}
const overview = join(registry, "plugins/acme/example.md");
writeFileSync(overview, "# Example\n\nA curated description.\n");
Expand Down
137 changes: 21 additions & 116 deletions scripts/lib/listing.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
import { parseMedia } from "./media.ts";
import { readAuthorOverview, requireOverview } from "./overview.ts";
import type { Category } from "./categories.ts";
import { authorOf, type NpmClient, resolveVersion, type VersionDoc } from "./npm.ts";
import { readOptional, withGitArtifact } from "./git-artifact.ts";
import { authorOf, type NpmClient, resolveVersion } from "./npm.ts";
import { resolveMetadata } from "./metadata.ts";
import type { PluginRecord } from "./record.ts";

/** What paseo-listing.json in the package may declare. */
export interface ListingFile {
name?: string;
icon?: string;
media?: string[];
}

/** One plugin as the website reads it. */
export interface PublishedPlugin {
id: string;
Expand Down Expand Up @@ -45,123 +37,36 @@ export interface PublishedIndex {
plugins: PublishedPlugin[];
}

const CDN = "https://cdn.jsdelivr.net/npm";

export function packageFileUrl(pkg: string, version: string, path: string): string {
return `${CDN}/${pkg}@${version}/${path.replace(/^\.?\//, "")}`;
}

function assetUrl(pkg: string, version: string, value: string): string {
return /^https:\/\//.test(value) ? value : packageFileUrl(pkg, version, value);
}

export function parseListingFile(text: string | null): ListingFile {
if (text === null) return {};
const raw = JSON.parse(text) as Record<string, unknown>;
const listing: ListingFile = {};
if (typeof raw.name === "string") listing.name = raw.name;
if (typeof raw.icon === "string") listing.icon = raw.icon;
if (raw.media !== undefined) listing.media = parseMedia(raw.media);
return listing;
}

export function humanizeId(id: string): string {
return id
.split("/")
.at(-1)!
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}

/** Pure merge of the record, the pinned npm version, and the package's listing file. */
export function mergeListing(input: {
record: PluginRecord;
doc: VersionDoc;
listingFile: ListingFile;
readme: string | null;
installs?: number;
}): PublishedPluginDetail {
const { record, doc, listingFile } = input;
const author = authorOf(doc);
const media = record.listing?.media ?? listingFile.media ?? [];
const icon = record.listing?.icon ?? listingFile.icon;
return {
id: record.id,
name: record.listing?.name ?? listingFile.name ?? humanizeId(record.id),
description: doc.description?.trim() ?? "",
artifact: record.artifact,
...(doc.license ? { license: doc.license } : {}),
repository: record.repository,
categories: record.categories,
author: { ...author, github: record.id.split("/")[0] },
...(icon ? { icon: assetUrl(doc.name, doc.version, icon) } : {}),
media,
submittedAt: new Date(record.submittedAt).toISOString(),
reviewedAt: new Date(record.reviewedAt).toISOString(),
publishedAt: new Date(record.reviewedAt).toISOString(),
updatedAt: new Date(record.reviewedAt).toISOString(),
...(input.installs !== undefined ? { installs: input.installs } : {}),
readme: input.readme ?? "",
};
}

export async function resolvePlugin(
client: NpmClient,
record: PluginRecord,
overview: string | null = null,
): Promise<PublishedPluginDetail> {
const readme = requireOverview(await readAuthorOverview(client, record), overview, record.id);
if (record.artifact.kind === "git") return resolveGitPlugin(record, readme);
const metadata = await resolveMetadata(client, record);
const artifact = record.artifact;
const packument = await client.packument(artifact.package);
const doc = resolveVersion(packument, artifact.version);
if (doc.dist.integrity !== artifact.integrity || doc.dist.tarball !== artifact.resolved) {
throw new Error(
`${artifact.package}@${artifact.version} integrity on npm differs from the pinned record`,
);
}
const listingFile = parseListingFile(
await client.file(doc.name, doc.version, "paseo-listing.json"),
);
return mergeListing({
record,
doc,
listingFile,
const doc = artifact.kind === "npm"
? resolveVersion(await client.packument(artifact.package), artifact.version)
: undefined;
const date = new Date(record.reviewedAt).toISOString();
return {
id: record.id,
...metadata,
description: doc ? doc.description?.trim() ?? "" : metadata.description,
artifact,
...(doc?.license ? { license: doc.license } : {}),
repository: record.repository,
categories: record.categories,
author: { ...(doc ? authorOf(doc) : {}), github: record.id.split("/")[0] },
submittedAt: new Date(record.submittedAt).toISOString(),
reviewedAt: date,
publishedAt: date,
updatedAt: date,
readme,
});
};
}

export function summarize(detail: PublishedPluginDetail): PublishedPlugin {
const { readme: _readme, ...summary } = detail;
return summary;
}

function resolveGitPlugin(record: PluginRecord, overview: string): PublishedPluginDetail {
const artifact = record.artifact;
if (artifact.kind !== "git") throw new Error("Expected git artifact");
return withGitArtifact(record, (directory) => {
const manifest = JSON.parse(readOptional(directory, "paseo-plugin.json")!);
const listing = parseListingFile(readOptional(directory, "paseo-listing.json"));
const base = `${artifact.remote.replace(/\.git$/, "")}/raw/${artifact.commit}/${artifact.pluginPath ? `${artifact.pluginPath}/` : ""}`;
const asset = (value: string) => (value.startsWith("https://") ? value : `${base}${value}`);
const icon = record.listing?.icon ?? listing.icon;
const date = new Date(record.reviewedAt).toISOString();
return {
id: record.id,
name: record.listing?.name ?? listing.name ?? humanizeId(record.id),
description: manifest.description ?? "",
artifact,
repository: record.repository,
categories: record.categories,
author: { github: record.id.split("/")[0] },
...(icon ? { icon: asset(icon) } : {}),
media: record.listing?.media ?? listing.media ?? [],
submittedAt: new Date(record.submittedAt).toISOString(),
reviewedAt: date,
updatedAt: date,
publishedAt: date,
readme: overview,
};
});
}
Loading
Loading