From 7db42e3acbb61bc86cc4b5e14408ac90d8bea776 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:30:10 +0000 Subject: [PATCH 1/2] feat(content): add public list pages to the Content Engine `delivery.list` gives a content type a public listing page, such as `/blog`, with pagination, search and filters read from the URL: - `defineContentType` validates the list path (static, not under /admin or /api), the page size (1-50) and that every filter is in `publicApi.filterableFields`. The build fails with `content-url-without-page` when no page serves the list URL. - `@vitnode/core/tanstack/content` adds `contentListSearch`, `contentListQuery`, `contentListPage` and `contentListPageHead`, plus `ContentList`, `ContentCard`, `ContentListEmpty`, `ContentListPagination`, `ContentListSearchForm` and `ContentListFilter`. - Only the plain first page is indexed, with canonical and hreflang links. Later pages, searches and filters answer `noindex, follow`, a robots value plugin routes can now return. A page past the end answers 404. - The blog gets `/blog` and the example plugin `/articles` with a "Featured" filter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DChMEKwVTEBMNHcKKJ1T9u --- apps/web/src/locales/@vitnode/blog/pl.json | 13 + apps/web/src/locales/@vitnode/core/pl.json | 4 +- packages/vitnode/src/content/define.ts | 3 +- .../vitnode/src/content/delivery.test-d.ts | 13 + packages/vitnode/src/content/delivery.test.ts | 130 ++++++ packages/vitnode/src/content/delivery.ts | 154 ++++++ packages/vitnode/src/content/index.ts | 6 + packages/vitnode/src/content/public-urls.ts | 21 +- packages/vitnode/src/content/types.ts | 45 ++ .../plugin-routes/content-urls.test.ts | 46 ++ .../framework/plugin-routes/content-urls.ts | 1 + packages/vitnode/src/locales/en.json | 4 +- packages/vitnode/src/routing/module.ts | 5 +- .../vitnode/src/tanstack/content/index.ts | 27 ++ .../src/tanstack/content/list-page.test.ts | 222 +++++++++ .../vitnode/src/tanstack/content/list-page.ts | 245 ++++++++++ .../src/tanstack/content/list.test.tsx | 276 +++++++++++ .../vitnode/src/tanstack/content/list.tsx | 438 ++++++++++++++++++ .../vitnode/src/tanstack/metadata/index.ts | 5 +- .../src/tanstack/plugin-routes/head.ts | 1 + plugins/blog/src/content/post.ts | 3 + plugins/blog/src/locales/en.json | 13 + plugins/blog/src/pages/posts-page.tsx | 165 +++++++ plugins/blog/src/routes.ts | 8 + plugins/example/src/content/article.ts | 2 + plugins/example/src/locales/en.json | 14 +- plugins/example/src/pages/articles-page.tsx | 144 ++++++ plugins/example/src/routes.ts | 8 + 28 files changed, 2008 insertions(+), 8 deletions(-) create mode 100644 packages/vitnode/src/tanstack/content/list-page.test.ts create mode 100644 packages/vitnode/src/tanstack/content/list-page.ts create mode 100644 packages/vitnode/src/tanstack/content/list.test.tsx create mode 100644 packages/vitnode/src/tanstack/content/list.tsx create mode 100644 plugins/blog/src/pages/posts-page.tsx create mode 100644 plugins/example/src/pages/articles-page.tsx diff --git a/apps/web/src/locales/@vitnode/blog/pl.json b/apps/web/src/locales/@vitnode/blog/pl.json index 7aeba7cf82..fe3ff329f4 100644 --- a/apps/web/src/locales/@vitnode/blog/pl.json +++ b/apps/web/src/locales/@vitnode/blog/pl.json @@ -129,6 +129,19 @@ "title": "Blog", "post": { "published": "Opublikowano" + }, + "list": { + "title": "Blog", + "desc": "Nowości, poradniki i historie od naszego zespołu.", + "search": "Szukaj artykułów", + "empty": { + "title": "Nie ma jeszcze artykułów", + "desc": "Zajrzyj wkrótce - pierwszy jest już w drodze." + }, + "no_results": { + "title": "Żaden artykuł nie pasuje do wyszukiwania", + "desc": "Spróbuj innego słowa albo wyczyść wyszukiwanie, aby zobaczyć wszystkie artykuły." + } } }, "@vitnode/blog:categories": "Kategorie", diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 5c2329ada1..8aa2a903bf 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -2626,7 +2626,9 @@ } } }, - "unsaved_changes": "Niezapisane zmiany" + "unsaved_changes": "Niezapisane zmiany", + "search": "Szukaj", + "all": "Wszystkie" }, "navigation": { "discover": { diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 41366dffe0..fd82c483c5 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -189,7 +189,8 @@ export const defineContentType = < ContentEditorialEnabled, ContentDeliveryTitleField, ContentDeliveryDescriptionField, - ContentDeliveryNoIndexField + ContentDeliveryNoIndexField, + TPublicField > | { enabled: false } = { enabled: false }, TPublicPath extends string = string, diff --git a/packages/vitnode/src/content/delivery.test-d.ts b/packages/vitnode/src/content/delivery.test-d.ts index a94259d684..f9e3b56990 100644 --- a/packages/vitnode/src/content/delivery.test-d.ts +++ b/packages/vitnode/src/content/delivery.test-d.ts @@ -68,6 +68,19 @@ const plainType = defineContentType({ }); describe("delivery configuration", () => { + it("lets a list page filter only by a public field", () => { + defineContentType({ + ...shared, + id: "typed.private-filter", + delivery: { + enabled: true, + // @ts-expect-error - `internalCode` is not in publicApi.fields. + list: { enabled: true, filters: ["internalCode"] }, + }, + tableName: "typed_private_filter", + }); + }); + it("keeps the `enabled` literal, so every conditional resolves", () => { expectTypeOf(deliveredType.delivery.enabled).toEqualTypeOf(); expectTypeOf(plainType.delivery.enabled).toEqualTypeOf(); diff --git a/packages/vitnode/src/content/delivery.test.ts b/packages/vitnode/src/content/delivery.test.ts index 9120074df7..27dcecad46 100644 --- a/packages/vitnode/src/content/delivery.test.ts +++ b/packages/vitnode/src/content/delivery.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { testContentLocaleRouting } from "@/tests/content-fixtures"; +import type { ContentDeliveryListConfig } from "./types"; + import { defineContentType } from "./define"; import { contentDeliveryHreflang, @@ -68,6 +70,13 @@ describe("delivery definition validation", () => { expect(plainType.delivery).toStrictEqual({ enabled: false, hreflang: { xDefault: null }, + list: { + enabled: false, + filters: [], + pageSize: 12, + path: "", + searchable: false, + }, path: "", redirects: { enabled: false }, seo: { @@ -618,6 +627,127 @@ describe("delivery.path", () => { }); }); +describe("delivery.list", () => { + const listFields = { + ...fields, + level: field.enum({ defaultValue: "easy", values: ["easy", "hard"] }), + }; + const listPublicApi = { + ...publicApi, + fields: [...publicApi.fields, "hidden", "views", "level"], + filterableFields: ["hidden", "views", "level"], + searchableFields: ["title"], + } as const; + // Typed as `never` filters so a runtime-only refusal can still be asserted; + // the field names themselves are checked by the types elsewhere. + const withList = (list: ContentDeliveryListConfig) => + defineContentType({ + ...base, + id: "delivery.listed", + delivery: { + enabled: true, + list: list as ContentDeliveryListConfig, + }, + fields: listFields, + publicApi: listPublicApi, + tableName: "delivery_listed", + }); + + it("defaults the page to the public API segment with 12 records", () => { + const type = withList({ enabled: true }); + + expect(type.delivery.list).toStrictEqual({ + enabled: true, + filters: [], + pageSize: 12, + path: "/articles", + searchable: true, + }); + }); + + it("is off when the content type declares no list", () => { + expect(articleType.delivery.list.enabled).toBe(false); + }); + + it("records how each filter is read from the URL", () => { + const type = withList({ + enabled: true, + filters: ["hidden", "views", "level"], + pageSize: 24, + path: "/library", + }); + + expect(type.delivery.list.path).toBe("/library"); + expect(type.delivery.list.pageSize).toBe(24); + expect(type.delivery.list.filters).toStrictEqual([ + { kind: "boolean", name: "hidden", values: null }, + { kind: "number", name: "views", values: null }, + { kind: "enum", name: "level", values: ["easy", "hard"] }, + ]); + }); + + it("is not searchable without publicApi.searchableFields", () => { + expect( + defineContentType({ + ...base, + id: "delivery.unsearchable", + delivery: { enabled: true, list: { enabled: true } }, + fields, + publicApi, + tableName: "delivery_unsearchable", + }).delivery.list.searchable, + ).toBe(false); + }); + + it("refuses a filter the public list route does not accept", () => { + expect(() => withList({ enabled: true, filters: ["title"] })).toThrow( + /"title", which is not in publicApi.filterableFields/, + ); + }); + + it("refuses a filter listed twice", () => { + expect(() => + withList({ enabled: true, filters: ["hidden", "hidden"] }), + ).toThrow(/lists "hidden" twice/); + }); + + it("refuses a page size the public API cannot return", () => { + expect(() => withList({ enabled: true, pageSize: 0 })).toThrow( + /pageSize is 0/, + ); + expect(() => withList({ enabled: true, pageSize: 51 })).toThrow( + /pageSize is 51/, + ); + expect(() => withList({ enabled: true, pageSize: 2.5 })).toThrow( + /pageSize is 2.5/, + ); + }); + + it("refuses a path with a parameter", () => { + expect(() => + withList({ enabled: true, path: "/articles/:category" }), + ).toThrow(/declares ":category"/); + expect(() => withList({ enabled: true, path: "/articles/*" })).toThrow( + /declares "\*"/, + ); + }); + + it("refuses the AdminCP and API prefixes", () => { + expect(() => withList({ enabled: true, path: "/admin/list" })).toThrow( + /reserved for the AdminCP/, + ); + expect(() => withList({ enabled: true, path: "/api" })).toThrow( + /reserved for the API/, + ); + }); + + it("refuses a route the router could not parse", () => { + expect(() => withList({ enabled: true, path: "articles" })).toThrow( + /delivery.list.path is not a valid route path/, + ); + }); +}); + describe("contentDeliveryPath", () => { it("writes the default locale without a prefix under as-needed", () => { expect( diff --git a/packages/vitnode/src/content/delivery.ts b/packages/vitnode/src/content/delivery.ts index 2de21e3745..8cd9e72ee4 100644 --- a/packages/vitnode/src/content/delivery.ts +++ b/packages/vitnode/src/content/delivery.ts @@ -7,6 +7,8 @@ import type { ContentSitemapChangeFrequency, DeliverableContentTypeDefinition, ResolvedContentDeliveryConfig, + ResolvedContentDeliveryListConfig, + ResolvedContentDeliveryListFilter, ResolvedContentPublicApiConfig, } from "./types"; @@ -16,6 +18,7 @@ import { CONTENT_DELIVERY_NO_INDEX_KINDS, CONTENT_DELIVERY_PATH_MAX_LENGTH, CONTENT_DELIVERY_TITLE_KINDS, + CONTENT_PUBLIC_MAX_PAGE_SIZE, isContentSitemapChangeFrequency, } from "./const"; import { ContentEngineError } from "./errors"; @@ -32,10 +35,23 @@ const noIndexKinds: ReadonlySet = new Set( CONTENT_DELIVERY_NO_INDEX_KINDS, ); +/** Records a list page shows when `delivery.list.pageSize` is not set. */ +export const CONTENT_DELIVERY_LIST_DEFAULT_PAGE_SIZE = 12; + +/** The disabled default every content type without `delivery.list` carries. */ +export const contentDeliveryListDisabled: ResolvedContentDeliveryListConfig = { + enabled: false, + filters: [], + pageSize: CONTENT_DELIVERY_LIST_DEFAULT_PAGE_SIZE, + path: "", + searchable: false, +}; + /** The disabled default every content type without `delivery` carries. */ export const contentDeliveryDisabled: ResolvedContentDeliveryConfig = { enabled: false, hreflang: { xDefault: null }, + list: contentDeliveryListDisabled, path: "", redirects: { enabled: false }, seo: { @@ -208,6 +224,138 @@ const resolveContentDeliveryPath = ({ return parsed.path; }; +const resolveContentDeliveryListPath = ({ + id, + path, + publicPath, +}: { + id: string; + path: string | undefined; + publicPath: string; +}): string => { + if (path === undefined) return `/${publicPath}`; + + if ( + typeof path !== "string" || + path.length > CONTENT_DELIVERY_PATH_MAX_LENGTH + ) { + throw new ContentEngineError( + `delivery.list.path must be a string of at most ${CONTENT_DELIVERY_PATH_MAX_LENGTH} characters.`, + { contentTypeId: id }, + ); + } + + const parsed = parseRoutePath(path); + if (!parsed.ok) { + throw new ContentEngineError( + `delivery.list.path is not a valid route path: ${parsed.reason}.`, + { contentTypeId: id }, + ); + } + + const dynamic = parsed.segments.find(segment => segment.kind !== "static"); + if (dynamic) { + throw new ContentEngineError( + `delivery.list.path "${path}" declares ${dynamic.kind === "param" ? `":${dynamic.name}"` : '"*"'}. A list page lists every published record, so its URL has no parameters - narrow it with \`filters\` and a query string instead.`, + { contentTypeId: id }, + ); + } + + const [first] = parsed.segments; + if (first?.kind === "static" && RESERVED_DELIVERY_PREFIXES.has(first.value)) { + throw new ContentEngineError( + `delivery.list.path "${path}" starts with "/${first.value}", which is reserved for the ${first.value === "api" ? "API" : "AdminCP"} and never served as a public page.`, + { contentTypeId: id }, + ); + } + + return parsed.path; +}; + +const listFilterOf = ( + descriptor: ContentFieldDescriptor, + name: string, +): ResolvedContentDeliveryListFilter => { + switch (descriptor.kind) { + case "boolean": + return { kind: "boolean", name, values: null }; + case "enum": + return { + kind: "enum", + name, + values: [...(descriptor as { values: readonly string[] }).values], + }; + case "number": + return { kind: "number", name, values: null }; + case "relation": + case "user": + return { kind: "reference", name, values: null }; + default: + return { kind: "text", name, values: null }; + } +}; + +const resolveContentDeliveryList = ({ + fields, + id, + list, + publicApi, +}: { + fields: ContentFieldMap; + id: string; + list: ContentDeliveryConfig["list"]; + publicApi: ResolvedContentPublicApiConfig; +}): ResolvedContentDeliveryListConfig => { + if (!list?.enabled) return contentDeliveryListDisabled; + + const pageSize = list.pageSize ?? CONTENT_DELIVERY_LIST_DEFAULT_PAGE_SIZE; + if ( + !Number.isInteger(pageSize) || + pageSize < 1 || + pageSize > CONTENT_PUBLIC_MAX_PAGE_SIZE + ) { + throw new ContentEngineError( + `delivery.list.pageSize is ${pageSize}; it must be a whole number from 1 to ${CONTENT_PUBLIC_MAX_PAGE_SIZE}, the most the public list route returns in one page.`, + { contentTypeId: id }, + ); + } + + const names = (list.filters ?? []).map(String); + const duplicate = names.find( + (name, position) => names.indexOf(name) !== position, + ); + if (duplicate !== undefined) { + throw new ContentEngineError( + `delivery.list.filters lists "${duplicate}" twice.`, + { contentTypeId: id }, + ); + } + + const filters = names.map(name => { + const target = resolveSeoTarget(fields, name); + if (!publicApi.filterableFields.includes(name) || target === null) { + throw new ContentEngineError( + `delivery.list.filters includes "${name}", which is not in publicApi.filterableFields. The page passes each filter to the public list route, which only accepts the fields that allowlist names.`, + { contentTypeId: id }, + ); + } + + return listFilterOf(target.descriptor, name); + }); + + return { + enabled: true, + filters, + pageSize, + path: resolveContentDeliveryListPath({ + id, + path: list.path, + publicPath: publicApi.path, + }), + searchable: publicApi.searchableFields.length > 0, + }; +}; + export const resolveContentDelivery = ({ delivery, editorial, @@ -428,6 +576,12 @@ export const resolveContentDelivery = ({ return { enabled: true, hreflang: { xDefault: delivery.hreflang?.xDefault ?? null }, + list: resolveContentDeliveryList({ + fields, + id, + list: delivery.list, + publicApi, + }), path, redirects: { enabled: redirects }, seo: { diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index a4a3cb8e2e..a8608d76d8 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -171,10 +171,12 @@ export { } from "./const"; export { defineContentType } from "./define"; export { + CONTENT_DELIVERY_LIST_DEFAULT_PAGE_SIZE, contentDeliveryAlternateHref, contentDeliveryDisabled, contentDeliveryHreflang, contentDeliveryInternalPath, + contentDeliveryListDisabled, contentDeliveryOpenGraph, contentDeliveryPath, contentDeliveryPublicUrl, @@ -364,6 +366,8 @@ export type { ContentDeliveryDescriptionField, ContentDeliveryEnabled, ContentDeliveryHreflangConfig, + ContentDeliveryListConfig, + ContentDeliveryListFilterKind, ContentDeliveryNoIndexField, ContentDeliveryOpenGraphConfig, ContentDeliveryRedirectsConfig, @@ -443,6 +447,8 @@ export type { PublicFilterableContentFieldName, ResolvedContentAdminConfig, ResolvedContentDeliveryConfig, + ResolvedContentDeliveryListConfig, + ResolvedContentDeliveryListFilter, ResolvedContentDeliverySeoConfig, ResolvedContentEditorialConfig, ResolvedContentIndex, diff --git a/packages/vitnode/src/content/public-urls.ts b/packages/vitnode/src/content/public-urls.ts index 77fbef28d7..07712218c8 100644 --- a/packages/vitnode/src/content/public-urls.ts +++ b/packages/vitnode/src/content/public-urls.ts @@ -10,7 +10,10 @@ export type ContentUrlErrorCode = | "incomplete-content-types" | "invalid-content-types-module"; -export type ContentUrlSetting = "delivery.path" | "search.pathTemplate"; +export type ContentUrlSetting = + | "delivery.list.path" + | "delivery.path" + | "search.pathTemplate"; export interface ContentUrlErrorDetails { code: ContentUrlErrorCode; @@ -39,7 +42,12 @@ export class ContentUrlError extends Error { } export interface ContentUrlDefinition { - delivery: { enabled: boolean; path: string }; + delivery: { + enabled: boolean; + /** Optional so a definition built before list pages existed still reads. */ + list?: { enabled: boolean; path: string }; + path: string; + }; id: string; search: { enabled: boolean; pathTemplate: string }; } @@ -63,6 +71,15 @@ export const contentPublicUrls = ( }); } + const list = definition.delivery.list; + if (definition.delivery.enabled && list?.enabled && list.path !== "") { + urls.push({ + declared: list.path, + routePath: list.path, + setting: "delivery.list.path", + }); + } + if (definition.search.enabled && definition.search.pathTemplate !== "") { urls.push({ declared: definition.search.pathTemplate, diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index a9cfb7b5a1..bd0eaf2bdd 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -1341,6 +1341,48 @@ export interface ContentDeliveryRedirectsConfig { enabled: true; } +/** + * A public page listing the published records, such as `/blog`. + * + * The page reads `?page=`, `?q=` (when `publicApi.searchableFields` is set) and + * one parameter per field in `filters` from its URL, and asks the public list + * route for exactly that. Anything else in the URL is ignored. + */ +export interface ContentDeliveryListConfig { + enabled: true; + /** Fields from `publicApi.filterableFields` the page reads from its URL. */ + filters?: readonly TFilter[]; + /** Records per page, 1-50. Defaults to 12. */ + pageSize?: number; + /** The page's English route, with no parameters. Defaults to `/{publicApi.path}`. */ + path?: string; +} + +/** How a list page reads one filter from its URL. */ +export type ContentDeliveryListFilterKind = + | "boolean" + | "enum" + | "number" + | "reference" + | "text"; + +export interface ResolvedContentDeliveryListFilter { + kind: ContentDeliveryListFilterKind; + name: string; + /** The allowed values of an `enum` filter, `null` for every other kind. */ + values: null | readonly string[]; +} + +/** `delivery.list` after `defineContentType` has filled in every default. */ +export interface ResolvedContentDeliveryListConfig { + enabled: boolean; + filters: readonly ResolvedContentDeliveryListFilter[]; + pageSize: number; + path: string; + /** Whether the page passes `?q=` to the API, from `publicApi.searchableFields`. */ + searchable: boolean; +} + export interface ContentDeliverySitemapConfig { /** One of the seven `changefreq` values the protocol defines. */ changeFrequency?: ContentSitemapChangeFrequency; @@ -1385,9 +1427,11 @@ export interface ContentDeliveryConfig< TTitle extends string = string, TDescription extends string = string, TNoIndex extends string = string, + TFilter extends string = string, > { enabled: TPublicEnabled extends true ? true : never; hreflang?: ContentDeliveryHreflangConfig; + list?: ContentDeliveryListConfig | { enabled: false }; path?: string; redirects?: TPublicEnabled extends true @@ -1424,6 +1468,7 @@ export interface ResolvedContentDeliveryConfig< > { enabled: TEnabled; hreflang: { xDefault: "defaultLocale" | null }; + list: ResolvedContentDeliveryListConfig; path: string; redirects: { enabled: boolean }; seo: ResolvedContentDeliverySeoConfig; diff --git a/packages/vitnode/src/framework/plugin-routes/content-urls.test.ts b/packages/vitnode/src/framework/plugin-routes/content-urls.test.ts index 1802bd9d13..469bc02747 100644 --- a/packages/vitnode/src/framework/plugin-routes/content-urls.test.ts +++ b/packages/vitnode/src/framework/plugin-routes/content-urls.test.ts @@ -157,6 +157,52 @@ describe("content URLs served by page routes", () => { expect(error.message).toContain('Declared in "@vitnode/blog/content".'); }); + it("fails on a delivery.list.path no page serves", () => { + const error = failureOf(() => + compilePluginRoutes({ + contentUrls: [ + blogContent({ + ...BLOG_POST, + delivery: { + ...BLOG_POST.delivery, + list: { enabled: true, path: "/blog" }, + }, + }), + ], + sources: [blogPage()], + }), + ); + + expect(error.code).toBe("content-url-without-page"); + expect(error.setting).toBe("delivery.list.path"); + expect(error.pattern).toBe("/blog"); + expect(error.message).toContain('Add a page route at "/blog"'); + expect(error.message).toContain("`list: { enabled: false }`"); + }); + + it("accepts a delivery.list.path a plugin page serves", () => { + expect(() => + compilePluginRoutes({ + contentUrls: [ + blogContent({ + ...BLOG_POST, + delivery: { + ...BLOG_POST.delivery, + list: { enabled: true, path: "/blog" }, + }, + }), + ], + sources: [ + routesOf( + "@vitnode/blog", + page("/blog", { component: lazyPage() }), + page("/blog/:slug", { component: lazyPage() }), + ), + ], + }), + ).not.toThrow(); + }); + it("fails on a search.pathTemplate no page serves", () => { const error = failureOf(() => compilePluginRoutes({ diff --git a/packages/vitnode/src/framework/plugin-routes/content-urls.ts b/packages/vitnode/src/framework/plugin-routes/content-urls.ts index e57af8a79b..4161a6513a 100644 --- a/packages/vitnode/src/framework/plugin-routes/content-urls.ts +++ b/packages/vitnode/src/framework/plugin-routes/content-urls.ts @@ -40,6 +40,7 @@ export interface AssertContentUrlsHavePagesOptions { } const disableHint: Record = { + "delivery.list.path": "turn the list page off (`list: { enabled: false }`)", "delivery.path": "turn delivery off (`delivery: { enabled: false }`)", "search.pathTemplate": "turn search off for this content type", }; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 82473aa549..7d60e16f29 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -643,7 +643,9 @@ } } }, - "unsaved_changes": "Unsaved changes" + "unsaved_changes": "Unsaved changes", + "search": "Search", + "all": "All" }, "navigation": { "discover": { diff --git a/packages/vitnode/src/routing/module.ts b/packages/vitnode/src/routing/module.ts index 2169799f70..4d88d52280 100644 --- a/packages/vitnode/src/routing/module.ts +++ b/packages/vitnode/src/routing/module.ts @@ -1,5 +1,8 @@ /** What a crawler may do with a plugin page. */ -export type PluginRouteRobots = "index, follow" | "noindex, nofollow"; +export type PluginRouteRobots = + | "index, follow" + | "noindex, follow" + | "noindex, nofollow"; export interface PluginRouteHead { alternates?: Readonly>; diff --git a/packages/vitnode/src/tanstack/content/index.ts b/packages/vitnode/src/tanstack/content/index.ts index 5b17ca0a4c..a4ef7c855b 100644 --- a/packages/vitnode/src/tanstack/content/index.ts +++ b/packages/vitnode/src/tanstack/content/index.ts @@ -8,3 +8,30 @@ export { contentDeliveryPageHead, contentPageItem, } from "./delivery-page"; +export type { ContentCardImage, ContentListFilterOption } from "./list"; +export { + ContentCard, + ContentList, + ContentListEmpty, + ContentListFilter, + ContentListItem, + contentListPageNumbers, + ContentListPagination, + ContentListSearchForm, +} from "./list"; +export type { + ContentListDefinition, + ContentListFilterValue, + ContentListQuery, + ContentListResponse, + ContentListSearch, +} from "./list-page"; +export { + CONTENT_LIST_SEARCH_MAX_LENGTH, + contentListPage, + contentListPageHead, + contentListQuery, + contentListSearch, + isContentListIndexable, + nextContentListSearch, +} from "./list-page"; diff --git a/packages/vitnode/src/tanstack/content/list-page.test.ts b/packages/vitnode/src/tanstack/content/list-page.test.ts new file mode 100644 index 0000000000..2ec5791299 --- /dev/null +++ b/packages/vitnode/src/tanstack/content/list-page.test.ts @@ -0,0 +1,222 @@ +// @vitest-environment node +import { isNotFound } from "@tanstack/react-router"; +import { describe, expect, it } from "vitest"; + +import type { ContentListDefinition } from "./list-page"; + +import { + contentListPage, + contentListPageHead, + contentListQuery, + contentListSearch, + nextContentListSearch, +} from "./list-page"; + +const recipes: ContentListDefinition = { + delivery: { + list: { + enabled: true, + filters: [ + { kind: "boolean", name: "vegetarian", values: null }, + { kind: "enum", name: "difficulty", values: ["easy", "hard"] }, + { kind: "number", name: "cookingTime", values: null }, + { kind: "reference", name: "category", values: null }, + { kind: "text", name: "cuisine", values: null }, + ], + pageSize: 12, + path: "/recipes", + searchable: true, + }, + }, +}; + +const unsearchable: ContentListDefinition = { + delivery: { list: { ...recipes.delivery.list, searchable: false } }, +}; + +const thrownBy = (run: () => unknown): unknown => { + try { + run(); + } catch (error) { + return error; + } + + throw new Error("Expected the list check to throw."); +}; + +describe("contentListSearch", () => { + const read = contentListSearch(recipes); + + it("reads nothing from a plain URL, so the first page has one address", () => { + expect(read({})).toStrictEqual({}); + expect(read({ page: 1 })).toStrictEqual({}); + }); + + it("reads the page, the term and every declared filter", () => { + expect( + read({ + category: "7", + cookingTime: "30", + cuisine: " Italian ", + difficulty: "easy", + page: "3", + q: " risotto ", + vegetarian: "true", + }), + ).toStrictEqual({ + category: 7, + cookingTime: 30, + cuisine: "Italian", + difficulty: "easy", + page: 3, + q: "risotto", + vegetarian: true, + }); + }); + + it("accepts the values the router already parsed from JSON", () => { + expect(read({ page: 2, q: 404, vegetarian: false })).toStrictEqual({ + page: 2, + q: "404", + vegetarian: false, + }); + }); + + it("drops values it cannot use instead of failing", () => { + expect( + read({ + category: "-1", + cookingTime: "soon", + difficulty: "impossible", + page: "two", + q: " ", + utm_source: "newsletter", + vegetarian: "yes", + }), + ).toStrictEqual({}); + }); + + it("ignores a term when the content type has nothing to search", () => { + expect(contentListSearch(unsearchable)({ q: "risotto" })).toStrictEqual({}); + }); + + it("cuts a long term to the length the page passes on", () => { + expect(read({ q: "a".repeat(400) }).q).toHaveLength(256); + }); +}); + +describe("contentListQuery", () => { + it("asks for one page of the declared size", () => { + expect(contentListQuery(recipes, {})).toStrictEqual({ first: "12" }); + }); + + it("passes the page, the term and the filters as strings", () => { + expect( + contentListQuery(recipes, { + category: 7, + page: 2, + q: "risotto", + vegetarian: true, + }), + ).toStrictEqual({ + category: "7", + first: "12", + page: "2", + search: "risotto", + vegetarian: "true", + }); + }); + + it("passes only declared filters and no term when search is off", () => { + expect( + contentListQuery(unsearchable, { author: 3, q: "risotto" }), + ).toStrictEqual({ first: "12" }); + }); +}); + +describe("contentListPage", () => { + const list = (currentPage: number, totalPages: number) => ({ + edges: [], + pageInfo: { currentPage, totalPages }, + }); + + it("hands back the list it was given", () => { + const loaded = list(2, 3); + + expect(contentListPage({ list: loaded, search: { page: 2 } })).toBe(loaded); + }); + + it("answers 404 for a page past the end", () => { + // The API clamps `?page=9` onto the last page it has. + expect( + isNotFound( + thrownBy(() => + contentListPage({ list: list(3, 3), search: { page: 9 } }), + ), + ), + ).toBe(true); + }); + + it("keeps an empty first page, which is a list with nothing in it yet", () => { + expect(() => + contentListPage({ list: list(1, 0), search: {} }), + ).not.toThrow(); + }); +}); + +describe("contentListPageHead", () => { + const locales = ["en", "pl"]; + + it("indexes the plain first page with one alternate per language", () => { + expect( + contentListPageHead(recipes, { + description: "Every recipe we cooked.", + locales, + search: {}, + title: "Recipes", + }), + ).toStrictEqual({ + alternates: { en: "/recipes", pl: "/recipes" }, + description: "Every recipe we cooked.", + title: "Recipes", + }); + }); + + it.each([ + ["a later page", { page: 2 }], + ["a search", { q: "risotto" }], + ["a filter", { vegetarian: true }], + ])("keeps %s out of the index but lets crawlers follow it", (_, search) => { + expect( + contentListPageHead(recipes, { locales, search, title: "Recipes" }), + ).toStrictEqual({ robots: "noindex, follow", title: "Recipes" }); + }); +}); + +describe("nextContentListSearch", () => { + it("goes back to the first page when a filter changes", () => { + expect( + nextContentListSearch({ page: 3, q: "soup" }, { vegetarian: true }), + ).toStrictEqual({ q: "soup", vegetarian: true }); + }); + + it("keeps the term and filters when only the page changes", () => { + expect( + nextContentListSearch({ q: "soup", vegetarian: true }, { page: 2 }), + ).toStrictEqual({ page: 2, q: "soup", vegetarian: true }); + }); + + it("removes a key set to undefined and never writes page 1", () => { + expect( + nextContentListSearch({ page: 2, vegetarian: true }, { page: 1 }), + ).toStrictEqual({ vegetarian: true }); + expect( + nextContentListSearch( + { q: "soup", vegetarian: true }, + { + vegetarian: undefined, + }, + ), + ).toStrictEqual({ q: "soup" }); + }); +}); diff --git a/packages/vitnode/src/tanstack/content/list-page.ts b/packages/vitnode/src/tanstack/content/list-page.ts new file mode 100644 index 0000000000..c3132ec5e0 --- /dev/null +++ b/packages/vitnode/src/tanstack/content/list-page.ts @@ -0,0 +1,245 @@ +import { notFound } from "@tanstack/react-router"; + +import type { + ResolvedContentDeliveryListConfig, + ResolvedContentDeliveryListFilter, +} from "@/content/types"; +import type { PluginRouteHead } from "@/routing"; + +import { getIntlRuntime } from "../i18n/runtime"; + +/** The longest `?q=` a list page passes on to the API. */ +export const CONTENT_LIST_SEARCH_MAX_LENGTH = 256; + +const TEXT_FILTER_MAX_LENGTH = 255; + +/** The part of a content type a list page reads. A whole definition fits. */ +export interface ContentListDefinition { + delivery: { list: ResolvedContentDeliveryListConfig }; +} + +export type ContentListFilterValue = boolean | number | string; + +/** + * A list page's URL, after {@link contentListSearch} has read it. + * + * `page` is left out on the first page and `q` when nothing is searched, so the + * plain `/blog` is the one address of the first page. Every other key is a + * filter from `delivery.list.filters`. + */ +export interface ContentListSearch { + [filter: string]: ContentListFilterValue | undefined; + page?: number; + q?: string; +} + +/** The query string a list page sends to the public list route. */ +export interface ContentListQuery { + [filter: string]: string | undefined; + first: string; + page?: string; + search?: string; +} + +/** The part of the public list response a list page checks. */ +export interface ContentListResponse { + pageInfo: { currentPage: null | number; totalPages: number }; +} + +const asInteger = (value: unknown): number | undefined => { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && /^\d+$/.test(value) + ? Number(value) + : Number.NaN; + + return Number.isSafeInteger(parsed) ? parsed : undefined; +}; + +const asText = (value: unknown, maxLength: number): string | undefined => { + if (typeof value !== "string" && typeof value !== "number") return undefined; + + const text = String(value).trim().slice(0, maxLength).trim(); + + return text.length > 0 ? text : undefined; +}; + +const readFilter = ( + filter: ResolvedContentDeliveryListFilter, + value: unknown, +): ContentListFilterValue | undefined => { + switch (filter.kind) { + case "boolean": + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + + return undefined; + case "enum": + return typeof value === "string" && filter.values?.includes(value) + ? value + : undefined; + case "number": { + const parsed = typeof value === "string" ? Number(value) : value; + + return typeof parsed === "number" && + Number.isFinite(parsed) && + String(value).trim() !== "" + ? parsed + : undefined; + } + case "reference": { + const id = asInteger(value); + + return id !== undefined && id > 0 ? id : undefined; + } + case "text": + return asText(value, TEXT_FILTER_MAX_LENGTH); + } +}; + +/** + * Builds a list page's `search` validator from its content type. + * + * Reads `page`, `q` and the declared filters, and drops anything it cannot use + * instead of failing: a stale bookmark or a tracking parameter still opens the + * page. + */ +export const contentListSearch = + ({ delivery: { list } }: ContentListDefinition) => + (input: Record): ContentListSearch => { + const search: ContentListSearch = {}; + + const page = asInteger(input.page); + if (page !== undefined && page > 1) search.page = page; + + if (list.searchable) { + const q = asText(input.q, CONTENT_LIST_SEARCH_MAX_LENGTH); + if (q !== undefined) search.q = q; + } + + for (const filter of list.filters) { + const value = readFilter(filter, input[filter.name]); + if (value !== undefined) search[filter.name] = value; + } + + return search; + }; + +/** + * Turns a list page's search into the query of the public list route. + * + * Spread it into `args.query` of the `fetcher` call that reads the list. + */ +export const contentListQuery = ( + { delivery: { list } }: ContentListDefinition, + search: ContentListSearch, +): ContentListQuery => { + const query: ContentListQuery = { first: String(list.pageSize) }; + + if (search.page !== undefined && search.page > 1) { + query.page = String(search.page); + } + if (list.searchable && search.q !== undefined) query.search = search.q; + + for (const filter of list.filters) { + const value = search[filter.name]; + if (value !== undefined) query[filter.name] = String(value); + } + + return query; +}; + +/** + * Checks a list the page loaded against the page it asked for. + * + * The API answers a page past the end with the last page, which would put the + * same records under every higher `?page=`. This turns that into a `404`. + */ +export const contentListPage = ({ + list, + search, +}: { + list: TList; + search: ContentListSearch; +}): TList => { + const requested = search.page ?? 1; + + if (requested > 1 && list.pageInfo.currentPage !== requested) { + // oxlint-disable-next-line typescript/only-throw-error + throw notFound(); + } + + return list; +}; + +/** Whether the search is the plain first page, with no `q` and no filter. */ +export const isContentListIndexable = (search: ContentListSearch): boolean => + Object.values(search).every(value => value === undefined); + +/** + * The `` of a list page. + * + * Only the plain first page is indexed, with a canonical link and one alternate + * per language. Later pages and every search or filter result answer + * `noindex, follow`: a crawler skips the page but still follows its links to + * the records. + */ +export const contentListPageHead = ( + { delivery: { list } }: ContentListDefinition, + { + description, + locales, + search, + title, + }: { + description?: string; + /** The languages the page is served in. Defaults to every app locale. */ + locales?: readonly string[]; + search: ContentListSearch | undefined; + title?: string; + }, +): PluginRouteHead => { + const head: PluginRouteHead = {}; + + if (title !== undefined) head.title = title; + if (description !== undefined) head.description = description; + + if (search !== undefined && !isContentListIndexable(search)) { + head.robots = "noindex, follow"; + + return head; + } + + if (list.path === "") return head; + + head.alternates = Object.fromEntries( + (locales ?? getIntlRuntime().localeRouting.locales).map(locale => [ + locale, + list.path, + ]), + ); + + return head; +}; + +/** + * The search of a link that changes the list: a filter, the term or the page. + * + * `undefined` removes a key. Any change other than `page` goes back to the + * first page, because page 3 of one result set is not page 3 of another. + */ +export const nextContentListSearch = ( + search: ContentListSearch, + changes: ContentListSearch, +): ContentListSearch => { + const next: ContentListSearch = { ...search, ...changes }; + if (!("page" in changes)) delete next.page; + if (next.page !== undefined && next.page <= 1) delete next.page; + + for (const [key, value] of Object.entries(next)) { + if (value === undefined) delete next[key]; + } + + return next; +}; diff --git a/packages/vitnode/src/tanstack/content/list.test.tsx b/packages/vitnode/src/tanstack/content/list.test.tsx new file mode 100644 index 0000000000..71c984f795 --- /dev/null +++ b/packages/vitnode/src/tanstack/content/list.test.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, + useSearch, +} from "@tanstack/react-router"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { ContentListSearch } from "./list-page"; + +import { + ContentCard, + ContentListEmpty, + ContentListFilter, + contentListPageNumbers, + ContentListPagination, + ContentListSearchForm, +} from "./list"; +import { contentListSearch } from "./list-page"; + +const validate = contentListSearch({ + delivery: { + list: { + enabled: true, + filters: [{ kind: "boolean", name: "vegetarian", values: null }], + pageSize: 12, + path: "/recipes", + searchable: true, + }, + }, +}); + +const mount = async ( + url: string, + Screen: (props: { search: ContentListSearch }) => React.ReactNode, +) => { + const ListScreen = () => { + const search = useSearch({ strict: false }); + + return ; + }; + + const rootRoute = createRootRoute({ component: Outlet }); + const listRoute = createRoute({ + component: ListScreen, + getParentRoute: () => rootRoute, + path: "/recipes", + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: [url] }), + routeTree: rootRoute.addChildren([listRoute]), + }); + + await act(async () => { + await router.load(); + }); + render(); + + return router; +}; + +const currentSearch = (router: Awaited>) => + validate(router.state.location.search); + +describe("ContentCard", () => { + it("links the title to the record and keeps the image out of the tab order", async () => { + await mount("/recipes", () => ( + + )); + + const title = await screen.findByRole("link", { name: "Shakshuka" }); + expect(title.getAttribute("href")).toBe("/recipes/shakshuka"); + expect(screen.getByRole("heading", { level: 2 }).textContent).toBe( + "Shakshuka", + ); + expect(screen.getByText("30 minutes")).toBeTruthy(); + expect( + screen.getByText("Eggs poached in a spicy tomato sauce."), + ).toBeTruthy(); + + const image = screen.getByAltText("A pan of shakshuka"); + const imageLink = image.closest("a"); + expect(imageLink?.getAttribute("tabindex")).toBe("-1"); + expect(imageLink?.getAttribute("aria-hidden")).toBe("true"); + // The image link is hidden, so assistive technology meets one link. + expect(screen.getAllByRole("link")).toHaveLength(1); + }); +}); + +describe("ContentListEmpty", () => { + it("announces an empty list with the default text", async () => { + await mount("/recipes", () => ); + + const status = await screen.findByRole("status"); + expect(status.textContent).toContain("core.global.no_results.title"); + }); +}); + +describe("contentListPageNumbers", () => { + it("shows every page of a short list", () => { + expect(contentListPageNumbers(2, 3)).toStrictEqual([1, 2, 3]); + }); + + it("keeps the ends and the neighbours of the current page", () => { + expect(contentListPageNumbers(5, 10)).toStrictEqual([ + 1, + null, + 4, + 5, + 6, + null, + 10, + ]); + }); +}); + +describe("ContentListPagination", () => { + it("renders nothing when one page holds everything", async () => { + await mount("/recipes", ({ search }) => ( + + )); + + expect(screen.queryByRole("navigation")).toBeNull(); + }); + + it("links every page and keeps the term and the filters", async () => { + await mount("/recipes?page=2&q=soup&vegetarian=true", ({ search }) => ( + + )); + + const current = await screen.findByRole("link", { current: "page" }); + expect(current.textContent).toBe("2"); + + const first = screen.getByRole("link", { name: "1" }); + expect(first.getAttribute("href")).toBe("/recipes?q=soup&vegetarian=true"); + + const next = screen.getByRole("link", { name: "core.global.next_page" }); + expect(next.getAttribute("href")).toBe( + "/recipes?page=3&q=soup&vegetarian=true", + ); + expect(next.getAttribute("rel")).toBe("next"); + expect( + screen.getByRole("link", { name: "core.global.previous_page" }), + ).toBeTruthy(); + }); + + it("has no previous link on the first page and no next link on the last", async () => { + await mount("/recipes", ({ search }) => ( + + )); + + await screen.findByRole("navigation"); + expect( + screen.queryByRole("link", { name: "core.global.previous_page" }), + ).toBeNull(); + expect( + screen.getByRole("link", { name: "core.global.next_page" }), + ).toBeTruthy(); + }); +}); + +describe("ContentListFilter", () => { + const options = [ + { label: "Vegetarian", value: true }, + { label: "With meat", value: false }, + ]; + + it("marks All as current until a value is chosen", async () => { + const router = await mount("/recipes?page=2", ({ search }) => ( + + )); + + expect( + (await screen.findByRole("navigation", { name: "Diet" })).tagName, + ).toBe("NAV"); + expect(screen.getByRole("link", { current: "page" }).textContent).toBe( + "core.global.all", + ); + + await act(async () => { + fireEvent.click(screen.getByRole("link", { name: "Vegetarian" })); + }); + + // Back to the first page: page 2 of all recipes is not page 2 of these. + expect(currentSearch(router)).toStrictEqual({ vegetarian: true }); + expect(screen.getByRole("link", { current: "page" }).textContent).toBe( + "Vegetarian", + ); + }); + + it("clears the filter from the All link", async () => { + const router = await mount( + "/recipes?vegetarian=false&q=soup", + ({ search }) => ( + + ), + ); + + await act(async () => { + fireEvent.click( + await screen.findByRole("link", { name: "core.global.all" }), + ); + }); + + expect(currentSearch(router)).toStrictEqual({ q: "soup" }); + }); +}); + +describe("ContentListSearchForm", () => { + it("searches from the first page and keeps the active filters", async () => { + const router = await mount( + "/recipes?page=3&vegetarian=true", + ({ search }) => , + ); + + const field = await screen.findByRole("searchbox", { + name: "core.global.search", + }); + expect( + document.querySelector('input[type="hidden"][name="vegetarian"]'), + ).toBeTruthy(); + + fireEvent.change(field, { target: { value: " risotto " } }); + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: "core.global.search" }), + ); + }); + + expect(currentSearch(router)).toStrictEqual({ + q: "risotto", + vegetarian: true, + }); + }); + + it("removes the term when the field is cleared", async () => { + const router = await mount("/recipes?q=soup", ({ search }) => ( + + )); + + const field = await screen.findByRole("searchbox"); + expect((field as HTMLInputElement).value).toBe("soup"); + + fireEvent.change(field, { target: { value: "" } }); + await act(async () => { + fireEvent.submit(screen.getByRole("search")); + }); + + expect(currentSearch(router)).toStrictEqual({}); + }); +}); diff --git a/packages/vitnode/src/tanstack/content/list.tsx b/packages/vitnode/src/tanstack/content/list.tsx new file mode 100644 index 0000000000..5aa0996e8b --- /dev/null +++ b/packages/vitnode/src/tanstack/content/list.tsx @@ -0,0 +1,438 @@ +import { Link, useNavigate } from "@tanstack/react-router"; +import { cn } from "cn"; +import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@/components/ui/input-group"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, +} from "@/components/ui/pagination"; + +import type { ContentListFilterValue, ContentListSearch } from "./list-page"; + +import { nextContentListSearch } from "./list-page"; + +/** A responsive grid of records: one column on phones, up to three on desktop. */ +export const ContentList = ({ + className, + ...props +}: React.ComponentProps<"ul">) => ( +