@plutocms/pluto 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.7.0](https://github.com/plutocms/pluto/compare/v0.6.0...v0.7.0) (2026-09-12)
5
+
6
+
7
+ ### Features
8
+
9
+ * **content:** add server-side content registry and generic CRUD routes (wave 2) ([#133](https://github.com/plutocms/pluto/issues/133)) ([7846340](https://github.com/plutocms/pluto/commit/784634081f5a267fbe33dde8264e6b848bf7bdda))
10
+
11
+ ## [0.6.0](https://github.com/plutocms/pluto/compare/v0.5.0...v0.6.0) (2026-09-12)
12
+
13
+
14
+ ### Features
15
+
16
+ * **content:** add content-model contracts (wave 1) ([#131](https://github.com/plutocms/pluto/issues/131)) ([95e157b](https://github.com/plutocms/pluto/commit/95e157b41a60b17ae8311878eb2ef79356feef43))
17
+
4
18
  ## [0.5.0](https://github.com/plutocms/pluto/compare/v0.4.0...v0.5.0) (2026-09-12)
5
19
 
6
20
 
package/FEATURES.md CHANGED
@@ -12,3 +12,8 @@ policies shared by every project here.
12
12
  - Permissions: a typed capability registry and a `usePlutoPermissions()` composable that let a
13
13
  layer declare capabilities and check what the current user holds, backed by a registered
14
14
  permissions driver. @.claude/skills/permissions/SKILL.md
15
+ - Content model: a typed content-type contract (`defineContentType`, field types, payload
16
+ validation, field-to-column mapping) and a media-adapter contract, with registry wiring and
17
+ consumer composables, plus a server-side content registry and generic list/get/create/update/
18
+ delete routes backed by a `PlutoContentAdapter` a backend layer implements. No real adapter or
19
+ generated UI yet. @.claude/skills/content-model/SKILL.md
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Resolves the component that renders `field` in the generic content form
3
+ * (a later wave). Matches an exact `widget` name first — a field can force
4
+ * a specific widget through `PlutoFieldBase.widget` — then falls back to
5
+ * the last-registered entry for the field's `type`. Returns `undefined`
6
+ * when nothing matches: this wave adds no fallback placeholder component,
7
+ * that is later, UI-wave work.
8
+ */
9
+ export function usePlutoContentFieldWidget(field: PlutoField) {
10
+ const registry = usePlutoRegistry()
11
+
12
+ return computed<Component | undefined>(() => {
13
+ const entries = registry.contentFieldWidgets.list.value
14
+
15
+ if (field.widget) {
16
+ const exact = entries.find((entry) => entry.widget === field.widget)
17
+ if (exact) {
18
+ return exact.component
19
+ }
20
+ }
21
+
22
+ return entries.filter((entry) => entry.fieldType === field.type).at(-1)?.component
23
+ })
24
+ }
@@ -0,0 +1,11 @@
1
+ /** Content types declared by every layer, in registry order. */
2
+ export function usePlutoContentTypes() {
3
+ const registry = usePlutoRegistry()
4
+
5
+ /** Looks up a content type by its own `name`, not the registry entry `id`. */
6
+ function byName(name: string): PlutoContentType | undefined {
7
+ return registry.contentTypes.list.value.find((entry) => entry.type.name === name)?.type
8
+ }
9
+
10
+ return { items: registry.contentTypes.list, byName }
11
+ }
@@ -26,4 +26,7 @@ export function definePlutoExtension(definition: PlutoExtension): void {
26
26
  definition.id,
27
27
  definition.permissionsDriver ? [definition.permissionsDriver] : []
28
28
  )
29
+ registry.contentTypes.set(definition.id, definition.contentTypes ?? [])
30
+ registry.contentFieldWidgets.set(definition.id, definition.contentFieldWidgets ?? [])
31
+ registry.mediaAdapters.set(definition.id, definition.mediaAdapters ?? [])
29
32
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The registered media adapter. Only one is meaningful per app: when more
3
+ * than one layer registers one, the last-registered entry wins, the same
4
+ * "last wins" rule `usePlutoSettings`/`usePlutoPermissions` use to resolve
5
+ * their driver.
6
+ */
7
+ export function usePlutoMediaAdapter() {
8
+ const registry = usePlutoRegistry()
9
+
10
+ const adapter = computed<PlutoMediaAdapter | undefined>(
11
+ () => registry.mediaAdapters.list.value.at(-1)?.adapter
12
+ )
13
+
14
+ return { adapter }
15
+ }
@@ -23,6 +23,9 @@ export function usePlutoRegistry(): PlutoRegistry {
23
23
  dashboardWidgets: createOwnedRegistry<PlutoDashboardWidget>(),
24
24
  capabilities: createOwnedRegistry<PlutoCapability>(),
25
25
  permissionsDrivers: createOwnedRegistry<PlutoPermissionsDriver>(),
26
+ contentTypes: createOwnedRegistry<PlutoContentTypeEntry>(),
27
+ contentFieldWidgets: createOwnedRegistry<PlutoContentFieldWidget>(),
28
+ mediaAdapters: createOwnedRegistry<PlutoMediaAdapterEntry>(),
26
29
  }
27
30
  }
28
31
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.5.0",
4
+ "version": "0.7.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Deletes one item of one content type. Generic, core-internal route — see
3
+ * the content-model skill for the `/_pluto/` prefix and the
4
+ * delete-capability enforcement this route follows.
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const id = getRouterParam(event, 'id') ?? ''
11
+
12
+ await deleteContentItem({ event, type }, id)
13
+
14
+ return { success: true }
15
+ })
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Gets one item of one content type, by id or slug. Generic, core-internal
3
+ * route — see the content-model skill for the `/_pluto/` prefix and the
4
+ * capability rule this route follows (no `read` capability check, ever).
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const idOrSlug = getRouterParam(event, 'id') ?? ''
11
+
12
+ return getContentItem({ event, type }, idOrSlug)
13
+ })
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Updates one item of one content type. Generic, core-internal route — see
3
+ * the content-model skill for the `/_pluto/` prefix, partial-payload
4
+ * validation, and the write-capability enforcement this route follows.
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const id = getRouterParam(event, 'id') ?? ''
11
+ const body = await readBody(event)
12
+
13
+ return updateContentItem({ event, type }, id, body)
14
+ })
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Lists items of one content type. Generic, core-internal route — see the
3
+ * content-model skill for the `/_pluto/` prefix and the capability rule
4
+ * this route follows (no `read` capability check, ever).
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const query = getQuery(event)
11
+ const limit = query.limit === undefined ? undefined : Number.parseInt(query.limit as string, 10)
12
+ const offset = query.offset === undefined ? undefined : Number.parseInt(query.offset as string, 10)
13
+ const search = query.search === undefined ? undefined : String(query.search)
14
+
15
+ return listContentItems({ event, type }, { limit, offset, search })
16
+ })
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Creates one item of one content type. Generic, core-internal route — see
3
+ * the content-model skill for the `/_pluto/` prefix, payload validation,
4
+ * and the write-capability enforcement this route follows.
5
+ */
6
+ export default defineEventHandler(async (event) => {
7
+ const typeName = getRouterParam(event, 'type')
8
+ const type = requireContentType(typeName ?? '')
9
+
10
+ const body = await readBody(event)
11
+
12
+ return createContentItem({ event, type }, body)
13
+ })
@@ -0,0 +1,168 @@
1
+ import type { H3Event } from 'h3'
2
+ import { createError } from 'h3'
3
+ import { validateContentPayload } from '../../shared/utils/content-validate'
4
+ import { getContentAdapter, getContentType } from './pluto-content'
5
+
6
+ /**
7
+ * The real logic behind the generic content routes, kept in plain exported
8
+ * functions — not inline in a `server/api/**` route file — so it can be
9
+ * unit-tested by calling it directly, the same way this workspace already
10
+ * tests everything else: no live Nitro server, no `@nuxt/test-utils`.
11
+ */
12
+
13
+ export function requireContentType(name: string): PlutoContentType {
14
+ const type = getContentType(name)
15
+
16
+ if (!type) {
17
+ throw createError({ statusCode: 404, statusMessage: `Content type "${name}" not found.` })
18
+ }
19
+
20
+ return type
21
+ }
22
+
23
+ export function requireContentAdapter(): PlutoContentAdapter {
24
+ const adapter = getContentAdapter()
25
+
26
+ if (!adapter) {
27
+ throw createError({ statusCode: 500, statusMessage: 'No content adapter is registered.' })
28
+ }
29
+
30
+ return adapter
31
+ }
32
+
33
+ /**
34
+ * Checks `capability` against `event`, but only when there is something to
35
+ * check against. Exported because both write handlers below share it, and
36
+ * a test exercises it indirectly through them.
37
+ *
38
+ * Fails open on purpose, in two cases:
39
+ * - `capability` is undefined (the content type declares no capability for
40
+ * this operation): nothing to enforce, anyone who can reach the route
41
+ * may proceed.
42
+ * - The registered adapter has no `authorize` method: the same fail-open
43
+ * rule `usePlutoPermissions().can()` already documents for "no
44
+ * permissions backend registered at all" — with no boundary to enforce,
45
+ * the adapter's own backing store (RLS, or equivalent) is still the real
46
+ * gate.
47
+ */
48
+ export async function authorizeContentOperation(event: H3Event, capability: string | undefined): Promise<void> {
49
+ if (!capability) {
50
+ return
51
+ }
52
+
53
+ const adapter = requireContentAdapter()
54
+
55
+ if (!adapter.authorize) {
56
+ return
57
+ }
58
+
59
+ await adapter.authorize(event, capability)
60
+ }
61
+
62
+ /**
63
+ * Lists items for `ctx.type`. Never checks `type.capabilities.read` and
64
+ * never calls `adapter.authorize` — see the content-model skill for why:
65
+ * in short, "read" is not all-or-nothing for a type like `posts` (an
66
+ * anonymous visitor can read published posts; only drafts need a
67
+ * capability), and that distinction already lives in the adapter's backing
68
+ * store (RLS, for the one adapter that exists so far). This route always
69
+ * asks the adapter for everything, unfiltered by capability, because it is
70
+ * admin-surface-only in intent — a public-facing read path is a layer's
71
+ * own hand-written endpoint, not this generic one.
72
+ */
73
+ export async function listContentItems(
74
+ ctx: PlutoContentContext,
75
+ query: {
76
+ limit?: number
77
+ offset?: number
78
+ search?: string
79
+ }
80
+ ): Promise<PlutoContentListResult> {
81
+ return requireContentAdapter().list(ctx, {
82
+ limit: query.limit,
83
+ offset: query.offset,
84
+ search: query.search,
85
+ sort: ctx.type.defaultSort,
86
+ includeUnpublished: true,
87
+ })
88
+ }
89
+
90
+ /** Gets one item by id or slug. No capability check — see `listContentItems`. */
91
+ export async function getContentItem(ctx: PlutoContentContext, idOrSlug: string | number): Promise<PlutoContentItem> {
92
+ const item = await requireContentAdapter().get(ctx, idOrSlug)
93
+
94
+ if (!item) {
95
+ throw createError({ statusCode: 404, statusMessage: 'Not found.' })
96
+ }
97
+
98
+ return item
99
+ }
100
+
101
+ function requirePlainObjectBody(body: unknown): Record<string, unknown> {
102
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
103
+ throw createError({ statusCode: 400, statusMessage: 'Request body must be a JSON object.' })
104
+ }
105
+
106
+ return body as Record<string, unknown>
107
+ }
108
+
109
+ /**
110
+ * Creates one item. Validates the full payload (not partial), authorizes
111
+ * against `type.capabilities.write`, then calls the adapter, then runs
112
+ * `type.hooks.afterCreate` when one is registered. A hook error propagates
113
+ * — it is not swallowed.
114
+ */
115
+ export async function createContentItem(ctx: PlutoContentContext, body: unknown): Promise<PlutoContentItem> {
116
+ const payload = requirePlainObjectBody(body)
117
+
118
+ const errors = validateContentPayload(ctx.type, payload)
119
+ if (errors.length > 0) {
120
+ throw createError({ statusCode: 400, statusMessage: 'Validation failed.', data: { errors } })
121
+ }
122
+
123
+ await authorizeContentOperation(ctx.event, ctx.type.capabilities?.write)
124
+
125
+ const item = await requireContentAdapter().create(ctx, payload)
126
+
127
+ if (ctx.type.hooks?.afterCreate) {
128
+ await ctx.type.hooks.afterCreate(ctx, item, payload)
129
+ }
130
+
131
+ return item
132
+ }
133
+
134
+ /**
135
+ * Updates one item. Validates the payload as a partial update, authorizes
136
+ * against `type.capabilities.write`, then calls the adapter, then runs
137
+ * `type.hooks.afterUpdate` when one is registered. A hook error propagates
138
+ * — it is not swallowed.
139
+ */
140
+ export async function updateContentItem(
141
+ ctx: PlutoContentContext,
142
+ id: string | number,
143
+ body: unknown
144
+ ): Promise<PlutoContentItem> {
145
+ const payload = requirePlainObjectBody(body)
146
+
147
+ const errors = validateContentPayload(ctx.type, payload, { partial: true })
148
+ if (errors.length > 0) {
149
+ throw createError({ statusCode: 400, statusMessage: 'Validation failed.', data: { errors } })
150
+ }
151
+
152
+ await authorizeContentOperation(ctx.event, ctx.type.capabilities?.write)
153
+
154
+ const item = await requireContentAdapter().update(ctx, id, payload)
155
+
156
+ if (ctx.type.hooks?.afterUpdate) {
157
+ await ctx.type.hooks.afterUpdate(ctx, item, payload)
158
+ }
159
+
160
+ return item
161
+ }
162
+
163
+ /** Deletes one item. Authorizes against `type.capabilities.delete` before calling the adapter. */
164
+ export async function deleteContentItem(ctx: PlutoContentContext, id: string | number): Promise<void> {
165
+ await authorizeContentOperation(ctx.event, ctx.type.capabilities?.delete)
166
+
167
+ await requireContentAdapter().remove(ctx, id)
168
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The server-side content-type and adapter registry. Deliberately a plain
3
+ * module-scope singleton — NOT built like `usePlutoRegistry` (the client
4
+ * registry in `app/composables/pluto-registry.ts`), and that is on purpose,
5
+ * not an oversight.
6
+ *
7
+ * The client registry lives on `nuxtApp` instead of module scope because a
8
+ * module-scope object on the server would leak state between requests and
9
+ * between apps during SSR: one user's data could end up rendered into
10
+ * another user's response. That risk does not apply here, for three
11
+ * reasons:
12
+ *
13
+ * 1. Registration happens once, at Nitro startup, from a layer's own
14
+ * `server/plugins/*.ts` file — not per request.
15
+ * 2. The registered values (`PlutoContentType`, `PlutoContentAdapter`) are
16
+ * static definition objects. They hold zero per-request state.
17
+ * 3. Anything that actually varies per request — the caller's identity,
18
+ * their session — travels in `PlutoContentContext.event`, which is
19
+ * passed fresh into every adapter call. It never touches the registry.
20
+ *
21
+ * So a future reader who knows why the client registry avoids module scope
22
+ * should not assume the same warning applies here: it does not, because
23
+ * nothing stored here ever differs between two requests.
24
+ */
25
+
26
+ const contentTypes = new Map<string, PlutoContentType>()
27
+ let contentAdapter: PlutoContentAdapter | undefined
28
+
29
+ export function registerContentType(type: PlutoContentType): void {
30
+ contentTypes.set(type.name, type)
31
+ }
32
+
33
+ export function getContentType(name: string): PlutoContentType | undefined {
34
+ return contentTypes.get(name)
35
+ }
36
+
37
+ /** Replaces whatever adapter was registered before. Last-registered wins, the same rule `mediaAdapters`/`settingsDrivers` use client-side. */
38
+ export function registerContentAdapter(adapter: PlutoContentAdapter): void {
39
+ contentAdapter = adapter
40
+ }
41
+
42
+ export function getContentAdapter(): PlutoContentAdapter | undefined {
43
+ return contentAdapter
44
+ }
@@ -0,0 +1,253 @@
1
+ import type { H3Event } from 'h3'
2
+
3
+ export type PlutoFieldType =
4
+ | 'text'
5
+ | 'textarea'
6
+ | 'richtext'
7
+ | 'slug'
8
+ | 'number'
9
+ | 'boolean'
10
+ | 'date'
11
+ | 'select'
12
+ | 'reference'
13
+ | 'media'
14
+
15
+ /**
16
+ * Fields every `PlutoField` shares, regardless of `type`. A concrete field
17
+ * (`PlutoTextField`, `PlutoSlugField`, and so on) narrows `type` to one
18
+ * literal and adds its own type-specific options on top of this base.
19
+ */
20
+ export interface PlutoFieldBase {
21
+ /** Field key. Used as the object key on a `PlutoContentItem`, and as the default storage column (see `fieldColumn` in `shared/utils/content-map.ts`). */
22
+ name: string
23
+ type: PlutoFieldType
24
+ label: string
25
+ description?: string
26
+ /** Storage column, when it differs from `name`. Falls back to `name` when unset. */
27
+ column?: string
28
+ required?: boolean
29
+ /** Show this field as a column in the generic list view. Default false. */
30
+ inList?: boolean
31
+ /** Show this field in the generic form view. Default true. */
32
+ inForm?: boolean
33
+ /** Lower comes first, in both the list and the form. Default 100. */
34
+ order?: number
35
+ default?: unknown
36
+ /** Name of a registered `PlutoContentFieldWidget` to force, overriding the default resolved by `type`. */
37
+ widget?: string
38
+ /** Which column of the generic form this field renders in. Default 'main'. */
39
+ region?: 'main' | 'side'
40
+ }
41
+
42
+ export interface PlutoTextField extends PlutoFieldBase {
43
+ type: 'text' | 'textarea' | 'richtext'
44
+ maxLength?: number
45
+ placeholder?: string
46
+ }
47
+
48
+ export interface PlutoSlugField extends PlutoFieldBase {
49
+ type: 'slug'
50
+ /** Name of the field this slug is generated from, for example 'title'. */
51
+ from?: string
52
+ preview?: string
53
+ }
54
+
55
+ export interface PlutoNumberField extends PlutoFieldBase {
56
+ type: 'number'
57
+ integer?: boolean
58
+ min?: number
59
+ max?: number
60
+ }
61
+
62
+ export interface PlutoSelectField extends PlutoFieldBase {
63
+ type: 'select'
64
+ options: Array<{
65
+ label: string
66
+ value: string | number
67
+ }>
68
+ }
69
+
70
+ export interface PlutoReferenceField extends PlutoFieldBase {
71
+ type: 'reference'
72
+ /** Name of the referenced content type, for example 'category'. */
73
+ target?: string
74
+ optionsUrl?: string
75
+ labelKey?: string
76
+ valueKey?: string
77
+ }
78
+
79
+ export interface PlutoMediaField extends PlutoFieldBase {
80
+ type: 'media'
81
+ multiple?: boolean
82
+ }
83
+
84
+ export interface PlutoBooleanField extends PlutoFieldBase {
85
+ type: 'boolean'
86
+ }
87
+
88
+ export interface PlutoDateField extends PlutoFieldBase {
89
+ type: 'date'
90
+ }
91
+
92
+ export type PlutoField =
93
+ | PlutoTextField
94
+ | PlutoSlugField
95
+ | PlutoNumberField
96
+ | PlutoSelectField
97
+ | PlutoReferenceField
98
+ | PlutoMediaField
99
+ | PlutoBooleanField
100
+ | PlutoDateField
101
+
102
+ /**
103
+ * Capability keys a content-type adapter checks before it reads, writes, or
104
+ * deletes a row. Plain strings, not registered `PlutoCapability` entries —
105
+ * see the permissions skill for the format (`<namespace>:<action>`).
106
+ */
107
+ export interface PlutoContentTypeCapabilities {
108
+ read?: string
109
+ write?: string
110
+ delete?: string
111
+ }
112
+
113
+ export interface PlutoContentStatus {
114
+ /** Storage column. Falls back to 'status' when unset. */
115
+ column?: string
116
+ values: Array<{
117
+ label: string
118
+ value: string
119
+ }>
120
+ default?: string
121
+ /** The `values[].value` that counts as published, for example 'published'. */
122
+ publishedValue?: string
123
+ publishedAtColumn?: string
124
+ }
125
+
126
+ export interface PlutoContentTimestamps {
127
+ /** Storage column, or `false` to disable this timestamp. Falls back to 'created_at' when unset. */
128
+ created?: string | false
129
+ /** Storage column, or `false` to disable this timestamp. Falls back to 'updated_at' when unset. */
130
+ updated?: string | false
131
+ }
132
+
133
+ export interface PlutoContentI18n {
134
+ /** Reserved for a future release. Only 'row' will ever be supported: one row per locale. Not implemented yet — declaring it costs nothing and avoids a breaking rename later. */
135
+ strategy: 'row'
136
+ localeColumn?: string
137
+ groupColumn?: string
138
+ }
139
+
140
+ /**
141
+ * The contract for one content type: what fields it has, where it is
142
+ * stored, and how the generic list/form UI (a later wave) should render it.
143
+ * A layer declares one of these per content type it owns, through
144
+ * `defineContentType`.
145
+ */
146
+ export interface PlutoContentType {
147
+ /**
148
+ * The content type's own identifier, for example 'post'. Kept separate
149
+ * from a registered `PlutoContentTypeEntry.id` for the exact same reason
150
+ * `PlutoCapability.key` is kept separate from `id` (see
151
+ * `shared/types/registry.ts`): the registry rewrites `id` to
152
+ * `'<extensionId>:<id>'`, which would corrupt a name used in a URL path
153
+ * segment or as a lookup key. Always read `name` for the real content
154
+ * type identity, never the registry entry's `id`.
155
+ */
156
+ name: string
157
+ label: string
158
+ labelPlural: string
159
+ icon?: string
160
+ /** Table or view name this content type reads and writes. */
161
+ source: string
162
+ /** Storage column for the primary key. Falls back to 'id' when unset. */
163
+ primaryKey?: string
164
+ /** Name of the field shown as the row's title in lists and breadcrumbs. */
165
+ titleField: string
166
+ fields: PlutoField[]
167
+ /** Name of the field that holds the slug, or `false` to disable slugging entirely. */
168
+ slug?: {
169
+ field: string
170
+ } | false
171
+ /** `false` disables status/publish workflow entirely for this content type. */
172
+ status?: PlutoContentStatus | false
173
+ /** `false` disables created/updated timestamps entirely for this content type. */
174
+ timestamps?: PlutoContentTimestamps | false
175
+ capabilities?: PlutoContentTypeCapabilities
176
+ defaultSort?: {
177
+ field: string
178
+ direction: 'asc' | 'desc'
179
+ }
180
+ /** Whether a later wave should generate list/edit/create admin routes for this content type. */
181
+ autoRoutes?: boolean
182
+ /** Base admin path for generated routes, for example '/admin/posts'. Only meaningful when `autoRoutes` is true. */
183
+ basePath?: string
184
+ navOrder?: number
185
+ i18n?: PlutoContentI18n
186
+ /**
187
+ * Server-only lifecycle hooks, run by the generic write handlers
188
+ * (`server/utils/pluto-content-handlers.ts`) after the adapter call
189
+ * succeeds and before the response goes out. Lets a layer with extra
190
+ * logic around a write — for example `supabase-shop` reconciling
191
+ * `product_media` rows after a product write — hook in without a bespoke
192
+ * endpoint just for that, and without polluting the generic adapter
193
+ * contract with a per-layer concern.
194
+ */
195
+ hooks?: {
196
+ afterCreate?: (ctx: PlutoContentContext, item: PlutoContentItem, rawBody: Record<string, unknown>) => Promise<void> | void
197
+ afterUpdate?: (ctx: PlutoContentContext, item: PlutoContentItem, rawBody: Record<string, unknown>) => Promise<void> | void
198
+ }
199
+ }
200
+
201
+ /**
202
+ * One stored content row, keyed by field name (not column name — see
203
+ * `mapColumnsToFields` in `shared/utils/content-map.ts` for the
204
+ * column-to-field translation). Named `PlutoContentItem`, not
205
+ * `PlutoEntryRecord`, to avoid visual confusion with `PlutoEntry` (a
206
+ * registry bucket item, see `shared/types/registry.ts`) — this type has
207
+ * nothing to do with the registry.
208
+ */
209
+ export type PlutoContentItem = Record<string, unknown> & {
210
+ id: string | number
211
+ }
212
+
213
+ /** Options for a generic list read. See `server/utils/pluto-content-handlers.ts`. */
214
+ export interface PlutoContentQuery {
215
+ limit?: number
216
+ offset?: number
217
+ search?: string
218
+ sort?: {
219
+ field: string
220
+ direction: 'asc' | 'desc'
221
+ }
222
+ includeUnpublished?: boolean
223
+ }
224
+
225
+ export interface PlutoContentListResult {
226
+ data: PlutoContentItem[]
227
+ total?: number
228
+ }
229
+
230
+ /** Per-request context an adapter method receives: the H3 event, and the content type being read or written. */
231
+ export interface PlutoContentContext {
232
+ event: H3Event
233
+ type: PlutoContentType
234
+ }
235
+
236
+ /**
237
+ * Lets a backend layer (`@plutocms/supabase`, and so on) plug its own
238
+ * storage into the generic content server routes, without core knowing
239
+ * which backend is in use — the same role `PlutoMediaAdapter` plays for
240
+ * media. Exactly one adapter is meaningful per app, registered through
241
+ * `registerContentAdapter` (see `server/utils/pluto-content.ts`).
242
+ */
243
+ export interface PlutoContentAdapter {
244
+ id: string
245
+ list: (ctx: PlutoContentContext, query: PlutoContentQuery) => Promise<PlutoContentListResult>
246
+ get: (ctx: PlutoContentContext, idOrSlug: string | number) => Promise<PlutoContentItem | null>
247
+ create: (ctx: PlutoContentContext, values: Record<string, unknown>) => Promise<PlutoContentItem>
248
+ update: (ctx: PlutoContentContext, id: string | number, values: Record<string, unknown>) => Promise<PlutoContentItem>
249
+ remove: (ctx: PlutoContentContext, id: string | number) => Promise<void>
250
+ /** Throws when `event`'s caller does not hold `capability`. Optional — see the fail-open rule in the content-model skill. */
251
+ authorize?: (event: H3Event, capability: string) => Promise<void>
252
+ describe?: (ctx: PlutoContentContext) => Promise<string[] | null>
253
+ }
@@ -0,0 +1,34 @@
1
+ export interface PlutoMediaItem {
2
+ id: string
3
+ name: string
4
+ url: string
5
+ mimeType?: string
6
+ size?: number
7
+ alt?: string
8
+ }
9
+
10
+ export interface PlutoMediaListOptions {
11
+ limit?: number
12
+ offset?: number
13
+ search?: string
14
+ }
15
+
16
+ /**
17
+ * Lets a storage layer (Supabase Storage, S3, R2, local disk...) plug into
18
+ * one shared media picker UI and one `media` field widget, without core
19
+ * knowing which backend is in use. Exactly one adapter is registered per
20
+ * app — see the `mediaAdapters` registry bucket, which keeps only the
21
+ * last-registered one, the same "last wins" rule as `settingsDrivers`.
22
+ */
23
+ export interface PlutoMediaAdapter {
24
+ id: string
25
+ list: (options: PlutoMediaListOptions) => Promise<{
26
+ data: PlutoMediaItem[]
27
+ total?: number
28
+ }>
29
+ upload: (file: File, meta?: {
30
+ alt?: string
31
+ }) => Promise<PlutoMediaItem>
32
+ remove: (id: string) => Promise<void>
33
+ getUrl: (id: string) => string
34
+ }
@@ -1,4 +1,6 @@
1
1
  import type { Component, ComputedRef } from 'vue'
2
+ import type { PlutoContentType, PlutoFieldType } from './content'
3
+ import type { PlutoMediaAdapter } from './media'
2
4
 
3
5
  export interface PlutoEntry {
4
6
  /** Unique inside this extension. Stored as `<extensionId>:<id>`. */
@@ -85,6 +87,32 @@ export interface PlutoPermissionsDriver extends PlutoEntry {
85
87
  load: () => Promise<string[]>
86
88
  }
87
89
 
90
+ /**
91
+ * A content type a layer declares, wrapped for the registry. `type.name`
92
+ * is the content type's real identity — see the doc comment on
93
+ * `PlutoContentType.name` in `shared/types/content.ts` for why it is kept
94
+ * separate from this entry's `id`.
95
+ */
96
+ export interface PlutoContentTypeEntry extends PlutoEntry {
97
+ type: PlutoContentType
98
+ }
99
+
100
+ /**
101
+ * A component that renders one field type, or one named `widget`, in the
102
+ * generic content form (a later wave). `fieldType` and `widget` are both
103
+ * optional so an entry can match by either — see
104
+ * `usePlutoContentFieldWidget` for the resolution order.
105
+ */
106
+ export interface PlutoContentFieldWidget extends PlutoEntry {
107
+ fieldType?: PlutoFieldType
108
+ widget?: string
109
+ component: Component
110
+ }
111
+
112
+ export interface PlutoMediaAdapterEntry extends PlutoEntry {
113
+ adapter: PlutoMediaAdapter
114
+ }
115
+
88
116
  export interface PlutoExtension {
89
117
  /** Layer name. Use the package's $meta.name, e.g. 'supabase-blog'. */
90
118
  id: string
@@ -97,6 +125,9 @@ export interface PlutoExtension {
97
125
  settingsDriver?: PlutoSettingsDriver
98
126
  capabilities?: PlutoCapability[]
99
127
  permissionsDriver?: PlutoPermissionsDriver
128
+ contentTypes?: PlutoContentTypeEntry[]
129
+ contentFieldWidgets?: PlutoContentFieldWidget[]
130
+ mediaAdapters?: PlutoMediaAdapterEntry[]
100
131
  }
101
132
 
102
133
  /**
@@ -121,4 +152,7 @@ export interface PlutoRegistry {
121
152
  dashboardWidgets: PlutoRegistryBucket<PlutoDashboardWidget>
122
153
  capabilities: PlutoRegistryBucket<PlutoCapability>
123
154
  permissionsDrivers: PlutoRegistryBucket<PlutoPermissionsDriver>
155
+ contentTypes: PlutoRegistryBucket<PlutoContentTypeEntry>
156
+ contentFieldWidgets: PlutoRegistryBucket<PlutoContentFieldWidget>
157
+ mediaAdapters: PlutoRegistryBucket<PlutoMediaAdapterEntry>
124
158
  }
@@ -0,0 +1,49 @@
1
+ /** Resolves the storage column for a field. Falls back to `field.name` when `field.column` is unset. */
2
+ export function fieldColumn(field: PlutoField): string {
3
+ return field.column ?? field.name
4
+ }
5
+
6
+ /**
7
+ * Converts a field-keyed payload into a column-keyed one, for every field
8
+ * in `type.fields` present in `payload`. A field absent from `payload` is
9
+ * left out of the result, so a partial update payload stays partial after
10
+ * mapping. An adapter (a later wave) sends the result straight to its
11
+ * storage layer.
12
+ */
13
+ export function mapFieldsToColumns(
14
+ type: PlutoContentType,
15
+ payload: Record<string, unknown>
16
+ ): Record<string, unknown> {
17
+ const columns: Record<string, unknown> = {}
18
+
19
+ for (const field of type.fields) {
20
+ if (Object.hasOwn(payload, field.name)) {
21
+ columns[fieldColumn(field)] = payload[field.name]
22
+ }
23
+ }
24
+
25
+ return columns
26
+ }
27
+
28
+ /**
29
+ * Converts a column-keyed row into a field-keyed `PlutoContentItem`, for
30
+ * every field in `type.fields`, plus the primary key column (`type.primaryKey`,
31
+ * default `'id'`) mapped to `item.id`. A column absent from `row` is left
32
+ * out of the result field.
33
+ */
34
+ export function mapColumnsToFields(
35
+ type: PlutoContentType,
36
+ row: Record<string, unknown>
37
+ ): PlutoContentItem {
38
+ const primaryKey = type.primaryKey ?? 'id'
39
+ const item: Record<string, unknown> = { id: row[primaryKey] }
40
+
41
+ for (const field of type.fields) {
42
+ const column = fieldColumn(field)
43
+ if (Object.hasOwn(row, column)) {
44
+ item[field.name] = row[column]
45
+ }
46
+ }
47
+
48
+ return item as PlutoContentItem
49
+ }
@@ -0,0 +1,100 @@
1
+ export interface ContentValidationError {
2
+ field: string
3
+ message: string
4
+ }
5
+
6
+ /**
7
+ * Checks a field-keyed payload against a content type's field rules. Pure
8
+ * TypeScript, no I/O and no Nuxt/Vue dependency, so a server route (a later
9
+ * wave) and an app composable can both call it and get the same answer.
10
+ *
11
+ * Pass `{ partial: true }` for a partial update (for example a PATCH):
12
+ * a required field missing from `payload` entirely is not an error, because
13
+ * omitting a field is not the same as clearing it. A required field present
14
+ * in `payload` with an empty value is still an error, partial or not.
15
+ */
16
+ export function validateContentPayload(
17
+ type: PlutoContentType,
18
+ payload: Record<string, unknown>,
19
+ options?: {
20
+ partial?: boolean
21
+ }
22
+ ): ContentValidationError[] {
23
+ const errors: ContentValidationError[] = []
24
+
25
+ for (const field of type.fields) {
26
+ const present = Object.hasOwn(payload, field.name)
27
+ const value = payload[field.name]
28
+
29
+ if (field.required) {
30
+ const skipBecausePartial = options?.partial === true && !present
31
+ const empty = value === undefined || value === null || value === ''
32
+ if (!skipBecausePartial && empty) {
33
+ errors.push({ field: field.name, message: `"${field.label}" is required.` })
34
+ continue
35
+ }
36
+ }
37
+
38
+ // Nothing more to check when the field was not supplied at all.
39
+ if (!present || value === undefined || value === null) {
40
+ continue
41
+ }
42
+
43
+ switch (field.type) {
44
+ case 'number': {
45
+ const numeric = typeof value === 'number' ? value : Number(value)
46
+ if ((typeof value !== 'number' && typeof value !== 'string') || Number.isNaN(numeric)) {
47
+ errors.push({ field: field.name, message: `"${field.label}" must be a number.` })
48
+ break
49
+ }
50
+ if (field.integer && !Number.isInteger(numeric)) {
51
+ errors.push({ field: field.name, message: `"${field.label}" must be an integer.` })
52
+ }
53
+ if (field.min !== undefined && numeric < field.min) {
54
+ errors.push({ field: field.name, message: `"${field.label}" must be at least ${field.min}.` })
55
+ }
56
+ if (field.max !== undefined && numeric > field.max) {
57
+ errors.push({ field: field.name, message: `"${field.label}" must be at most ${field.max}.` })
58
+ }
59
+ break
60
+ }
61
+
62
+ case 'boolean': {
63
+ if (typeof value !== 'boolean') {
64
+ errors.push({ field: field.name, message: `"${field.label}" must be a boolean.` })
65
+ }
66
+ break
67
+ }
68
+
69
+ case 'select': {
70
+ if (!field.options.some((option) => option.value === value)) {
71
+ errors.push({ field: field.name, message: `"${field.label}" must be one of the allowed options.` })
72
+ }
73
+ break
74
+ }
75
+
76
+ case 'text':
77
+ case 'textarea':
78
+ case 'richtext':
79
+ case 'slug': {
80
+ // Only PlutoTextField carries maxLength; PlutoSlugField never does,
81
+ // so the 'in' check both narrows the type and skips slug fields.
82
+ const maxLength = 'maxLength' in field ? field.maxLength : undefined
83
+ if (maxLength !== undefined && typeof value === 'string' && value.length > maxLength) {
84
+ errors.push({
85
+ field: field.name,
86
+ message: `"${field.label}" must be at most ${maxLength} characters.`,
87
+ })
88
+ }
89
+ break
90
+ }
91
+
92
+ // 'date' | 'reference' | 'media': no format check in this wave, only
93
+ // the required check above.
94
+ default:
95
+ break
96
+ }
97
+ }
98
+
99
+ return errors
100
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Declares a content type. This is an identity function: it returns `type`
3
+ * unchanged. Its only job is type inference at the call site — wrapping a
4
+ * content type literal in `defineContentType` lets TypeScript narrow each
5
+ * `PlutoField`'s `type` correctly — plus a handful of dev-only sanity
6
+ * checks below. This mirrors `definePlutoExtension`'s role: a thin, typed
7
+ * entry point, not a place that does real work.
8
+ *
9
+ * The checks only warn, and only in dev (`import.meta.dev` strips them
10
+ * from a production build). A content type with a typo still loads. The
11
+ * warning exists to catch the typo before it reaches a later wave's
12
+ * generic list/form UI or server adapter, where it would fail silently or
13
+ * confusingly instead.
14
+ */
15
+ export function defineContentType(type: PlutoContentType): PlutoContentType {
16
+ if (import.meta.dev) {
17
+ const prefix = `[pluto] content type "${type.name}":`
18
+ const seen = new Set<string>()
19
+
20
+ for (const field of type.fields) {
21
+ if (seen.has(field.name)) {
22
+ console.warn(`${prefix} duplicate field name "${field.name}".`)
23
+ }
24
+ seen.add(field.name)
25
+
26
+ if (field.type === 'select' && field.options.length === 0) {
27
+ console.warn(`${prefix} select field "${field.name}" has no options.`)
28
+ }
29
+ }
30
+
31
+ if (!type.fields.some((field) => field.name === type.titleField)) {
32
+ console.warn(`${prefix} titleField "${type.titleField}" does not match any field.`)
33
+ }
34
+
35
+ if (type.slug) {
36
+ const slug = type.slug
37
+ const field = type.fields.find((candidate) => candidate.name === slug.field)
38
+ if (!field || field.type !== 'slug') {
39
+ console.warn(`${prefix} slug.field "${slug.field}" does not name a field of type "slug".`)
40
+ }
41
+ }
42
+
43
+ if (type.status && type.status.values.length === 0) {
44
+ console.warn(`${prefix} status has no values.`)
45
+ }
46
+ }
47
+
48
+ return type
49
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ define: {
5
+ 'import.meta.dev': 'true',
6
+ },
7
+ })