Serve paseo.sh in Simplified Chinese (#7373)

* feat(website): serve paseo.sh in Simplified Chinese

Every page of the website now has a Simplified Chinese version under
/zh-CN: the interface, docs, blog posts, comparison pages, legal pages,
all changelog releases and the homepage mockup. English URLs are unchanged.

The URL alone decides the language. There is no browser-language redirect
and no stored preference, because either would have to redirect the
unprefixed English URLs that people share and search engines index.
Internal links carry the page's language instead, and hreflang alternates
point Chinese search results at /zh-CN.

Translated headings pin the English anchor with {#id}, so section links
work in both languages. content-translations.test.ts fails when a page
has no translation or a translation's headings drift from the English.
Changelog releases and plugin listings without a translation render
whole in English under a notice, so a release never waits on one.

Plugin manifests and the registry gain optional translations and
OVERVIEW.zh-CN.md fields; older clients and registries ignore them.

* feat(website): replace the native language select with an EN/ZH menu

The header showed an OS-native select whose popup did not match the site.
The trigger now shows the language abbreviated (EN, ZH) and opens a menu in
the app's dropdown style that names each language in itself, with its name
in the page language underneath. Rows are real links to the same page in
that language; arrows, Escape, outside presses and taps behave as a menu
button, and the menu keeps itself on screen when a narrow header pushes the
trigger toward an edge.

Also drops the stray space after the full-width comma in the Chinese
homepage subtitle.

* docs(website): translate main's new docs and 0.12.0 release notes

Main added Keep run workspaces to the schedules docs, rewrote plugin icons
around inline SVG, and promoted 0.12.0 with new entries. Bring the Chinese
pages and changelog in line so no section falls back to English.

* fix(server): leave plugin listing translations to the registry

The daemon rejected manifests whose translations the registry would refuse,
so a plugin main installs could fail to load here. Paseo never reads
translations; the registry validates them at publish. Docs now say which
checks are automated at publish and which are human review.

* docs(plugins): describe the registry's final overview translation discovery

* style(server): format manifest test

* test(app): measure sidebar rows one at a time

After a reload the developer error toast appears while expectVerticalOrder
measures every row at once. Each concurrent boundingBox runs the toast
locator handler, and only one of them resumes, so the poll timed out on
every attempt since #7424. Measuring sequentially keeps one action in flight.

* test(app): return sidebar row measuring to main

#7480 hides the developer toast instead of running a locator handler, so
measuring the rows concurrently no longer races it. Reverts 41ac23016.

* test(app): wait for the compact shell before opening compact Changes

The shell survives the breakpoint, so right after the resize the wide
Explorer could still be visible: the test skipped the Explorer toggle and
the tab then disappeared, or toggled at the wrong time. Wait for the
compact-shell marker, as the composer helpers do since #7424.

* docs(website): translate main's plugin file panel docs

* test(app): edit the code font size after the font family remount

A committed appearance change remounts the settings screen through
AppearanceStyleBoundary, and the new form starts from the stored size.
Filling the size before that remount lost the edit in CI.

* docs(website): translate main's client/ui import docs

Follows #7474: one /client/ui entry, StreamedText instead of useRevealedText,
SettingsGroup dropped from the settings table, and the new
'UI imports in v0.13' migration section.

* test(app): wait for the previous layout's file panel picker to leave

After a resize the old layout's picker animates out while the new one is
already mounted, so the Use button matched two elements and the strict
locator failed. Wait for the shell's layout and a single picker first.
This commit is contained in:
Mohamed Boudra
2026-10-21 13:13:03 +07:00
committed by GitHub
parent c70a6cfb9e
commit bd15e40e94
266 changed files with 22495 additions and 3863 deletions
+5 -3
View File
@@ -1,5 +1,7 @@
import { Link } from "@tanstack/react-router";
import { type ReactNode, useMemo } from "react";
import type { ReactNode } from "react";
import { useLocalizePath } from "~/i18n";
import { pluginOwnerHref } from "./links";
import { type Author, authorAvatarUrl } from "./registry";
const SIZE_CLASS = {
@@ -38,9 +40,9 @@ export function AuthorLink({
className?: string;
children?: ReactNode;
}) {
const params = useMemo(() => ({ owner: author.username }), [author.username]);
const localize = useLocalizePath();
return (
<Link to="/plugins/$owner" params={params} className={className}>
<Link to={localize(pluginOwnerHref(author.username))} className={className}>
{children ?? (
<>
<AuthorAvatar author={author} size="sm" />
+66 -35
View File
@@ -1,5 +1,8 @@
import type { TFunction } from "i18next";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SiteShell } from "~/components/site-shell";
import { useLocale, useLocalizePath } from "~/i18n";
import { ContributeLinks } from "./contribute-links";
import type { InstallCounts } from "./installs";
import { type BrowseQuery, browseHref } from "./links";
@@ -48,13 +51,17 @@ export function BrowsePage({
now: string;
query: BrowseQuery;
}) {
const { t } = useTranslation();
const locale = useLocale();
const localize = useLocalizePath();
const category = query.category ? getCategory(query.category) : null;
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;
const title = category?.label ?? "All plugins";
const clearHref = browseHref({ ...query, q: undefined });
const matches = query.q ? searchPlugins(ordered, query.q, locale) : ordered;
const results = category ? getPluginsInCategory(matches, category) : matches;
const categoryLabel = category ? t(`plugins.categories.${category}.label`) : undefined;
const title = categoryLabel ?? t("plugins.allPlugins");
const clearHref = localize(browseHref({ ...query, q: undefined }));
return (
<SiteShell width="wide">
<div className="lg:flex lg:gap-12">
@@ -65,14 +72,14 @@ export function BrowsePage({
<div className="min-w-0 flex-1">
<div className="mb-6 flex items-baseline justify-between gap-4">
<h1 className="text-3xl font-medium tracking-tight">
{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title}
{resultsTitle({ term: query.q, categoryLabel, title, t })}
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
{results.length}
</span>
</h1>
{query.q && (
<a href={clearHref} className={CLEAR_CLASS}>
Clear
{t("plugins.clear")}
</a>
)}
</div>
@@ -80,20 +87,18 @@ export function BrowsePage({
<SortTabs query={query} />
{results.length === 0 && query.q && (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">No plugins match.</p>
<p className="text-sm text-muted-foreground">{t("plugins.noMatches")}</p>
<a
href={clearHref}
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Clear filters
{t("plugins.clearFilters")}
</a>
</div>
)}
{results.length === 0 && !query.q && (
<p className="text-sm text-muted-foreground">
{query.sort === "updated"
? "No plugins have been updated since they were listed."
: "No plugins in this category yet."}
{query.sort === "updated" ? t("plugins.noneUpdated") : t("plugins.noneInCategory")}
</p>
)}
{results.length > 0 && (
@@ -112,6 +117,24 @@ export function BrowsePage({
);
}
/** The heading: the category or all plugins, or the search term and where it searched. */
function resultsTitle({
term,
categoryLabel,
title,
t,
}: {
term: string | undefined;
categoryLabel: string | undefined;
title: string;
t: TFunction;
}): string {
if (!term) return title;
return categoryLabel
? t("plugins.resultsInCategory", { term, category: categoryLabel })
: t("plugins.results", { term });
}
function sortPlugins(
plugins: Plugin[],
installs: Record<string, InstallCounts>,
@@ -134,8 +157,9 @@ function CardDetail({
now: string;
query: BrowseQuery;
}) {
if (query.sort === "new") return addedAgo(plugin, now);
if (query.sort === "updated") return updatedAgo(plugin, now);
const { t } = useTranslation();
if (query.sort === "new") return addedAgo(plugin, now, t);
if (query.sort === "updated") return updatedAgo(plugin, now, t);
return <InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />;
}
@@ -150,26 +174,28 @@ interface NavEntry {
/** Category sidebar on wide screens, a row of chips on narrow ones. Counts follow the search. */
function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery }) {
const { t } = useTranslation();
const localize = useLocalizePath();
const entries: NavEntry[] = [
{
href: browseHref({ ...query, category: undefined }),
label: "All plugins",
chip: "All",
href: localize(browseHref({ ...query, category: undefined })),
label: t("plugins.allPlugins"),
chip: t("plugins.allChip"),
count: plugins.length,
},
...CATEGORIES.map((category) => ({
href: browseHref({ ...query, category: category.slug }),
label: category.label,
chip: category.label,
slug: category.slug,
count: getPluginsInCategory(plugins, category.slug).length,
href: localize(browseHref({ ...query, category })),
label: t(`plugins.categories.${category}.label`),
chip: t(`plugins.categories.${category}.label`),
slug: category,
count: getPluginsInCategory(plugins, category).length,
})),
];
return (
<>
<nav aria-label="Categories" className="-ml-2.5 hidden lg:block">
<nav aria-label={t("plugins.categoriesTitle")} className="-ml-2.5 hidden lg:block">
{entries.map((entry, index) => (
<div key={entry.label}>
<div key={entry.href}>
<a
href={entry.href}
aria-current={query.category === entry.slug ? "page" : undefined}
@@ -188,10 +214,13 @@ function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery
<ContributeLinks />
</div>
</nav>
<nav aria-label="Categories" className="-mx-6 flex gap-2 overflow-x-auto px-6 pb-1 lg:hidden">
<nav
aria-label={t("plugins.categoriesTitle")}
className="-mx-6 flex gap-2 overflow-x-auto px-6 pb-1 lg:hidden"
>
{entries.map((entry) => (
<a
key={entry.label}
key={entry.href}
href={entry.href}
aria-current={query.category === entry.slug ? "page" : undefined}
className={query.category === entry.slug ? CHIP_ON : CHIP_OFF}
@@ -205,13 +234,15 @@ function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery
}
function SortTabs({ query }: { query: BrowseQuery }) {
const { t } = useTranslation();
const localize = useLocalizePath();
const windowHrefs = useMemo(
() => ({
week: browseHref({ ...query, window: "week" }),
month: browseHref({ ...query, window: "month" }),
all: browseHref({ ...query, window: "all" }),
week: localize(browseHref({ ...query, window: "week" })),
month: localize(browseHref({ ...query, window: "month" })),
all: localize(browseHref({ ...query, window: "all" })),
}),
[query],
[localize, query],
);
const windowSwitch = query.sort === "installs" && (
<WindowSwitch current={query.window} hrefs={windowHrefs} />
@@ -221,25 +252,25 @@ function SortTabs({ query }: { query: BrowseQuery }) {
<div className="flex items-baseline justify-between gap-6 border-b border-white/10">
<div className="flex gap-6">
<a
href={browseHref({ ...query, sort: "installs" })}
href={localize(browseHref({ ...query, sort: "installs" }))}
aria-current={query.sort === "installs" ? "page" : undefined}
className={query.sort === "installs" ? TAB_ON : TAB_OFF}
>
Most installed
{t("plugins.mostInstalled")}
</a>
<a
href={browseHref({ ...query, sort: "new" })}
href={localize(browseHref({ ...query, sort: "new" }))}
aria-current={query.sort === "new" ? "page" : undefined}
className={query.sort === "new" ? TAB_ON : TAB_OFF}
>
Newest
{t("plugins.newest")}
</a>
<a
href={browseHref({ ...query, sort: "updated" })}
href={localize(browseHref({ ...query, sort: "updated" }))}
aria-current={query.sort === "updated" ? "page" : undefined}
className={query.sort === "updated" ? TAB_ON : TAB_OFF}
>
Recently updated
{t("plugins.recentlyUpdated")}
</a>
</div>
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
+11 -49
View File
@@ -1,52 +1,14 @@
/** Category slugs in display order. Labels and descriptions are site copy under `plugins.categories`. */
export const CATEGORIES = [
{
slug: "daemon-management",
label: "Daemon management",
description: "Manage hosts, daemon jobs, resources, approvals, and MCP servers.",
},
{
slug: "themes",
label: "Themes",
description: "Change the app color scheme.",
},
{
slug: "providers",
label: "Providers",
description:
"Connect coding agents, ACP servers, and model routers, with account and quota fallback.",
},
{
slug: "orchestration",
label: "Orchestration",
description: "Coordinate agents, delegate work, and send messages across hosts.",
},
{
slug: "git",
label: "Git",
description: "Work with branches, worktrees, pull requests, stacks, diffs, and reviews.",
},
{
slug: "workspaces",
label: "Workspaces",
description: "Add workspace panels and tabs for boards, browsers, documents, and terminals.",
},
{
slug: "sidebar",
label: "Sidebar",
description: "Add sidebar items and panels for usage, notifications, pricing, and links.",
},
{
slug: "extras",
label: "Extras",
description: "Add presence, pets, puzzles, wellbeing reminders, and demos.",
},
{
slug: "utils",
label: "Utils",
description:
"Extend the composer and timeline with commands, prompts, skills, and small helpers.",
},
"daemon-management",
"themes",
"providers",
"orchestration",
"git",
"workspaces",
"sidebar",
"extras",
"utils",
] as const;
export type CategorySlug = (typeof CATEGORIES)[number]["slug"];
export type Category = (typeof CATEGORIES)[number];
export type CategorySlug = (typeof CATEGORIES)[number];
@@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { useLocalizePath } from "~/i18n";
import { DOCS_URL, SUBMIT_URL } from "./links";
const LINK_CLASS =
@@ -9,13 +11,15 @@ const SECONDARY_BUTTON_CLASS =
/** "Read the docs" and "Submit a plugin", as two quiet links. */
export function ContributeLinks() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<>
<a href={DOCS_URL} className={LINK_CLASS}>
Read the docs
<a href={localize(DOCS_URL)} className={LINK_CLASS}>
{t("plugins.readDocs")}
</a>
<a href={SUBMIT_URL} className={LINK_CLASS}>
Submit a plugin
{t("plugins.submit")}
</a>
</>
);
@@ -23,13 +27,15 @@ export function ContributeLinks() {
/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */
export function ContributeButtons() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<>
<a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}>
Submit a plugin
{t("plugins.submit")}
</a>
<a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}>
Read the docs
<a href={localize(DOCS_URL)} className={SECONDARY_BUTTON_CLASS}>
{t("plugins.readDocs")}
</a>
</>
);
@@ -37,17 +43,17 @@ export function ContributeButtons() {
/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */
export function ContributeSection() {
const { t } = useTranslation();
return (
<section
aria-labelledby="build-your-own"
className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 text-center sm:px-8"
>
<h2 id="build-your-own" className="text-lg font-medium">
Build your own plugin
{t("plugins.buildTitle")}
</h2>
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one
with the plugin docs, then submit it to get it listed here.
{t("plugins.buildDescription")}
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<ContributeButtons />
+8 -7
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { ContributeButtons } from "./contribute-links";
import type { BrowseQuery } from "./links";
import { PluginSearch } from "./plugin-search";
@@ -9,22 +10,22 @@ interface PluginsHeroProps {
searchScope: BrowseQuery;
}
function plural(count: number, word: string): string {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */
export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) {
const { t } = useTranslation();
return (
<header>
<h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-3xl font-medium tracking-tight">Plugins</span>
<span className="text-3xl font-medium tracking-tight">{t("plugins.title")}</span>
<span className="text-sm font-normal tabular-nums text-extra-muted-foreground">
{plural(pluginCount, "plugin")} by {plural(authorCount, "author")}
{t("plugins.counts", {
plugins: t("plugins.pluginCount", { count: pluginCount }),
authors: t("plugins.authorCount", { count: authorCount }),
})}
</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground">
Browse community plugins that extend Paseo with new functionality.
{t("plugins.lead")}
</p>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-3">
@@ -1,13 +1,15 @@
import { Download } from "lucide-react";
import { useTranslation } from "react-i18next";
import { formatInstalls } from "./registry";
/** An install count as a download icon and the number; inherits the surrounding text style. */
export function InstallCount({ count }: { count: number }) {
const { t } = useTranslation();
return (
<span className="relative inline-flex items-center gap-1 tabular-nums">
<Download aria-hidden className="h-3 w-3" />
{formatInstalls(count)}
<span className="sr-only">installs</span>
<span className="sr-only">{t("plugins.installs")}</span>
</span>
);
}
+3
View File
@@ -30,6 +30,9 @@ export function parseSort(value: unknown): BrowseSort | undefined {
export function pluginHref(id: string): string {
return `/plugins/${id}`;
}
export function pluginOwnerHref(owner: string): string {
return `/plugins/${owner}`;
}
export function categoryHref(category: CategorySlug): string {
return `/plugins/category/${category}`;
}
+24 -11
View File
@@ -1,5 +1,6 @@
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry";
import type { TFunction } from "i18next";
import { ChevronLeft, ChevronRight, Play, X } from "lucide-react";
import {
type CSSProperties,
@@ -11,6 +12,7 @@ import {
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
const TILE_CLASS =
"relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%]";
@@ -34,10 +36,11 @@ interface MediaItem {
/** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */
export function MediaGallery({ name, media }: { name: string; media: string[] }) {
const { t } = useTranslation();
const dialog = useRef<HTMLDialogElement>(null);
const entries = useMemo(
() => media.map((url, index) => mediaEntry(name, url, index)),
[name, media],
() => media.map((url, index) => mediaEntry({ name, url, index, t })),
[name, media, t],
);
const items = useMemo(() => entries.filter((entry) => typeof entry !== "string"), [entries]);
const [viewing, setViewing] = useState<number | null>(null);
@@ -104,7 +107,7 @@ export function MediaGallery({ name, media }: { name: string; media: string[] })
<>
<button
type="button"
aria-label="Previous"
aria-label={t("plugins.previous")}
onClick={showPrevious}
className={`${VIEWER_BUTTON_CLASS} left-4 top-1/2 -translate-y-1/2`}
>
@@ -112,7 +115,7 @@ export function MediaGallery({ name, media }: { name: string; media: string[] })
</button>
<button
type="button"
aria-label="Next"
aria-label={t("plugins.next")}
onClick={showNext}
className={`${VIEWER_BUTTON_CLASS} right-4 top-1/2 -translate-y-1/2`}
>
@@ -122,7 +125,7 @@ export function MediaGallery({ name, media }: { name: string; media: string[] })
)}
<button
type="button"
aria-label="Close"
aria-label={t("common.close")}
onClick={close}
className={`${VIEWER_BUTTON_CLASS} right-4 top-4`}
>
@@ -134,15 +137,25 @@ export function MediaGallery({ name, media }: { name: string; media: string[] })
}
/** A registry media entry as a gallery item, or the raw entry when it is not a safe URL. */
function mediaEntry(name: string, url: string, index: number): MediaItem | string {
function mediaEntry({
name,
url,
index,
t,
}: {
name: string;
url: string;
index: number;
t: TFunction;
}): MediaItem | string {
const source = pluginOverviewUrl(url);
if (!source) return url;
const kind = pluginMediaKind(source);
return {
url: source,
kind,
label: `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`,
};
const label = t(kind === "video" ? "plugins.video" : "plugins.screenshot", {
name,
index: index + 1,
});
return { url: source, kind, label };
}
/** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */
+19 -8
View File
@@ -1,20 +1,31 @@
import { Link } from "@tanstack/react-router";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Breadcrumbs } from "~/components/breadcrumbs";
import { SiteShell } from "~/components/site-shell";
import { useLocalizePath } from "~/i18n";
const NOT_FOUND_CRUMBS = [{ label: "Plugins", href: "/plugins" }, { label: "Not found" }];
/** What was not found; each has a title and an explanation under `plugins.notFound`. */
export type PluginsNotFoundKind = "author" | "plugin" | "category";
export function PluginsNotFound({ title, children }: { title: string; children: string }) {
export function PluginsNotFound({ kind }: { kind: PluginsNotFoundKind }) {
const { t } = useTranslation();
const localize = useLocalizePath();
const crumbs = useMemo(
() => [{ label: t("plugins.title"), href: "/plugins" }, { label: t("plugins.notFound.crumb") }],
[t],
);
return (
<SiteShell width="default">
<Breadcrumbs items={NOT_FOUND_CRUMBS} />
<h1 className="text-3xl font-medium tracking-tight mb-4">{title}</h1>
<Breadcrumbs items={crumbs} />
<h1 className="text-3xl font-medium tracking-tight mb-4">
{t(`plugins.notFound.${kind}Title`)}
</h1>
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">
{children}{" "}
<Link to="/plugins" className="underline hover:text-white">
Browse all plugins
{t(`plugins.notFound.${kind}Body`)}{" "}
<Link to={localize("/plugins")} className="underline hover:text-white">
{t("plugins.notFound.browseAll")}
</Link>
.
</p>
</SiteShell>
);
+19 -6
View File
@@ -1,8 +1,14 @@
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import registry from "../../e2e/registry.fixture.json";
import { PluginTile } from "./plugin-tile";
import { PluginCard } from "./plugin-card";
import { authorAvatarUrl, getAuthor, type Plugin } from "./registry";
import React, { createElement } from "react";
import React, { createElement, type ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
import { describe, expect, it } from "vitest";
@@ -20,7 +26,14 @@ function elements(html: string): Element[] {
return result;
}
function render(markdown: string) {
return renderToStaticMarkup(<PluginOverview>{markdown}</PluginOverview>);
return renderToStaticMarkup(<PluginOverview lang="en">{markdown}</PluginOverview>);
}
/** Renders an element on an English page, as the site's route components do. */
async function renderPage(element: ReactElement): Promise<string> {
const routeTree = createRootRoute({ component: () => element });
const router = createRouter({ routeTree, history: createMemoryHistory() });
await router.load();
return renderToStaticMarkup(<RouterProvider router={router} />);
}
function attribute(element: Element, name: string) {
return element.attrs.find((attr) => attr.name === name)?.value;
@@ -120,27 +133,27 @@ describe("registry images", () => {
"vbscript:msgbox(1)",
"./icon.png",
"//example.com/x",
])("does not load %s in cards or icons", (url) => {
])("does not load %s in cards or icons", async (url) => {
const plugin = { ...registry.plugins[0], icon: url, media: [url] } as Plugin;
for (const component of [
createElement(PluginTile, { plugin, size: "sm" }),
createElement(PluginCard, { plugin }),
]) {
expect(
imageSources(renderToStaticMarkup(component)).filter((src) => src !== avatarUrl(plugin)),
imageSources(await renderPage(component)).filter((src) => src !== avatarUrl(plugin)),
).toEqual([]);
}
});
});
it("uses the first HTTPS screenshot when an earlier screenshot is rejected", () => {
it("uses the first HTTPS screenshot when an earlier screenshot is rejected", async () => {
const source = "https://example.com/screenshot.png";
const plugin = {
...registry.plugins[0],
icon: undefined,
media: ["data:image/png;base64,AAAA", source],
} as Plugin;
expect(imageSources(renderToStaticMarkup(createElement(PluginCard, { plugin })))).toEqual([
expect(imageSources(await renderPage(createElement(PluginCard, { plugin })))).toEqual([
source,
avatarUrl(plugin),
]);
+3 -2
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import type { Locale } from "~/i18n";
/** A rejected destination leaves its label as text, without even an empty anchor. */
export function PluginContentLink({
@@ -38,9 +39,9 @@ const components: Components = {
};
/** Untrusted overviews do not inherit documentation directives, anchors, or HTML highlighters. */
export function PluginOverview({ children }: { children: string }) {
export function PluginOverview({ children, lang }: { children: string; lang: Locale }) {
return (
<div className="docs-prose">
<div className="docs-prose" lang={lang}>
<ReactMarkdown
skipHtml={!pluginOverviewPolicy.rawHtml}
allowedElements={allowedElements}
+15 -7
View File
@@ -1,10 +1,12 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLocale, useLocalizePath } from "~/i18n";
import { AuthorAvatar } from "./author-link";
import { InstallCount } from "./install-count";
import { pluginCardScreenshot } from "./thumbnails";
import { pluginHref } from "./links";
import { PluginTile } from "./plugin-tile";
import { firstMediaImage, getAuthor, getCategory, type Plugin } from "./registry";
import { firstMediaImage, getAuthor, getCategory, type Plugin, pluginListing } from "./registry";
export const PLUGIN_GRID_CLASS =
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
@@ -40,16 +42,18 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
*/
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
const author = getAuthor(plugin);
const listing = pluginListing(plugin, useLocale());
const localize = useLocalizePath();
return (
// Grid items size to their content by default; min-w-0 keeps the card in its column.
<a href={pluginHref(plugin.id)} className="group block min-w-0">
<a href={localize(pluginHref(plugin.id))} className="group block min-w-0">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p>
<p className="min-w-0 truncate text-sm font-medium text-white">{listing.name}</p>
</div>
<p className="mt-2 line-clamp-2 min-h-10 break-words text-xs leading-5 text-muted-foreground">
{plugin.description}
{listing.description}
</p>
<p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground">
<AuthorAvatar author={author} size="xs" />
@@ -75,17 +79,21 @@ export function PluginRankRow({
rank: number;
installs: number;
}) {
const { t } = useTranslation();
const listing = pluginListing(plugin, useLocale());
const localize = useLocalizePath();
const category = getCategory(plugin.categories[0]);
return (
<a
href={pluginHref(plugin.id)}
href={localize(pluginHref(plugin.id))}
className="flex items-center gap-3 rounded-lg px-4 py-2.5 transition-colors hover:bg-white/[0.04]"
>
<span className="text-sm tabular-nums text-extra-muted-foreground">{rank}</span>
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{plugin.name}</p>
<p className="truncate text-sm text-white">{listing.name}</p>
<p className="truncate text-xs text-extra-muted-foreground">
{getCategory(plugin.categories[0])?.label}
{category && t(`plugins.categories.${category}.label`)}
</p>
</div>
<span className="text-xs text-muted-foreground">
+15 -6
View File
@@ -1,6 +1,8 @@
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Search, X } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocalizePath } from "~/i18n";
import { type BrowseQuery, browseHref, DEFAULT_WINDOW, parseSearchTerm } from "./links";
const ICON_CLASS = "h-3.5 w-3.5 text-extra-muted-foreground";
@@ -20,6 +22,8 @@ export function PluginSearch({
live?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const localize = useLocalizePath();
const navigate = useNavigate();
const router = useRouter();
const input = useRef<HTMLInputElement>(null);
@@ -35,9 +39,12 @@ export function PluginSearch({
);
const show = useCallback(
(next: string, { replace }: { replace: boolean }) => {
void navigate({ href: browseHref({ ...scope, q: next.trim() ? next : undefined }), replace });
void navigate({
href: localize(browseHref({ ...scope, q: next.trim() ? next : undefined })),
replace,
});
},
[navigate, scope],
[localize, navigate, scope],
);
const edit = useCallback(
(next: string) => {
@@ -72,7 +79,9 @@ export function PluginSearch({
<form
role="search"
method="get"
action={browseHref({ ...scope, q: undefined, sort: "installs", window: DEFAULT_WINDOW })}
action={localize(
browseHref({ ...scope, q: undefined, sort: "installs", window: DEFAULT_WINDOW }),
)}
onSubmit={handleSubmit}
className={className}
>
@@ -90,8 +99,8 @@ export function PluginSearch({
name="q"
value={term}
onChange={handleChange}
placeholder="Search plugins"
aria-label="Search plugins"
placeholder={t("plugins.search")}
aria-label={t("plugins.search")}
// Typing on the directory lands here; keep the caret in the box.
autoFocus={Boolean(scope.q)}
className="w-full rounded-lg border border-white/10 bg-white/[0.03] py-1.5 pl-8 pr-8 text-sm text-foreground placeholder:text-extra-muted-foreground focus:border-white/20 focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
@@ -100,7 +109,7 @@ export function PluginSearch({
<button
type="button"
onClick={handleClear}
aria-label="Clear search"
aria-label={t("plugins.clearSearch")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 transition-colors hover:bg-white/[0.06]"
>
<X className={ICON_CLASS} />
+4 -2
View File
@@ -1,5 +1,6 @@
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import type { Plugin } from "./registry";
import { useLocale } from "~/i18n";
import { type Plugin, pluginListing } from "./registry";
const SIZE_CLASS = {
xs: "h-6 w-6 rounded-md text-xs",
@@ -9,6 +10,7 @@ const SIZE_CLASS = {
/** PNG icon with a letter tile when no icon is published. */
export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeof SIZE_CLASS }) {
const locale = useLocale();
return (
<div
aria-hidden
@@ -17,7 +19,7 @@ export function PluginTile({ plugin, size }: { plugin: Plugin; size: keyof typeo
{pluginOverviewUrl(plugin.icon) ? (
<img src={plugin.icon} alt="" className="h-full w-full rounded-[inherit] object-cover" />
) : (
plugin.name.charAt(0).toUpperCase()
pluginListing(plugin, locale).name.charAt(0).toUpperCase()
)}
</div>
);
+17 -4
View File
@@ -3,6 +3,8 @@ import {
PluginRegistryIndexSchema,
PublishedPluginDetailSchema,
} from "@getpaseo/protocol/plugin-registry";
import { languageAlternates, LOCALES, localizePath } from "~/i18n";
import { SITE_ORIGIN } from "~/meta";
import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache";
import { handlePluginThumbnailRequest } from "./thumbnails";
import { CATEGORIES } from "./categories";
@@ -75,16 +77,27 @@ export async function handlePluginRegistryRequest(
// IDs are validated owner/slug strings, so these paths contain no XML metacharacters.
const paths = new Set<string>(["/plugins", "/plugins/all"]);
for (const category of CATEGORIES) {
if (index.plugins.some((plugin) => plugin.categories.includes(category.slug)))
paths.add(`/plugins/category/${category.slug}`);
if (index.plugins.some((plugin) => plugin.categories.includes(category)))
paths.add(`/plugins/category/${category}`);
}
for (const plugin of index.plugins) {
paths.add(`/plugins/${plugin.id.split("/")[0]}`);
paths.add(`/plugins/${plugin.id}`);
}
const urls = [...paths].sort().map((path) => `<url><loc>https://paseo.sh${path}</loc></url>`);
const urls = [...paths].sort().flatMap((path) => {
const alternates = languageAlternates(path)
.map(
(alternate) =>
`<xhtml:link rel="alternate" hreflang="${alternate.hreflang}" href="${SITE_ORIGIN}${alternate.path}"/>`,
)
.join("");
return LOCALES.map(
(locale) =>
`<url><loc>${SITE_ORIGIN}${localizePath(path, locale)}</loc>${alternates}</url>`,
);
});
return new Response(
`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join("")}</urlset>`,
`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls.join("")}</urlset>`,
{
headers: {
"content-type": "application/xml; charset=utf-8",
+94 -11
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import { getTranslator } from "~/i18n";
import {
addedAgo,
featuredPlugins,
@@ -9,6 +10,8 @@ import {
mostInstalled,
newestFirst,
type Plugin,
pluginListing,
pluginOverview,
readmeBody,
recentlyUpdated,
reportBugUrl,
@@ -101,8 +104,10 @@ describe("plugin registry", () => {
"acme/listed-first",
]);
const now = "2026-10-03T12:00:00.000Z";
expect(addedAgo(listedLater, now)).toBe("3d ago");
expect(updatedAgo(listedFirst, now)).toBe("yesterday");
expect(addedAgo(listedLater, now, getTranslator("en"))).toBe("3d ago");
expect(updatedAgo(listedFirst, now, getTranslator("en"))).toBe("yesterday");
expect(addedAgo(listedLater, now, getTranslator("zh-CN"))).toBe("3 天前");
expect(updatedAgo(listedFirst, now, getTranslator("zh-CN"))).toBe("昨天");
});
it("lists only plugins updated after their first listing as recently updated, latest update first", () => {
const bumped = {
@@ -248,12 +253,12 @@ describe("plugin registry", () => {
author: { github: "zed" },
};
const plugins = [plugin, other];
expect(searchPlugins(plugins, "EXAMPLE").map((p) => p.id)).toEqual(["acme/example"]);
expect(searchPlugins(plugins, "unrelated").map((p) => p.id)).toEqual(["zed/other"]);
expect(searchPlugins(plugins, "zed/").map((p) => p.id)).toEqual(["zed/other"]);
expect(searchPlugins(plugins, "acme").map((p) => p.id)).toEqual(["acme/example"]);
expect(searchPlugins(plugins, " ")).toEqual(plugins);
expect(searchPlugins(plugins, "nothing")).toEqual([]);
expect(searchPlugins(plugins, "EXAMPLE", "en").map((p) => p.id)).toEqual(["acme/example"]);
expect(searchPlugins(plugins, "unrelated", "en").map((p) => p.id)).toEqual(["zed/other"]);
expect(searchPlugins(plugins, "zed/", "en").map((p) => p.id)).toEqual(["zed/other"]);
expect(searchPlugins(plugins, "acme", "en").map((p) => p.id)).toEqual(["acme/example"]);
expect(searchPlugins(plugins, " ", "en")).toEqual(plugins);
expect(searchPlugins(plugins, "nothing", "en")).toEqual([]);
});
it("ranks name, then author, then description matches, keeping the given order on ties", () => {
const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({
@@ -271,15 +276,75 @@ describe("plugin registry", () => {
named("acme/history", "History", "Inspect raw composer records"),
named("acme/omp", "OMP", "Paseo integration for OMP"),
];
expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([
expect(searchPlugins(plugins, "omp", "en").map((p) => p.id)).toEqual([
"acme/omp",
"acme/kit",
"omp/tools",
"acme/editor",
"acme/history",
]);
expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]);
expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]);
expect(searchPlugins(plugins, "prompt", "en").map((p) => p.id)).toEqual(["acme/kit"]);
expect(searchPlugins(plugins, "tools omp", "en").map((p) => p.id)).toEqual(["omp/tools"]);
});
it("shows each listing field in the reader's language, falling back to English per field", () => {
const translated: Plugin = {
...plugin,
translations: { "zh-CN": { description: "一个示例" } },
};
expect(pluginListing(translated, "zh-CN")).toEqual({
name: "Example",
description: "一个示例",
});
expect(pluginListing(translated, "en")).toEqual({ name: "Example", description: "An example" });
expect(pluginListing(plugin, "zh-CN")).toEqual({ name: "Example", description: "An example" });
});
it("shows a translated overview whole, or the whole English README when there is none", () => {
const detail = { ...plugin, readme: "# Example\n\nEnglish body\n" };
expect(pluginOverview(detail, "zh-CN")).toEqual({ raw: detail.readme, locale: "en" });
const translated = { ...detail, overviewTranslations: { "zh-CN": "# 示例\n\n中文正文\n" } };
expect(pluginOverview(translated, "zh-CN")).toEqual({
raw: "# 示例\n\n中文正文\n",
locale: "zh-CN",
});
expect(pluginOverview(translated, "en")).toEqual({ raw: detail.readme, locale: "en" });
});
it("searches the reader's language and English, but not other languages", () => {
const theme: Plugin = {
...plugin,
id: "acme/midnight",
name: "Midnight",
description: "A dark theme",
translations: { "zh-CN": { name: "午夜", description: "深色主题" } },
};
const plugins = [plugin, theme];
expect(searchPlugins(plugins, "午夜", "zh-CN").map((p) => p.id)).toEqual(["acme/midnight"]);
expect(searchPlugins(plugins, "深色", "zh-CN").map((p) => p.id)).toEqual(["acme/midnight"]);
expect(searchPlugins(plugins, "midnight", "zh-CN").map((p) => p.id)).toEqual(["acme/midnight"]);
expect(searchPlugins(plugins, "dark", "zh-CN").map((p) => p.id)).toEqual(["acme/midnight"]);
expect(searchPlugins(plugins, "午夜", "en")).toEqual([]);
});
it("lists every plugin page in each language with reciprocal alternates", async () => {
const response = await handlePluginRegistryRequest(
new Request("https://paseo.sh/sitemap-plugins.xml"),
{ PLUGINS_REGISTRY_URL: "https://registry.example.test" },
{ cache: seededIndex(), waitUntil: () => undefined },
);
const xml = (await response?.text()) ?? "";
const english = "https://paseo.sh/plugins/acme/example";
const chinese = "https://paseo.sh/zh-CN/plugins/acme/example";
for (const loc of [english, chinese]) {
expect(xml).toContain(
`<url><loc>${loc}</loc>` +
`<xhtml:link rel="alternate" hreflang="en" href="${english}"/>` +
`<xhtml:link rel="alternate" hreflang="zh-CN" href="${chinese}"/>` +
`<xhtml:link rel="alternate" hreflang="x-default" href="${english}"/></url>`,
);
}
expect(xml).toContain("<loc>https://paseo.sh/zh-CN/plugins/category/themes</loc>");
});
it("strips the README title and quoted description that the page already shows", () => {
@@ -309,6 +374,24 @@ describe("plugin registry", () => {
});
});
/** A cache holding a fresh registry index with `plugin`, so no request reaches the registry. */
function seededIndex(): KVNamespace {
const cache = memoryKv();
const index = {
schemaVersion: 1,
registry: { name: "Test", url: "https://registry.example.test" },
categories: [],
plugins: [plugin],
featured: [],
generatedAt: "2026-10-03",
};
void cache.put(
"plugins:index:v1:https://registry.example.test",
JSON.stringify({ fetchedAt: Date.now(), value: index }),
);
return cache;
}
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
function memoryKv(now = Date.now): KVNamespace & { values: Map<string, string> } {
const values = new Map<string, string>();
+58 -22
View File
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import type { z } from "zod";
import {
isGitHubHost,
@@ -5,13 +6,25 @@ import {
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import {
pluginMediaKind,
type PublishedPluginDetailSchema,
type PublishedPluginSchema,
} from "@getpaseo/protocol/plugin-registry";
import { scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { CATEGORIES, type Category, type CategorySlug } from "./categories";
import { DEFAULT_LOCALE, type Locale } from "~/i18n";
import type { LocalizedMarkdown } from "~/markdown-content";
import { CATEGORIES, type CategorySlug } from "./categories";
import type { InstallCounts, InstallWindow } from "./installs";
export { CATEGORIES, type Category, type CategorySlug };
export { CATEGORIES, type CategorySlug };
export type { InstallCounts, InstallWindow };
export type Plugin = z.infer<typeof PublishedPluginSchema>;
export type PluginDetail = z.infer<typeof PublishedPluginDetailSchema>;
/** What a plugin page and card say about the plugin, in the reader's language. */
export interface PluginListing {
name: string;
description: string;
}
export interface Author {
username: string;
name: string;
@@ -19,8 +32,26 @@ export interface Author {
npm?: string;
}
const DAY_MS = 24 * 60 * 60 * 1000;
export function getCategory(slug: string): Category | null {
return CATEGORIES.find((category) => category.slug === slug) ?? null;
export function getCategory(slug: string): CategorySlug | null {
return CATEGORIES.find((category) => category === slug) ?? null;
}
/** The name and description in a language. Each field falls back to English on its own. */
export function pluginListing(plugin: Plugin, locale: Locale): PluginListing {
const translation = plugin.translations?.[locale];
return {
name: translation?.name ?? plugin.name,
description: translation?.description ?? plugin.description,
};
}
/**
* The overview in a language. Without a translation the whole English README is shown, never a mix
* of the two.
*/
export function pluginOverview(plugin: PluginDetail, locale: Locale): LocalizedMarkdown {
const translation = plugin.overviewTranslations?.[locale];
return translation === undefined
? { raw: plugin.readme, locale: DEFAULT_LOCALE }
: { raw: translation, locale };
}
/** The plugin's first HTTPS image, for cards and link previews; videos never stand in for one. */
export function firstMediaImage(plugin: Plugin): string | undefined {
@@ -80,19 +111,24 @@ export function formatInstalls(count: number): string {
const k = count / 1000;
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
}
/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */
function searchFieldGroups(plugin: Plugin): string[][] {
/**
* Fields a search term can hit, best first: a name match outranks an author match, which outranks a
* description match. Names and descriptions match in the reader's language and in English.
*/
function searchFieldGroups(plugin: Plugin, locale: Locale): string[][] {
const author = getAuthor(plugin);
const name = [plugin.name, plugin.id.split("/")[1]];
const listing = pluginListing(plugin, locale);
const name = [listing.name, plugin.name, plugin.id.split("/")[1]];
const owner = [author.name, author.github, plugin.id];
return [name, owner, [plugin.description], [...name, ...owner, plugin.description]];
const description = [listing.description, plugin.description];
return [name, owner, description, [...name, ...owner, ...description]];
}
interface SearchRank {
field: number;
tier: number;
}
function searchRank(plugin: Plugin, term: string): SearchRank | null {
const groups = searchFieldGroups(plugin);
function searchRank(plugin: Plugin, term: string, locale: Locale): SearchRank | null {
const groups = searchFieldGroups(plugin, locale);
for (let field = 0; field < groups.length; field += 1) {
const score = scoreTextFields(term, groups[field], { subsequence: false });
if (score) return { field, tier: score.tier };
@@ -104,11 +140,11 @@ function searchRank(plugin: Plugin, term: string): SearchRank | null {
* Name matches come first, then author, then description; within each, exact words beat partial
* ones, and equal matches keep the given order.
*/
export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
export function searchPlugins(plugins: Plugin[], term: string, locale: Locale): Plugin[] {
if (!term.trim()) return plugins;
return plugins
.flatMap((plugin) => {
const rank = searchRank(plugin, term);
const rank = searchRank(plugin, term, locale);
return rank ? [{ plugin, rank }] : [];
})
.sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier)
@@ -142,19 +178,19 @@ export function mostInstalled(
return [...plugins].sort((a, b) => count(b) - count(a));
}
/** How long ago the plugin was first listed, relative to the loader's clock. */
export function addedAgo(plugin: Plugin, now: string): string {
return ago(plugin.publishedAt, now);
export function addedAgo(plugin: Plugin, now: string, t: TFunction): string {
return ago(plugin.publishedAt, now, t);
}
/** 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);
export function updatedAgo(plugin: Plugin, now: string, t: TFunction): string {
return ago(plugin.updatedAt, now, t);
}
function ago(date: string, now: string): string {
function ago(date: string, now: string, t: TFunction): 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`;
return `${Math.round(days / 7)}w ago`;
if (days <= 0) return t("plugins.ago.today");
if (days === 1) return t("plugins.ago.yesterday");
if (days < 14) return t("plugins.ago.days", { count: days });
return t("plugins.ago.weeks", { count: Math.round(days / 7) });
}
/**
* A README usually opens with the plugin's name and quoted description; the page shows both.
+28 -11
View File
@@ -1,6 +1,13 @@
import { createElement } from "react";
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import { createElement, type ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { LocaleProvider } from "~/i18n";
import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails";
import { PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
@@ -31,17 +38,27 @@ const plugin: Plugin = {
const avatar = 'src="https://github.com/acme.png?size=80"';
/** Renders a card on an English page, inside the router and language provider every page has. */
async function renderOnPage(card: ReactElement): Promise<string> {
const router = createRouter({
routeTree: createRootRoute({ component: () => createElement(LocaleProvider, null, card) }),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
await router.load();
return renderToStaticMarkup(createElement(RouterProvider, { router }));
}
describe("plugin card thumbnails", () => {
it("uses the first image in the plugin's media, skipping a leading video", () => {
const html = renderToStaticMarkup(
it("uses the first image in the plugin's media, skipping a leading video", async () => {
const html = await renderOnPage(
createElement(PluginCard, { plugin: { ...plugin, media: [video, source] } }),
);
expect(html).toContain(`/plugins/thumb/592/${encodeURIComponent(source)}?`);
expect(html).not.toContain(encodeURIComponent(video));
});
it("shows the plugin tile when the media has no image", () => {
const html = renderToStaticMarkup(
it("shows the plugin tile when the media has no image", async () => {
const html = await renderOnPage(
createElement(PluginCard, { plugin: { ...plugin, media: [video] } }),
);
expect(html.match(/<img [^>]*>/g)).toEqual([expect.stringContaining(avatar)]);
@@ -49,23 +66,23 @@ describe("plugin card thumbnails", () => {
expect(html).toContain(">E</div>");
});
it("keeps a screenshot on an unsupported host visible at its original URL", () => {
it("keeps a screenshot on an unsupported host visible at its original URL", async () => {
const screenshot = "https://example.com/preview.png";
const html = renderToStaticMarkup(
const html = await renderOnPage(
createElement(PluginCard, { plugin: { ...plugin, media: [screenshot] } }),
);
expect(html).toContain(`src="${screenshot}"`);
expect(html).not.toContain("/plugins/thumb/");
});
it("shows the author's avatar and name beside the card detail", () => {
const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today"));
it("shows the author's avatar and name beside the card detail", async () => {
const html = await renderOnPage(createElement(PluginCard, { plugin }, "today"));
expect(html).toContain(avatar);
expect(html).toMatch(/>acme<\/span><span aria-hidden="true">·<\/span><span[^>]*>today<\/span>/);
});
it("renders a thumbnail instead of downloading the original screenshot", () => {
const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today"));
it("renders a thumbnail instead of downloading the original screenshot", async () => {
const html = await renderOnPage(createElement(PluginCard, { plugin }, "today"));
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
+9 -11
View File
@@ -1,10 +1,7 @@
import { useTranslation } from "react-i18next";
import type { InstallWindow } from "./installs";
const WINDOWS: { value: InstallWindow; label: string }[] = [
{ value: "week", label: "This week" },
{ value: "month", label: "This month" },
{ value: "all", label: "All time" },
];
const WINDOWS: readonly InstallWindow[] = ["week", "month", "all"];
/** Plain text time-window switch for install rankings. */
export function WindowSwitch({
@@ -14,20 +11,21 @@ export function WindowSwitch({
current: InstallWindow;
hrefs: Record<InstallWindow, string>;
}) {
const { t } = useTranslation();
return (
<nav aria-label="Time window" className="flex items-baseline gap-4 text-sm">
<nav aria-label={t("plugins.window.label")} className="flex items-baseline gap-4 text-sm">
{WINDOWS.map((option) => (
<a
key={option.value}
href={hrefs[option.value]}
aria-current={option.value === current ? "true" : undefined}
key={option}
href={hrefs[option]}
aria-current={option === current ? "true" : undefined}
className={
option.value === current
option === current
? "text-foreground"
: "text-extra-muted-foreground transition-colors hover:text-muted-foreground"
}
>
{option.label}
{t(`plugins.window.${option}`)}
</a>
))}
</nav>