@plutocms/core 0.40.0 → 0.42.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,26 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.42.0](https://github.com/plutocms/pluto/compare/v0.41.0...v0.42.0) (2026-10-31)
5
+
6
+
7
+ ### Features
8
+
9
+ * @plutocms/docs moves into the monorepo ([f409bdf](https://github.com/plutocms/pluto/commit/f409bdf3f1ded0a1c146e9ad2724ac5222c30c1d))
10
+ * @plutocms/docs moves into the monorepo (packages/layers/docs) ([5117e4e](https://github.com/plutocms/pluto/commit/5117e4e507559bd3b8859401665a176e2749cbc5))
11
+
12
+ ## [0.41.0](https://github.com/plutocms/pluto/compare/v0.40.0...v0.41.0) (2026-10-31)
13
+
14
+
15
+ ### ⚠ BREAKING CHANGES
16
+
17
+ * @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.
18
+
19
+ ### Features
20
+
21
+ * **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))
22
+ * 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))
23
+
4
24
  ## [0.40.0](https://github.com/plutocms/pluto/compare/v0.39.2...v0.40.0) (2026-10-31)
5
25
 
6
26
 
@@ -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>
@@ -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
@@ -178,6 +178,7 @@ export function usePlutoAdminNav() {
178
178
  children: settingsPages.value.map((page) => ({
179
179
  label: page.label,
180
180
  to: page.to,
181
+ icon: page.icon,
181
182
  })),
182
183
  }
183
184
  : item
@@ -207,3 +208,96 @@ export function usePlutoAdminNav() {
207
208
 
208
209
  return { items }
209
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
+ }
@@ -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,12 +28,11 @@ 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',
35
34
  'supabase-blog': 'supabase-blog',
36
- docs: 'docs',
35
+ docs: 'pluto/packages/layers/docs',
37
36
  '3d': 'pluto/packages/layers/addons/3d',
38
37
  }
39
38
 
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: [
@@ -104,7 +100,8 @@ export default defineNuxtConfig({
104
100
  compatibilityDate: '2025-08-15',
105
101
 
106
102
  typescript: {
107
- includeWorkspace: true,
103
+ // No `includeWorkspace`: in the monorepo it would read every package. Each package has its own typecheck.
104
+ includeWorkspace: false,
108
105
  typeCheck: false,
109
106
  },
110
107
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/core",
3
3
  "type": "module",
4
- "version": "0.40.0",
4
+ "version": "0.42.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",
@@ -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
+ }