@plutocms/pluto 0.6.0 → 0.8.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 +4 -1
- package/app/components/Pluto/Content/PlutoContentField.vue +34 -0
- package/app/components/Pluto/Content/PlutoContentForm.vue +222 -0
- package/app/components/Pluto/Content/PlutoContentList.vue +197 -0
- package/app/components/Pluto/Content/fields/PlutoFieldBoolean.vue +23 -0
- package/app/components/Pluto/Content/fields/PlutoFieldDate.vue +41 -0
- package/app/components/Pluto/Content/fields/PlutoFieldNumber.vue +30 -0
- package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +79 -0
- package/app/components/Pluto/Content/fields/PlutoFieldSelect.vue +28 -0
- package/app/components/Pluto/Content/fields/PlutoFieldSlug.vue +33 -0
- package/app/components/Pluto/Content/fields/PlutoFieldText.vue +40 -0
- package/app/composables/pluto-admin-nav.ts +49 -9
- package/app/composables/pluto-admin-pages.ts +28 -3
- package/app/composables/pluto-content-item.ts +98 -0
- package/app/composables/pluto-content-list.ts +60 -0
- package/app/pages/admin/content/[type]/[id].vue +19 -0
- package/app/pages/admin/content/[type]/index.vue +19 -0
- package/app/pages/admin/content/[type]/new.vue +19 -0
- package/app/plugins/pluto-core-registrations.ts +32 -3
- package/package.json +2 -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 +57 -0
|
@@ -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
|
+
}
|
package/shared/types/content.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
|
|
1
3
|
export type PlutoFieldType =
|
|
2
4
|
| 'text'
|
|
3
5
|
| 'textarea'
|
|
@@ -181,6 +183,19 @@ export interface PlutoContentType {
|
|
|
181
183
|
basePath?: string
|
|
182
184
|
navOrder?: number
|
|
183
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
|
+
}
|
|
184
199
|
}
|
|
185
200
|
|
|
186
201
|
/**
|
|
@@ -194,3 +209,45 @@ export interface PlutoContentType {
|
|
|
194
209
|
export type PlutoContentItem = Record<string, unknown> & {
|
|
195
210
|
id: string | number
|
|
196
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
|
+
}
|