@plutocms/pluto 0.3.2 → 0.5.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 CHANGED
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.5.0](https://github.com/plutocms/pluto/compare/v0.4.0...v0.5.0) (2026-09-12)
5
+
6
+
7
+ ### Features
8
+
9
+ * **permissions:** add capability registry and usePlutoPermissions composable ([#129](https://github.com/plutocms/pluto/issues/129)) ([c98419f](https://github.com/plutocms/pluto/commit/c98419fc967f8e20678389169e937db759c308bc))
10
+
11
+ ## [0.4.0](https://github.com/plutocms/pluto/compare/v0.3.2...v0.4.0) (2026-09-12)
12
+
13
+
14
+ ### Features
15
+
16
+ * **registry:** add typed extension registry for admin UI ([#127](https://github.com/plutocms/pluto/issues/127)) ([f3c6b0a](https://github.com/plutocms/pluto/commit/f3c6b0a38870ab738b4267e8b6252bb0720dae13))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **deps:** move nuxt runtime modules to dependencies ([#125](https://github.com/plutocms/pluto/issues/125)) ([7d66096](https://github.com/plutocms/pluto/commit/7d660962dcd0b8ce7703da3487a861be2a5064a4)), closes [#79](https://github.com/plutocms/pluto/issues/79)
22
+
4
23
  ## [0.3.2](https://github.com/plutocms/pluto/compare/v0.3.1...v0.3.2) (2026-09-10)
5
24
 
6
25
 
package/FEATURES.md CHANGED
@@ -5,3 +5,10 @@ with full detail, so this file stays short.
5
5
 
6
6
  See the workspace-level `CLAUDE.md` (one directory up) for the delegation, writing, and git
7
7
  policies shared by every project here.
8
+
9
+ - Extension registry: a typed, ordered, reactive registry that lets a layer plug its admin-UI
10
+ pieces (navbar, sidebar nav, pages, dashboard widgets, settings panels and driver) into core
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
package/README.md CHANGED
@@ -69,9 +69,24 @@ The core layer provides the foundation for your CMS:
69
69
  - `<PlutoNavbarAdmin>` and `<PlutoNavbarAdminActions>`
70
70
  Provide structure for injecting actions from other layers
71
71
 
72
- Backend layers can provide the complete admin navbar with
73
- `useNavbarAdmin().registerNavbar(component)`. Feature layers can append controls
74
- with `useNavbarAdminActions().addAction(component)`.
72
+ A layer plugs into the admin UI with one function, `definePlutoExtension`, called from a
73
+ plugin. It registers any of: the admin navbar, navbar actions, sidebar nav entries, admin page
74
+ metadata, dashboard widgets, and a settings panel or driver:
75
+
76
+ ```ts
77
+ // app/plugins/pluto-registrations.ts
78
+ export default defineNuxtPlugin(() => {
79
+ definePlutoExtension({
80
+ id: 'supabase-blog',
81
+ nav: [{ id: 'posts', label: 'Posts', icon: 'lucide:file-text', to: '/admin/posts' }],
82
+ })
83
+ })
84
+ ```
85
+
86
+ See `.claude/skills/extension-registry/SKILL.md` for the full type of each registrable thing
87
+ and the ordering/dedupe rules. The old `useNavbarAdmin().registerNavbar(component)` and
88
+ `useNavbarAdminActions().addAction(component)` composables still work, through a compatibility
89
+ bridge, but `definePlutoExtension` is the current recommended way to plug in.
75
90
 
76
91
  ### Backend layers
77
92
 
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ const { items } = usePlutoDashboardWidgets()
3
+ </script>
4
+
5
+ <template>
6
+ <div v-if="items.length" class="grid grid-cols-1 gap-4 lg:grid-cols-3 lg:gap-x-4 lg:gap-y-6">
7
+ <div
8
+ v-for="widget in items"
9
+ :key="widget.id"
10
+ :class="{ 'lg:col-span-2': widget.colSpan === 2, 'lg:col-span-3': widget.colSpan === 3 }"
11
+ >
12
+ <component :is="widget.component" />
13
+ </div>
14
+ </div>
15
+
16
+ <UCard v-else>
17
+ <p class="text-muted text-sm">
18
+ No dashboard widgets are registered yet. A layer can add one with
19
+ <code>definePlutoExtension</code>.
20
+ </p>
21
+ </UCard>
22
+ </template>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
- const { actions } = useNavbarAdminActions()
2
+ const { items } = usePlutoNavbarActions()
3
3
  </script>
4
4
 
5
5
  <template>
6
- <component :is="action" v-for="(action, i) in actions" :key="i" />
6
+ <component :is="item.component" v-for="item in items" :key="item.id" />
7
7
  </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- const { navbar } = useNavbarAdmin()
2
+ const { component: navbar } = usePlutoNavbarShell()
3
3
  </script>
4
4
 
5
5
  <template>
@@ -0,0 +1,63 @@
1
+ // Nuxt auto-imports `app/composables/*.ts` at depth 1 only, so this file
2
+ // sits under `internal/` on purpose: it stays out of auto-import, and it can
3
+ // be unit-tested directly with an explicit import, outside a Nuxt build
4
+ // context. Import from 'vue' explicitly here, instead of relying on
5
+ // auto-import, so this file works the same way under plain Vitest.
6
+ import type { ComputedRef } from 'vue'
7
+ import { computed, markRaw, shallowRef } from 'vue'
8
+
9
+ export interface OwnedEntry {
10
+ id: string
11
+ order?: number
12
+ enabled?: () => boolean
13
+ }
14
+
15
+ /**
16
+ * An owner-keyed registry. Each owner (a layer id) contributes a list of
17
+ * entries. Calling `set` again for the same owner replaces its whole
18
+ * contribution — this makes registration idempotent, so re-running a
19
+ * plugin (for example on HMR) never duplicates entries and needs no
20
+ * dedupe warning.
21
+ *
22
+ * `list` merges every owner's entries, drops disabled ones, and sorts by
23
+ * `order` (default 100), breaking ties by first-registration sequence.
24
+ */
25
+ export function createOwnedRegistry<T extends OwnedEntry>() {
26
+ const owners = shallowRef<Map<string, T[]>>(new Map())
27
+ let sequence = 0
28
+ const seq = new Map<string, number>()
29
+
30
+ function set(owner: string, entries: T[]) {
31
+ const next = new Map(owners.value)
32
+ next.set(
33
+ owner,
34
+ entries.map((entry) => {
35
+ const id = `${owner}:${entry.id}`
36
+ if (!seq.has(id)) {
37
+ seq.set(id, sequence++)
38
+ }
39
+ return markRaw({ ...entry, id }) as T
40
+ })
41
+ )
42
+ owners.value = next
43
+ }
44
+
45
+ function remove(owner: string) {
46
+ const next = new Map(owners.value)
47
+ next.delete(owner)
48
+ owners.value = next
49
+ }
50
+
51
+ const list: ComputedRef<T[]> = computed(() =>
52
+ [...owners.value.values()]
53
+ .flat()
54
+ .filter((entry) => entry.enabled?.() ?? true)
55
+ .sort(
56
+ (a, b) =>
57
+ (a.order ?? 100) - (b.order ?? 100) ||
58
+ (seq.get(a.id) ?? 0) - (seq.get(b.id) ?? 0)
59
+ )
60
+ )
61
+
62
+ return { set, remove, list }
63
+ }
@@ -0,0 +1,44 @@
1
+ // The old `NavigationMenuItem`/`NavigationMenuChildItem` shape (from
2
+ // `@plutocms/utils`) always carries a plain `href`, never Nuxt's typed `to`.
3
+ // This narrows just the field the bridge below reads.
4
+ interface LegacyHrefLink {
5
+ href?: string
6
+ }
7
+
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.
14
+ */
15
+ export function usePlutoAdminNav() {
16
+ const registry = usePlutoRegistry()
17
+ const legacy = useSidebarAdminActions()
18
+
19
+ const items = computed<PlutoNavItem[]>(() => {
20
+ const registered = registry.nav.list.value
21
+ const seen = new Set(registered.map((item) => item.label))
22
+
23
+ const bridged = legacy.actions.value
24
+ .filter((action) => !seen.has(String(action.label)))
25
+ .map((action, index) => ({
26
+ id: `legacy:sidebar-${index}`,
27
+ order: 500,
28
+ label: String(action.label ?? ''),
29
+ icon: action.icon,
30
+ to: (action as LegacyHrefLink).href,
31
+ defaultOpen: action.defaultOpen,
32
+ children: (action.children ?? []).map((child) => ({
33
+ label: String(child.label ?? ''),
34
+ to: (child as LegacyHrefLink).href ?? '',
35
+ })),
36
+ }))
37
+
38
+ return [...registered, ...bridged].sort(
39
+ (a, b) => (a.order ?? 100) - (b.order ?? 100)
40
+ )
41
+ })
42
+
43
+ return { items }
44
+ }
@@ -0,0 +1,14 @@
1
+ /**
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.
5
+ */
6
+ export function usePlutoAdminPages() {
7
+ const registry = usePlutoRegistry()
8
+ const route = useRoute()
9
+
10
+ const items = computed(() => registry.pages.list.value)
11
+ const current = computed(() => items.value.find((page) => page.path === route.path))
12
+
13
+ return { items, current }
14
+ }
@@ -0,0 +1,5 @@
1
+ /** Widgets shown on the admin dashboard (`/admin/home`). */
2
+ export function usePlutoDashboardWidgets() {
3
+ const registry = usePlutoRegistry()
4
+ return { items: registry.dashboardWidgets.list }
5
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The one function a layer author calls to plug into the admin UI. Every
3
+ * bucket gets set for this extension's id on every call, so a layer's whole
4
+ * admin-UI contribution lives in one place.
5
+ *
6
+ * Call this from a plugin (`app/plugins/*.ts`). Calling it again with the
7
+ * same `id` replaces that layer's whole contribution — it does not add to
8
+ * it — so re-running the plugin (for example on HMR) never duplicates
9
+ * entries.
10
+ */
11
+ export function definePlutoExtension(definition: PlutoExtension): void {
12
+ const registry = usePlutoRegistry()
13
+
14
+ registry.navbarShell.set(definition.id, definition.navbar ? [definition.navbar] : [])
15
+ registry.navbarActions.set(definition.id, definition.navbarActions ?? [])
16
+ registry.nav.set(definition.id, definition.nav ?? [])
17
+ registry.pages.set(definition.id, definition.pages ?? [])
18
+ registry.settingsPanels.set(definition.id, definition.settingsPanels ?? [])
19
+ registry.settingsDrivers.set(
20
+ definition.id,
21
+ definition.settingsDriver ? [definition.settingsDriver] : []
22
+ )
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
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The admin navbar shell (the whole top bar, provided by a backend layer).
3
+ * Reads the new registry and falls back to the old `useNavbarAdmin`
4
+ * composable (`@plutocms/utils`) when no layer has migrated yet. The legacy
5
+ * ref is read inside this `computed`, not once at setup, so a navbar
6
+ * registered after this composable first runs is never lost.
7
+ */
8
+ export function usePlutoNavbarShell() {
9
+ const registry = usePlutoRegistry()
10
+ const legacy = useNavbarAdmin()
11
+
12
+ const component = computed<Component | null>(() => {
13
+ const registered = registry.navbarShell.list.value
14
+ const winner = registered.at(-1) // highest-order wins; ties go to most recent
15
+ return winner?.component ?? legacy.navbar.value ?? null
16
+ })
17
+
18
+ return { component }
19
+ }
20
+
21
+ /**
22
+ * The small action buttons rendered inside the navbar. Merges the new
23
+ * registry with the old `useNavbarAdminActions` composable
24
+ * (`@plutocms/utils`), so an unmigrated layer's actions keep showing up.
25
+ */
26
+ export function usePlutoNavbarActions() {
27
+ const registry = usePlutoRegistry()
28
+ const legacy = useNavbarAdminActions()
29
+
30
+ const items = computed<PlutoNavbarAction[]>(() => [
31
+ ...registry.navbarActions.list.value,
32
+ ...legacy.actions.value.map((component, index) => ({
33
+ id: `legacy:navbar-action-${index}`,
34
+ order: 500,
35
+ component,
36
+ })),
37
+ ])
38
+
39
+ return { items }
40
+ }
@@ -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
+ }
@@ -0,0 +1,34 @@
1
+ import { createOwnedRegistry } from './internal/registry-factory'
2
+
3
+ /**
4
+ * The one registry container for this Nuxt app instance. Created lazily on
5
+ * `nuxtApp`, not at module scope, so it is per-request on the server and
6
+ * per-app on the client — never a process-wide singleton, and never
7
+ * serialized into the SSR payload.
8
+ *
9
+ * No plugin-ordering contract is needed: a layer's plugin can call this
10
+ * before or after any other layer's plugin.
11
+ */
12
+ export function usePlutoRegistry(): PlutoRegistry {
13
+ const nuxtApp = useNuxtApp()
14
+
15
+ if (!nuxtApp._plutoRegistry) {
16
+ nuxtApp._plutoRegistry = {
17
+ navbarShell: createOwnedRegistry<PlutoNavbarShell>(),
18
+ navbarActions: createOwnedRegistry<PlutoNavbarAction>(),
19
+ nav: createOwnedRegistry<PlutoNavItem>(),
20
+ pages: createOwnedRegistry<PlutoAdminPage>(),
21
+ settingsPanels: createOwnedRegistry<PlutoSettingsPanel>(),
22
+ settingsDrivers: createOwnedRegistry<PlutoSettingsDriver>(),
23
+ dashboardWidgets: createOwnedRegistry<PlutoDashboardWidget>(),
24
+ capabilities: createOwnedRegistry<PlutoCapability>(),
25
+ permissionsDrivers: createOwnedRegistry<PlutoPermissionsDriver>(),
26
+ }
27
+ }
28
+
29
+ // Cast rather than rely on control-flow narrowing of a global-augmented
30
+ // property: that narrowing is fragile across vue-tsc's separate project
31
+ // references (app/server/shared), and has been observed to resolve to
32
+ // `unknown` instead of `PlutoRegistry` in some of them.
33
+ return nuxtApp._plutoRegistry as PlutoRegistry
34
+ }
@@ -0,0 +1,65 @@
1
+ /** Settings panels registered by every layer, in registry order. */
2
+ export function usePlutoSettingsPanels() {
3
+ const registry = usePlutoRegistry()
4
+ return { items: registry.settingsPanels.list }
5
+ }
6
+
7
+ /**
8
+ * The settings value store, backed by whichever settings driver is
9
+ * registered. `useState` is correct here: these are plain strings, not
10
+ * components, and `/admin/**` is client-only per `nuxt.config.ts`, so there
11
+ * is no SSR-serialization concern.
12
+ */
13
+ export function usePlutoSettings() {
14
+ const registry = usePlutoRegistry()
15
+ const driver = computed(() => registry.settingsDrivers.list.value.at(-1) ?? null)
16
+
17
+ const values = useState<Record<string, string>>('pluto-settings-values', () => ({}))
18
+ const dirty = useState<Record<string, string>>('pluto-settings-dirty', () => ({}))
19
+ const status = ref<'idle' | 'loading' | 'saving'>('idle')
20
+
21
+ async function load() {
22
+ if (!driver.value) {
23
+ return
24
+ }
25
+ status.value = 'loading'
26
+ try {
27
+ values.value = await driver.value.load()
28
+ } finally {
29
+ status.value = 'idle'
30
+ }
31
+ }
32
+
33
+ async function save() {
34
+ if (!driver.value) {
35
+ return
36
+ }
37
+ status.value = 'saving'
38
+ try {
39
+ await driver.value.save(dirty.value)
40
+ values.value = { ...values.value, ...dirty.value }
41
+ dirty.value = {}
42
+ } finally {
43
+ status.value = 'idle'
44
+ }
45
+ }
46
+
47
+ function field(key: string, fallback = '') {
48
+ return computed<string>({
49
+ get: () => dirty.value[key] ?? values.value[key] ?? fallback,
50
+ set: (value) => {
51
+ dirty.value[key] = value
52
+ },
53
+ })
54
+ }
55
+
56
+ return {
57
+ values,
58
+ dirty,
59
+ status,
60
+ hasDriver: computed(() => Boolean(driver.value)),
61
+ load,
62
+ save,
63
+ field,
64
+ }
65
+ }
@@ -6,7 +6,28 @@ import type { NavigationMenuItem } from '@nuxt/ui'
6
6
  // so drop `to` here to avoid a cross-app type mismatch on that field.
7
7
  type SidebarMenuItem = Omit<NavigationMenuItem, 'to'>
8
8
 
9
- const { actions: sidebarActions } = useSidebarAdminActions()
9
+ function toMenuItem(item: PlutoNavItem): SidebarMenuItem {
10
+ return {
11
+ label: item.label,
12
+ icon: item.icon,
13
+ href: item.to,
14
+ // Top-level items SPA-navigate instead of doing a full page load. This
15
+ // matches what supabase-blog and supabase-shop's own (pre-registry)
16
+ // sidebar entries already did — the registry now does it for every
17
+ // item instead of only theirs. Children stay href-only, same as those
18
+ // two layers' own child entries did.
19
+ onSelect: item.to ? () => navigateTo(item.to) : undefined,
20
+ defaultOpen: item.defaultOpen,
21
+ children: item.children?.map((child) => ({
22
+ label: child.label,
23
+ href: child.to,
24
+ })),
25
+ }
26
+ }
27
+
28
+ const { items: navItems } = usePlutoAdminNav()
29
+ const menu = computed<SidebarMenuItem[]>(() => navItems.value.map(toMenuItem))
30
+
10
31
  const route = useRoute()
11
32
  const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
12
33
 
@@ -14,20 +35,6 @@ function closeSidebar() {
14
35
  isSidebarOpen.value = false
15
36
  }
16
37
 
17
- const menu = shallowRef<SidebarMenuItem[]>([
18
- {
19
- label: 'Home',
20
- href: '/admin/home',
21
- icon: 'lucide:house',
22
- },
23
- ...(sidebarActions.value as unknown as SidebarMenuItem[]),
24
- {
25
- label: 'Settings',
26
- href: '/admin/settings',
27
- icon: 'lucide:settings',
28
- },
29
- ])
30
-
31
38
  watch(
32
39
  () => route.fullPath,
33
40
  closeSidebar
@@ -9,13 +9,7 @@ useHead({
9
9
  <AdminView>
10
10
  <h1 class="text-3xl font-bold lg:text-4xl">Admin Dashboard</h1>
11
11
 
12
- <div class="grid grid-cols-1 gap-4 lg:grid-cols-3 lg:gap-x-4 lg:gap-y-6">
13
- <div
14
- v-for="i in 6"
15
- :key="i"
16
- class="h-50 rounded-3xl border border-gray-500"
17
- />
18
- </div>
12
+ <PlutoDashboardWidgets />
19
13
  </AdminView>
20
14
  </main>
21
15
  </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ useHead({
3
+ title: 'Settings',
4
+ })
5
+
6
+ const { items } = usePlutoSettingsPanels()
7
+ const { hasDriver, status, load, save } = usePlutoSettings()
8
+
9
+ onMounted(() => {
10
+ if (hasDriver.value) {
11
+ load()
12
+ }
13
+ })
14
+ </script>
15
+
16
+ <template>
17
+ <main>
18
+ <AdminView>
19
+ <h1 class="text-3xl font-bold lg:text-4xl">Settings</h1>
20
+
21
+ <UAlert
22
+ v-if="!hasDriver"
23
+ color="warning"
24
+ title="No settings storage"
25
+ description="No installed layer provides settings storage yet."
26
+ />
27
+
28
+ <template v-else>
29
+ <UCard v-for="panel in items" :key="panel.id">
30
+ <template #header>
31
+ <h2 class="font-semibold">{{ panel.title }}</h2>
32
+ <p v-if="panel.description" class="text-muted text-sm">{{ panel.description }}</p>
33
+ </template>
34
+
35
+ <component :is="panel.component" />
36
+ </UCard>
37
+
38
+ <div class="flex">
39
+ <UButton :loading="status === 'saving'" icon="lucide:save" @click="save">
40
+ Save
41
+ </UButton>
42
+ </div>
43
+ </template>
44
+ </AdminView>
45
+ </main>
46
+ </template>
@@ -0,0 +1,16 @@
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).
4
+ export default defineNuxtPlugin(() => {
5
+ definePlutoExtension({
6
+ id: 'pluto',
7
+ nav: [
8
+ { id: 'home', order: 0, label: 'Home', icon: 'lucide:house', to: '/admin/home' },
9
+ { id: 'settings', order: 1000, label: 'Settings', icon: 'lucide:settings', to: '/admin/settings' },
10
+ ],
11
+ pages: [
12
+ { id: 'home', path: '/admin/home', title: 'Dashboard', icon: 'lucide:house' },
13
+ { id: 'settings', path: '/admin/settings', title: 'Settings', icon: 'lucide:settings' },
14
+ ],
15
+ })
16
+ })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/pluto",
3
3
  "type": "module",
4
- "version": "0.3.2",
4
+ "version": "0.5.0",
5
5
  "description": "The layerable CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -44,13 +44,15 @@
44
44
  "dependencies": {
45
45
  "@iconify-json/lucide": "^1.2.116",
46
46
  "@iconify-json/simple-icons": "^1.2.89",
47
+ "@nuxt/eslint": "^1.16.0",
48
+ "@nuxt/ui": "^4.9.0",
47
49
  "@plutocms/utils": "^0.2.1",
48
- "@vueuse/nuxt": "^14.3.0"
50
+ "@vueuse/nuxt": "^14.3.0",
51
+ "reka-ui": "^2.10.1",
52
+ "tailwind-merge": "^3.6.0"
49
53
  },
50
54
  "devDependencies": {
51
55
  "@antfu/eslint-config": "^9.1.0",
52
- "@nuxt/eslint": "^1.16.0",
53
- "@nuxt/ui": "^4.9.0",
54
56
  "@tailwindcss/vite": "^4.2.1",
55
57
  "@types/bun": "^1.3.14",
56
58
  "eslint": "^10.6.0",
@@ -58,8 +60,6 @@
58
60
  "nuxt": "^4.3.1",
59
61
  "prettier": "^3.9.4",
60
62
  "prettier-plugin-tailwindcss": "^0.8.0",
61
- "reka-ui": "^2.10.1",
62
- "tailwind-merge": "^3.6.0",
63
63
  "tailwindcss": "^4.3.2",
64
64
  "typescript": "6.0.3",
65
65
  "vitest": "^5.0.0",
@@ -0,0 +1,14 @@
1
+ import type { PlutoRegistry } from './registry'
2
+
3
+ // Lives in `shared/` (as a `.d.ts` file) so every generated Nuxt TS project
4
+ // (app, server, shared) picks it up automatically, the same way
5
+ // `runtime-config.d.ts` augments `@nuxt/schema`'s `RuntimeConfig` in
6
+ // `@plutocms/supabase`. `_plutoRegistry` is created lazily per `NuxtApp`
7
+ // instance by `usePlutoRegistry` — see `app/composables/pluto-registry.ts`.
8
+ declare module '#app' {
9
+ interface NuxtApp {
10
+ _plutoRegistry?: PlutoRegistry
11
+ }
12
+ }
13
+
14
+ export {}
@@ -0,0 +1,124 @@
1
+ import type { Component, ComputedRef } from 'vue'
2
+
3
+ export interface PlutoEntry {
4
+ /** Unique inside this extension. Stored as `<extensionId>:<id>`. */
5
+ id: string
6
+ /** Lower comes first. Default 100. Core uses 0 for Home and 1000 for Settings. */
7
+ order?: number
8
+ /** Reactive visibility test. Called inside a computed. */
9
+ enabled?: () => boolean
10
+ }
11
+
12
+ export interface PlutoNavChild {
13
+ label: string
14
+ to: string
15
+ }
16
+
17
+ export interface PlutoNavItem extends PlutoEntry {
18
+ label: string
19
+ icon?: string
20
+ /** Admin route path, for example '/admin/posts'. Plain string on purpose. */
21
+ to?: string
22
+ children?: PlutoNavChild[]
23
+ defaultOpen?: boolean
24
+ }
25
+
26
+ export interface PlutoNavbarShell extends PlutoEntry {
27
+ component: Component
28
+ }
29
+
30
+ export interface PlutoNavbarAction extends PlutoEntry {
31
+ component: Component
32
+ }
33
+
34
+ export interface PlutoAdminPage extends PlutoEntry {
35
+ /** The route Nuxt already created from a file. This registry only adds metadata. */
36
+ path: string
37
+ title: string
38
+ icon?: string
39
+ /** Full id of a parent page (e.g. 'supabase-blog:posts'), for a breadcrumb trail. */
40
+ parent?: string
41
+ }
42
+
43
+ export interface PlutoDashboardWidget extends PlutoEntry {
44
+ component: Component
45
+ title?: string
46
+ /** Grid columns on large screens. 1 to 3. Default 1. */
47
+ colSpan?: 1 | 2 | 3
48
+ }
49
+
50
+ export interface PlutoSettingsPanel extends PlutoEntry {
51
+ title: string
52
+ description?: string
53
+ icon?: string
54
+ component: Component
55
+ }
56
+
57
+ export interface PlutoSettingsDriver extends PlutoEntry {
58
+ load: () => Promise<Record<string, string>>
59
+ save: (patch: Record<string, string>) => Promise<void>
60
+ }
61
+
62
+ /**
63
+ * A capability a layer defines. `key` is the real capability string, for
64
+ * example 'posts:publish'. It is kept separate from `id` on purpose: the
65
+ * registry rewrites `id` to '<extensionId>:<id>' (see registry-factory.ts),
66
+ * which would corrupt a capability string if `id` were reused as the
67
+ * capability itself. `id` identifies this registry entry; `key` identifies
68
+ * the permission.
69
+ */
70
+ export interface PlutoCapability extends PlutoEntry {
71
+ /** Format: '<namespace>:<action>', lowercase, for example 'posts:publish'. */
72
+ key: string
73
+ label: string
74
+ description?: string
75
+ }
76
+
77
+ /**
78
+ * Loads the current user's capability list. One backend layer registers
79
+ * this, the same way one layer registers a PlutoSettingsDriver. `load`
80
+ * returns a flat list of capability key strings the user holds. The
81
+ * single entry '*' means "every capability" (used by the built-in admin
82
+ * role).
83
+ */
84
+ export interface PlutoPermissionsDriver extends PlutoEntry {
85
+ load: () => Promise<string[]>
86
+ }
87
+
88
+ export interface PlutoExtension {
89
+ /** Layer name. Use the package's $meta.name, e.g. 'supabase-blog'. */
90
+ id: string
91
+ navbar?: PlutoNavbarShell
92
+ navbarActions?: PlutoNavbarAction[]
93
+ nav?: PlutoNavItem[]
94
+ pages?: PlutoAdminPage[]
95
+ dashboardWidgets?: PlutoDashboardWidget[]
96
+ settingsPanels?: PlutoSettingsPanel[]
97
+ settingsDriver?: PlutoSettingsDriver
98
+ capabilities?: PlutoCapability[]
99
+ permissionsDriver?: PlutoPermissionsDriver
100
+ }
101
+
102
+ /**
103
+ * The shape `createOwnedRegistry` returns, one per registrable thing. Declared
104
+ * again here, rather than imported from `app/composables/internal`, because
105
+ * the shared TS project does not include `app/`. Structural typing keeps this
106
+ * in sync with the real return type.
107
+ */
108
+ export interface PlutoRegistryBucket<T extends PlutoEntry> {
109
+ set: (owner: string, entries: T[]) => void
110
+ remove: (owner: string) => void
111
+ list: ComputedRef<T[]>
112
+ }
113
+
114
+ export interface PlutoRegistry {
115
+ navbarShell: PlutoRegistryBucket<PlutoNavbarShell>
116
+ navbarActions: PlutoRegistryBucket<PlutoNavbarAction>
117
+ nav: PlutoRegistryBucket<PlutoNavItem>
118
+ pages: PlutoRegistryBucket<PlutoAdminPage>
119
+ settingsPanels: PlutoRegistryBucket<PlutoSettingsPanel>
120
+ settingsDrivers: PlutoRegistryBucket<PlutoSettingsDriver>
121
+ dashboardWidgets: PlutoRegistryBucket<PlutoDashboardWidget>
122
+ capabilities: PlutoRegistryBucket<PlutoCapability>
123
+ permissionsDrivers: PlutoRegistryBucket<PlutoPermissionsDriver>
124
+ }