From 8022297ab5dbf94f7cf5ec21ced3016aea891023 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:00:21 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(blog):=20=E2=9C=A8=20add=20the=20/blog?= =?UTF-8?q?=20index=20and=20reusable=20public=20content-list=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Content Engine: `publicApi.listFields` now shapes the list route's response schema (`schemas.publicListSelectObject`) and `ContentPublicListRow`, so list types and OpenAPI match the runtime projection. - `@vitnode/core/tanstack/content`: `defineContentList`, `contentListQueryOptions`, `contentListPageHead`, `ContentListResults`, `ContentListPagination` and `useContentListSearchInput`. - `@vitnode/core/content/server`: export `resolveContentRequestLocale`, `contentLocaleHeaders`, `rememberContentPublicRead` and the page cache tag helpers; the public routes use the shared locale helper. - Plugin route robots accept `noindex, follow` for list variants. - Blog: `/blog` with search, category filter, sort and numbered pagination in the URL, cards from a lightweight `listFields` projection, and a public `GET /categories` summary route cached per locale. - Docs: public lists guide, embedding guide, API reference and blog guide. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B89NT73f4bRH7pk5MU6XiU --- .../web/content/docs/content-engine/meta.json | 3 + .../docs/content-engine/public-api.mdx | 2 +- .../docs/content-engine/public-lists-api.mdx | 122 ++++++ .../content-engine/public-lists-embed.mdx | 102 +++++ .../public-lists-no-results.webp | Bin 0 -> 35560 bytes .../docs/content-engine/public-lists.mdx | 315 ++++++++++++++ .../content/docs/content-engine/reference.mdx | 2 +- apps/web/content/docs/guides/blog-index.webp | Bin 0 -> 111038 bytes apps/web/content/docs/guides/blog.mdx | 83 +++- apps/web/src/page-cache.gen.ts | 1 + packages/vitnode/src/content/define.ts | 21 +- packages/vitnode/src/content/index.ts | 1 + packages/vitnode/src/content/schemas.ts | 30 +- packages/vitnode/src/content/server/index.ts | 9 + .../src/content/server/public-routes.ts | 76 +--- .../src/content/server/request-locale.ts | 76 ++++ packages/vitnode/src/content/types.ts | 72 +++- packages/vitnode/src/routing/module.ts | 11 +- .../vitnode/src/tanstack/content/index.ts | 20 + .../vitnode/src/tanstack/content/list-head.ts | 68 ++++ .../vitnode/src/tanstack/content/list-ui.tsx | 319 +++++++++++++++ packages/vitnode/src/tanstack/content/list.ts | 314 ++++++++++++++ .../vitnode/src/tanstack/metadata/index.ts | 5 +- .../src/tanstack/plugin-routes/head.ts | 1 + .../modules/categories/categories.module.ts | 132 ++++++ plugins/blog/src/config.api.ts | 8 +- plugins/blog/src/content/post.ts | 12 + plugins/blog/src/locales/en.json | 32 ++ plugins/blog/src/pages/blog-card.tsx | 119 ++++++ plugins/blog/src/pages/blog-list.ts | 35 ++ plugins/blog/src/pages/blog-page.tsx | 384 ++++++++++++++++++ plugins/blog/src/pages/blog-queries.ts | 110 +++++ plugins/blog/src/pages/post-query.ts | 3 + plugins/blog/src/routes.ts | 21 +- 34 files changed, 2407 insertions(+), 102 deletions(-) create mode 100644 apps/web/content/docs/content-engine/public-lists-api.mdx create mode 100644 apps/web/content/docs/content-engine/public-lists-embed.mdx create mode 100644 apps/web/content/docs/content-engine/public-lists-no-results.webp create mode 100644 apps/web/content/docs/content-engine/public-lists.mdx create mode 100644 apps/web/content/docs/guides/blog-index.webp create mode 100644 packages/vitnode/src/content/server/request-locale.ts create mode 100644 packages/vitnode/src/tanstack/content/list-head.ts create mode 100644 packages/vitnode/src/tanstack/content/list-ui.tsx create mode 100644 packages/vitnode/src/tanstack/content/list.ts create mode 100644 plugins/blog/src/api/modules/categories/categories.module.ts create mode 100644 plugins/blog/src/pages/blog-card.tsx create mode 100644 plugins/blog/src/pages/blog-list.ts create mode 100644 plugins/blog/src/pages/blog-page.tsx create mode 100644 plugins/blog/src/pages/blog-queries.ts diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index ea2190734e..4f5998faa2 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -12,6 +12,8 @@ "public-write", "public-write-frontend", "public-pages", + "public-lists", + "public-lists-embed", "seo", "search", "caching", @@ -43,6 +45,7 @@ "reference", "fields", "plugin-files", + "public-lists-api", "public-write-api", "container-policies-api", "live-editing-api" diff --git a/apps/web/content/docs/content-engine/public-api.mdx b/apps/web/content/docs/content-engine/public-api.mdx index 558925354c..46a1320bc8 100644 --- a/apps/web/content/docs/content-engine/public-api.mdx +++ b/apps/web/content/docs/content-engine/public-api.mdx @@ -251,7 +251,7 @@ All routes are `GET` and live under `/api/{pluginId}/content/{publicApi.path}`. | `/delivery/sitemap` | Sitemap entries, only with `delivery.sitemap` | | `/thread?root={id}` | One page of a reply tree under a root record, only with [`thread`](/docs/content-engine/threads) | -The list accepts `first` or `last` (at most 50, also the default), `cursor`, `page`, `search` over `searchableFields`, `orderBy`, `order` and one parameter per filterable field. A filterable [container field](/docs/content-engine/fields#container) also accepts `includeDescendants=true`, so `?board=4&includeDescendants=true` lists board 4 and every board below it; an unknown container answers `404`. See [Containers on the frontend](/docs/content-engine/container-frontend#add-the-board-page). A record is public only when its `status` is `published`, its `publishedAt` is set and not later than the database clock, and it is not [hidden](/docs/content-engine/content-visibility). List rows carry every field in `fields` unless you set `listFields` to a smaller set - handy when a `body` would make every page heavy. Every `publicApi` option is in the [content type reference](/docs/content-engine/reference#publicapi). +The list accepts `first` or `last` (at most 50, also the default), `cursor`, `page`, `search` over `searchableFields`, `orderBy`, `order` and one parameter per filterable field. A filterable [container field](/docs/content-engine/fields#container) also accepts `includeDescendants=true`, so `?board=4&includeDescendants=true` lists board 4 and every board below it; an unknown container answers `404`. See [Containers on the frontend](/docs/content-engine/container-frontend#add-the-board-page). A record is public only when its `status` is `published`, its `publishedAt` is set and not later than the database clock, and it is not [hidden](/docs/content-engine/content-visibility). List rows carry every field in `fields` unless you set `listFields` to a smaller set - handy when a `body` would make every page heavy. To build a page on top of this route, follow [Public lists](/docs/content-engine/public-lists). Every `publicApi` option is in the [content type reference](/docs/content-engine/reference#publicapi). Next, give each article a web page with [Public pages](/docs/content-engine/public-pages), or see how these reads are cached in [Caching](/docs/content-engine/caching). diff --git a/apps/web/content/docs/content-engine/public-lists-api.mdx b/apps/web/content/docs/content-engine/public-lists-api.mdx new file mode 100644 index 0000000000..c352f1aba1 --- /dev/null +++ b/apps/web/content/docs/content-engine/public-lists-api.mdx @@ -0,0 +1,122 @@ +--- +title: Public list API +description: Reference for the VitNode Content Engine public list helpers in @vitnode/core/tanstack/content - defineContentList, contentListQueryOptions, contentListPageHead, ContentListResults, ContentListPagination and useContentListSearchInput. +icon: FileJson +--- + +Every helper on this page is exported from `@vitnode/core/tanstack/content` and is universal unless noted. For a guided setup, follow [Public lists](/docs/content-engine/public-lists). Examples use the blog plugin's `/blog` list. + +## `defineContentList` + +Declares a list's sorts, URL filters and page size, and returns pure helpers for the URL state. + +```ts +const blogPostList = defineContentList({ + defaultSort: 'newest', + filters: { category: contentListIdFilter }, + pageSize: 12, + sorts: { + newest: { order: 'desc', orderBy: 'publishedAt' }, + oldest: { order: 'asc', orderBy: 'publishedAt' }, + }, +}) +``` + +| Name | Type | Required | Default | Description | +| ------------- | ------------------------------------------------------ | -------- | ------- | -------------------------------------------------------------------------------------------------- | +| `sorts` | `Record` | Yes | | Each URL value of `sort`. `orderBy` must be in `publicApi.orderableFields` or be `publishedAt` | +| `defaultSort` | a key of `sorts` | Yes | | The sort a URL without `sort` uses. Never written to the URL | +| `filters` | `Record value \| undefined>` | No | none | URL filters by URL name. A parser returns a string, number or boolean, or `undefined` to ignore it | +| `pageSize` | `number` | No | `12` | Sent as `first`. Clamped to 1-50, the public list route's limit | + +`contentListIdFilter` parses a positive integer id, such as `?category=3`. + +| Method | Returns | Behavior | +| ---------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `parseSearch(input)` | `ContentListSearch` | The route's `search` validator. Keeps `page` above 1, a trimmed `q` of up to 256 characters, a non-default `sort` and valid filters. Drops everything else | +| `withSearch(current, patch)` | `ContentListSearch` | Applies a change. Changing anything but `page` resets it to the first page. `undefined` clears a value | +| `isFiltered(search)` | `boolean` | `true` when a term, a filter or a non-default sort is set. `page` does not count | +| `apiQuery(search)` | `ContentListApiQuery` | `{ first, order, orderBy, page, search? }` for the list route. Map filters yourself | +| `href(pathname, search)` | `string` | The internal URL of one state, e.g. `/blog?category=3&page=2` | +| `sorts` | `string[]` | Every sort, in declaration order | +| `defaultSort`, `pageSize` | value | The resolved config | + +## `contentListQueryOptions` + +TanStack Query options for one page of a list. Use them in a loader with `queryClient.query(...)` and in a component with `useQuery(...)`. + +| Name | Type | Required | Default | Description | +| ----------- | ---------------------------------------- | -------- | -------- | ------------------------------------------------------------------------- | +| `queryKey` | `QueryKey` | Yes | | The prefix every page shares, e.g. `["@vitnode/blog", "posts"]` | +| `locale` | `string` | Yes | | The page's locale. Pass it on to the API as `?locale=` | +| `search` | object | Yes | | The normalized state from `parseSearch`, or a fixed one such as `{}` | +| `fetch` | `({ locale, search }) => Promise` | Yes | | Makes the request. Write the `fetcher` call here so its route stays typed | +| `staleTime` | `number` | No | `60_000` | Milliseconds a page stays fresh, `RECORD_STALE_TIME` | + +The key is `[...queryKey, { locale, ...search }]`. Add `meta: LOADER_DATA_QUERY_META` when a route returns the data from its loader, so it is not sent to the browser twice. + +## `contentListPageHead` + +Builds a plugin route `head` for a list page. + +| Name | Type | Required | Default | Description | +| ------------- | ------------------- | -------- | ----------------------- | -------------------------------------- | +| `pathname` | `string` | Yes | | The list's internal path, e.g. `/blog` | +| `search` | object | Yes | | The normalized state | +| `title` | `string` | No | | The page title, already translated | +| `description` | `string` | No | | The meta description | +| `locales` | `readonly string[]` | No | every configured locale | The locales the page exists in | + +With an empty `search`, the head is `index, follow` with a canonical link and one `hreflang` alternate per locale. Any other state is `noindex, follow` without a canonical. + +## `ContentListResults` + +Renders one of the list's states. A React component; item rendering and every label come from you. + +| Prop | Type | Required | Default | Description | +| ---------------------------------------- | ---------------------------- | -------- | ------- | ------------------------------------------------ | +| `items` | `TItem[] \| undefined` | Yes | | `undefined` while the first page loads | +| `renderItem` | `(item, index) => ReactNode` | Yes | | One item, inside an `
  • ` | +| `getKey` | `(item) => Key` | Yes | | The React key | +| `label` | `string` | Yes | | The list's accessible name | +| `isFiltered` | `boolean` | Yes | | Picks `noResults` over `empty` for an empty page | +| `loading`, `error`, `empty`, `noResults` | `ReactNode` | Yes | | What each state shows | +| `isError` | `boolean` | No | `false` | Shows `error` while there are no items | +| `isFetching` | `boolean` | No | `false` | Marks the list `aria-busy` and dims it | +| `as` | `"ul" \| "ol"` | No | `"ul"` | The list element | +| `className`, `itemClassName` | `string` | No | | Classes for the list and each `
  • ` | + +## `ContentListPagination` + +Numbered page links from the list route's `pageInfo`. Renders nothing for a single page. Links go through `HrefLink`, so the locale prefix is added and every page works without JavaScript. + +| Prop | Type | Required | Default | Description | +| ------------ | -------------------------- | -------- | ------------ | ----------------------------------------------- | +| `pageInfo` | `ContentListPageInfo` | Yes | | The list response's `pageInfo` | +| `hrefFor` | `(page: number) => string` | Yes | | A page's internal URL, usually `list.href(...)` | +| `label` | `string` | No | "Pagination" | The `