From b21f68d0c9f2df99a36216455bff2225542a7ccb Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sun, 23 Aug 2026 15:21:26 +0200 Subject: [PATCH 1/4] feat: let a reader walk the whole of an author's shelf --- apps/web/app/components/pagination.tsx | 94 ++++++++++++++ apps/web/app/lib/filter.test.ts | 21 ++- apps/web/app/lib/filter.ts | 11 ++ apps/web/app/lib/pagination.test.ts | 37 ++++++ apps/web/app/lib/pagination.ts | 20 +++ apps/web/app/lib/paths.ts | 26 +++- apps/web/app/lib/specs.server.ts | 16 ++- apps/web/app/routes/author.tsx | 170 +++++++++++++++---------- 8 files changed, 315 insertions(+), 80 deletions(-) create mode 100644 apps/web/app/components/pagination.tsx create mode 100644 apps/web/app/lib/pagination.test.ts create mode 100644 apps/web/app/lib/pagination.ts diff --git a/apps/web/app/components/pagination.tsx b/apps/web/app/components/pagination.tsx new file mode 100644 index 0000000..fc06b02 --- /dev/null +++ b/apps/web/app/components/pagination.tsx @@ -0,0 +1,94 @@ +import { Link } from "react-router"; + +/** Beyond this many, the numbers are thinned to the ends and the neighbourhood. */ +const SHOWN = 7; + +/** A number to offer, or the run of them left out in front of the next one. */ +type Cell = { page: number } | { gapBefore: number }; + +const cells = (page: number, pages: number): Cell[] => { + if (pages <= SHOWN) return Array.from({ length: pages }, (_, index) => ({ page: index + 1 })); + + const near = [page - 1, page, page + 1].filter((n) => n > 1 && n < pages); + const shown = [1, ...near, pages]; + + return shown.flatMap((n, index): Cell[] => { + const previous = shown[index - 1]; + if (previous === undefined || n === previous + 1) return [{ page: n }]; + // A gap of exactly one page is spelled out instead: an ellipsis standing in + // for a single number is longer than the number and hides the way to it. + return n === previous + 2 + ? [{ page: previous + 1 }, { page: n }] + : [{ gapBefore: n }, { page: n }]; + }); +}; + +const CELL = "rounded-sm px-2 py-1 font-mono text-[0.6875rem] uppercase tracking-[0.14em]"; +const LINK = `${CELL} text-muted hover:text-ink`; + +const Step = ({ to, rel, children }: { to: string | null; rel: string; children: string }) => + to === null ? ( + // An end of the list, kept in place so the numbers between the two steps do + // not shift sideways as a reader walks the pages. + + ) : ( + + {children} + + ); + +export const Pagination = ({ + page, + pages, + href, + label, +}: { + page: number; + pages: number; + href: (page: number) => string; + /** What is being paged through, for a reader who lands on the control itself. */ + label: string; +}) => { + if (pages <= 1) return null; + + return ( + + ); +}; diff --git a/apps/web/app/lib/filter.test.ts b/apps/web/app/lib/filter.test.ts index d00d00b..ed63c49 100644 --- a/apps/web/app/lib/filter.test.ts +++ b/apps/web/app/lib/filter.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest"; -import { parseSearchQuery, parseSpecFilter } from "./filter"; +import { parsePage, parseSearchQuery, parseSpecFilter } from "./filter"; const filterOf = (query: string) => parseSpecFilter(new URLSearchParams(query)); const searchOf = (query: string) => parseSearchQuery(new URLSearchParams(query)); +const pageOf = (query: string) => parsePage(new URLSearchParams(query)); describe("parseSpecFilter", () => { it("reads a topic and a kind", () => { @@ -34,6 +35,24 @@ describe("parseSpecFilter", () => { }); }); +describe("parsePage", () => { + it("reads the page asked for", () => { + expect(pageOf("page=2")).toBe(2); + expect(pageOf("topic=nostr&page=12")).toBe(12); + }); + + it("is page one when nothing was asked for", () => { + expect(pageOf("")).toBe(1); + expect(pageOf("topic=nostr")).toBe(1); + }); + + it("is page one for anything that is not a page number", () => { + for (const query of ["page=abc", "page=0", "page=-2", "page=1.5", "page=", "page=9999"]) { + expect(pageOf(query), query).toBe(1); + } + }); +}); + describe("parseSearchQuery", () => { it("reads what the visitor typed", () => { expect(searchOf("q=relay+discovery")).toBe("relay discovery"); diff --git a/apps/web/app/lib/filter.ts b/apps/web/app/lib/filter.ts index efb0828..d179a69 100644 --- a/apps/web/app/lib/filter.ts +++ b/apps/web/app/lib/filter.ts @@ -2,6 +2,7 @@ export type SpecFilterParams = { topic?: string; kind?: number }; const TOPIC = /^[a-z0-9][a-z0-9\-_.]{0,63}$/; const KIND = /^\d{1,7}$/; +const PAGE = /^\d{1,3}$/; /** Long enough for a sentence, short enough that a URL stays a URL. */ const MAX_QUERY = 100; @@ -20,6 +21,16 @@ export const parseSpecFilter = (params: URLSearchParams): SpecFilterParams => { }; }; +/** + * Anything that is not a page number is page one, on the same rule as the filter + * above: the caller rebuilds its canonical URL from what survived here, so a + * listing is never indexed under a dozen spellings of its first page. + */ +export const parsePage = (params: URLSearchParams): number => { + const page = params.get("page")?.trim() ?? ""; + return PAGE.test(page) ? Math.max(1, Number(page)) : 1; +}; + /** * Kept apart from the filter above, which relays are asked: this one never * reaches a relay, and never reaches a feed either. It is tidied rather than diff --git a/apps/web/app/lib/pagination.test.ts b/apps/web/app/lib/pagination.test.ts new file mode 100644 index 0000000..6e2cd38 --- /dev/null +++ b/apps/web/app/lib/pagination.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { pageOf } from "./pagination"; + +const items = Array.from({ length: 94 }, (_, index) => index); + +describe("pageOf", () => { + it("cuts a listing into pages of the size asked for", () => { + expect(pageOf(items, 1, 20).items).toEqual([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + ]); + expect(pageOf(items, 2, 20).items[0]).toBe(20); + expect(pageOf(items, 5, 20)).toMatchObject({ page: 5, pages: 5, total: 94 }); + }); + + it("leaves the last page short rather than padding it", () => { + expect(pageOf(items, 5, 20).items).toHaveLength(14); + }); + + it("counts an exact multiple without an empty page after it", () => { + expect(pageOf(items.slice(0, 60), 1, 20).pages).toBe(3); + }); + + it("is page one of one when there is nothing to show", () => { + expect(pageOf([], 1, 20)).toEqual({ items: [], page: 1, pages: 1, total: 0 }); + }); + + it("clamps a page past the end to the last one", () => { + expect(pageOf(items, 99, 20)).toMatchObject({ page: 5 }); + expect(pageOf([], 4, 20)).toMatchObject({ page: 1 }); + }); + + it("clamps a page below the first one", () => { + for (const page of [0, -3, 0.5]) { + expect(pageOf(items, page, 20).page, String(page)).toBe(1); + } + }); +}); diff --git a/apps/web/app/lib/pagination.ts b/apps/web/app/lib/pagination.ts new file mode 100644 index 0000000..53e8118 --- /dev/null +++ b/apps/web/app/lib/pagination.ts @@ -0,0 +1,20 @@ +export type Paged = { items: T[]; page: number; pages: number; total: number }; + +/** + * The page asked for is clamped rather than refused: a number past the end is + * usually a listing that shrank since the link was made, and a redirect derived + * from a window that moves every minute would outlive the window itself. An + * empty listing is still page one of one, so a caller never has to special-case + * having nothing. + */ +export const pageOf = (items: T[], page: number, size: number): Paged => { + const pages = Math.max(1, Math.ceil(items.length / size)); + const current = Math.min(Math.max(1, Math.floor(page)), pages); + const start = (current - 1) * size; + return { + items: items.slice(start, start + size), + page: current, + pages, + total: items.length, + }; +}; diff --git a/apps/web/app/lib/paths.ts b/apps/web/app/lib/paths.ts index 1f0f707..f719618 100644 --- a/apps/web/app/lib/paths.ts +++ b/apps/web/app/lib/paths.ts @@ -1,4 +1,8 @@ -export type SpecsQuery = { topic?: string; kind?: string | number; q?: string }; +export type SpecsQuery = { topic?: string; kind?: string | number; q?: string; page?: number }; + +/** The first page is the listing itself, so it never carries a number of its own. */ +const pageParam = (page: number | undefined): string | null => + page !== undefined && page > 1 ? String(page) : null; const withFilter = (base: string, query: SpecsQuery = {}): string => { const params = new URLSearchParams(); @@ -6,16 +10,19 @@ const withFilter = (base: string, query: SpecsQuery = {}): string => { if (query.q) params.set("q", query.q); if (query.topic) params.set("topic", query.topic); if (query.kind !== undefined && query.kind !== "") params.set("kind", String(query.kind)); + // Last, since it narrows nothing: it only says where in the result you are. + const page = pageParam(query.page); + if (page !== null) params.set("page", page); const search = params.toString(); return search === "" ? base : `${base}?${search}`; }; export const specsPath = (query: SpecsQuery = {}): string => withFilter("/specs", query); -/** A feed cannot replay a search, so `q` never reaches one. */ -export const rssPath = (query: Omit = {}): string => withFilter("/rss.xml", query); -export const atomPath = (query: Omit = {}): string => - withFilter("/atom.xml", query); +/** A feed cannot replay a search and has no pages, so neither reaches one. */ +export type FeedQuery = Omit; +export const rssPath = (query: FeedQuery = {}): string => withFilter("/rss.xml", query); +export const atomPath = (query: FeedQuery = {}): string => withFilter("/atom.xml", query); export const listingTitle = (query: SpecsQuery = {}): string => { if (query.q) return `Search results for "${query.q}"`; @@ -42,6 +49,15 @@ export const ogImagePath = (npub: string, identifier: string): string => /** The same, for an author, drawn from their profile and their shelf. */ export const authorOgImagePath = (npub: string): string => `/og/${npub}`; +/** + * Further down one author's shelf. `authorPath` in the schema names the key, and + * that address is not the place to say which page of it you are reading. + */ +export const authorPagePath = (npub: string, page?: number): string => { + const value = pageParam(page); + return value === null ? `/${npub}` : `/${npub}?page=${value}`; +}; + /** * An author's feeds hang under the author, not under the listing: what a reader * subscribes to here is a person, and a person is not a query. diff --git a/apps/web/app/lib/specs.server.ts b/apps/web/app/lib/specs.server.ts index 1b99ce2..4165341 100644 --- a/apps/web/app/lib/specs.server.ts +++ b/apps/web/app/lib/specs.server.ts @@ -102,12 +102,18 @@ export const loadSpecs = (filter: SpecFilter = {}, limit = 30): Promise => - loadSpecs({ author: pubkey }, AUTHOR_LIMIT); + loadSpecs({ author: pubkey }, LISTING_WINDOW); diff --git a/apps/web/app/routes/author.tsx b/apps/web/app/routes/author.tsx index ad8055f..7900352 100644 --- a/apps/web/app/routes/author.tsx +++ b/apps/web/app/routes/author.tsx @@ -10,17 +10,23 @@ import { data, redirect } from "react-router"; import { AuthorAvatar } from "~/components/author-avatar"; import { CopyButton } from "~/components/copy-button"; import { ErrorPage } from "~/components/error-page"; +import { Pagination } from "~/components/pagination"; import { Shell } from "~/components/shell"; import { SpecRow } from "~/components/spec-row"; import { keyTextColor } from "~/lib/color"; +import { parsePage } from "~/lib/filter"; import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http"; import { publicOrigin } from "~/lib/origin.server"; -import { authorAtomPath, authorOgImagePath, authorRssPath } from "~/lib/paths"; +import { pageOf } from "~/lib/pagination"; +import { authorAtomPath, authorOgImagePath, authorPagePath, authorRssPath } from "~/lib/paths"; import { type Author, authorDescription, authorName } from "~/lib/profile"; import { loadAuthor } from "~/lib/profile.server"; -import { loadAuthorSpecs, type SpecCard } from "~/lib/specs.server"; +import { loadAuthorSpecs } from "~/lib/specs.server"; import type { Route } from "./+types/author"; +/** Short enough that the whole page is one glance down the shelf. */ +const PAGE_SIZE = 20; + /** * Only the `name@domain` form is resolved here. A bare domain is a NIP-05 * address too, but at the root of the site it would turn every mistyped path @@ -40,27 +46,45 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw data({ missing: "address" }, { status: 404, headers: NOT_FOUND_HEADERS }); } + const url = new URL(request.url); // One author, one URL: a hex key, an nprofile and a NIP-05 address all name // the same person, and only the npub is the name that cannot be taken away. + // The search rides along, so a link deep into the shelf lands where it aimed. const npub = toNpub(pubkey); - const path = authorPath(pubkey); - if (params.author !== npub) throw redirect(path, 301); + if (params.author !== npub) throw redirect(`${authorPath(pubkey)}${url.search}`, 301); - const [author, specs] = await Promise.all([loadAuthor(pubkey), loadAuthorSpecs(pubkey)]); + const [author, shelf] = await Promise.all([loadAuthor(pubkey), loadAuthorSpecs(pubkey)]); // A key with neither a document nor a profile is not a page. Relays hold - // millions of them, and every one would be an empty page to be crawled. - if (author === null && specs.length === 0) { + // millions of them, and every one would be an empty page to be crawled. The + // whole shelf is what decides it, never the page of it being read. + if (author === null && shelf.length === 0) { throw data({ missing: "author" }, { status: 404, headers: NOT_FOUND_HEADERS }); } + const { items, page, pages, total } = pageOf(shelf, parsePage(url.searchParams), PAGE_SIZE); + // Read off the whole shelf: a page of it says when this author last wrote, + // not when they started. + const oldest = shelf.reduce( + (first, spec) => Math.min(first, spec.publishedAt), + Number.POSITIVE_INFINITY, + ); + const origin = publicOrigin(request); + const pageUrl = (n: number) => `${origin}${authorPagePath(npub, n)}`; return { pubkey, npub, author, - specs, + specs: items, + page, + pages, + total, + oldest, origin, - canonical: `${origin}${path}`, + // The clamped page, so a number past the end still points at a page there is. + canonical: pageUrl(page), + previous: page > 1 ? pageUrl(page - 1) : null, + next: page < pages ? pageUrl(page + 1) : null, ogImage: `${origin}${authorOgImagePath(npub)}`, }; } @@ -72,21 +96,27 @@ export function headers({ errorHeaders }: Route.HeadersArgs) { export function meta({ loaderData }: Route.MetaArgs) { if (!loaderData) return [{ title: "Author | Open Specs" }]; - const { author, npub, specs, canonical, origin, ogImage } = loaderData; + const { author, npub, total, page, canonical, previous, next, origin, ogImage } = loaderData; const name = authorName(author, npub); - const description = authorDescription(author, npub, specs.length); + const description = authorDescription(author, npub, total); + // Every page of a shelf is a different set of documents, so each one says + // which it is rather than sitting in a result list under the same title. + const title = page === 1 ? name : `${name}, page ${page}`; return [ - { title: `${name} | Open Specs` }, + { title: `${title} | Open Specs` }, { name: "description", content: description }, { tagName: "link", rel: "canonical", href: canonical }, + // Kept so a crawler walks the whole shelf rather than the first page of it. + ...(previous === null ? [] : [{ tagName: "link", rel: "prev", href: previous }]), + ...(next === null ? [] : [{ tagName: "link", rel: "next", href: next }]), // An author with nothing published is a page for whoever typed the key, not // one for an index: there is nothing on it to find. - ...(specs.length === 0 ? [{ name: "robots", content: "noindex, follow" }] : []), + ...(total === 0 ? [{ name: "robots", content: "noindex, follow" }] : []), { property: "og:type", content: "profile" }, { property: "og:site_name", content: "Open Specs" }, - { property: "og:title", content: name }, + { property: "og:title", content: title }, { property: "og:description", content: description }, { property: "og:url", content: canonical }, { property: "og:image", content: ogImage }, @@ -94,7 +124,7 @@ export function meta({ loaderData }: Route.MetaArgs) { { property: "og:image:height", content: "630" }, { name: "twitter:card", content: "summary_large_image" }, - { name: "twitter:title", content: name }, + { name: "twitter:title", content: title }, { name: "twitter:description", content: description }, { name: "twitter:image", content: ogImage }, @@ -158,83 +188,85 @@ const Masthead = ({ pubkey, npub, author, - specs, + total, + oldest, }: { pubkey: string; npub: string; author: Author | null; - specs: SpecCard[]; -}) => { - const oldest = specs.reduce( - (first, spec) => Math.min(first, spec.publishedAt), - Number.POSITIVE_INFINITY, - ); - - return ( -
-
- -
-

- {authorName(author, npub)} -

- {/* A claim the author makes about themselves, which nothing here resolves. */} - {author?.nip05 && ( -

{author.nip05}

- )} -
+ total: number; + oldest: number; +}) => ( +
+
+ +
+

+ {authorName(author, npub)} +

+ {/* A claim the author makes about themselves, which nothing here resolves. */} + {author?.nip05 && ( +

{author.nip05}

+ )}
+
- {author?.about && ( -

- {author.about} -

- )} + {author?.about && ( +

{author.about}

+ )} -
- {npub} - {specs.length > 0 && ( - {`since ${new Date(oldest * 1000).toISOString().slice(0, 10)}`} - )} -
+
+ {npub} + {total > 0 && ( + {`since ${new Date(oldest * 1000).toISOString().slice(0, 10)}`} + )} +
-
- - RSS - Atom -
-
- ); -}; +
+ + RSS + Atom +
+
+); export default function AuthorRoute({ loaderData }: Route.ComponentProps) { - const { pubkey, npub, author, specs } = loaderData; + const { pubkey, npub, author, specs, page, pages, total, oldest } = loaderData; return (
- +

- {shelf(specs.length)} + {shelf(total)} + {pages > 1 && {` / page ${page} of ${pages}`}}

- {specs.length === 0 ? ( + {total === 0 ? (

Nothing signed by this key has reached the relays this server reads. A document published elsewhere appears here as soon as one of them holds it.

) : ( -
    - {specs.map((spec) => ( - // Every row here is signed by the same key, so the mark beside each - // one would say what the page already says at the top. - - ))} -
+ <> +
    + {specs.map((spec) => ( + // Every row here is signed by the same key, so the mark beside each + // one would say what the page already says at the top. + + ))} +
+ authorPagePath(npub, n)} + label="Specifications by this key" + /> + )}
From 060820bcd66731b6463503964457fbcab41d3ecf Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sun, 23 Aug 2026 15:32:30 +0200 Subject: [PATCH 2/4] feat: let a reader walk past the first page of a listing --- apps/web/app/lib/specs.server.ts | 2 +- apps/web/app/routes/author.tsx | 8 ++-- apps/web/app/routes/specs.tsx | 76 ++++++++++++++++++++++++-------- 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/apps/web/app/lib/specs.server.ts b/apps/web/app/lib/specs.server.ts index 4165341..1b177ed 100644 --- a/apps/web/app/lib/specs.server.ts +++ b/apps/web/app/lib/specs.server.ts @@ -108,7 +108,7 @@ export const loadSpecs = (filter: SpecFilter = {}, limit = 30): Promise { +/** A shelf that fills the window may be hiding more, so it is described rather than counted. */ +const shelf = (count: number, capped: boolean): string => { if (count === 0) return "No specification"; + if (capped) return `The newest ${count} specifications`; return count === 1 ? "One specification" : `${count} specifications`; }; @@ -243,7 +245,7 @@ export default function AuthorRoute({ loaderData }: Route.ComponentProps) {

- {shelf(total)} + {shelf(total, total >= LISTING_WINDOW)} {pages > 1 && {` / page ${page} of ${pages}`}}

{total === 0 ? ( diff --git a/apps/web/app/routes/specs.tsx b/apps/web/app/routes/specs.tsx index eb0a30d..4819640 100644 --- a/apps/web/app/routes/specs.tsx +++ b/apps/web/app/routes/specs.tsx @@ -1,11 +1,13 @@ import { Link, type ShouldRevalidateFunctionArgs } from "react-router"; import { ErrorPage } from "~/components/error-page"; +import { Pagination } from "~/components/pagination"; import { SearchResults } from "~/components/search-results"; import { Shell } from "~/components/shell"; import { SpecRow } from "~/components/spec-row"; -import { parseSearchQuery, parseSpecFilter } from "~/lib/filter"; +import { parsePage, parseSearchQuery, parseSpecFilter } from "~/lib/filter"; import { PAGE_HEADERS } from "~/lib/http"; import { publicOrigin } from "~/lib/origin.server"; +import { pageOf } from "~/lib/pagination"; import { atomPath, feedTitle, @@ -15,11 +17,12 @@ import { specsPath, } from "~/lib/paths"; import { loadAuthors } from "~/lib/profile.server"; -import { loadSpecs } from "~/lib/specs.server"; +import { LISTING_WINDOW, loadSpecs } from "~/lib/specs.server"; import { topicsByFrequency } from "~/lib/topics"; import type { Route } from "./+types/specs"; -const LIMIT = 60; +/** Short enough that a page is one glance down the listing. */ +const PAGE_SIZE = 20; /** Enough to browse by, few enough to read at a glance. */ const TOPICS_SHOWN = 14; @@ -31,14 +34,21 @@ export async function loader({ request }: Route.LoaderArgs) { const origin = publicOrigin(request); // The unfiltered listing is the home page's, already loaded and cached. - const [specs, all] = await Promise.all([ - loadSpecs({ topic, kind }, LIMIT).catch(() => []), + const [listing, all] = await Promise.all([ + loadSpecs({ topic, kind }, LISTING_WINDOW).catch(() => []), loadSpecs({}, 30).catch(() => []), ]); + const { items, page, pages } = pageOf(listing, parsePage(params), PAGE_SIZE); + const pageUrl = (n: number) => `${origin}${specsPath({ topic, kind, page: n })}`; + return { - specs, - authors: await loadAuthors(specs.map((spec) => spec.pubkey)), + specs: items, + page, + pages, + // Only the page being shown: a profile lookup for every document in the + // window would be what this listing actually costs. + authors: await loadAuthors(items.map((spec) => spec.pubkey)), // Counted over the unfiltered listing: under a filter it would only ever // offer the filter already applied. topics: topicsByFrequency(all, TOPICS_SHOWN), @@ -46,9 +56,12 @@ export async function loader({ request }: Route.LoaderArgs) { kind: kind ?? null, query: query ?? null, // Canonical drops anything the filter did not recognise, so one listing is - // never indexed under a dozen spellings of the same query. + // never indexed under a dozen spellings of the same query. The page is the + // clamped one, so a number past the end still points at a page there is. origin, - canonical: `${origin}${specsPath({ topic, kind })}`, + canonical: pageUrl(page), + previous: page > 1 ? pageUrl(page - 1) : null, + next: page < pages ? pageUrl(page + 1) : null, filtered, }; } @@ -59,7 +72,8 @@ export function headers(_: Route.HeadersArgs) { /** * A search is answered in the browser, so the loader owes it nothing: asking the - * server again on every keystroke would fetch the listing it already has. + * server again on every keystroke would fetch the listing it already has. The + * page is on the other side of that line, since the server is what cuts one. */ export function shouldRevalidate({ currentUrl, @@ -67,7 +81,8 @@ export function shouldRevalidate({ defaultShouldRevalidate, }: ShouldRevalidateFunctionArgs) { if (currentUrl.pathname !== nextUrl.pathname) return defaultShouldRevalidate; - const listing = (url: URL) => `${url.searchParams.get("topic")}:${url.searchParams.get("kind")}`; + const listing = (url: URL) => + ["topic", "kind", "page"].map((name) => url.searchParams.get(name)).join(":"); return listing(currentUrl) === listing(nextUrl) ? false : defaultShouldRevalidate; } @@ -75,19 +90,27 @@ export function meta({ loaderData }: Route.MetaArgs) { if (!loaderData) return [{ title: "Specifications | Open Specs" }]; const query = { topic: loaderData.topic ?? undefined, kind: loaderData.kind ?? undefined }; - const title = listingTitle({ ...query, q: loaderData.query ?? undefined }); + const listing = listingTitle({ ...query, q: loaderData.query ?? undefined }); const description = listingDescription(query); // Results are read out of the visitor's browser, so there is no page here to // index and no canonical to point a crawler at. if (loaderData.query !== null) { - return [{ title: `${title} | Open Specs` }, { name: "robots", content: "noindex, follow" }]; + return [{ title: `${listing} | Open Specs` }, { name: "robots", content: "noindex, follow" }]; } + // Every page holds different documents, so each one says which it is rather + // than sitting in a result list under the same title as the others. + const { page, previous, next } = loaderData; + const title = page === 1 ? listing : `${listing}, page ${page}`; + return [ { title: `${title} | Open Specs` }, { name: "description", content: description }, { tagName: "link", rel: "canonical", href: loaderData.canonical }, + // Kept so a crawler walks the whole listing rather than the first page of it. + ...(previous === null ? [] : [{ tagName: "link", rel: "prev", href: previous }]), + ...(next === null ? [] : [{ tagName: "link", rel: "next", href: next }]), { property: "og:type", content: "website" }, { property: "og:site_name", content: "Open Specs" }, { property: "og:title", content: title }, @@ -127,7 +150,7 @@ const Chip = ({ to, active, children }: { to: string; active: boolean; children: ); export default function Specs({ loaderData }: Route.ComponentProps) { - const { specs, authors, topics, topic, kind, query, filtered } = loaderData; + const { specs, authors, topics, topic, kind, query, filtered, page, pages } = loaderData; return ( @@ -181,11 +204,26 @@ export default function Specs({ loaderData }: Route.ComponentProps) { : "No documents came back from the relays. They may be unreachable from this server right now."}

) : ( -
    - {specs.map((spec) => ( - - ))} -
+ <> + {pages > 1 && ( +

+ {`Page ${page} of ${pages}`} +

+ )} +
    1 ? "mt-6" : "mt-10"}> + {specs.map((spec) => ( + + ))} +
+ + specsPath({ topic: topic ?? undefined, kind: kind ?? undefined, page: n }) + } + label="Specifications" + /> + )}
From 50d157c29d636514a7cdb9aaa5463218e674c6e2 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sun, 23 Aug 2026 15:37:49 +0200 Subject: [PATCH 3/4] feat: let a crawler find every document the listing reaches --- apps/web/app/routes/sitemap.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/web/app/routes/sitemap.ts b/apps/web/app/routes/sitemap.ts index 528717c..b8fefb7 100644 --- a/apps/web/app/routes/sitemap.ts +++ b/apps/web/app/routes/sitemap.ts @@ -2,21 +2,22 @@ import { authorPath } from "@openspecs/nostr"; import { publicOrigin } from "~/lib/origin.server"; import { specsPath } from "~/lib/paths"; import { newestByAuthor, type SitemapEntry, sitemapXml } from "~/lib/sitemap"; -import { loadSpecs } from "~/lib/specs.server"; +import { LISTING_WINDOW, loadSpecs } from "~/lib/specs.server"; import { topicsByFrequency } from "~/lib/topics"; import type { Route } from "./+types/sitemap"; -/** - * What one relay query can honestly report. Relays keep no index this server can - * page through, so the sitemap covers the recent window, and the whole corpus - * only becomes listable once the indexer of lot 6 exists. - */ -const DOCUMENTS = 200; const TOPICS = 30; +/** + * The same window the listing pages, so this reports exactly what a crawler can + * reach and the two never disagree. Relays keep no index to page through, so the + * window is still the ceiling: the whole corpus only becomes listable once the + * indexer of lot 6 exists. Every document is named here in its own right, so the + * numbered listing pages are left out rather than repeated as a second way in. + */ export async function loader({ request }: Route.LoaderArgs) { const origin = publicOrigin(request); - const specs = await loadSpecs({}, DOCUMENTS).catch(() => []); + const specs = await loadSpecs({}, LISTING_WINDOW).catch(() => []); const newest = specs.reduce((latest, spec) => Math.max(latest, spec.revisedAt), 0); const entries: SitemapEntry[] = [ From 4306ae7189851d48e3811ce69a2cac12b948e7b5 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sun, 23 Aug 2026 15:43:53 +0200 Subject: [PATCH 4/4] feat: let a reader walk past the first sixty matches --- apps/web/app/components/search-results.tsx | 57 ++++++++++++++++------ apps/web/app/routes/specs.tsx | 12 +++-- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/apps/web/app/components/search-results.tsx b/apps/web/app/components/search-results.tsx index c3c3c57..4b2e735 100644 --- a/apps/web/app/components/search-results.tsx +++ b/apps/web/app/components/search-results.tsx @@ -1,10 +1,15 @@ import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { useSearchParams } from "react-router"; import { corpusState, serverCorpusState, startCorpus, subscribeCorpus } from "~/lib/corpus"; +import { parsePage } from "~/lib/filter"; +import { pageOf } from "~/lib/pagination"; +import { specsPath } from "~/lib/paths"; import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles"; import { searchDocs, searchTerms } from "~/lib/search"; +import { Pagination } from "./pagination"; import { SpecRow } from "./spec-row"; -const LIMIT = 60; +const PAGE_SIZE = 20; const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`; @@ -29,6 +34,10 @@ export const SearchResults = ({ serverCorpusState, ); + // Read off the URL rather than the loader: the server is not asked again when + // only the page changed, so its idea of which page this is would be stale. + const [params] = useSearchParams(); + const scoped = useMemo( () => docs.filter( @@ -38,14 +47,21 @@ export const SearchResults = ({ ), [docs, topic, kind], ); - const hits = useMemo(() => searchDocs(scoped, query, LIMIT), [scoped, query]); + const hits = useMemo(() => searchDocs(scoped, query), [scoped, query]); const terms = useMemo(() => searchTerms(query), [query]); + // The corpus arrives in pages of its own, so the last page grows under the + // reader while the relays are still being read. Clamping is what absorbs it. + const requested = parsePage(params); + const { items, page, pages } = useMemo( + () => pageOf(hits, requested, PAGE_SIZE), + [hits, requested], + ); // Only the authors a reader ended up in front of: the corpus holds far more. const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); useEffect(() => { - wantAuthors(hits.map((hit) => hit.doc.pubkey)); - }, [hits]); + wantAuthors(items.map((hit) => hit.doc.pubkey)); + }, [items]); const walking = status === "idle" || status === "loading" || status === "syncing"; const count = walking @@ -68,6 +84,7 @@ export const SearchResults = ({ className="mt-10 border-t border-rule pt-6 font-mono text-[0.6875rem] uppercase tracking-[0.18em] text-muted" > {count} + {pages > 1 && `, page ${page} of ${pages}`} {status === "failed" && ", some relays did not answer"}

@@ -78,17 +95,27 @@ export const SearchResults = ({ : "No document carries these words. Try fewer of them."}

) : ( -
    - {hits.map((hit) => ( - - ))} -
+ <> +
    + {items.map((hit) => ( + + ))} +
+ + specsPath({ q: query, topic: topic ?? undefined, kind: kind ?? undefined, page: n }) + } + label="Search results" + /> + )} ); diff --git a/apps/web/app/routes/specs.tsx b/apps/web/app/routes/specs.tsx index 4819640..345e9b1 100644 --- a/apps/web/app/routes/specs.tsx +++ b/apps/web/app/routes/specs.tsx @@ -72,8 +72,9 @@ export function headers(_: Route.HeadersArgs) { /** * A search is answered in the browser, so the loader owes it nothing: asking the - * server again on every keystroke would fetch the listing it already has. The - * page is on the other side of that line, since the server is what cuts one. + * server again on every keystroke would fetch the listing it already has. Its + * pages are answered there too, out of results already in hand, so only a page + * of the server's own listing is worth another round trip. */ export function shouldRevalidate({ currentUrl, @@ -81,8 +82,11 @@ export function shouldRevalidate({ defaultShouldRevalidate, }: ShouldRevalidateFunctionArgs) { if (currentUrl.pathname !== nextUrl.pathname) return defaultShouldRevalidate; - const listing = (url: URL) => - ["topic", "kind", "page"].map((name) => url.searchParams.get(name)).join(":"); + const listing = (url: URL) => { + const params = url.searchParams; + const page = parseSearchQuery(params) === undefined ? params.get("page") : null; + return `${params.get("topic")}:${params.get("kind")}:${page}`; + }; return listing(currentUrl) === listing(nextUrl) ? false : defaultShouldRevalidate; }