@plutocms/pluto 0.5.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 +7 -0
- package/FEATURES.md +4 -0
- package/app/composables/pluto-content-fields.ts +24 -0
- package/app/composables/pluto-content-types.ts +11 -0
- package/app/composables/pluto-extension.ts +3 -0
- package/app/composables/pluto-media-adapter.ts +15 -0
- package/app/composables/pluto-registry.ts +3 -0
- package/package.json +1 -1
- package/shared/types/content.ts +196 -0
- package/shared/types/media.ts +34 -0
- package/shared/types/registry.ts +34 -0
- package/shared/utils/content-map.ts +49 -0
- package/shared/utils/content-validate.ts +100 -0
- package/shared/utils/content.ts +49 -0
- package/vitest.config.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
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
|
+
|
|
4
11
|
## [0.5.0](https://github.com/plutocms/pluto/compare/v0.4.0...v0.5.0) (2026-09-12)
|
|
5
12
|
|
|
6
13
|
|
package/FEATURES.md
CHANGED
|
@@ -12,3 +12,7 @@ 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. 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
|
+
}
|
|
@@ -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
|
@@ -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
|
+
}
|
package/shared/types/registry.ts
CHANGED
|
@@ -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
|
+
}
|