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
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, and widget.
regionismainorside. The default ismain.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.
A textarea grows with the text from rows (default 4) up to maxRows (default 14).
A text 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.
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 always sorts with it. |
status | The publish workflow. false means no workflow. |
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. |
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, reference, or media field gets only the required check.
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, 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, and search. The sort always comes from defaultSort of the type. For a type with a status workflow, published=1 returns published rows only.
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.
})
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 then shows "This item changed since you opened it." 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')
const { item, pending, loadError, save, remove } = usePlutoContentItem('post', () => route.params.id)
usePlutoContentListloads the list page by page when you pass{ pageSize }.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.
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.