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 `
}
+ noResults={null}
+ renderItem={(post) => (
+
+ {post.title}
+
+ )}
+ />
+
+ )
+}
+```
+
+Translate the strings through your own locale namespace in real code. They are inline here to keep the example short.
+
+
+
+
+
+### Render it on the server too
+
+`useQuery` does not fetch during SSR, so the box starts as `loading`. To send it with the HTML, warm the same options in the host route's loader. Leave out `LOADER_DATA_QUERY_META`, so the query is dehydrated and `useQuery` finds it in the browser:
+
+```ts title="src/pages/home-page.tsx"
+import { blogPostsQueryOptions } from '@vitnode/blog/pages/blog-queries'
+import { defineRoute } from '@vitnode/core/tanstack/plugin-routes'
+
+export const route = defineRoute({
+ load: async ({ context }) => {
+ await context.queryClient.query(
+ blogPostsQueryOptions({ locale: context.locale, search: {} }),
+ )
+ },
+})
+```
+
+Both calls build the same key, `["@vitnode/blog", "posts", { locale }]` - the same one the first page of `/blog` uses - so they share one cache entry, and a later `invalidateQueries({ queryKey: ["@vitnode/blog", "posts"] })` refreshes the box together with `/blog`.
+
+
+
+
+
+## Check the result
+
+Open the page that renders `` and view its source: the article titles are in the HTML. Publish an article in **AdminCP → Blog → Articles** and reload the page: it appears at the top. An empty `search` is the plain list, newest first.
+
+## Use a different list shape
+
+The blog's list shows 12 rows a page. For a box that needs a different page size, sort or filter set, declare a second list with [`defineContentList`](/docs/content-engine/public-lists-api#definecontentlist) and its own query options. Keep the same `queryKey` prefix, such as `["@vitnode/blog", "posts"]`, so one invalidation still covers both.
diff --git a/apps/web/content/docs/content-engine/public-lists-no-results.webp b/apps/web/content/docs/content-engine/public-lists-no-results.webp
new file mode 100644
index 0000000000000000000000000000000000000000..59eed875963c6dab1f72e617b2974f1ae357dcef
GIT binary patch
literal 35560
zcmeFXRdi&@mL(`=W@ctqh?$w0r4TctiOo(oF%4ub+A$chsq
zPDWaYB4bWN0uJ%LEr4$3ujY->F1%NMCch~?_Pqc$K1BdnpUfX!Z}sO=PsG288~U<<&x8Q7wi{G28aUTKlag4cZ)zI7i#z@tmoH$-6m-8*JG
zzgzITV$>BP7z7LoiEz;Wml{ggaY=s|%5a>ZO;xBo_CAy>qLWrLVo
zInixb{Qy)(+bEKur5IOa1lNnc=442JaVYpdp?a#{O^u3I>aT^@IB2vH;4dB1kkAL*vPJHWO>;<%++2f?`
zJd!Wz6W@9G{_eyWk|<1Az`=1ctj@c=sxJ&r3skSSpkEZn_R<;l<$mx+V5i`}yL<#;
zeQ;tzi`ONYk$)=dFyfaLfqLblZ{q#Guq4sE{c(%#z_!*^*e$<>KPo&uhiWIt(*cnC
zreZJ=FF>#Rme+N|l8z$xiYQ6=PlTp(VS`~k0;-l57A-0vpZ|{6f@CsJd_?9gv71cC
z+eJU7(0_`tzWHz`G9u;0bt{Myg=V6WTi
z`h>DhNadhTtD_}Vexetj1*P>AJD_lQKV`%IThzSYyGhm05-NJLL_KkVr%VH%V7e<|8vE5}BH4RYC~RWc3fj(&>W01^6f
z#umj(?XKlYmwUwk!wwht?9=gX5J6-wrL&!*&q{!J