@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/FEATURES.md +4 -1
  3. package/app/components/Pluto/Content/PlutoContentField.vue +34 -0
  4. package/app/components/Pluto/Content/PlutoContentForm.vue +222 -0
  5. package/app/components/Pluto/Content/PlutoContentList.vue +197 -0
  6. package/app/components/Pluto/Content/fields/PlutoFieldBoolean.vue +23 -0
  7. package/app/components/Pluto/Content/fields/PlutoFieldDate.vue +41 -0
  8. package/app/components/Pluto/Content/fields/PlutoFieldNumber.vue +30 -0
  9. package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +79 -0
  10. package/app/components/Pluto/Content/fields/PlutoFieldSelect.vue +28 -0
  11. package/app/components/Pluto/Content/fields/PlutoFieldSlug.vue +33 -0
  12. package/app/components/Pluto/Content/fields/PlutoFieldText.vue +40 -0
  13. package/app/composables/pluto-admin-nav.ts +49 -9
  14. package/app/composables/pluto-admin-pages.ts +28 -3
  15. package/app/composables/pluto-content-item.ts +98 -0
  16. package/app/composables/pluto-content-list.ts +60 -0
  17. package/app/pages/admin/content/[type]/[id].vue +19 -0
  18. package/app/pages/admin/content/[type]/index.vue +19 -0
  19. package/app/pages/admin/content/[type]/new.vue +19 -0
  20. package/app/plugins/pluto-core-registrations.ts +32 -3
  21. package/package.json +2 -1
  22. package/server/api/_pluto/content/[type]/[id].delete.ts +15 -0
  23. package/server/api/_pluto/content/[type]/[id].get.ts +13 -0
  24. package/server/api/_pluto/content/[type]/[id].patch.ts +14 -0
  25. package/server/api/_pluto/content/[type]/index.get.ts +16 -0
  26. package/server/api/_pluto/content/[type]/index.post.ts +13 -0
  27. package/server/utils/pluto-content-handlers.ts +168 -0
  28. package/server/utils/pluto-content.ts +44 -0
  29. package/shared/types/content.ts +57 -0
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ // A plain text input plus a preview line. This widget does not auto-derive
3
+ // the slug from another field — only `PlutoContentForm` knows about the
4
+ // *other* field (`field.from`) a slug derives from, so that logic lives
5
+ // there, not here. See the content-model skill for the slug bug this
6
+ // separation is meant to avoid repeating.
7
+ const props = defineProps<{
8
+ field: PlutoSlugField
9
+ modelValue: unknown
10
+ disabled?: boolean
11
+ }>()
12
+
13
+ const emit = defineEmits<{
14
+ 'update:modelValue': [value: string]
15
+ }>()
16
+
17
+ const value = computed(() => (props.modelValue as string | undefined) ?? '')
18
+ </script>
19
+
20
+ <template>
21
+ <UFormField :label="field.label" :description="field.description" :required="field.required">
22
+ <UInput
23
+ :model-value="value"
24
+ :disabled="disabled"
25
+ class="w-full"
26
+ @update:model-value="emit('update:modelValue', $event)"
27
+ />
28
+
29
+ <p v-if="field.preview" class="text-muted mt-1 text-sm">
30
+ {{ field.preview }}{{ value }}
31
+ </p>
32
+ </UFormField>
33
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ // Renders both 'text' and 'textarea' fields. `richtext` has no core widget
3
+ // — a layer (for example @plutocms/supabase-blog) registers its own.
4
+ const props = defineProps<{
5
+ field: PlutoTextField
6
+ modelValue: unknown
7
+ disabled?: boolean
8
+ }>()
9
+
10
+ defineEmits<{
11
+ 'update:modelValue': [value: string]
12
+ }>()
13
+
14
+ const isTextarea = computed(() => props.field.type === 'textarea')
15
+ const value = computed(() => (props.modelValue as string | undefined) ?? undefined)
16
+ </script>
17
+
18
+ <template>
19
+ <UFormField :label="field.label" :description="field.description" :required="field.required">
20
+ <UTextarea
21
+ v-if="isTextarea"
22
+ :model-value="value"
23
+ :placeholder="field.placeholder"
24
+ :maxlength="field.maxLength"
25
+ :disabled="disabled"
26
+ class="w-full"
27
+ @update:model-value="$emit('update:modelValue', $event)"
28
+ />
29
+
30
+ <UInput
31
+ v-else
32
+ :model-value="value"
33
+ :placeholder="field.placeholder"
34
+ :maxlength="field.maxLength"
35
+ :disabled="disabled"
36
+ class="w-full"
37
+ @update:model-value="$emit('update:modelValue', $event)"
38
+ />
39
+ </UFormField>
40
+ </template>
@@ -6,21 +6,41 @@ interface LegacyHrefLink {
6
6
  }
7
7
 
8
8
  /**
9
- * The admin sidebar nav. Merges the new registry with the old
10
- * `useSidebarAdminActions` composable (`@plutocms/utils`), so an unmigrated
11
- * layer's sidebar entries keep showing up. A bridged entry is skipped when a
12
- * registered entry already uses the same label, so a layer that migrates
13
- * does not get a duplicate item.
9
+ * The admin sidebar nav. Merges three sources:
10
+ *
11
+ * 1. The registry's own `nav` bucket — a layer's own `definePlutoExtension`
12
+ * call.
13
+ * 2. The old `useSidebarAdminActions` composable (`@plutocms/utils`), so an
14
+ * unmigrated layer's sidebar entries keep showing up. A bridged entry is
15
+ * skipped when a registered entry already uses the same label, so a
16
+ * layer that migrates does not get a duplicate item.
17
+ * 3. One synthesized entry per auto-routed content type (`type.autoRoutes
18
+ * !== false`) — a layer that declares a content type gets a nav entry
19
+ * for free, without calling `definePlutoExtension` itself. Gated on the
20
+ * same "no capability declared = always visible" rule the generic
21
+ * content routes already use server-side (see the content-model skill):
22
+ * when `type.capabilities.read` is unset, the entry is always visible;
23
+ * when it is set, `can(...)` decides.
24
+ *
25
+ * `entry.enabled` is read and enforced uniformly across all three sources
26
+ * here. Source 1 already filters on it a second time, inside
27
+ * `createOwnedRegistry` itself (see
28
+ * `app/composables/internal/registry-factory.ts`) — applying the same
29
+ * filter again here is harmless for that source, and is what makes sources
30
+ * 2 and 3 (plain objects that never pass through that registry) respect
31
+ * `enabled` too, instead of only source 1.
14
32
  */
15
33
  export function usePlutoAdminNav() {
16
34
  const registry = usePlutoRegistry()
17
35
  const legacy = useSidebarAdminActions()
36
+ const { items: contentTypes } = usePlutoContentTypes()
37
+ const { can } = usePlutoPermissions()
18
38
 
19
39
  const items = computed<PlutoNavItem[]>(() => {
20
40
  const registered = registry.nav.list.value
21
41
  const seen = new Set(registered.map((item) => item.label))
22
42
 
23
- const bridged = legacy.actions.value
43
+ const bridged: PlutoNavItem[] = legacy.actions.value
24
44
  .filter((action) => !seen.has(String(action.label)))
25
45
  .map((action, index) => ({
26
46
  id: `legacy:sidebar-${index}`,
@@ -35,9 +55,29 @@ export function usePlutoAdminNav() {
35
55
  })),
36
56
  }))
37
57
 
38
- return [...registered, ...bridged].sort(
39
- (a, b) => (a.order ?? 100) - (b.order ?? 100)
40
- )
58
+ const contentNav: PlutoNavItem[] = contentTypes.value
59
+ .filter((entry) => entry.type.autoRoutes !== false)
60
+ .map((entry) => {
61
+ const type = entry.type
62
+ const basePath = type.basePath ?? `/admin/content/${type.name}`
63
+
64
+ return {
65
+ id: `content:${type.name}`,
66
+ order: type.navOrder ?? 100,
67
+ label: type.labelPlural,
68
+ icon: type.icon,
69
+ to: basePath,
70
+ enabled: () => !type.capabilities?.read || can(type.capabilities.read),
71
+ children: [
72
+ { label: `All ${type.labelPlural}`, to: basePath },
73
+ { label: `Create new ${type.label}`, to: `${basePath}/new` },
74
+ ],
75
+ }
76
+ })
77
+
78
+ return [...registered, ...bridged, ...contentNav]
79
+ .filter((item) => item.enabled?.() ?? true)
80
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
41
81
  })
42
82
 
43
83
  return { items }
@@ -1,13 +1,38 @@
1
1
  /**
2
2
  * Metadata for admin pages, registered on top of routes Nuxt already
3
- * creates from files. No layer registers page metadata today, so this has
4
- * no legacy bridge.
3
+ * creates from files, plus two synthesized entries per auto-routed content
4
+ * type (`type.autoRoutes !== false`): a list page and a "new" page — the
5
+ * page-metadata equivalent of the nav entry `usePlutoAdminNav` synthesizes
6
+ * for the same content type. No layer registers page metadata today, so
7
+ * the registry bucket itself has no legacy bridge.
5
8
  */
6
9
  export function usePlutoAdminPages() {
7
10
  const registry = usePlutoRegistry()
8
11
  const route = useRoute()
12
+ const { items: contentTypes } = usePlutoContentTypes()
13
+
14
+ const items = computed<PlutoAdminPage[]>(() => {
15
+ const contentPages: PlutoAdminPage[] = contentTypes.value
16
+ .filter((entry) => entry.type.autoRoutes !== false)
17
+ .flatMap((entry) => {
18
+ const type = entry.type
19
+ const basePath = type.basePath ?? `/admin/content/${type.name}`
20
+ const navId = `content:${type.name}`
21
+
22
+ return [
23
+ { id: `${navId}:list`, path: basePath, title: type.labelPlural },
24
+ {
25
+ id: `${navId}:new`,
26
+ path: `${basePath}/new`,
27
+ title: `Create new ${type.label}`,
28
+ parent: navId,
29
+ },
30
+ ]
31
+ })
32
+
33
+ return [...registry.pages.list.value, ...contentPages]
34
+ })
9
35
 
10
- const items = computed(() => registry.pages.list.value)
11
36
  const current = computed(() => items.value.find((page) => page.path === route.path))
12
37
 
13
38
  return { items, current }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Loads (or creates) one item of one content type, for the generic form page
3
+ * (`PlutoContentForm`).
4
+ *
5
+ * This is one half of the split that replaces the old `usePost`/`useProduct`
6
+ * pattern (see `usePlutoContentList` for the other half). That old pattern
7
+ * fetched a list AND a single item from one composable every time, and threw
8
+ * away whichever one the caller did not need. `usePlutoContentItem` only
9
+ * ever fetches a single item, and only when `id` is defined — a "new entry"
10
+ * form never fires a GET at all.
11
+ *
12
+ * It is also not a "fake thenable": it returns plain refs and explicit
13
+ * methods, so a caller `await`s `save()`/`remove()`/`refresh()` directly,
14
+ * instead of `await`ing the composable's own return value through a
15
+ * hand-rolled `.then()`.
16
+ *
17
+ * `id` is read once, with `toValue`, at composable-creation time. It is not
18
+ * watched for later changes — each of the three generated pages
19
+ * (`app/pages/admin/content/[type]/{index,new,[id]}.vue`) mounts fresh on
20
+ * every route change, so there is no case in this wave where `id` changes
21
+ * under a still-mounted `PlutoContentForm`.
22
+ */
23
+ export function usePlutoContentItem(
24
+ typeName: string,
25
+ id: MaybeRefOrGetter<string | number | undefined>
26
+ ) {
27
+ const resolvedId = toValue(id)
28
+ const isNew = resolvedId === undefined
29
+
30
+ const item = ref<Record<string, unknown>>({})
31
+ const pending = ref(false)
32
+ const error = ref<unknown>(null)
33
+
34
+ async function refresh(): Promise<void> {
35
+ // A new, unsaved entry has nothing to fetch — `item` stays the empty
36
+ // object it started as.
37
+ if (isNew) {
38
+ return
39
+ }
40
+
41
+ pending.value = true
42
+ error.value = null
43
+
44
+ try {
45
+ item.value = await $fetch<PlutoContentItem>(`/api/_pluto/content/${typeName}/${resolvedId}`)
46
+ } catch (caught) {
47
+ error.value = caught
48
+ throw caught
49
+ } finally {
50
+ pending.value = false
51
+ }
52
+ }
53
+
54
+ // Fire the initial fetch right away, same reasoning as
55
+ // usePlutoContentList: /admin/** is client-only, so there is no SSR
56
+ // payload concern. The failure path already lands in `error` for a
57
+ // reactive consumer to read; swallow the rejection here so it never
58
+ // surfaces as an unhandled rejection. A caller invoking `refresh()`
59
+ // again later still gets a real rejection to `await`.
60
+ refresh().catch(() => {})
61
+
62
+ async function save(values: Record<string, unknown>): Promise<PlutoContentItem> {
63
+ error.value = null
64
+
65
+ try {
66
+ const saved = isNew
67
+ ? await $fetch<PlutoContentItem>(`/api/_pluto/content/${typeName}`, {
68
+ method: 'POST',
69
+ body: values,
70
+ })
71
+ : await $fetch<PlutoContentItem>(`/api/_pluto/content/${typeName}/${resolvedId}`, {
72
+ method: 'PATCH',
73
+ body: values,
74
+ })
75
+
76
+ item.value = saved
77
+ return saved
78
+ } catch (caught) {
79
+ error.value = caught
80
+ throw caught
81
+ }
82
+ }
83
+
84
+ async function remove(): Promise<void> {
85
+ if (isNew) {
86
+ throw new Error('Cannot remove an entry that has not been saved yet.')
87
+ }
88
+
89
+ try {
90
+ await $fetch(`/api/_pluto/content/${typeName}/${resolvedId}`, { method: 'DELETE' })
91
+ } catch (caught) {
92
+ error.value = caught
93
+ throw caught
94
+ }
95
+ }
96
+
97
+ return { item, pending, error, refresh, save, remove }
98
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Fetches one content type's list of items, for the generic list page
3
+ * (`PlutoContentList`).
4
+ *
5
+ * This is one half of the split that replaces the old `usePost`/`useProduct`
6
+ * pattern (see `usePlutoContentItem` for the other half). That old pattern
7
+ * fetched a list AND a single item from one composable every time, and threw
8
+ * away whichever one the caller did not need — a list page paid for an item
9
+ * fetch it never used, and an edit page paid for a list fetch it never used.
10
+ * `usePlutoContentList` only ever fetches a list, because a list page only
11
+ * ever needs a list.
12
+ *
13
+ * It is also not a "fake thenable": it returns plain refs and explicit
14
+ * methods, so a caller `await`s `refresh()` or `remove()` directly, instead
15
+ * of `await`ing the composable's own return value through a hand-rolled
16
+ * `.then()`.
17
+ */
18
+ export function usePlutoContentList(typeName: string) {
19
+ const items = ref<PlutoContentItem[]>([])
20
+ const total = ref<number | undefined>(undefined)
21
+ const pending = ref(false)
22
+ const error = ref<unknown>(null)
23
+
24
+ async function refresh(): Promise<void> {
25
+ pending.value = true
26
+ error.value = null
27
+
28
+ try {
29
+ const result = await $fetch<PlutoContentListResult>(`/api/_pluto/content/${typeName}`)
30
+ items.value = result.data
31
+ total.value = result.total
32
+ } catch (caught) {
33
+ error.value = caught
34
+ throw caught
35
+ } finally {
36
+ pending.value = false
37
+ }
38
+ }
39
+
40
+ // Fire the initial fetch right away. /admin/** is client-only
41
+ // (ssr: false in nuxt.config.ts), so there is no SSR payload concern —
42
+ // this only ever runs in the browser. The failure path already lands in
43
+ // `error` for a reactive consumer to read; swallow the rejection here so
44
+ // it never surfaces as an unhandled rejection. A caller invoking
45
+ // `refresh()` again later still gets a real rejection to `await`.
46
+ refresh().catch(() => {})
47
+
48
+ async function remove(id: string | number): Promise<void> {
49
+ try {
50
+ await $fetch(`/api/_pluto/content/${typeName}/${id}`, { method: 'DELETE' })
51
+ } catch (caught) {
52
+ error.value = caught
53
+ throw caught
54
+ }
55
+
56
+ await refresh()
57
+ }
58
+
59
+ return { items, total, pending, error, refresh, remove }
60
+ }
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const typeName = route.params.type as string
4
+
5
+ const { byName } = usePlutoContentTypes()
6
+ const type = byName(typeName)
7
+
8
+ if (!type) {
9
+ throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
10
+ }
11
+
12
+ useHead({
13
+ title: `Edit ${type.label}`,
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <PlutoContentForm :id="route.params.id as string" :type="type.name" />
19
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const typeName = route.params.type as string
4
+
5
+ const { byName } = usePlutoContentTypes()
6
+ const type = byName(typeName)
7
+
8
+ if (!type) {
9
+ throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
10
+ }
11
+
12
+ useHead({
13
+ title: type.labelPlural,
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <PlutoContentList :type="type.name" />
19
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const typeName = route.params.type as string
4
+
5
+ const { byName } = usePlutoContentTypes()
6
+ const type = byName(typeName)
7
+
8
+ if (!type) {
9
+ throw createError({ statusCode: 404, statusMessage: 'Content type not found.', fatal: true })
10
+ }
11
+
12
+ useHead({
13
+ title: `New ${type.label}`,
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <PlutoContentForm :type="type.name" />
19
+ </template>
@@ -1,6 +1,25 @@
1
- // Registers core's own Home and Settings anchors. `order: 0` and
2
- // `order: 1000` are reserved for these two entries, so every other layer's
3
- // nav item sorts between them by default (default order is 100).
1
+ import PlutoFieldBoolean from '../components/Pluto/Content/fields/PlutoFieldBoolean.vue'
2
+ import PlutoFieldDate from '../components/Pluto/Content/fields/PlutoFieldDate.vue'
3
+ import PlutoFieldNumber from '../components/Pluto/Content/fields/PlutoFieldNumber.vue'
4
+ import PlutoFieldReference from '../components/Pluto/Content/fields/PlutoFieldReference.vue'
5
+ import PlutoFieldSelect from '../components/Pluto/Content/fields/PlutoFieldSelect.vue'
6
+ import PlutoFieldSlug from '../components/Pluto/Content/fields/PlutoFieldSlug.vue'
7
+ import PlutoFieldText from '../components/Pluto/Content/fields/PlutoFieldText.vue'
8
+
9
+ // Registers core's own Home and Settings anchors, plus the eight built-in
10
+ // content field widgets. `order: 0` and `order: 1000` are reserved for the
11
+ // Home/Settings nav entries, so every other layer's nav item sorts between
12
+ // them by default (default order is 100).
13
+ //
14
+ // Everything core itself contributes to the registry lives in this one
15
+ // `definePlutoExtension` call — `definePlutoExtension` replaces a layer's
16
+ // whole contribution on every call, not just the buckets that call passes,
17
+ // so a second `id: 'pluto'` call elsewhere would silently wipe this one out.
18
+ //
19
+ // `richtext` and `media` have no core widget here on purpose — a layer
20
+ // registers those (blog registers `richtext`, storage registers `media`),
21
+ // because core has no real editor or file picker to offer for either. See
22
+ // the content-model skill.
4
23
  export default defineNuxtPlugin(() => {
5
24
  definePlutoExtension({
6
25
  id: 'pluto',
@@ -12,5 +31,15 @@ export default defineNuxtPlugin(() => {
12
31
  { id: 'home', path: '/admin/home', title: 'Dashboard', icon: 'lucide:house' },
13
32
  { id: 'settings', path: '/admin/settings', title: 'Settings', icon: 'lucide:settings' },
14
33
  ],
34
+ contentFieldWidgets: [
35
+ { id: 'text', fieldType: 'text', component: PlutoFieldText },
36
+ { id: 'textarea', fieldType: 'textarea', component: PlutoFieldText },
37
+ { id: 'slug', fieldType: 'slug', component: PlutoFieldSlug },
38
+ { id: 'number', fieldType: 'number', component: PlutoFieldNumber },
39
+ { id: 'boolean', fieldType: 'boolean', component: PlutoFieldBoolean },
40
+ { id: 'date', fieldType: 'date', component: PlutoFieldDate },
41
+ { id: 'select', fieldType: 'select', component: PlutoFieldSelect },
42
+ { id: 'reference', fieldType: 'reference', component: PlutoFieldReference },
43
+ ],
15
44
  })
16
45
  })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.6.0",
4
+ "version": "0.8.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -44,6 +44,7 @@
44
44
  "dependencies": {
45
45
  "@iconify-json/lucide": "^1.2.116",
46
46
  "@iconify-json/simple-icons": "^1.2.89",
47
+ "@internationalized/date": "^3.12.2",
47
48
  "@nuxt/eslint": "^1.16.0",
48
49
  "@nuxt/ui": "^4.9.0",
49
50
  "@plutocms/utils": "^0.2.1",
@@ -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
+ })