@plutocms/pluto 0.4.0 → 0.6.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.6.0](https://github.com/plutocms/pluto/compare/v0.5.0...v0.6.0) (2026-09-12)
5
+
6
+
7
+ ### Features
8
+
9
+ * **content:** add content-model contracts (wave 1) ([#131](https://github.com/plutocms/pluto/issues/131)) ([95e157b](https://github.com/plutocms/pluto/commit/95e157b41a60b17ae8311878eb2ef79356feef43))
10
+
11
+ ## [0.5.0](https://github.com/plutocms/pluto/compare/v0.4.0...v0.5.0) (2026-09-12)
12
+
13
+
14
+ ### Features
15
+
16
+ * **permissions:** add capability registry and usePlutoPermissions composable ([#129](https://github.com/plutocms/pluto/issues/129)) ([c98419f](https://github.com/plutocms/pluto/commit/c98419fc967f8e20678389169e937db759c308bc))
17
+
4
18
  ## [0.4.0](https://github.com/plutocms/pluto/compare/v0.3.2...v0.4.0) (2026-09-12)
5
19
 
6
20
 
package/FEATURES.md CHANGED
@@ -9,3 +9,10 @@ policies shared by every project here.
9
9
  - Extension registry: a typed, ordered, reactive registry that lets a layer plug its admin-UI
10
10
  pieces (navbar, sidebar nav, pages, dashboard widgets, settings panels and driver) into core
11
11
  with one function call. @.claude/skills/extension-registry/SKILL.md
12
+ - Permissions: a typed capability registry and a `usePlutoPermissions()` composable that let a
13
+ layer declare capabilities and check what the current user holds, backed by a registered
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. Client-side contracts only; no server registry or generated UI yet.
18
+ @.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
+ }
@@ -21,4 +21,12 @@ export function definePlutoExtension(definition: PlutoExtension): void {
21
21
  definition.settingsDriver ? [definition.settingsDriver] : []
22
22
  )
23
23
  registry.dashboardWidgets.set(definition.id, definition.dashboardWidgets ?? [])
24
+ registry.capabilities.set(definition.id, definition.capabilities ?? [])
25
+ registry.permissionsDrivers.set(
26
+ definition.id,
27
+ definition.permissionsDriver ? [definition.permissionsDriver] : []
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 ?? [])
24
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
+ }
@@ -0,0 +1,74 @@
1
+ /** Capabilities declared by every layer, in registry order. */
2
+ export function usePlutoCapabilities() {
3
+ const registry = usePlutoRegistry()
4
+ return { items: registry.capabilities.list }
5
+ }
6
+
7
+ /**
8
+ * The current user's capabilities, backed by whichever permissions driver
9
+ * is registered. `useState` is correct for the loaded list itself (plain
10
+ * strings, and /admin/** is client-only per nuxt.config.ts) — this
11
+ * mirrors usePlutoSettings()'s values/dirty state exactly. The driver
12
+ * registration itself goes through the registry, same as
13
+ * PlutoSettingsDriver.
14
+ */
15
+ export function usePlutoPermissions() {
16
+ const registry = usePlutoRegistry()
17
+ const driver = computed(() => registry.permissionsDrivers.list.value.at(-1) ?? null)
18
+
19
+ const granted = useState<string[]>('pluto-permissions-granted', () => [])
20
+ const loaded = useState<boolean>('pluto-permissions-loaded', () => false)
21
+ const status = ref<'idle' | 'loading'>('idle')
22
+
23
+ async function load() {
24
+ if (!driver.value) {
25
+ return
26
+ }
27
+
28
+ status.value = 'loading'
29
+ try {
30
+ granted.value = await driver.value.load()
31
+ loaded.value = true
32
+ } finally {
33
+ status.value = 'idle'
34
+ }
35
+ }
36
+
37
+ function clear() {
38
+ granted.value = []
39
+ loaded.value = false
40
+ }
41
+
42
+ /**
43
+ * True when the user holds this capability.
44
+ *
45
+ * Fails OPEN when no permissions driver is registered — a site with no
46
+ * permissions backend also has no server-side guard, so hiding UI in
47
+ * that case would only be theatre, and would break a core-only
48
+ * install with nothing registered yet. Read `hasDriver` to tell the two
49
+ * cases apart if a caller needs to.
50
+ *
51
+ * This is never a security boundary by itself. The server-side guard
52
+ * (a backend layer's own requireCapability-equivalent, backed by RLS)
53
+ * is what actually enforces anything. This only decides what the UI
54
+ * shows.
55
+ */
56
+ function can(capability: string): boolean {
57
+ if (!driver.value) {
58
+ return true
59
+ }
60
+
61
+ return granted.value.includes('*') || granted.value.includes(capability)
62
+ }
63
+
64
+ return {
65
+ granted,
66
+ loaded,
67
+ status,
68
+ hasDriver: computed(() => Boolean(driver.value)),
69
+ isAdmin: computed(() => granted.value.includes('*')),
70
+ load,
71
+ clear,
72
+ can,
73
+ }
74
+ }
@@ -21,6 +21,11 @@ export function usePlutoRegistry(): PlutoRegistry {
21
21
  settingsPanels: createOwnedRegistry<PlutoSettingsPanel>(),
22
22
  settingsDrivers: createOwnedRegistry<PlutoSettingsDriver>(),
23
23
  dashboardWidgets: createOwnedRegistry<PlutoDashboardWidget>(),
24
+ capabilities: createOwnedRegistry<PlutoCapability>(),
25
+ permissionsDrivers: createOwnedRegistry<PlutoPermissionsDriver>(),
26
+ contentTypes: createOwnedRegistry<PlutoContentTypeEntry>(),
27
+ contentFieldWidgets: createOwnedRegistry<PlutoContentFieldWidget>(),
28
+ mediaAdapters: createOwnedRegistry<PlutoMediaAdapterEntry>(),
24
29
  }
25
30
  }
26
31
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -0,0 +1,196 @@
1
+ export type PlutoFieldType =
2
+ | 'text'
3
+ | 'textarea'
4
+ | 'richtext'
5
+ | 'slug'
6
+ | 'number'
7
+ | 'boolean'
8
+ | 'date'
9
+ | 'select'
10
+ | 'reference'
11
+ | 'media'
12
+
13
+ /**
14
+ * Fields every `PlutoField` shares, regardless of `type`. A concrete field
15
+ * (`PlutoTextField`, `PlutoSlugField`, and so on) narrows `type` to one
16
+ * literal and adds its own type-specific options on top of this base.
17
+ */
18
+ export interface PlutoFieldBase {
19
+ /** Field key. Used as the object key on a `PlutoContentItem`, and as the default storage column (see `fieldColumn` in `shared/utils/content-map.ts`). */
20
+ name: string
21
+ type: PlutoFieldType
22
+ label: string
23
+ description?: string
24
+ /** Storage column, when it differs from `name`. Falls back to `name` when unset. */
25
+ column?: string
26
+ required?: boolean
27
+ /** Show this field as a column in the generic list view. Default false. */
28
+ inList?: boolean
29
+ /** Show this field in the generic form view. Default true. */
30
+ inForm?: boolean
31
+ /** Lower comes first, in both the list and the form. Default 100. */
32
+ order?: number
33
+ default?: unknown
34
+ /** Name of a registered `PlutoContentFieldWidget` to force, overriding the default resolved by `type`. */
35
+ widget?: string
36
+ /** Which column of the generic form this field renders in. Default 'main'. */
37
+ region?: 'main' | 'side'
38
+ }
39
+
40
+ export interface PlutoTextField extends PlutoFieldBase {
41
+ type: 'text' | 'textarea' | 'richtext'
42
+ maxLength?: number
43
+ placeholder?: string
44
+ }
45
+
46
+ export interface PlutoSlugField extends PlutoFieldBase {
47
+ type: 'slug'
48
+ /** Name of the field this slug is generated from, for example 'title'. */
49
+ from?: string
50
+ preview?: string
51
+ }
52
+
53
+ export interface PlutoNumberField extends PlutoFieldBase {
54
+ type: 'number'
55
+ integer?: boolean
56
+ min?: number
57
+ max?: number
58
+ }
59
+
60
+ export interface PlutoSelectField extends PlutoFieldBase {
61
+ type: 'select'
62
+ options: Array<{
63
+ label: string
64
+ value: string | number
65
+ }>
66
+ }
67
+
68
+ export interface PlutoReferenceField extends PlutoFieldBase {
69
+ type: 'reference'
70
+ /** Name of the referenced content type, for example 'category'. */
71
+ target?: string
72
+ optionsUrl?: string
73
+ labelKey?: string
74
+ valueKey?: string
75
+ }
76
+
77
+ export interface PlutoMediaField extends PlutoFieldBase {
78
+ type: 'media'
79
+ multiple?: boolean
80
+ }
81
+
82
+ export interface PlutoBooleanField extends PlutoFieldBase {
83
+ type: 'boolean'
84
+ }
85
+
86
+ export interface PlutoDateField extends PlutoFieldBase {
87
+ type: 'date'
88
+ }
89
+
90
+ export type PlutoField =
91
+ | PlutoTextField
92
+ | PlutoSlugField
93
+ | PlutoNumberField
94
+ | PlutoSelectField
95
+ | PlutoReferenceField
96
+ | PlutoMediaField
97
+ | PlutoBooleanField
98
+ | PlutoDateField
99
+
100
+ /**
101
+ * Capability keys a content-type adapter checks before it reads, writes, or
102
+ * deletes a row. Plain strings, not registered `PlutoCapability` entries —
103
+ * see the permissions skill for the format (`<namespace>:<action>`).
104
+ */
105
+ export interface PlutoContentTypeCapabilities {
106
+ read?: string
107
+ write?: string
108
+ delete?: string
109
+ }
110
+
111
+ export interface PlutoContentStatus {
112
+ /** Storage column. Falls back to 'status' when unset. */
113
+ column?: string
114
+ values: Array<{
115
+ label: string
116
+ value: string
117
+ }>
118
+ default?: string
119
+ /** The `values[].value` that counts as published, for example 'published'. */
120
+ publishedValue?: string
121
+ publishedAtColumn?: string
122
+ }
123
+
124
+ export interface PlutoContentTimestamps {
125
+ /** Storage column, or `false` to disable this timestamp. Falls back to 'created_at' when unset. */
126
+ created?: string | false
127
+ /** Storage column, or `false` to disable this timestamp. Falls back to 'updated_at' when unset. */
128
+ updated?: string | false
129
+ }
130
+
131
+ export interface PlutoContentI18n {
132
+ /** 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. */
133
+ strategy: 'row'
134
+ localeColumn?: string
135
+ groupColumn?: string
136
+ }
137
+
138
+ /**
139
+ * The contract for one content type: what fields it has, where it is
140
+ * stored, and how the generic list/form UI (a later wave) should render it.
141
+ * A layer declares one of these per content type it owns, through
142
+ * `defineContentType`.
143
+ */
144
+ export interface PlutoContentType {
145
+ /**
146
+ * The content type's own identifier, for example 'post'. Kept separate
147
+ * from a registered `PlutoContentTypeEntry.id` for the exact same reason
148
+ * `PlutoCapability.key` is kept separate from `id` (see
149
+ * `shared/types/registry.ts`): the registry rewrites `id` to
150
+ * `'<extensionId>:<id>'`, which would corrupt a name used in a URL path
151
+ * segment or as a lookup key. Always read `name` for the real content
152
+ * type identity, never the registry entry's `id`.
153
+ */
154
+ name: string
155
+ label: string
156
+ labelPlural: string
157
+ icon?: string
158
+ /** Table or view name this content type reads and writes. */
159
+ source: string
160
+ /** Storage column for the primary key. Falls back to 'id' when unset. */
161
+ primaryKey?: string
162
+ /** Name of the field shown as the row's title in lists and breadcrumbs. */
163
+ titleField: string
164
+ fields: PlutoField[]
165
+ /** Name of the field that holds the slug, or `false` to disable slugging entirely. */
166
+ slug?: {
167
+ field: string
168
+ } | false
169
+ /** `false` disables status/publish workflow entirely for this content type. */
170
+ status?: PlutoContentStatus | false
171
+ /** `false` disables created/updated timestamps entirely for this content type. */
172
+ timestamps?: PlutoContentTimestamps | false
173
+ capabilities?: PlutoContentTypeCapabilities
174
+ defaultSort?: {
175
+ field: string
176
+ direction: 'asc' | 'desc'
177
+ }
178
+ /** Whether a later wave should generate list/edit/create admin routes for this content type. */
179
+ autoRoutes?: boolean
180
+ /** Base admin path for generated routes, for example '/admin/posts'. Only meaningful when `autoRoutes` is true. */
181
+ basePath?: string
182
+ navOrder?: number
183
+ i18n?: PlutoContentI18n
184
+ }
185
+
186
+ /**
187
+ * One stored content row, keyed by field name (not column name — see
188
+ * `mapColumnsToFields` in `shared/utils/content-map.ts` for the
189
+ * column-to-field translation). Named `PlutoContentItem`, not
190
+ * `PlutoEntryRecord`, to avoid visual confusion with `PlutoEntry` (a
191
+ * registry bucket item, see `shared/types/registry.ts`) — this type has
192
+ * nothing to do with the registry.
193
+ */
194
+ export type PlutoContentItem = Record<string, unknown> & {
195
+ id: string | number
196
+ }
@@ -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>`. */
@@ -59,6 +61,58 @@ export interface PlutoSettingsDriver extends PlutoEntry {
59
61
  save: (patch: Record<string, string>) => Promise<void>
60
62
  }
61
63
 
64
+ /**
65
+ * A capability a layer defines. `key` is the real capability string, for
66
+ * example 'posts:publish'. It is kept separate from `id` on purpose: the
67
+ * registry rewrites `id` to '<extensionId>:<id>' (see registry-factory.ts),
68
+ * which would corrupt a capability string if `id` were reused as the
69
+ * capability itself. `id` identifies this registry entry; `key` identifies
70
+ * the permission.
71
+ */
72
+ export interface PlutoCapability extends PlutoEntry {
73
+ /** Format: '<namespace>:<action>', lowercase, for example 'posts:publish'. */
74
+ key: string
75
+ label: string
76
+ description?: string
77
+ }
78
+
79
+ /**
80
+ * Loads the current user's capability list. One backend layer registers
81
+ * this, the same way one layer registers a PlutoSettingsDriver. `load`
82
+ * returns a flat list of capability key strings the user holds. The
83
+ * single entry '*' means "every capability" (used by the built-in admin
84
+ * role).
85
+ */
86
+ export interface PlutoPermissionsDriver extends PlutoEntry {
87
+ load: () => Promise<string[]>
88
+ }
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
+
62
116
  export interface PlutoExtension {
63
117
  /** Layer name. Use the package's $meta.name, e.g. 'supabase-blog'. */
64
118
  id: string
@@ -69,6 +123,11 @@ export interface PlutoExtension {
69
123
  dashboardWidgets?: PlutoDashboardWidget[]
70
124
  settingsPanels?: PlutoSettingsPanel[]
71
125
  settingsDriver?: PlutoSettingsDriver
126
+ capabilities?: PlutoCapability[]
127
+ permissionsDriver?: PlutoPermissionsDriver
128
+ contentTypes?: PlutoContentTypeEntry[]
129
+ contentFieldWidgets?: PlutoContentFieldWidget[]
130
+ mediaAdapters?: PlutoMediaAdapterEntry[]
72
131
  }
73
132
 
74
133
  /**
@@ -91,4 +150,9 @@ export interface PlutoRegistry {
91
150
  settingsPanels: PlutoRegistryBucket<PlutoSettingsPanel>
92
151
  settingsDrivers: PlutoRegistryBucket<PlutoSettingsDriver>
93
152
  dashboardWidgets: PlutoRegistryBucket<PlutoDashboardWidget>
153
+ capabilities: PlutoRegistryBucket<PlutoCapability>
154
+ permissionsDrivers: PlutoRegistryBucket<PlutoPermissionsDriver>
155
+ contentTypes: PlutoRegistryBucket<PlutoContentTypeEntry>
156
+ contentFieldWidgets: PlutoRegistryBucket<PlutoContentFieldWidget>
157
+ mediaAdapters: PlutoRegistryBucket<PlutoMediaAdapterEntry>
94
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
+ })