@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 +14 -0
- package/FEATURES.md +5 -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/server/api/_pluto/content/[type]/[id].delete.ts +15 -0
- package/server/api/_pluto/content/[type]/[id].get.ts +13 -0
- package/server/api/_pluto/content/[type]/[id].patch.ts +14 -0
- package/server/api/_pluto/content/[type]/index.get.ts +16 -0
- package/server/api/_pluto/content/[type]/index.post.ts +13 -0
- package/server/utils/pluto-content-handlers.ts +168 -0
- package/server/utils/pluto-content.ts +44 -0
- package/shared/types/content.ts +253 -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,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
|
@@ -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
|
+
}
|
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
|
+
}
|