Content model
A content type is one typed contract. It lists the fields, the storage source, and the publish workflow. Core reads the contract to make the server routes and the admin UI.
Declare a content type
Wrap a content type in defineContentType. It gives TypeScript the narrowed field types.
export const postType = defineContentType({
name: 'post',
label: 'Post',
labelPlural: 'Posts',
source: 'posts',
titleField: 'title',
fields: [
{ name: 'title', type: 'text', label: 'Title', required: true, inList: true },
{ name: 'body', type: 'richtext', label: 'Body' }
],
status: {
values: [{ label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }],
default: 'draft',
publishedValue: 'published'
}
})
defineContentType returns the type unchanged. In development, it prints a warning for these problems:
- A duplicate field
name. - A
titleFieldthat names no field. - A
slug.fieldthat names no field of typeslug. - A
selectfield with no options. - A
referencefield with notargetand nooptionsUrl. - A
referencefield that setscreate,publicFields, orrememberwith notarget. - An empty
sourcewith nofiles.nested. - A canvas form whose
form.canvasFieldnames no field. - A
statuswith no values.
A type with a problem still loads.
Field types
| Type | Built-in widget |
|---|---|
text, textarea | PlutoFieldText |
slug | PlutoFieldSlug |
number | PlutoFieldNumber |
boolean | PlutoFieldBoolean |
date | PlutoFieldDate |
select | PlutoFieldSelect |
reference | PlutoFieldReference |
media | PlutoFieldMedia. The picker opens the media library. |
richtext | None in core. A layer registers the widget. The Git CMS registers a Markdown widget for richtext. |
Every field has name, type, and label. It can also have display hints: inList, inForm, order, region, group, widget, filter, and sortable.
regionismainorside. The default ismain.groupputs a side field in a collapsible group of the type (sideGroups). See Groups in the aside.columnsets the storage column when it differs fromname.virtual: truemarks a field that has no storage column. The value passes through validation and the form payload. A hook can then read it.listFormatis a pure function. It sets the text of the field in the generic list.filteradds (true) or removes (false) the filter of the field in the generic list. Areferencewith atargethas a filter by default. Aselectand abooleanhave one when they areinList. Other types cannot filter.sortable: falsestops the sort on the column of the field. AninListfield of typetext,textarea,slug,number,select,boolean, ordatesorts by default.defaultgives a new item its first value in the admin form. The server does not apply it.
A textarea grows with the text from rows (default 4) up to maxRows (default 14).
A richtext field can set editor to 'auto' (the default), 'visual', or 'source'. The Markdown widget of the Git CMS reads it to pick the first mode of its Visual/Source switch. Other widgets ignore it. See Editing.
name and id
A content type is registered as an entry with an id, and the type goes under type. The registry rewrites the id to <extensionId>:<id>. The type.name is not rewritten. Always read type.name for the real identity of a content type.
Register a content type
A layer registers its types in a plugin:
export default defineNuxtPlugin(() => {
definePlutoExtension({
id: 'my-layer',
contentTypes: [{ id: 'post', type: postType }]
})
})
On the server, the layer registers the type in a Nitro plugin:
export default defineNitroPlugin(() => {
registerContentType(postType)
})
A backend layer registers the adapter on the server. The last adapter wins.
Extend a content type of another layer
A layer can add fields to a type that another layer registers. The SEO layer adds its fields to post this way. An extension is a pure function that returns a new type. Give the same extension on the client and on the server:
export const ratingExtension: PlutoContentTypeExtension = {
id: 'rating',
extend: (type) =>
type.name === 'post'
? { ...type, fields: [...type.fields, { name: 'rating', type: 'number', label: 'Rating' }] }
: type
}
export default defineNuxtPlugin(() => {
definePlutoExtension({ id: 'rating', contentTypeExtensions: [ratingExtension] })
})
export default defineNitroPlugin(() => {
extendContentTypes(ratingExtension)
})
The registered type does not change. getContentType, listContentTypes and usePlutoContentTypes() give the extended type. An extension with the same id replaces the earlier one. On Supabase, a new field needs a column in the table of the type.
Content type options
| Option | Purpose |
|---|---|
source | The table or view name that the type reads and writes. For the Git CMS, it is the folder. |
primaryKey | The storage column of the id. The default is id. |
slug | { field } names the slug field. false turns slugs off. |
defaultSort | { field, direction }. The generic list sorts with it when the user picks no sort. |
status | The publish workflow. false means no workflow. schedule and review add scheduled publish and pending review. See Scheduled publish and review. |
excerpt | true or { field }. A short summary. See Excerpt and cover image. |
cover | true or { field }. The cover image. See Excerpt and cover image. |
description | One sentence about the type for the editor. The page of a singleton type shows it. |
singleton | true, or { id }. The type has one item, edited in one form. See Singleton types. |
trash | true keeps a removed item in the trash for 30 days. { column } sets the Supabase column (default deleted_at). See Trash. |
timestamps | The created and updated columns. Set a key to false to disable it, or set false for both. |
capabilities | read, write, delete, and publish. |
autoRoutes | Set false to skip the generated nav entry and pages. |
basePath, newPath, editPath | URLs for the list, new, and edit pages. editPath is a function of the id. |
adminBar | true, or { new, edit }. Buttons of the type in the admin bar. See Admin bar buttons. |
liveOnSave | true when the public site reads the type at run time, so a save shows at once. The build status then shows Live right after a save. |
api | { public, fields }. public: true serves the type on the Public API. fields lists the fields of the public answer. |
form | Layout options for the generic form. See Lists and forms. |
files | Options for the file adapters (the Git CMS): nested, exclude, and frontmatter. See Nested content types. |
hooks | afterCreate, afterUpdate and toPublic. They run on the server. |
previewUrl | A function of the item. It gives the public URL for the View link of the form, or undefined when the item has no public page. It must be pure. |
navOrder, icon | Navigation order and icon. |
navGroup | The id of a nav group, for example 'pages'. The types of a group show as one sidebar item and in one New menu of the admin bar. |
Admin bar buttons
Set adminBar to show buttons of the type in the admin bar:
new: a New button (for example New Post) on every page where the bar shows. It opens the new page of the type.edit: an Edit button (for example Edit Post) on a public page that callsusePlutoEditLink(type, id). It opens the edit page of that item.
adminBar: true gives both. With no adminBar, the type adds no button. The write capability of the type decides who sees them.
When two or more types can show a New button, the bar shows one New button that opens a menu. The types without a group are items of the menu. A group (navGroup) with two or more types is a submenu after them. A group with one type shows that type as an item. When all types are in one group, the menu lists them directly, without a submenu. The menu opens on hover too.
In the admin sidebar, each content type item has a + button at the right end. It shows on hover and on keyboard focus, and it opens the new page of the type.
const postType: PlutoContentType = {
name: 'post',
// ...
adminBar: true,
}
The helpers contentTypeNewPath(type) and contentTypeEditPath(type, id) give the same URLs as the list and the form. See Admin shell.
Capabilities
If a type declares no capability for an operation, that operation is open to anyone who can reach the route.
writeanddeleteare enforced by the generic routes through the adapterauthorizemethod.readis never enforced by the generic routes. It only decides if the nav entry shows. The adapter and its store decide what a caller can read.publishfalls back towritewhen unset.
can('') as a stand-in for "no capability declared". Test for the capability first, as the generic list and form do.Hooks
hooks.afterCreate and hooks.afterUpdate run on the server after the adapter call succeeds. If a hook throws, the whole write fails.
hooks: {
afterCreate: async (ctx, item, rawBody) => { /* ... */ },
afterUpdate: async (ctx, item, rawBody) => { /* ... */ }
}
A virtual field reaches the hook in rawBody.
hooks.toPublic(ctx, data, { view, item, batch }) changes the answer of the Public API for one item. Return the object to send. batch holds every item of the answer, so the hook can load related rows once for all of them. Set it where the layer registers the type on the server.
Validate a payload
validateContentPayload(type, payload, options) checks a payload against the field rules. It has no I/O.
const errors = validateContentPayload(postType, { title: '' })
// [{ field: 'title', message: '"Title" is required.' }]
Pass { partial: true } for a PATCH. A required field that is missing is then not an error. A required field with an empty value is always an error.
The function checks required, number (numeric, integer, min, max), boolean, select options, and maxLength. A date field gets only the required check. A reference field must hold a list of ids when it is multiple, else one id. An empty list fails required. This is true for a multiple media field too.
Each error can have a code and params for translation.
The generic server routes
All routes use the prefix /api/_pluto/content/. They are internal routes, for the admin. They are not a stable contract. A public site uses the Public API.
Every official layer follows one rule: an internal route is under /api/_pluto/<area>/ (core: content, content-activity, content-trash, content-revisions, content-revision, content-backup, media, mail; a layer: its own name, for example /api/_pluto/supabase/ or /api/_pluto/shop/). Core blocks a cross-site write to /api/_pluto/** for every layer.
| Route | Method | Action |
|---|---|---|
/api/_pluto/content/[type] | GET | List |
/api/_pluto/content/[type] | POST | Create |
/api/_pluto/content/[type]/[...id] | GET | Get one |
/api/_pluto/content/[type]/[...id] | PATCH | Update |
/api/_pluto/content/[type]/[...id] | DELETE | Delete |
The id routes are catch-alls, so an id can hold slashes (1.start/2.install).
The list route accepts limit, offset, search, status, filter[<field>], and sort (field for ascending, -field for descending). parseAdminListQuery reads them, with the rules of the generic list. A value that the type does not allow gives 400. Without sort, the list uses defaultSort. For a type with a status workflow, published=1 returns published rows only.
An adapter applies query.status on list: it returns only the items with that status. An item with no stored status has the default status. A type with no workflow ignores it. The Git CMS and the Supabase adapters apply it.
An adapter can implement the optional countCreated. The dashboard activity chart uses it.
For the Public API, an adapter applies query.filters (equality, compared as text), and can implement timestampKeys(type) (the item keys of the created, updated and published-at times) and toPublic (its own keys in the public answer).
An adapter can also implement the optional readFile(ctx, relativePath). It reads one raw file in the folder of the type, for example 1.start/.navigation.yml, and returns { text, version } or null. The Git CMS adapter implements it. It returns null for a path that can leave the folder (.., an absolute path, a backslash). A .md file follows the rules of a get, so an anonymous caller never reads a draft.
Public reads from a layer
A layer route that serves public pages calls markPublicRead(event) first. It is auto-imported on the server. The adapter then treats the caller as anonymous: the CMS adapter reads no session, and the Supabase adapter reads with the anon key. Only published items come out. Put such a route under /api/pluto/v1/<layer>/.
export default defineEventHandler(async (event) => {
markPublicRead(event)
// Read through the adapter of the type.
})
Singleton types
A singleton type has one item: the site info, the contact data, the social links, a home page with fixed sections. Set singleton: true:
export const contactType = defineContentType({
name: 'contact',
label: 'Contact info',
labelPlural: 'Contact info',
description: 'The email, phone and address in the footer of every page.',
icon: 'lucide:contact',
source: 'settings',
titleField: 'email',
singleton: true, // <-- This defines the singleton type
status: false,
adminBar: { edit: true },
api: { public: true },
fields: [
{ name: 'email', type: 'text', label: 'Email', required: true },
{ name: 'phone', type: 'text', label: 'Phone' },
{ name: 'address', type: 'textarea', label: 'Address' }
]
})
Register it like any type. Then:
- The sidebar has one item, with the
label. It opens the form at/admin/content/contact, under the icon, the label, thedescriptionand the time of the last save. There is no list, no New and no Delete. Before the first save, the form is empty./newand/<id>go to the form. - The server refuses a second item (409) and a delete (400).
- The dashboard leaves the type out of its counts.
Storage:
- Git CMS: one file,
<dir>/<source>/<id>.md. Theidissingleton.id, else the typename. The example writescontent/settings/contact.md, so several singletons can share thesettingsfolder. The id must be lowercase letters, numbers and dashes. - Supabase: the
sourcetable, with one row. Create the table like for any type (see Add a content type).
On the site, usePlutoSingleton(type) reads the item. The type needs api.public: true. Your editor suggests the names of the singleton types, and item has the fields of the type (see Item types). item is null until an editor saves the item, so keep a fallback:
<script setup lang="ts">
const { item: contact } = await usePlutoSingleton('contact')
</script>
<template>
<footer>
<a v-if="contact" :href="`mailto:${contact.email}`">{{ contact.email }}</a>
</footer>
</template>
With adminBar.edit, the admin bar shows Edit Contact info on every page that calls it. Another stack reads GET /api/pluto/v1/content/contact?limit=1 and takes data[0].
singleton ignores trash and slug.
Item types
Pluto reads the item type from the definition. Each export const x = defineContentType({ ... }) in the shared/utils/ folder of your app or a layer joins the global PlutoContentTypes map, under its name. Nuxt writes the map at build time and in dev.
const contact: PlutoItem<'contact'> = ...
// { id: string | number, email: string, phone?: string | null, address?: string | null, createdAt?: string, ... }
- A
requiredfield is always there. Any other field is optional and can benull. - A
selectgives its option values, anumbera number, abooleana boolean. Areferencewith atargetgives{ id, label, slug? }, amediafield the media record. api.fieldslimits the item to those fields.PlutoContentTypeNameis every name, andPlutoSingletonTypeNamethe names of the singleton types.
export const x: PlutoContentType = defineContentType(...)). The annotation hides the literal name and fields, and the type leaves the map. Keep the file at the top level of shared/utils/: Nuxt does not auto-import a subfolder.A type that Pluto cannot find takes the item type as a type argument: usePlutoSingleton<MyContact>('contact').
Trash
For a type with trash, the delete route moves the item to the trash. Without it, the delete is final. The admin shows the trash in a tab of the list. See The trash.
An adapter with a trash has the trash object, with five methods. Each method needs the delete capability of the type.
| Method | Does |
|---|---|
move(ctx, id) | Moves a live item to the trash. 404 when it does not exist. |
list(ctx, { limit, offset, search }) | The items in the trash, the last one first. Each item has _deletedAt. search matches the title. |
restore(ctx, id) | Moves the item back. 404 when it is not in the trash. 409 when a live item has the id. |
purge(ctx, id) | Deletes an item in the trash forever, and the links to it. |
purgeBefore(ctx, before) | Deletes forever every item that went to the trash before before (ISO). Returns the count. |
An item in the trash is not in list, get, countCreated, the public API, or the reference expansion. It keeps its id and the links to it until the purge.
The routes:
| Route | Method | Does |
|---|---|---|
/api/_pluto/content-trash/[type] | GET | Deletes the items older than 30 days, then lists the trash (limit, offset, search). |
/api/_pluto/content-trash/[type] | DELETE | Empties the trash. Answers { purged }. |
/api/_pluto/content-trash/[type]/[...id] | POST | Restores the item. |
/api/_pluto/content-trash/[type]/[...id] | DELETE | Deletes the item forever. |
A type without trash gives 404. An adapter without trash gives 501.
The Nitro task pluto:trash:purge runs every day at 03:00 (nitro.scheduledTasks in the core nuxt.config.ts). It calls purgeBefore for each type with trash, as a system request (createPlutoSystemEvent). A system request has no user. The adapter gives it the server credentials of its backend: the GitHub App for the Git CMS, the service role key for Supabase. Without them, the task skips the type and logs a warning. To stop the task, set nitro.scheduledTasks['0 3 * * *'] to [] in your app. In development, POST /_nitro/tasks/pluto:trash:purge runs it.
On the Git CMS, an item in the trash is the file <dir>/.trash/<type name>/<id>.md, with deletedAt in the frontmatter. On Supabase, it is a row with deleted_at set. See The content adapter.
Scheduled publish and review
Two options of status add a step to the publish workflow. Both are off by default.
status: {
column: 'status',
default: 'draft',
publishedValue: 'published',
publishedAtColumn: 'publishedAt', // schedule needs it
values: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
schedule: true, // or { value: 'planned' }. The default value is 'scheduled'.
review: true, // or { value: 'pending' }. The default value is 'review'.
},
capabilities: { write: 'posts:write', publish: 'posts:publish' },
- Do not add the scheduled and review values to
values. Core adds them. - The time of a scheduled item is in
publishedAtColumn. There is no other column. A type withoutpublishedAtColumncannot schedule. - A scheduled save sends
status: 'scheduled'andpublishAt(an ISO date after now). A missing or past time gives 400. A save that keeps the scheduled status keeps the time. Publish now sets the time to now. A scheduled item that goes back to a draft or to review loses its time. - Live means published, or scheduled with a time that has come. The public API, the reference expansion, and the Git CMS reads use this rule. The rule is
isLiveStatus(type, status, publishedAt)inshared/utils/content-status.ts(auto-imported). - Capabilities. A user with the write capability but not the publish capability can keep a draft or a review item, and move it between draft and review. The publish capability is necessary to set or to leave published or scheduled, and to change a published or scheduled item. This applies only when
capabilities.publishis not the same ascapabilities.write. The rule isstatusChangeNeedsPublish(type, from, to).
An adapter that can publish the due items has publishDue(ctx, now). It sets every scheduled item with a time of now or earlier to published, and keeps the time. It returns the count.
The Nitro task pluto:publish:scheduled runs every 5 minutes. It calls publishDue for each type with schedule, as a system request (see Trash). A Node, Bun, or Deno server and Cloudflare run it. Vercel and Netlify do not run Nitro tasks with Nitro 2. There, the read-time rule shows the item at its time, and the admin list of a user who can publish calls publishDue when it opens. In development, POST /_nitro/tasks/pluto:publish:scheduled runs it.
Excerpt and cover image
Any content type can have a standard excerpt and a standard cover image:
excerpt: true, // or { field: 'summary' }. The default field is 'excerpt'.
cover: true, // or { field: 'hero' }. The default field is 'cover'.
- Core adds the field when the type does not have it: a text area for the excerpt, an image field of the media library for the cover. Both show in the side panel of the form. A field that the type declares stays as it is.
- The public API gives
excerpt: the excerpt field. When it is empty, the API uses the start of the body as plain text (200 characters). - The public API gives
cover: the media record of the image (url,width,height,alt), also when the field has another name. - The admin list shows a small image of the cover before the title.
- On the site,
usePlutoPublicItemuses the cover forog:image, and the excerpt for the description.
The Git CMS post type has both. The post type of the Blog layer has the cover. Its excerpt comes from the body, because the posts table has no excerpt column.
Embeds
A paragraph of a Markdown body that is one http(s) URL alone (a bare URL, or [url](url)) is an embed. Core owns the block and the security. A provider gives the player.
The HTML. The renderer of core (and of the Blog layer) writes such a paragraph as:
<p data-pluto-embed=""><a href="https://youtu.be/…">https://youtu.be/…</a></p>
It is a normal paragraph with a link, so a client of the public API that does not know embeds shows a link. The sanitizer keeps data-pluto-embed on p only, always empty. No iframe is in the stored or the rendered HTML.
The players. PlutoHtml shows the player of the first provider that matches the URL, in place of the paragraph. With no provider, the paragraph stays a link. Core has two providers, with the facade players of @nuxt/scripts: a thumbnail, and the player after a click.
| Provider | URLs |
|---|---|
youtube | youtube.com/watch?v=…, /shorts/…, /embed/…, /live/…, youtu.be/…. t or start sets the start time. |
vimeo | vimeo.com/<id>, vimeo.com/<id>/<hash> (an unlisted video), vimeo.com/channels/<name>/<id>, player.vimeo.com/video/<id> |
Add a provider. A layer adds a provider with embedProviders in definePlutoExtension:
definePlutoExtension({
id: 'my-embeds',
embedProviders: [
{
id: 'bluesky',
order: 30,
label: 'Bluesky',
// Strict: check the host and the id pattern. Return null for any other URL.
match: (url) => {
const parts = /^\/profile\/([^/]+)\/post\/([a-z0-9]+)$/.exec(url.pathname)
return url.hostname === 'bsky.app' && parts
? { handle: parts[1]!, post: parts[2]! }
: null
},
component: defineAsyncComponent(() => import('./BlueskyEmbed.vue')),
},
],
})
match(url)gets aURLand returns text values, ornull. It must be pure: it runs on the server and in the browser.- The component gets two props:
url(the URL of the line) andmatch(whatmatchreturned). It renders the player. Load the third-party code only after a click, or on the server. - The first provider by
orderthat matches wins. Core uses 10 (YouTube) and 20 (Vimeo). @nuxt/scriptshas embeds for X, Instagram, and Bluesky that fetch the post on your server, with no script of the provider in the page.
Content components
A Markdown body can hold a component of the site, with props only, at the top level:
::promo-card{title="Spring sale" featured}
::
The component must be in components/content/ and call definePlutoBlock. The site renders it, with safe props only. The Visual editor shows it as a box with its settings. See Components in posts.
Revisions
A backend that keeps the saved versions of an item implements two optional adapter methods. Then the edit form of every type shows History.
listRevisions(ctx, id, { limit, cursor })returns{ data, nextCursor }: the versions, newest first. The first one is the version that the item has now. Each version hasid,createdAt, and optionallyauthor(name,login,avatarUrl),action(create,update,publish,unpublish,rename) andmessage.getRevision(ctx, id, revisionId)returns the item as it was at that version, ornull.
| Route | Method | Action |
|---|---|---|
/api/_pluto/content-revisions/[type]/[...id] | GET | List the versions (limit 1 to 50, cursor) |
/api/_pluto/content-revision/[type]/[revision]/[...id] | GET | Get one version |
Both routes need the write capability of the type. Without the adapter methods, the list answers { supported: false, data: [] }.
There is no restore route. A restore copies the fields of a version into the form, except the slug and the status, and the user saves. So a restore goes through validation, capabilities, hooks and the conflict check, like any save.
The Git CMS implements both methods: a version is a commit that changed the file of the item. The Supabase layer keeps the versions in a table.
Backups
The edit form keeps a backup of the unsaved changes in the browser. A backend that can also keep a copy on the server implements the optional adapter key backups. Then a user can restore the changes on another device. Without it, the form keeps the browser backup only.
get(ctx, key)returns the backup record of the caller, ornull.put(ctx, key, record)replaces the record of the caller. It returns{ otherDevice }:truewhen the replaced record came from another device in the last 10 minutes.remove(ctx, key)deletes the record of the caller.
key is the item id as text, or +new for a new item. The adapter reads the caller from ctx.event, and a caller sees only its own records. Core also calls remove after a create, an update, and a delete. A failure there only logs a warning.
| Route | Method | Action |
|---|---|---|
/api/_pluto/content-backup/<type>?id=<id> | GET | Read the record |
/api/_pluto/content-backup/<type>?id=<id> | PUT | Write the record |
/api/_pluto/content-backup/<type>?id=<id> | DELETE | Remove the record |
The id is a query, because an id can hold slashes. A call with no id is for a new item. The routes need the write capability of the type. The answer has supported. Without the adapter key, it is { supported: false }, checked before any capability. The PUT body is at most 1,000,000 characters (413), and core keeps only the fields that the type declares. The origin check covers PUT and DELETE.
The Supabase layer keeps the records in a table. The Git CMS has no backups. A type can turn the backup off with form.backup.
Optimistic concurrency
An item can have a _version token. An adapter that uses it compares the token on update. If the token is stale, the adapter throws a 409 error. The form keeps the unsaved changes of the user in a backup and shows "Someone saved this item after you opened it." The button Load the latest and keep my changes loads the newest item and puts the changes on top of it. A 409 for a taken slug shows an error toast instead. Do not declare a field named _version.
The Git CMS adapter also returns _frontmatter on a get: the whole frontmatter of the file, with the keys that no field maps. The form does not send it back. Do not declare a field named _frontmatter.
An adapter can also change the id of an item on update, for example when it renames a file. The form then goes to the new edit path.
Nested content types
The file adapters of the Git CMS can store a type in nested folders. Set files.nested to true:
files: {
nested: true,
exclude: ['index', 'drafts/'],
frontmatter: {
// Add the flat keys that the fields read.
read: fm => ({ ...fm }),
// Fold the flat keys back. Unknown keys must stay.
write: fm => ({ ...fm })
}
}
- The id is the path under the folder of the type, without
.md, for example1.start/2.install. - Each segment is a lowercase slug with an optional numeric prefix (
2.install). A path has at most 6 segments. - With
nested,sourcecan be''. The type then uses the content folder itself. excludelists ids (index) and folders with a trailing slash (drafts/) that the type must not list or edit.frontmatter.readandfrontmatter.writemap the frontmatter between the file and the fields. Both must be pure. A field that a hook maps uses a flat key as itscolumn.
compareContentIds(a, b) is a sort comparator for such ids. In each segment, index comes first. A numeric prefix comes next, as a number. The name comes last.
contentIdPath(id) makes the URL path of an id. It encodes each segment and keeps the / between them. Use it in your own editPath when ids can hold slashes. The admin item page is a catch-all, /admin/content/<type>/[...id].
See Git CMS for the storage rules.
Composables
const { items, byName } = usePlutoContentTypes()
byName('post') // looks up by type.name
const { items, total, pending, error, refresh, remove, loadMore } = usePlutoContentList('post', {
pageSize: 50,
query: () => ({ search: 'snow', status: 'draft', sort: { field: 'title', direction: 'asc' } }),
})
const { item, pending, loadError, save, remove } = usePlutoContentItem('post', () => route.params.id)
usePlutoContentListloads the list page by page when you pass{ pageSize }.queryis a ref or a getter of the search, the status, the filters, and the sort. When it changes, the list loads again from the first page.usePlutoContentItemfetches one item only when the id is defined.savecreates when the id is undefined and updates when it is defined.usePlutoContentItemreads the id once. Mount the form again when the id changes.
Slugs
slugifyTitle(value) makes a slug from a title. It removes accents, lowercases, joins words with single dashes, and cuts the result at 100 characters. For example, "Pão de Mel" becomes pao-de-mel.
The generic form derives the slug from the title only while you create an item. It stops when you edit an existing item, or when you type in the slug field.
Reference fields
A reference field stores the id of an item of another content type.
{ name: 'tags', type: 'reference', label: 'Tags', target: 'tag', multiple: true, create: true }
| Option | Meaning |
|---|---|
target | The name of the referenced content type. The field stores the id of one of its items. |
multiple | The field stores a list of ids, in order. Without it, the field stores one id. |
create | The widget can create an item of target from the field. It needs target. The option shows only for a user with the write capability of target. |
optionsUrl | A URL for the options, when the target is not a content type. Use it in place of target. |
publicFields | A list of field names. The public API adds these fields of the target to each public reference. A media field gives its record. Only a public field of the target counts. The toPublic hooks of the target do not run. It needs target. |
remember | A new item gets the value that the user saved last time, for each browser. A value that does not exist anymore is not filled. It needs target. |
- The widget is a multi-select with a search box. It loads up to 1000 items of
target. - If
targetis a public type, the Public API gives{ id, label, slug }in place of each id. filter[<field>]=<id or slug>on the public list route finds the items that have this id in the list.- An adapter gets
PlutoContentQuery.idsonlist: it returns only the items with these ids. An empty list returns no item. Core checks the ids again, so an adapter that ignoresidsstill works, with more reads. - A filter on a list value matches when the list has the value.
See Tags and categories and Authors.
Media fields
A media field stores one media id, or a list of ids with multiple. accept narrows the file types. The core widget edits it, and the public API gives the records in place of the ids. See Media.