@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 +14 -0
- package/FEATURES.md +7 -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 +8 -0
- package/app/composables/pluto-media-adapter.ts +15 -0
- package/app/composables/pluto-permissions.ts +74 -0
- package/app/composables/pluto-registry.ts +5 -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 +64 -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.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
|
@@ -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>`. */
|
|
@@ -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
|
+
}
|