diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 93da12f78f..346183a201 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -96,7 +96,9 @@ test("browses from the directory into a category, a plugin, and its author", asy await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0); }); -test("ranks plugins on browse pages by installs in a window or by newest", async ({ page }) => { +test("ranks plugins on browse pages by installs in a window, by newest, or by latest update", async ({ + page, +}) => { await page.goto("/plugins/all"); await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); await expect(page.getByRole("link", { name: "Most installed" })).toHaveAttribute( @@ -116,6 +118,21 @@ test("ranks plugins on browse pages by installs in a window or by newest", async await expect(page.getByRole("main").getByRole("link", { name: /Base2Tone/ })).toContainText( "ago", ); + + await page.getByRole("link", { name: "Recently updated" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(/^All plugins\s*2$/); + await expect(page.getByRole("main").getByRole("link", { name: /Defer|OMP/ })).toHaveText([ + /Defer/, + /OMP/, + ]); +}); + +test("shows each plugin's author with their avatar on browse cards", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /Defer/ }); + await expect(card).toContainText("Tom Gringauz"); + await expect(card.locator('img[src="https://github.com/tomgrin10.png?size=80"]')).toHaveCount(1); }); test("filters by search and clears to all plugins", async ({ page }) => { @@ -577,7 +594,9 @@ test.describe("registry fixture layout", () => { test("shows the plugin tile on cards when its media has no image", async ({ page }) => { await page.goto("/plugins/all"); const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ }); - await expect(card.locator("img, video")).toHaveCount(0); + // The only image is the author's avatar. + await expect(card.locator("img, video")).toHaveCount(1); + await expect(card.locator('img[src^="https://github.com/gpambrozio.png"]')).toHaveCount(1); await card.click(); await expect( page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"), @@ -611,6 +630,18 @@ test.describe("registry fixture layout", () => { /Defer/, ]); }); + + test("lists only plugins updated since they were listed under Recently updated", async ({ + page, + }) => { + await openPlugins(page); + await expect(pluginCards(page, "Recently updated")).toHaveText([/Defer/, /OMP/]); + await page + .getByRole("region", { name: "Recently updated" }) + .getByRole("link", { name: "See all" }) + .click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/); + }); }); function galleryTiles(links: Element[]) { diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json index 2f6560780c..6f7cd1b23c 100644 --- a/packages/website/e2e/registry.fixture.json +++ b/packages/website/e2e/registry.fixture.json @@ -84,9 +84,9 @@ "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" ], "submittedAt": "2026-09-24T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-24T00:00:00.000Z", "publishedAt": "2026-09-24T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-24T00:00:00.000Z", "installs": 0, "readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md." }, @@ -117,9 +117,9 @@ "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", "publishedAt": "2026-09-22T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", "installs": 0, "readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks" }, @@ -150,9 +150,9 @@ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions" }, @@ -183,9 +183,9 @@ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel" }, @@ -215,9 +215,9 @@ "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel" }, @@ -247,9 +247,9 @@ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter" }, @@ -277,9 +277,9 @@ }, "media": [], "submittedAt": "2026-09-19T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", "publishedAt": "2026-09-19T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", "installs": 0, "readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it" }, @@ -309,9 +309,9 @@ "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", "publishedAt": "2026-09-22T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", "installs": 0, "readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer." }, @@ -342,9 +342,9 @@ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster." }, @@ -375,9 +375,9 @@ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace." }, @@ -409,9 +409,9 @@ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo." }, @@ -442,9 +442,9 @@ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-18T00:00:00.000Z", "publishedAt": "2026-09-18T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-18T00:00:00.000Z", "installs": 0, "readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees" }, @@ -475,9 +475,9 @@ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-10-03T09:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-10-03T09:00:00.000Z", "installs": 0, "readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling." }, @@ -505,9 +505,9 @@ }, "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces." }, @@ -538,9 +538,9 @@ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-18T00:00:00.000Z", "publishedAt": "2026-09-18T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-18T00:00:00.000Z", "installs": 0, "readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client." }, @@ -568,9 +568,9 @@ }, "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", "publishedAt": "2026-09-17T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", "installs": 0, "readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host." }, @@ -599,9 +599,9 @@ "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-10-03T15:00:00.000Z", "publishedAt": "2026-09-22T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-10-03T15:00:00.000Z", "installs": 0, "readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset." }, @@ -628,9 +628,9 @@ }, "media": [], "submittedAt": "2026-09-22T00:00:00.000Z", - "reviewedAt": "2026-10-03T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", "publishedAt": "2026-09-22T00:00:00.000Z", - "updatedAt": "2026-10-03T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", "installs": 0, "readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo." } diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 274c68a5c0..38b6393662 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -16,7 +16,9 @@ import { mostInstalled, newestFirst, type Plugin, + recentlyUpdated, searchPlugins, + updatedAgo, } from "./registry"; import { WindowSwitch } from "./window-switch"; @@ -34,7 +36,7 @@ const TAB_OFF = `${TAB_BASE} border-transparent text-muted-foreground hover:text const CLEAR_CLASS = "text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground"; -/** All plugins or one category: sidebar, Most installed / Newest tabs, and the card grid. */ +/** All plugins or one category: sidebar, Most installed / Newest / Recently updated tabs, and the card grid. */ export function BrowsePage({ plugins, installs, @@ -47,8 +49,7 @@ export function BrowsePage({ query: BrowseQuery; }) { const category = query.category ? getCategory(query.category) : null; - const ordered = - query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window); + const ordered = sortPlugins(plugins, installs, query); // Search ranks by relevance; the tab's order breaks ties. const matches = query.q ? searchPlugins(ordered, query.q) : ordered; const results = category ? getPluginsInCategory(matches, category.slug) : matches; @@ -89,21 +90,19 @@ export function BrowsePage({ )} {results.length === 0 && !query.q && ( -

No plugins in this category yet.

+

+ {query.sort === "updated" + ? "No plugins have been updated since they were listed." + : "No plugins in this category yet."} +

)} {results.length > 0 && (
- {results.map((plugin) => - query.sort === "new" ? ( - - {addedAgo(plugin, now)} - - ) : ( - - - - ), - )} + {results.map((plugin) => ( + + + + ))}
)} @@ -113,6 +112,33 @@ export function BrowsePage({ ); } +function sortPlugins( + plugins: Plugin[], + installs: Record, + query: BrowseQuery, +): Plugin[] { + if (query.sort === "new") return newestFirst(plugins); + if (query.sort === "updated") return recentlyUpdated(plugins); + return mostInstalled(plugins, installs, query.window); +} + +/** What the card shows beside the author: the date the tab sorts by, or the install count. */ +function CardDetail({ + plugin, + installs, + now, + query, +}: { + plugin: Plugin; + installs: Record; + now: string; + query: BrowseQuery; +}) { + if (query.sort === "new") return addedAgo(plugin, now); + if (query.sort === "updated") return updatedAgo(plugin, now); + return ; +} + interface NavEntry { href: string; label: string; @@ -208,6 +234,13 @@ function SortTabs({ query }: { query: BrowseQuery }) { > Newest + + Recently updated + {windowSwitch &&
{windowSwitch}
} diff --git a/packages/website/src/plugins/links.ts b/packages/website/src/plugins/links.ts index 6e785b4df0..18eab1535f 100644 --- a/packages/website/src/plugins/links.ts +++ b/packages/website/src/plugins/links.ts @@ -5,7 +5,7 @@ export const DOCS_URL = "/docs/plugins"; export const SUBMIT_URL = "https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml"; -export type BrowseSort = "installs" | "new"; +export type BrowseSort = "installs" | "new" | "updated"; export interface BrowseQuery { /** Absent for all plugins. */ category?: CategorySlug; @@ -24,7 +24,7 @@ export function parseSearchTerm(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } export function parseSort(value: unknown): BrowseSort | undefined { - return value === "new" ? value : undefined; + return value === "new" || value === "updated" ? value : undefined; } export function pluginHref(id: string): string { @@ -37,7 +37,7 @@ export function browseHref({ category, sort, window, q }: BrowseQuery): string { const path = category ? categoryHref(category) : "/plugins/all"; const params = new URLSearchParams(); if (q) params.set("q", q); - if (sort === "new") params.set("sort", sort); + if (sort !== "installs") params.set("sort", sort); else if (window !== DEFAULT_WINDOW) params.set("window", window); const search = params.toString(); return search ? `${path}?${search}` : path; diff --git a/packages/website/src/plugins/overview.test.tsx b/packages/website/src/plugins/overview.test.tsx index c9e6591c94..45bccecb37 100644 --- a/packages/website/src/plugins/overview.test.tsx +++ b/packages/website/src/plugins/overview.test.tsx @@ -1,7 +1,7 @@ import registry from "../../e2e/registry.fixture.json"; import { PluginTile } from "./plugin-tile"; import { PluginCard } from "./plugin-card"; -import type { Plugin } from "./registry"; +import { authorAvatarUrl, getAuthor, type Plugin } from "./registry"; import React, { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { parseFragment, type DefaultTreeAdapterMap } from "parse5"; @@ -25,6 +25,15 @@ function render(markdown: string) { function attribute(element: Element, name: string) { return element.attrs.find((attr) => attr.name === name)?.value; } +function imageSources(html: string) { + return elements(html) + .filter((node) => node.tagName === "img") + .map((node) => attribute(node, "src")); +} +/** Cards show the author's GitHub avatar beside the plugin's own images. */ +function avatarUrl(plugin: Plugin) { + return authorAvatarUrl(getAuthor(plugin)); +} const allowedTags = new Set([ "div", "p", @@ -118,7 +127,7 @@ describe("registry images", () => { createElement(PluginCard, { plugin }), ]) { expect( - elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"), + imageSources(renderToStaticMarkup(component)).filter((src) => src !== avatarUrl(plugin)), ).toEqual([]); } }); @@ -131,8 +140,8 @@ it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () icon: undefined, media: ["data:image/png;base64,AAAA", source], } as Plugin; - const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin }))); - expect( - nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")), - ).toEqual([source]); + expect(imageSources(renderToStaticMarkup(createElement(PluginCard, { plugin })))).toEqual([ + source, + avatarUrl(plugin), + ]); }); diff --git a/packages/website/src/plugins/plugin-card.tsx b/packages/website/src/plugins/plugin-card.tsx index b68ae654db..543876afbe 100644 --- a/packages/website/src/plugins/plugin-card.tsx +++ b/packages/website/src/plugins/plugin-card.tsx @@ -35,13 +35,13 @@ function PluginShot({ plugin }: { plugin: Plugin }) { } /** - * What's new and Featured card, in three rows under the screenshot: tile and name, description, - * then author and `children`, such as when the plugin was added or its install count. + * Gallery card, in three rows under the screenshot: tile and name, description, then author and + * `children`, such as when the plugin was added or its install count. */ -export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) { +export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) { const author = getAuthor(plugin); return ( - +
@@ -64,23 +64,6 @@ export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ); } -/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */ -export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) { - return ( - - -
- -

{plugin.name}

- {children} -
-

- {plugin.description} -

-
- ); -} - /** Ranked row for the directory's Most installed list. */ export function PluginRankRow({ plugin, diff --git a/packages/website/src/plugins/plugin-search.tsx b/packages/website/src/plugins/plugin-search.tsx index ab648bbd58..400341d5a4 100644 --- a/packages/website/src/plugins/plugin-search.tsx +++ b/packages/website/src/plugins/plugin-search.tsx @@ -76,7 +76,7 @@ export function PluginSearch({ onSubmit={handleSubmit} className={className} > - {scope.sort === "new" && } + {scope.sort !== "installs" && } {scope.sort === "installs" && scope.window !== DEFAULT_WINDOW && ( )} diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts index 9dd132f1b5..cf3af56794 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -10,7 +10,9 @@ import { newestFirst, type Plugin, readmeBody, + recentlyUpdated, searchPlugins, + updatedAgo, } from "./registry"; import { readInstallCounts, recordInstall } from "./installs"; import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published"; @@ -76,38 +78,57 @@ describe("plugin registry", () => { ]); expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]); }); - it("lists what's new by submission time, not review, listing or update dates", () => { - const submittedFirst = { + it("lists what's new by first listing time, so neither a slow review nor an update moves a plugin", () => { + const listedFirst = { ...plugin, - id: "acme/submitted-first", - submittedAt: "2026-09-02T00:00:00.000Z", + id: "acme/listed-first", + submittedAt: "2026-09-30T00:00:00.000Z", + publishedAt: "2026-09-30T06:00:00.000Z", reviewedAt: "2026-10-02T00:00:00.000Z", updatedAt: "2026-10-02T00:00:00.000Z", - publishedAt: "2026-10-02T00:00:00.000Z", }; - const submittedLater = { + const listedLater = { ...plugin, - id: "acme/submitted-later", - submittedAt: "2026-09-30T00:00:00.000Z", - reviewedAt: "2026-09-30T00:00:00.000Z", - updatedAt: "2026-09-30T00:00:00.000Z", - publishedAt: "2026-09-30T00:00:00.000Z", + id: "acme/listed-later", + submittedAt: "2026-09-02T00:00:00.000Z", + publishedAt: "2026-09-30T08:00:00.000Z", + reviewedAt: "2026-09-30T08:00:00.000Z", + updatedAt: "2026-09-30T08:00:00.000Z", }; - expect(newestFirst([submittedFirst, submittedLater]).map((p) => p.id)).toEqual([ - "acme/submitted-later", - "acme/submitted-first", + expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([ + "acme/listed-later", + "acme/listed-first", ]); const now = "2026-10-03T12:00:00.000Z"; - expect(addedAgo(submittedLater, now)).toBe("3d ago"); - expect(addedAgo(submittedFirst, now)).toBe("4w ago"); + expect(addedAgo(listedLater, now)).toBe("3d ago"); + expect(updatedAgo(listedFirst, now)).toBe("yesterday"); }); - it("orders same-day submissions by their time", () => { - const morning = { ...plugin, id: "acme/morning", submittedAt: "2026-10-12T00:00:00.000Z" }; - const noon = { ...plugin, id: "acme/noon", submittedAt: "2026-10-12T11:38:00.000Z" }; - expect(newestFirst([morning, noon]).map((p) => p.id)).toEqual(["acme/noon", "acme/morning"]); + it("lists only plugins updated after their first listing as recently updated, latest update first", () => { + const bumped = { + ...plugin, + id: "acme/bumped", + publishedAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-10-01T09:00:00.000Z", + }; + const bumpedLater = { + ...plugin, + id: "acme/bumped-later", + publishedAt: "2026-09-20T00:00:00.000Z", + updatedAt: "2026-10-01T17:00:00.000Z", + }; + const neverUpdated = { + ...plugin, + id: "acme/never-updated", + publishedAt: "2026-10-02T00:00:00.000Z", + updatedAt: "2026-10-02T00:00:00.000Z", + }; + expect(recentlyUpdated([bumped, neverUpdated, bumpedLater]).map((p) => p.id)).toEqual([ + "acme/bumped-later", + "acme/bumped", + ]); }); - it("orders by submission time and by installs in a window, keeping index order for ties", () => { - const older = { ...plugin, id: "acme/older", submittedAt: "2026-09-01" }; + it("orders by first listing time and by installs in a window, keeping index order for ties", () => { + const older = { ...plugin, id: "acme/older", publishedAt: "2026-09-01" }; const twin = { ...plugin, id: "acme/twin" }; expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([ "acme/example", diff --git a/packages/website/src/plugins/registry.ts b/packages/website/src/plugins/registry.ts index c55741ecdc..df8dfb4ca6 100644 --- a/packages/website/src/plugins/registry.ts +++ b/packages/website/src/plugins/registry.ts @@ -106,9 +106,18 @@ export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); return [...new Set(featured)].flatMap((id) => byId.get(id) ?? []); } -/** Most recently submitted first, so updates never resurface a plugin; equal times keep index order. */ +/** + * Most recently listed first. A slow review does not push a plugin down, and an update does not + * bring it back up. Equal times keep index order. + */ export function newestFirst(plugins: Plugin[]): Plugin[] { - return [...plugins].sort((a, b) => Date.parse(b.submittedAt) - Date.parse(a.submittedAt)); + return [...plugins].sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt)); +} +/** Plugins with a version approved after their first listing, most recent update first. */ +export function recentlyUpdated(plugins: Plugin[]): Plugin[] { + return plugins + .filter((plugin) => Date.parse(plugin.updatedAt) > Date.parse(plugin.publishedAt)) + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); } /** Most installs in the window first; equal counts keep index order. */ export function mostInstalled( @@ -119,9 +128,16 @@ export function mostInstalled( const count = (plugin: Plugin) => installs[plugin.id]?.[window] ?? 0; return [...plugins].sort((a, b) => count(b) - count(a)); } -/** How long ago the plugin was submitted, relative to the loader's clock. */ +/** How long ago the plugin was first listed, relative to the loader's clock. */ export function addedAgo(plugin: Plugin, now: string): string { - const days = Math.floor((Date.parse(now) - Date.parse(plugin.submittedAt)) / DAY_MS); + return ago(plugin.publishedAt, now); +} +/** How long ago the plugin's current version was approved, relative to the loader's clock. */ +export function updatedAgo(plugin: Plugin, now: string): string { + return ago(plugin.updatedAt, now); +} +function ago(date: string, now: string): string { + const days = Math.floor((Date.parse(now) - Date.parse(date)) / DAY_MS); if (days <= 0) return "today"; if (days === 1) return "yesterday"; if (days < 14) return `${days}d ago`; diff --git a/packages/website/src/plugins/thumbnails.test.ts b/packages/website/src/plugins/thumbnails.test.ts index fb9a571b93..aaaaf47418 100644 --- a/packages/website/src/plugins/thumbnails.test.ts +++ b/packages/website/src/plugins/thumbnails.test.ts @@ -2,7 +2,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails"; -import { NewPluginCard, PluginCard } from "./plugin-card"; +import { PluginCard } from "./plugin-card"; import type { Plugin } from "./registry"; const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png"; @@ -29,6 +29,8 @@ const plugin: Plugin = { installs: 42, }; +const avatar = 'src="https://github.com/acme.png?size=80"'; + describe("plugin card thumbnails", () => { it("uses the first image in the plugin's media, skipping a leading video", () => { const html = renderToStaticMarkup( @@ -42,7 +44,7 @@ describe("plugin card thumbnails", () => { const html = renderToStaticMarkup( createElement(PluginCard, { plugin: { ...plugin, media: [video] } }), ); - expect(html).not.toContain("]*>/g)).toEqual([expect.stringContaining(avatar)]); expect(html).not.toContain("E
"); }); @@ -56,11 +58,14 @@ describe("plugin card thumbnails", () => { expect(html).not.toContain("/plugins/thumb/"); }); - it.each([ - ["PluginCard", () => createElement(PluginCard, { plugin }, "today")], - ["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")], - ])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => { - const html = renderToStaticMarkup(card()); + it("shows the author's avatar and name beside the card detail", () => { + const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today")); + expect(html).toContain(avatar); + expect(html).toMatch(/>acme<\/span>