From 7321d8a4884efe8f7d4b1d977ef5eeb25fdbaf3f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 12 Oct 2026 19:47:02 +0700 Subject: [PATCH] List new plugins by first listing, add Recently updated, show authors on grid cards (#6718) * List new plugins by first listing, add Recently updated, and show authors on grid cards What's new and the Newest tab sort by publishedAt, which the registry now keeps at the first listing. Recently updated lists plugins whose current version was approved after their first listing, on the directory and as a browse tab. Browse and author grid cards show the author's avatar and name like the directory cards. * Rename the card image helper so it does not shadow a test parameter --------- Co-authored-by: paseo-bot[bot] --- packages/website/e2e/plugins.spec.ts | 35 ++++++++- packages/website/e2e/registry.fixture.json | 72 +++++++++---------- packages/website/src/plugins/browse-page.tsx | 63 ++++++++++++---- packages/website/src/plugins/links.ts | 6 +- .../website/src/plugins/overview.test.tsx | 21 ++++-- packages/website/src/plugins/plugin-card.tsx | 25 ++----- .../website/src/plugins/plugin-search.tsx | 2 +- packages/website/src/plugins/registry.test.ts | 65 +++++++++++------ packages/website/src/plugins/registry.ts | 24 +++++-- .../website/src/plugins/thumbnails.test.ts | 19 +++-- packages/website/src/routes/plugins/index.tsx | 41 ++++++++--- 11 files changed, 248 insertions(+), 125 deletions(-) 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>