Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,4 @@ Pages hold 1,000 entries by default (`?limit=` up to 50,000), and `nextCursor` f

Every URL delivery builds follows the app's [localized URL rules](/docs/dev/i18n/localized-urls): language prefixes, per-language domains and translated route segments. With [localization](/docs/dev/content-engine/localization) and a localized slug, and an app that translates `/recipes` in its `routePaths`, the Polish recipe can live at `/pl/przepisy/szakszuka-dla-dwojga` while the English one stays at `/recipes/shakshuka-for-two`. Only published translations become alternate links.

Every `delivery` option, with its default and requirements, is in the [content type reference](/docs/dev/content-engine/reference#delivery).
To list every recipe on one page with search and filters, continue with [Show a list of records](/docs/dev/content-engine/list-pages). Every `delivery` option, with its default and requirements, is in the [content type reference](/docs/dev/content-engine/reference#delivery).
3 changes: 2 additions & 1 deletion apps/web/content/docs/dev/content-engine/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const recipeContentType = defineContentType({
| Every content type | Staff routes and `can_view`, `can_create`, `can_edit` and `can_delete` permissions |
| `publication` | Draft and published states, `can_publish`, and Publish buttons |
| `publicApi` | Read-only public routes that return only the fields you list |
| `delivery` | Page URL resolution, SEO metadata, slug redirects and sitemap entries |
| `delivery` | Page URL resolution, SEO metadata, slug redirects, sitemap entries and an optional list page |
| `editorial` | Revision history with restore, signed preview links and scheduled publishing |
| `localization` | A translations table, per-language fields and per-language publishing |
| `search` | Records in the site [search](/docs/dev/search) index |
Expand All @@ -54,6 +54,7 @@ Content types live in code and in your migrations, not in the database. There is
| Build a content type from scratch, with AdminCP screens | [Create your first content type](/docs/dev/content-engine/defining-a-content-type) |
| Add drafts and a public, read-only API | [Publish content with a public API](/docs/dev/content-engine/public-api-and-caching) |
| Give each record its own page with SEO metadata | [Show content on a public page](/docs/dev/content-engine/content-delivery-and-seo) |
| List published records with pagination, search and filters | [Show a list of records](/docs/dev/content-engine/list-pages) |
| Link records together, group fields or repeat rows | [Relations and advanced fields](/docs/dev/content-engine/relations-and-advanced-modeling) |
| Translate content into several languages | [Translate content](/docs/dev/content-engine/localization) |
| Keep revisions, share draft previews or schedule publishing | [Revisions, previews and scheduling](/docs/dev/content-engine/publication-and-editorial) |
Expand Down
81 changes: 81 additions & 0 deletions apps/web/content/docs/dev/content-engine/list-pages-reference.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
title: List page reference
description: Every helper and component VitNode provides for Content Engine list pages, from @vitnode/core/tanstack/content, with parameters, defaults and return values.
icon: BookOpen
---

Everything a [list page](/docs/dev/content-engine/list-pages) uses comes from `@vitnode/core/tanstack/content`. The helpers read a content type's resolved `delivery.list`, so any definition returned by `defineContentType` can be passed where a `definition` is expected.

```ts
import {
contentListPage,
contentListPageHead,
contentListQuery,
contentListSearch,
} from "@vitnode/core/tanstack/content";
```

## `ContentListSearch`

The page's URL after `contentListSearch` has read it.

| Key | Type | Description |
| ------------------ | -------------------------------- | ----------------------------------------------------------- |
| `page` | `number` | Set from 2 upwards. Page 1 has no `?page=`, so it has one URL |
| `q` | `string` | The trimmed search term, at most 256 characters |
| any declared filter | `boolean` \| `number` \| `string` | One key per field in `delivery.list.filters` |

## Helpers

| Helper | Returns | Behavior |
| ------ | ------- | -------- |
| `contentListSearch(definition)` | `(input) => ContentListSearch` | The route's `search` validator. Drops a value it cannot read instead of failing |
| `contentListQuery(definition, search)` | `ContentListQuery` | The `args.query` of the public list route: `first`, plus `page`, `search` and each filter as strings |
| `contentListPage({ list, search })` | the `list` it was given | Throws `notFound()` when `search.page` is past the last page |
| `contentListPageHead(definition, options)` | `PluginRouteHead` | The route's `head`. See below |
| `nextContentListSearch(search, changes)` | `ContentListSearch` | The search of a link that changes the list. `undefined` removes a key, and any change except `page` goes back to page 1 |
| `isContentListIndexable(search)` | `boolean` | `true` only for the plain first page |

### How filters are read

`contentListSearch` reads each filter by the kind of its field:

| Field kind | URL value | Search value |
| ------------------------------ | --------------------- | ------------ |
| `field.boolean()` | `true` or `false` | `boolean` |
| `field.enum()` | one of its `values` | `string` |
| `field.number()` | a finite number | `number` |
| `field.relation()`, `field.user()` | a positive whole number | `number` |
| `field.text()`, `field.slug()` | up to 255 characters | `string` |

### `contentListPageHead` options

| Name | Type | Required | Default | Description |
| ------------- | --------------------------------- | -------- | ---------------- | --------------------------------- |
| `search` | `ContentListSearch \| undefined` | Yes | | The `search` that `head` receives |
| `title` | `string` | No | | The page title, already translated |
| `description` | `string` | No | | The meta description |
| `locales` | `readonly string[]` | No | every app locale | Languages that get an `hreflang` link |

The plain first page gets a canonical link and one alternate per language, all pointing at `delivery.list.path`. Any other page returns `robots: "noindex, follow"` and no alternates.

## Components

All components render semantic HTML, take a `className`, and translate their own labels through the `core.global` namespace.

| Component | Renders | Props |
| --------- | ------- | ----- |
| `ContentList` | A `<ul>` grid: one column on phones, two from `sm`, three from `lg` | Any `<ul>` prop |
| `ContentListItem` | An `<li>` for one card | Any `<li>` prop |
| `ContentCard` | An `<article>` with an optional image, a meta line, the title as a link and a description | `href`, `title`, `description?`, `image?` (`{ src, alt, width?, height? }`), `meta?`, `headingLevel?` (`2` or `3`, default `2`) |
| `ContentListEmpty` | A `role="status"` message for an empty list | `title?`, `description?`. Defaults to "No results found" |
| `ContentListPagination` | A `<nav>` with previous, numbered and next links | `page`, `totalPages`, `search`. Renders nothing for one page |
| `ContentListSearchForm` | A `role="search"` `GET` form with a search field and a **Search** button | `search`, `label?`, `placeholder?` |
| `ContentListFilter` | A `<nav>` with a visible label and a row of links, **All** first | `name`, `label`, `options` (`{ label, value }[]`), `search`, `allLabel?` |

### Links and accessibility

- Pagination and filter links are plain `<a href>` elements built with the router's `Link`, so they work without JavaScript and crawlers can follow them. The current page and the active filter carry `aria-current="page"`.
- `ContentCard` makes only the title a link for assistive technology. The image links to the same page with `tabIndex={-1}` and `aria-hidden`, so a keyboard user reaches each record once.
- `ContentListSearchForm` submits as a normal `GET` form without JavaScript. With it, the router navigates without a reload. Active filters travel along as hidden fields.
- `ContentListPagination` shows the first page, the last page and the neighbours of the current one, with an ellipsis for the gaps.
Loading
Loading