@plutocms/core 0.39.2 → 0.41.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.41.0](https://github.com/plutocms/pluto/compare/v0.40.0...v0.41.0) (2026-10-31)
5
+
6
+
7
+ ### ⚠ BREAKING CHANGES
8
+
9
+ * @plutocms/core does not extend @plutocms/utils anymore. Its auto-imports (formatCurrency, slugify, fileNameToKebabCase) are gone from apps that relied on core to bring them. domainFromUrl is now in core.
10
+
11
+ ### Features
12
+
13
+ * **admin:** section tabs for every menu item with a submenu ([#315](https://github.com/plutocms/pluto/issues/315)) ([98afbe4](https://github.com/plutocms/pluto/commit/98afbe45833caa6f834aa70703611970eadd5ccb))
14
+ * core no longer extends @plutocms/utils (retired); domainFromUrl is in core ([#314](https://github.com/plutocms/pluto/issues/314)) ([5fe3947](https://github.com/plutocms/pluto/commit/5fe394707c009d4686e6e7fe6f72af900dfe4035))
15
+
16
+ ## [0.40.0](https://github.com/plutocms/pluto/compare/v0.39.2...v0.40.0) (2026-10-31)
17
+
18
+
19
+ ### Features
20
+
21
+ * **admin:** navbar menus in the bar color, palette overlay class, nav children with enabled ([#310](https://github.com/plutocms/pluto/issues/310)) ([cec92ac](https://github.com/plutocms/pluto/commit/cec92ac7061fdb8a209d832344328fae8838f826))
22
+
4
23
  ## [0.39.2](https://github.com/plutocms/pluto/compare/v0.39.1...v0.39.2) (2026-10-31)
5
24
 
6
25
 
@@ -69,12 +69,15 @@
69
69
  }
70
70
 
71
71
  /*
72
- * The overlay of the admin search (Cmd/Ctrl+K) is darker than the Nuxt UI
73
- * default and blurs the page under it. The overlay is the sibling just before
74
- * the modal content, which carries `pluto-admin-search` (app/layouts/admin.vue).
75
- * Other modals keep the Nuxt UI overlay.
72
+ * The overlay of a palette (the admin search, Cmd/Ctrl+K, and the palettes of
73
+ * the layers, such as the block palette) is darker than the Nuxt UI default and
74
+ * blurs the page under it. The overlay is the sibling just before the modal
75
+ * content. The content carries `pluto-admin-search` (app/layouts/admin.vue) or
76
+ * `pluto-palette`: a layer sets `:ui="{ content: 'pluto-palette' }"` on the
77
+ * `UModal` of its palette. Other modals keep the Nuxt UI overlay.
76
78
  */
77
- :root[data-pluto-admin] [data-slot='overlay']:has(+ .pluto-admin-search) {
79
+ :root[data-pluto-admin]
80
+ [data-slot='overlay']:has(+ :is(.pluto-admin-search, .pluto-palette)) {
78
81
  background-color: oklch(13% 0.03 273 / 0.6);
79
82
  backdrop-filter: blur(6px);
80
83
  }
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ // The tabs of the section of the page: the children of the menu item whose
3
+ // submenu holds the page (usePlutoAdminNavTabs). The "new" child of the
4
+ // section is a button at the end of the tabs, not a tab. Nothing shows when the
5
+ // page is in no submenu. `page`: under the title of a page (Settings, Users).
6
+ // `toolbar`: a bar under the toolbar of a list page.
7
+ import type { NavigationMenuItem } from '@nuxt/ui'
8
+
9
+ withDefaults(
10
+ defineProps<{
11
+ variant?: 'page' | 'toolbar'
12
+ }>(),
13
+ { variant: 'page' }
14
+ )
15
+
16
+ const { tabs, action, label } = usePlutoAdminNavTabs()
17
+
18
+ // The active tab is set by hand (`active`), not by the router: a parent path
19
+ // such as `/admin/settings` must not be active on the pages below it.
20
+ const items = computed<NavigationMenuItem[]>(() =>
21
+ tabs.value.map((tab) => ({
22
+ label: tab.label,
23
+ icon: tab.icon,
24
+ href: tab.to,
25
+ active: tab.active,
26
+ onSelect: () => navigateTo(tab.to),
27
+ }))
28
+ )
29
+ </script>
30
+
31
+ <template>
32
+ <template v-if="items.length > 0">
33
+ <UDashboardToolbar
34
+ v-if="variant === 'toolbar'"
35
+ :ui="{ root: 'border-muted' }"
36
+ >
37
+ <UNavigationMenu
38
+ :items="items"
39
+ :aria-label="label"
40
+ orientation="horizontal"
41
+ variant="link"
42
+ class="-mx-1 min-w-0 flex-1 overflow-x-auto"
43
+ highlight
44
+ />
45
+ <UButton
46
+ v-if="action"
47
+ :to="action.to"
48
+ :label="action.label"
49
+ icon="i-lucide-plus"
50
+ color="neutral"
51
+ variant="ghost"
52
+ size="sm"
53
+ class="shrink-0"
54
+ />
55
+ </UDashboardToolbar>
56
+
57
+ <div v-else class="border-default -mt-2 flex items-center gap-2 border-b">
58
+ <UNavigationMenu
59
+ :items="items"
60
+ :aria-label="label"
61
+ orientation="horizontal"
62
+ variant="link"
63
+ class="min-w-0 flex-1 overflow-x-auto"
64
+ />
65
+ <UButton
66
+ v-if="action"
67
+ :to="action.to"
68
+ :label="action.label"
69
+ icon="i-lucide-plus"
70
+ color="neutral"
71
+ variant="ghost"
72
+ size="sm"
73
+ class="shrink-0"
74
+ />
75
+ </div>
76
+ </template>
77
+ </template>
@@ -135,7 +135,11 @@ const portalTarget = usePlutoAdminPortal()
135
135
  <div class="h-full">
136
136
  <UDropdownMenu
137
137
  :items="items"
138
- :ui="{ content: 'w-64' }"
138
+ :content="{ sideOffset: 2 }"
139
+ :ui="{
140
+ content: 'w-64 bg-(--pluto-admin-navbar)',
141
+ arrow: 'fill-(--pluto-admin-navbar)',
142
+ }"
139
143
  :modal="false"
140
144
  :portal="portalTarget ?? true"
141
145
  :arrow="{ width: 14, height: 7 }"
@@ -214,10 +214,13 @@ const editButton = computed(() => {
214
214
  <UDropdownMenu
215
215
  :open="menuOpen"
216
216
  :items="newMenuItems"
217
- :content="{ align: 'start', onCloseAutoFocus }"
217
+ :content="{ align: 'start', sideOffset: 2, onCloseAutoFocus }"
218
218
  :modal="false"
219
219
  :portal="portalTarget ?? true"
220
- :ui="{ content: 'min-w-48' }"
220
+ :ui="{
221
+ content: 'min-w-48 bg-(--pluto-admin-navbar)',
222
+ arrow: 'fill-(--pluto-admin-navbar)',
223
+ }"
221
224
  :arrow="{ width: 14, height: 7 }"
222
225
  @update:open="onUpdateOpen"
223
226
  >
@@ -266,6 +266,8 @@ const footer = computed(() => {
266
266
  </UButton>
267
267
  </template>
268
268
  </PlutoViewToolbar>
269
+ <!-- The tabs of the menu item that holds this list (for example Pages). -->
270
+ <PlutoAdminNavTabs variant="toolbar" />
269
271
  </template>
270
272
 
271
273
  <PlutoSkeletonList
@@ -57,14 +57,23 @@ export function usePlutoAdminNav() {
57
57
 
58
58
  const items = computed<PlutoNavItem[]>(() => {
59
59
  const registered = registry.nav.list.value
60
- const resolveLabels = (item: PlutoNavItem): PlutoNavItem => ({
61
- ...item,
62
- label: tx(item.label),
63
- children: item.children?.map((child) => ({
64
- ...child,
65
- label: tx(child.label),
66
- })),
67
- })
60
+ // A child with `enabled` shows only when the test passes (for example a
61
+ // child for admins only). An item with its own `to` and one visible child
62
+ // stays a plain link, like Settings with one page.
63
+ const resolveLabels = (item: PlutoNavItem): PlutoNavItem => {
64
+ const children = item.children
65
+ ?.filter((child) => child.enabled?.() ?? true)
66
+ .map((child) => ({
67
+ ...child,
68
+ label: tx(child.label),
69
+ }))
70
+ return {
71
+ ...item,
72
+ label: tx(item.label),
73
+ children:
74
+ item.to && children && children.length < 2 ? undefined : children,
75
+ }
76
+ }
68
77
 
69
78
  const isVisible = (type: PlutoContentType): boolean =>
70
79
  !type.capabilities?.read || can(type.capabilities.read)
@@ -169,6 +178,7 @@ export function usePlutoAdminNav() {
169
178
  children: settingsPages.value.map((page) => ({
170
179
  label: page.label,
171
180
  to: page.to,
181
+ icon: page.icon,
172
182
  })),
173
183
  }
174
184
  : item
@@ -198,3 +208,96 @@ export function usePlutoAdminNav() {
198
208
 
199
209
  return { items }
200
210
  }
211
+
212
+ /** One tab of the section of the current page. See usePlutoAdminNavTabs. */
213
+ export interface PlutoAdminNavTab {
214
+ label: string
215
+ icon?: string
216
+ to: string
217
+ active: boolean
218
+ }
219
+
220
+ /** The "new" link of a section: a button at the end of its tabs. */
221
+ export interface PlutoAdminNavAction {
222
+ label: string
223
+ to: string
224
+ }
225
+
226
+ // Trailing slashes off, so `/admin/users/` matches `/admin/users`.
227
+ function trimPath(path: string): string {
228
+ return path.replace(/\/+$/, '') || '/'
229
+ }
230
+
231
+ /**
232
+ * The tabs of the section of the current page: the children of the menu item
233
+ * whose submenu holds the page. Settings, the Users item of a backend layer and
234
+ * a group of content types (Pages) all get them.
235
+ *
236
+ * - A child matches the path exactly, or as a prefix (`/admin/users` matches
237
+ * `/admin/users/5`). The longest match wins, so General (`/admin/settings`)
238
+ * is not active on `/admin/settings/email`.
239
+ * - A "new" child (its `to` is the `newTo` of the item, or ends with `/new`)
240
+ * is not a tab: it is `action`, a button at the end of the tabs.
241
+ * - The tabs show only with two or more of them. A section of one list and its
242
+ * "new" page (a content type: All posts, New post) shows none: the list page
243
+ * has its own New button.
244
+ *
245
+ * `label` is the label of the section (for `aria-label`). A page shows the
246
+ * tabs with `PlutoAdminNavTabs`.
247
+ */
248
+ export function usePlutoAdminNavTabs() {
249
+ const { items } = usePlutoAdminNav()
250
+ const route = useRoute()
251
+
252
+ const isNewChild = (item: PlutoNavItem, child: PlutoNavChild): boolean =>
253
+ (item.newTo !== undefined && trimPath(child.to) === trimPath(item.newTo)) ||
254
+ trimPath(child.to).endsWith('/new')
255
+
256
+ const section = computed(() => {
257
+ const path = trimPath(route.path)
258
+ let best: { item: PlutoNavItem; to: string } | undefined
259
+ for (const item of items.value) {
260
+ const pages = (item.children ?? []).filter(
261
+ (child) => !isNewChild(item, child)
262
+ )
263
+ if (pages.length < 2) {
264
+ continue
265
+ }
266
+ for (const child of pages) {
267
+ const to = trimPath(child.to)
268
+ const matches = path === to || path.startsWith(`${to}/`)
269
+ if (matches && (!best || to.length > best.to.length)) {
270
+ best = { item, to }
271
+ }
272
+ }
273
+ }
274
+ return best
275
+ })
276
+
277
+ const tabs = computed<PlutoAdminNavTab[]>(() => {
278
+ const current = section.value
279
+ if (!current) {
280
+ return []
281
+ }
282
+ return (current.item.children ?? [])
283
+ .filter((child) => !isNewChild(current.item, child))
284
+ .map((child) => ({
285
+ label: child.label,
286
+ icon: child.icon,
287
+ to: child.to,
288
+ active: trimPath(child.to) === current.to,
289
+ }))
290
+ })
291
+
292
+ const action = computed<PlutoAdminNavAction | undefined>(() => {
293
+ const item = section.value?.item
294
+ const child = item?.children?.find((entry) => isNewChild(item, entry))
295
+ return child ? { label: child.label, to: child.to } : undefined
296
+ })
297
+
298
+ return {
299
+ tabs,
300
+ action,
301
+ label: computed(() => section.value?.item.label ?? ''),
302
+ }
303
+ }
@@ -8,12 +8,17 @@
8
8
  * The composable makes the element itself. A target that Vue renders can
9
9
  * appear after the portal looks for it. Pass `portalTarget ?? true` to the
10
10
  * `portal` prop of `UDropdownMenu`.
11
+ *
12
+ * The menus look like the bar: the target also carries `data-pluto-admin-dark`
13
+ * (the dark tokens in both color modes), and a menu sets the bar color
14
+ * (`bg-(--pluto-admin-navbar)`, also on its arrow) and `sideOffset: 2`.
11
15
  */
12
16
  export function usePlutoAdminPortal() {
13
17
  const portalTarget = shallowRef<HTMLElement>()
14
18
  onMounted(() => {
15
19
  const el = document.createElement('div')
16
20
  el.setAttribute('data-pluto-admin-scope', '')
21
+ el.setAttribute('data-pluto-admin-dark', '')
17
22
  document.body.append(el)
18
23
  portalTarget.value = el
19
24
  })
@@ -1,36 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  // The parent of every Settings page: the title, the tabs, and the child route.
3
3
  // A layer adds a tab with a file at `pages/admin/settings/<x>.vue` and a
4
- // `settingsPages` entry. See the extension-registry skill.
5
- import type { NavigationMenuItem } from '@nuxt/ui'
6
-
4
+ // `settingsPages` entry. The tabs are the children of the Settings item of the
5
+ // menu (PlutoAdminNavTabs), like every item with a submenu. See the
6
+ // extension-registry skill.
7
7
  const { t } = usePlutoI18n()
8
- const route = useRoute()
9
8
 
10
9
  useHead({
11
10
  title: () => t('pluto.pages.settings'),
12
11
  })
13
12
 
14
- const { items, current, allowed } = usePlutoSettingsPages()
15
-
16
- // Active state by hand: General (`/admin/settings`) matches exactly, so it is
17
- // not active on the pages below it. Any other tab matches by prefix.
18
- function isActive(to: string): boolean {
19
- const path = route.path.replace(/\/+$/, '') || '/'
20
- return to === '/admin/settings'
21
- ? path === to
22
- : path === to || path.startsWith(`${to}/`)
23
- }
24
-
25
- const tabs = computed<NavigationMenuItem[]>(() =>
26
- items.value.map((page) => ({
27
- label: page.label,
28
- icon: page.icon,
29
- href: page.to,
30
- active: isActive(page.to),
31
- onSelect: () => navigateTo(page.to),
32
- }))
33
- )
13
+ const { current, allowed } = usePlutoSettingsPages()
34
14
  </script>
35
15
 
36
16
  <template>
@@ -40,15 +20,8 @@ const tabs = computed<NavigationMenuItem[]>(() =>
40
20
  {{ t('pluto.pages.settings') }}
41
21
  </h1>
42
22
 
43
- <!-- One page (General only) needs no tabs. -->
44
- <UNavigationMenu
45
- v-if="tabs.length >= 2"
46
- :items="tabs"
47
- :aria-label="t('pluto.settings.tabs.label')"
48
- orientation="horizontal"
49
- variant="link"
50
- class="border-default -mt-2 overflow-x-auto border-b"
51
- />
23
+ <!-- One page (General only) has no submenu, so no tabs. -->
24
+ <PlutoAdminNavTabs />
52
25
 
53
26
  <UAlert
54
27
  v-if="!allowed"
@@ -167,7 +167,6 @@
167
167
  "saveFailedDescription": "The language changed on this device only. {reason}"
168
168
  },
169
169
  "tabs": {
170
- "label": "Settings sections",
171
170
  "general": "General",
172
171
  "email": "Email",
173
172
  "github": "GitHub"
@@ -167,7 +167,6 @@
167
167
  "saveFailedDescription": "O idioma mudou apenas neste dispositivo. {reason}"
168
168
  },
169
169
  "tabs": {
170
- "label": "Seções das configurações",
171
170
  "general": "Geral",
172
171
  "email": "E-mail",
173
172
  "github": "GitHub"
package/layers.ts CHANGED
@@ -28,7 +28,6 @@ export type PlutoLayerEntry = [source: string, options: PlutoLayerEntryOptions]
28
28
  * without the scope. A package of a monorepo is in a folder of its repo.
29
29
  */
30
30
  export const PLUTO_LAYER_PATHS: Readonly<Record<string, string>> = {
31
- utils: 'utils',
32
31
  core: 'pluto/packages/core',
33
32
  supabase: 'supabase',
34
33
  'supabase-shop': 'supabase-shop',
package/nuxt.config.ts CHANGED
@@ -1,13 +1,9 @@
1
1
  const websiteName = process.env.WEBSITE_NAME || 'Untitled'
2
2
 
3
- // Point PLUTO_UTILS_PATH at a local checkout (e.g. `../../../utils`) to test
4
- // unpublished changes; unset, it resolves to the published npm package.
5
- const utilsLayer = process.env.PLUTO_UTILS_PATH || '@plutocms/utils'
6
-
7
3
  // https://nuxt.com/docs/api/configuration/nuxt-config
4
+ // Core extends no other layer. The helpers of the retired `@plutocms/utils` that
5
+ // core uses are in shared/utils (`domainFromUrl`).
8
6
  export default defineNuxtConfig({
9
- extends: [utilsLayer],
10
-
11
7
  // No @nuxt/eslint: a module of a layer runs in every app that extends it. The
12
8
  // repo lints with @nuxt/eslint-config (eslint.config.mjs at the root).
13
9
  modules: [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/core",
3
3
  "type": "module",
4
- "version": "0.39.2",
4
+ "version": "0.41.0",
5
5
  "description": "The base layer every Pluto app extends: the admin, the content model, and the GitHub CMS",
6
6
  "author": "Victor Ribeiro",
7
7
  "license": "MIT",
@@ -43,7 +43,6 @@
43
43
  "@lezer/highlight": "^1.2.5",
44
44
  "@nuxt/image": "^2.1.0",
45
45
  "@nuxt/ui": "^4.12.0",
46
- "@plutocms/utils": ">=0.3.0 <1.0.0",
47
46
  "@tanstack/charts": "^1.1.0",
48
47
  "@vueuse/nuxt": "^15.0.0",
49
48
  "image-meta": "^0.3.0",
@@ -59,6 +59,8 @@ export interface PlutoNavChild {
59
59
  newTo?: string
60
60
  /** The text of that "+" (`New <type>`). */
61
61
  newLabel?: string
62
+ /** Reactive visibility test, like `PlutoEntry.enabled`. A child without it shows. */
63
+ enabled?: () => boolean
62
64
  }
63
65
 
64
66
  export interface PlutoNavItem extends PlutoEntry {
@@ -6,8 +6,7 @@ export const SLUG_MAX_LENGTH = 100
6
6
  * "pao-de-mel". The result is lowercase ASCII, with single dashes. It can
7
7
  * be an empty string.
8
8
  *
9
- * The form and the GitHub CMS both use this function. Do not name it
10
- * `slugify`: `@plutocms/utils` already auto-imports that name.
9
+ * The form and the GitHub CMS both use this function.
11
10
  */
12
11
  export function slugifyTitle(value: string): string {
13
12
  return value
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The host of a URL, without the scheme and `www.`: `https://www.example.com/a`
3
+ * gives `example.com`. No URL gives `undefined`; a value that does not parse
4
+ * gives an empty string. The admin bar shows it next to "View site".
5
+ */
6
+ export function domainFromUrl(url: string | null | undefined) {
7
+ if (!url) {
8
+ return
9
+ }
10
+
11
+ const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^/]+)/i)
12
+ return match ? match[1] : ''
13
+ }