@plutocms/pluto 0.11.0 → 0.13.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 (35) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/FEATURES.md +13 -0
  3. package/README.md +30 -0
  4. package/app/assets/css/admin-theme.css +216 -0
  5. package/app/assets/css/pluto-layer.css +40 -0
  6. package/app/components/ColorModeButton.vue +4 -2
  7. package/app/components/Pluto/Content/PlutoContentForm.vue +1 -0
  8. package/app/components/Pluto/Content/PlutoContentList.vue +122 -20
  9. package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +114 -0
  10. package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +103 -0
  11. package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +70 -0
  12. package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +9 -1
  13. package/app/components/Pluto/View/PlutoViewAdminPanel.vue +105 -19
  14. package/app/components/Pluto/View/PlutoViewAside.vue +26 -9
  15. package/app/components/modal/Modal.vue +1 -1
  16. package/app/composables/pluto-admin-theme.ts +14 -0
  17. package/app/composables/pluto-command-palette.ts +59 -0
  18. package/app/composables/pluto-content-activity.ts +37 -0
  19. package/app/composables/pluto-content-list.ts +88 -7
  20. package/app/composables/pluto-content-stats.ts +78 -0
  21. package/app/composables/pluto-view.ts +9 -0
  22. package/app/layouts/admin.vue +30 -6
  23. package/app/layouts/auth.vue +4 -2
  24. package/app/pages/admin/home.vue +34 -7
  25. package/app/plugins/pluto-core-registrations.ts +34 -0
  26. package/modules/pluto-css/css-entries.ts +182 -0
  27. package/modules/pluto-css/index.ts +86 -0
  28. package/nuxt.config.ts +3 -1
  29. package/package.json +2 -1
  30. package/scripts/generate-admin-theme.cjs +110 -0
  31. package/server/api/_pluto/content/[type]/index.get.ts +4 -1
  32. package/server/api/_pluto/content-activity/[type].get.ts +15 -0
  33. package/server/utils/pluto-content-handlers.ts +65 -1
  34. package/shared/types/content.ts +24 -0
  35. package/app/assets/css/tailwind.css +0 -26
@@ -1,3 +1,13 @@
1
+ export interface PlutoContentListOptions {
2
+ /**
3
+ * Rows per request. Set it to load the list page by page: the first page
4
+ * on creation, the next pages through `loadMore()`. Leave it unset for one
5
+ * request that returns every row (an adapter can cap that request; the
6
+ * Supabase adapter returns at most 1000 rows).
7
+ */
8
+ pageSize?: number
9
+ }
10
+
1
11
  /**
2
12
  * Fetches one content type's list of items, for the generic list page
3
13
  * (`PlutoContentList`).
@@ -15,25 +25,86 @@
15
25
  * of `await`ing the composable's own return value through a hand-rolled
16
26
  * `.then()`.
17
27
  */
18
- export function usePlutoContentList(typeName: string) {
28
+ export function usePlutoContentList(typeName: string, options: PlutoContentListOptions = {}) {
29
+ const { pageSize } = options
30
+ const url = `/api/_pluto/content/${typeName}`
31
+
19
32
  const items = ref<PlutoContentItem[]>([])
20
33
  const total = ref<number | undefined>(undefined)
21
34
  const pending = ref(false)
35
+ const loadingMore = ref(false)
36
+ /** `true` while more pages exist. Always `false` without `pageSize`. */
37
+ const hasMore = ref(false)
22
38
  const error = ref<unknown>(null)
23
39
 
40
+ // A refresh() while a page is in flight makes that page stale. Each fetch
41
+ // remembers the generation it started in, and drops its result when a
42
+ // newer refresh() has started since.
43
+ let generation = 0
44
+
45
+ function fetchPage(offset: number): Promise<PlutoContentListResult> {
46
+ return pageSize === undefined
47
+ ? $fetch<PlutoContentListResult>(url)
48
+ : $fetch<PlutoContentListResult>(url, { query: { limit: pageSize, offset } })
49
+ }
50
+
51
+ function updateHasMore(lastPageLength: number) {
52
+ if (pageSize === undefined) {
53
+ hasMore.value = false
54
+ return
55
+ }
56
+ // Without a total, a full page means there may be another one.
57
+ hasMore.value = total.value === undefined ? lastPageLength === pageSize : items.value.length < total.value
58
+ }
59
+
24
60
  async function refresh(): Promise<void> {
61
+ const current = ++generation
25
62
  pending.value = true
26
63
  error.value = null
27
64
 
28
65
  try {
29
- const result = await $fetch<PlutoContentListResult>(`/api/_pluto/content/${typeName}`)
66
+ const result = await fetchPage(0)
67
+ if (current !== generation) {
68
+ return
69
+ }
30
70
  items.value = result.data
31
71
  total.value = result.total
72
+ updateHasMore(result.data.length)
32
73
  } catch (caught) {
33
- error.value = caught
74
+ if (current === generation) {
75
+ error.value = caught
76
+ }
77
+ throw caught
78
+ } finally {
79
+ if (current === generation) {
80
+ pending.value = false
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Appends the next page. Does nothing without `pageSize`, without more pages, or while a fetch runs. */
86
+ async function loadMore(): Promise<void> {
87
+ if (!hasMore.value || pending.value || loadingMore.value) {
88
+ return
89
+ }
90
+ const current = generation
91
+ loadingMore.value = true
92
+
93
+ try {
94
+ const result = await fetchPage(items.value.length)
95
+ if (current !== generation) {
96
+ return
97
+ }
98
+ items.value = [...items.value, ...result.data]
99
+ total.value = result.total ?? total.value
100
+ updateHasMore(result.data.length)
101
+ } catch (caught) {
102
+ if (current === generation) {
103
+ error.value = caught
104
+ }
34
105
  throw caught
35
106
  } finally {
36
- pending.value = false
107
+ loadingMore.value = false
37
108
  }
38
109
  }
39
110
 
@@ -47,14 +118,24 @@ export function usePlutoContentList(typeName: string) {
47
118
 
48
119
  async function remove(id: string | number): Promise<void> {
49
120
  try {
50
- await $fetch(`/api/_pluto/content/${typeName}/${id}`, { method: 'DELETE' })
121
+ await $fetch(`${url}/${id}`, { method: 'DELETE' })
51
122
  } catch (caught) {
52
123
  error.value = caught
53
124
  throw caught
54
125
  }
55
126
 
56
- await refresh()
127
+ if (pageSize === undefined) {
128
+ await refresh()
129
+ return
130
+ }
131
+
132
+ // Paged: drop the row in place, so the user keeps the pages already
133
+ // loaded and the scroll position.
134
+ items.value = items.value.filter((item) => item.id !== id)
135
+ if (total.value !== undefined) {
136
+ total.value = Math.max(0, total.value - 1)
137
+ }
57
138
  }
58
139
 
59
- return { items, total, pending, error, refresh, remove }
140
+ return { items, total, pending, loadingMore, hasMore, error, refresh, loadMore, remove }
60
141
  }
@@ -0,0 +1,78 @@
1
+ export interface PlutoContentStat {
2
+ name: string
3
+ label: string
4
+ labelPlural: string
5
+ icon?: string
6
+ /** Admin list path for the content type. */
7
+ path: string
8
+ total: number
9
+ /** Present only for a content type with a status workflow. */
10
+ published?: number
11
+ /** Present only for a content type with a status workflow. */
12
+ drafts?: number
13
+ }
14
+
15
+ /**
16
+ * Counts the items of every registered content type, for the dashboard.
17
+ *
18
+ * Each count is a list request with `limit=1`, so the adapter returns the
19
+ * count without the rows. A type with a status workflow gets a second
20
+ * request with `published=1`. Drafts are the total minus the published
21
+ * count. An adapter that does not return `total` gets one request without
22
+ * a limit, and the count is the number of rows.
23
+ */
24
+ export function usePlutoContentStats() {
25
+ const { items: contentTypes } = usePlutoContentTypes()
26
+
27
+ const stats = ref<PlutoContentStat[]>([])
28
+ const pending = ref(false)
29
+ const error = ref<unknown>(null)
30
+
31
+ async function count(typeName: string, published: boolean): Promise<number> {
32
+ const url = `/api/_pluto/content/${typeName}`
33
+ const query = published ? { limit: 1, published: 1 } : { limit: 1 }
34
+ const result = await $fetch<PlutoContentListResult>(url, { query })
35
+ if (result.total !== undefined) {
36
+ return result.total
37
+ }
38
+ const all = await $fetch<PlutoContentListResult>(url, { query: published ? { published: 1 } : {} })
39
+ return all.data.length
40
+ }
41
+
42
+ async function refresh(): Promise<void> {
43
+ pending.value = true
44
+ error.value = null
45
+
46
+ try {
47
+ stats.value = await Promise.all(
48
+ contentTypes.value.map(async ({ type }) => {
49
+ const [total, published] = await Promise.all([
50
+ count(type.name, false),
51
+ type.status ? count(type.name, true) : Promise.resolve(undefined),
52
+ ])
53
+
54
+ return {
55
+ name: type.name,
56
+ label: type.label,
57
+ labelPlural: type.labelPlural,
58
+ icon: type.icon,
59
+ path: type.basePath ?? `/admin/content/${type.name}`,
60
+ total,
61
+ ...(published === undefined ? {} : { published, drafts: Math.max(0, total - published) }),
62
+ }
63
+ })
64
+ )
65
+ } catch (caught) {
66
+ error.value = caught
67
+ throw caught
68
+ } finally {
69
+ pending.value = false
70
+ }
71
+ }
72
+
73
+ // Same pattern as usePlutoContentList: fetch right away, and keep a
74
+ // failure in `error` instead of an unhandled rejection.
75
+ refresh().catch(() => {})
76
+
77
+ return { stats, pending, error, refresh }
78
+ }
@@ -0,0 +1,9 @@
1
+ import type { InjectionKey, Ref } from 'vue'
2
+
3
+ /**
4
+ * `PlutoViewAdminPanel` provides this key. It is `true` while the panel
5
+ * holds its aside in a resizable splitter panel (desktop, aside open).
6
+ * `PlutoViewAside` then fills that panel instead of using its own fixed
7
+ * `width`.
8
+ */
9
+ export const plutoViewAsideResizableKey: InjectionKey<Ref<boolean>> = Symbol('plutoViewAsideResizable')
@@ -32,11 +32,23 @@ const menu = computed<SidebarMenuItem[]>(() => navItems.value.map(toMenuItem))
32
32
  // (`app/components/navbar/NavbarAdmin.vue`) writes to this exact state key
33
33
  // to open the sidebar from its own hamburger button. Keep the name as-is.
34
34
  const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
35
+
36
+ const commandGroups = usePlutoCommandPalette()
37
+
38
+ // Keep the consumer app's public-site theme out of the admin.
39
+ usePlutoAdminTheme()
40
+
41
+ // Start the dashboard group below the fixed admin navbar. An inline `top`
42
+ // overrides the `top: 0` from the group's `inset-0` base class, whatever
43
+ // order the classes merge in. Do not use a class replacer function here:
44
+ // in @nuxt/ui 4.12 a replacer only runs for themes with slots, and the
45
+ // group theme has only `base`, so the replacer was silently ignored.
46
+ const groupStyle = { top: 'var(--ui-header-height)' }
35
47
  </script>
36
48
 
37
49
  <template>
38
50
  <UDashboardGroup
39
- :class="(base: string) => base.replace('inset-0', 'inset-x-0 bottom-0 top-(--ui-header-height)')"
51
+ :style="groupStyle"
40
52
  storage="cookie"
41
53
  storage-key="pluto-admin"
42
54
  unit="rem"
@@ -48,23 +60,32 @@ const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
48
60
  :min-size="12"
49
61
  :max-size="24"
50
62
  :ui="{
51
- root: 'min-h-0 dark:bg-admin-sidebar light:bg-zinc-100',
52
- content: 'lg:hidden w-[min(18rem,85vw)]',
63
+ root: 'min-h-0 bg-(--pluto-admin-sidebar)',
64
+ content: 'lg:hidden w-[min(18rem,85vw)] bg-(--pluto-admin-sidebar)',
65
+ header: 'h-auto py-3',
53
66
  }"
54
67
  resizable
55
68
  >
69
+ <!--
70
+ The search button must sit inside UDashboardGroup. Outside it, the
71
+ button has no dashboard context and its click does nothing.
72
+ -->
73
+ <template #header>
74
+ <UDashboardSearchButton class="w-full" />
75
+ </template>
76
+
56
77
  <UNavigationMenu
57
78
  :items="menu"
58
- :ui="{ link: 'text-base gap-x-3 font-normal' }"
79
+ :ui="{ link: 'gap-x-2.5 py-2', linkLeadingIcon: 'size-[18px]' }"
59
80
  orientation="vertical"
81
+ variant="pill"
60
82
  class="data-[orientation=vertical]:w-full"
61
- highlight
62
83
  />
63
84
  </UDashboardSidebar>
64
85
 
65
86
  <UDashboardPanel
66
87
  :ui="{
67
- root: 'min-h-0 dark:bg-admin-content light:bg-white font-outfit',
88
+ root: 'min-h-0 bg-(--pluto-admin-canvas)',
68
89
  body: 'p-0 gap-0 overflow-x-hidden',
69
90
  }"
70
91
  >
@@ -72,5 +93,8 @@ const isSidebarOpen = useState<boolean>('pluto-admin-sidebar-open', () => false)
72
93
  <slot />
73
94
  </template>
74
95
  </UDashboardPanel>
96
+
97
+ <!-- Cmd/Ctrl+K. See usePlutoCommandPalette for the commands. -->
98
+ <UDashboardSearch :groups="commandGroups" placeholder="Search pages and actions…" />
75
99
  </UDashboardGroup>
76
100
  </template>
@@ -1,7 +1,9 @@
1
- <script setup lang="ts"></script>
1
+ <script setup lang="ts">
2
+ usePlutoAdminTheme()
3
+ </script>
2
4
 
3
5
  <template>
4
- <div class="bg-admin-content font-outfit flex min-h-dvh grow flex-col">
6
+ <div class="flex min-h-dvh grow flex-col bg-(--pluto-admin-canvas)">
5
7
  <slot />
6
8
  </div>
7
9
  </template>
@@ -1,15 +1,42 @@
1
1
  <script setup lang="ts">
2
2
  useHead({
3
- title: `Admin`,
3
+ title: 'Dashboard',
4
4
  })
5
+
6
+ const today = new Intl.DateTimeFormat(undefined, { weekday: 'long', month: 'long', day: 'numeric' }).format(new Date())
7
+
8
+ // The header's Create button reuses the command palette's Create commands.
9
+ const groups = usePlutoCommandPalette()
10
+ const createItems = computed(() => groups.value.find((group) => group.id === 'pluto-create')?.items ?? [])
11
+
12
+ function run(item: {
13
+ onSelect?: (event: Event) => void
14
+ }) {
15
+ item.onSelect?.(new Event('select'))
16
+ }
5
17
  </script>
6
18
 
7
19
  <template>
8
- <main>
9
- <AdminView>
10
- <h1 class="text-3xl font-bold lg:text-4xl">Admin Dashboard</h1>
20
+ <PlutoViewAdminPanel>
21
+ <header class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
22
+ <div>
23
+ <p class="text-muted text-sm">{{ today }}</p>
24
+ <h1 class="text-highlighted text-2xl font-semibold tracking-tight lg:text-3xl">Dashboard</h1>
25
+ </div>
26
+
27
+ <UButton
28
+ v-if="createItems.length === 1"
29
+ :label="createItems[0]!.label"
30
+ icon="lucide:plus"
31
+ size="md"
32
+ @click="run(createItems[0]!)"
33
+ />
34
+
35
+ <UDropdownMenu v-else-if="createItems.length > 1" :items="createItems" :content="{ align: 'end' }">
36
+ <UButton label="Create" icon="lucide:plus" trailing-icon="lucide:chevron-down" size="md" />
37
+ </UDropdownMenu>
38
+ </header>
11
39
 
12
- <PlutoDashboardWidgets />
13
- </AdminView>
14
- </main>
40
+ <PlutoDashboardWidgets />
41
+ </PlutoViewAdminPanel>
15
42
  </template>
@@ -5,6 +5,9 @@ import PlutoFieldReference from '../components/Pluto/Content/fields/PlutoFieldRe
5
5
  import PlutoFieldSelect from '../components/Pluto/Content/fields/PlutoFieldSelect.vue'
6
6
  import PlutoFieldSlug from '../components/Pluto/Content/fields/PlutoFieldSlug.vue'
7
7
  import PlutoFieldText from '../components/Pluto/Content/fields/PlutoFieldText.vue'
8
+ import PlutoDashboardContentActivity from '../components/Pluto/Dashboard/PlutoDashboardContentActivity.vue'
9
+ import PlutoDashboardContentOverview from '../components/Pluto/Dashboard/PlutoDashboardContentOverview.vue'
10
+ import PlutoDashboardQuickActions from '../components/Pluto/Dashboard/PlutoDashboardQuickActions.vue'
8
11
 
9
12
  // Registers core's own Home and Settings anchors, plus the eight built-in
10
13
  // content field widgets. `order: 0` and `order: 1000` are reserved for the
@@ -41,5 +44,36 @@ export default defineNuxtPlugin(() => {
41
44
  { id: 'select', fieldType: 'select', component: PlutoFieldSelect },
42
45
  { id: 'reference', fieldType: 'reference', component: PlutoFieldReference },
43
46
  ],
47
+ // `order: 0` puts the content overview first. It spans the full row
48
+ // because it lays out its own grid of tiles. It is disabled while no
49
+ // content type is registered, so the dashboard shows its own empty
50
+ // message instead of a blank cell.
51
+ dashboardWidgets: [
52
+ {
53
+ id: 'content-overview',
54
+ order: 0,
55
+ title: 'Content',
56
+ colSpan: 3,
57
+ component: PlutoDashboardContentOverview,
58
+ enabled: () => usePlutoContentTypes().items.value.length > 0,
59
+ },
60
+ // The activity chart and the quick actions share the next row (2 + 1
61
+ // columns). A layer widget with the default order (100) goes below.
62
+ {
63
+ id: 'content-activity',
64
+ order: 10,
65
+ title: 'Content activity',
66
+ colSpan: 2,
67
+ component: PlutoDashboardContentActivity,
68
+ enabled: () => usePlutoContentTypes().items.value.length > 0,
69
+ },
70
+ {
71
+ id: 'quick-actions',
72
+ order: 20,
73
+ title: 'Quick actions',
74
+ colSpan: 1,
75
+ component: PlutoDashboardQuickActions,
76
+ },
77
+ ],
44
78
  })
45
79
  })
@@ -0,0 +1,182 @@
1
+ // Pure helpers for the pluto-css module. They do not import `nuxt/kit`, so
2
+ // the unit tests can call them directly.
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
4
+
5
+ /** A layer's own CSS file, by convention. See the single-tailwind-build skill. */
6
+ export const LAYER_CSS_FILE = 'assets/css/pluto-layer.css'
7
+
8
+ function toPosix(path: string): string {
9
+ return path.replaceAll('\\', '/')
10
+ }
11
+
12
+ /**
13
+ * Returns the `pluto-layer.css` file of every layer that has one, base
14
+ * layer first. Nuxt lists the layers top first (the app, then each layer it
15
+ * extends), so the list is reversed: a higher layer's `@theme` then comes
16
+ * later in the build and wins.
17
+ */
18
+ export function findLayerCssFiles(layerAppDirs: string[], exists: (path: string) => boolean): string[] {
19
+ return [...layerAppDirs]
20
+ .reverse()
21
+ .map((appDir) => toPosix(join(appDir, LAYER_CSS_FILE)))
22
+ .filter((file) => exists(file))
23
+ }
24
+
25
+ /**
26
+ * Renders `#build/pluto.css`: one `@import` for each layer CSS file. Each
27
+ * path is relative to the build dir, where the file is written. A path on
28
+ * another drive (Windows) stays absolute.
29
+ */
30
+ export function renderPlutoCss(files: string[], buildDir: string): string {
31
+ const lines = files.map((file) => {
32
+ const fromBuild = toPosix(relative(buildDir, file))
33
+ const path = isAbsolute(fromBuild) ? fromBuild : fromBuild.startsWith('.') ? fromBuild : `./${fromBuild}`
34
+ return `@import "${path}";`
35
+ })
36
+ return [
37
+ '/* Generated by @plutocms/pluto (modules/pluto-css). Do not edit. */',
38
+ '/* Each Pluto layer\'s app/assets/css/pluto-layer.css, base layer first. */',
39
+ ...lines,
40
+ '',
41
+ ].join('\n')
42
+ }
43
+
44
+ /** The fallback entry, used only when the app has no Tailwind entry. */
45
+ export function renderPlutoEntryCss(): string {
46
+ return [
47
+ '/* Generated by @plutocms/pluto (modules/pluto-css). Do not edit. */',
48
+ '/* Fallback Tailwind entry: the app has no CSS entry that imports Tailwind. */',
49
+ '@import "tailwindcss";',
50
+ '@import "@nuxt/ui";',
51
+ '@import "#build/pluto.css";',
52
+ '',
53
+ ].join('\n')
54
+ }
55
+
56
+ /** Removes CSS comments. */
57
+ function stripComments(css: string): string {
58
+ return css.replace(/\/\*[\s\S]*?\*\//g, '')
59
+ }
60
+
61
+ /**
62
+ * Returns the specifier of every `@import` in a CSS text: `@import 'x'`,
63
+ * `@import "x" source(none)`, `@import url(x)`, and `@import url('x')`.
64
+ */
65
+ export function parseCssImports(css: string): string[] {
66
+ const imports: string[] = []
67
+ // Two simple steps keep both patterns linear: take the body of each
68
+ // `@import ...;`, then read the specifier at its start.
69
+ for (const statement of stripComments(css).matchAll(/@import\s([^;]*);/g)) {
70
+ const body = statement[1]!.trim().replace(/^url\(\s*/, '')
71
+ const specifier = /^(['"])(.*?)\1/.exec(body)?.[2] ?? /^[^\s)]+/.exec(body)?.[0]
72
+ if (specifier) {
73
+ imports.push(specifier)
74
+ }
75
+ }
76
+ return imports
77
+ }
78
+
79
+ export interface CssEntryInfo {
80
+ /** The CSS entry, as resolved to a file. */
81
+ file: string
82
+ /** It imports Tailwind (`@import 'tailwindcss'`), so it is a Tailwind build. */
83
+ tailwind: boolean
84
+ /** It imports `@nuxt/ui`. */
85
+ nuxtUi: boolean
86
+ /** It imports `#build/pluto.css`. */
87
+ plutoCss: boolean
88
+ }
89
+
90
+ /**
91
+ * Follows the local `@import`s of a CSS entry, to `maxDepth`, and reports
92
+ * what it imports. `resolveImport` turns a specifier into a file path, or
93
+ * returns `undefined` for a package import (such as `tailwindcss`), which is
94
+ * not followed.
95
+ */
96
+ export function analyzeCssEntry(
97
+ file: string,
98
+ read: (path: string) => string | undefined,
99
+ resolveImport: (specifier: string, fromFile: string) => string | undefined,
100
+ maxDepth = 3
101
+ ): CssEntryInfo {
102
+ const info: CssEntryInfo = { file, tailwind: false, nuxtUi: false, plutoCss: false }
103
+ const seen = new Set<string>()
104
+
105
+ function visit(path: string, depth: number) {
106
+ if (seen.has(path) || depth > maxDepth) {
107
+ return
108
+ }
109
+ seen.add(path)
110
+ const css = read(path)
111
+ if (css === undefined) {
112
+ return
113
+ }
114
+ for (const specifier of parseCssImports(css)) {
115
+ if (specifier === 'tailwindcss') {
116
+ info.tailwind = true
117
+ } else if (specifier === '@nuxt/ui') {
118
+ info.nuxtUi = true
119
+ } else if (/(?:^|\/)pluto\.css$/.test(specifier) && specifier.includes('#build')) {
120
+ info.plutoCss = true
121
+ } else {
122
+ const next = resolveImport(specifier, path)
123
+ if (next) {
124
+ visit(next, depth + 1)
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ visit(file, 0)
131
+ return info
132
+ }
133
+
134
+ /** Resolves a relative `@import` (`./x.css`, `../x.css`) against its file. */
135
+ export function resolveRelativeImport(specifier: string, fromFile: string): string | undefined {
136
+ return specifier.startsWith('./') || specifier.startsWith('../') ? toPosix(resolve(dirname(fromFile), specifier)) : undefined
137
+ }
138
+
139
+ export interface CssSetupReport {
140
+ /** Inject the fallback entry: no CSS entry imports Tailwind. */
141
+ injectFallback: boolean
142
+ warnings: string[]
143
+ }
144
+
145
+ /**
146
+ * Checks the app's CSS setup against the single-build rules and returns
147
+ * what to do. `layerRoots` are the root dirs of every layer except the app.
148
+ */
149
+ export function checkCssSetup(entries: CssEntryInfo[], layerRoots: string[]): CssSetupReport {
150
+ const warnings: string[] = []
151
+ const tailwindEntries = entries.filter((entry) => entry.tailwind)
152
+ const snippet = ' @import \'tailwindcss\';\n @import \'@nuxt/ui\';\n @import \'#build/pluto.css\';'
153
+
154
+ if (tailwindEntries.length === 0) {
155
+ return { injectFallback: true, warnings }
156
+ }
157
+
158
+ for (const entry of tailwindEntries) {
159
+ const layerRoot = layerRoots.find((root) => toPosix(entry.file).startsWith(toPosix(root)))
160
+ if (layerRoot) {
161
+ warnings.push(
162
+ `Layer ${layerRoot} registers its own Tailwind build (${entry.file}). Remove it from the layer's \`css\` option, and move its tokens to app/${LAYER_CSS_FILE}.`
163
+ )
164
+ }
165
+ }
166
+
167
+ if (tailwindEntries.length > 1) {
168
+ warnings.push(
169
+ `Found ${tailwindEntries.length} Tailwind builds. Keep one, in the app. Builds:\n${tailwindEntries.map((entry) => ` - ${entry.file}`).join('\n')}`
170
+ )
171
+ }
172
+
173
+ if (!tailwindEntries.some((entry) => entry.plutoCss)) {
174
+ warnings.push(`No CSS entry imports #build/pluto.css. The admin theme and layer tokens are missing. Start the app's CSS entry with:\n${snippet}`)
175
+ }
176
+
177
+ if (!tailwindEntries.some((entry) => entry.nuxtUi)) {
178
+ warnings.push(`No CSS entry imports @nuxt/ui. Nuxt UI styles, the \`light:\` variant, and the layer sources are missing. Start the app's CSS entry with:\n${snippet}`)
179
+ }
180
+
181
+ return { injectFallback: false, warnings }
182
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { addTemplate, defineNuxtModule, getLayerDirectories, resolvePath, useLogger } from '@nuxt/kit'
3
+ import {
4
+ analyzeCssEntry,
5
+ checkCssSetup,
6
+ findLayerCssFiles,
7
+ renderPlutoCss,
8
+ renderPlutoEntryCss,
9
+ resolveRelativeImport,
10
+ } from './css-entries'
11
+
12
+ export interface PlutoCssOptions {
13
+ /** Warn at build time about a CSS setup that breaks the single build. Default true. */
14
+ check?: boolean
15
+ }
16
+
17
+ /**
18
+ * One Tailwind build per app. The app's CSS entry owns the build:
19
+ *
20
+ * @import 'tailwindcss';
21
+ * @import '@nuxt/ui';
22
+ * @import '#build/pluto.css';
23
+ *
24
+ * - `#build/pluto.css` imports each layer's app/assets/css/pluto-layer.css
25
+ * (tokens, the admin theme). Nuxt UI already adds `@source` for every
26
+ * layer's app/ dir, so this file adds no sources.
27
+ * - An app with no Tailwind entry (a layer repo run on its own, or an app
28
+ * with no CSS) gets the fallback entry `#build/pluto-entry.css`.
29
+ * - The module warns about a layer that registers its own Tailwind build,
30
+ * more than one build, or a missing import.
31
+ *
32
+ * See the single-tailwind-build skill.
33
+ */
34
+ export default defineNuxtModule<PlutoCssOptions>({
35
+ meta: { name: 'pluto-css', configKey: 'plutoCss' },
36
+ defaults: { check: true },
37
+ async setup(options, nuxt) {
38
+ const logger = useLogger('pluto-css')
39
+ const layers = getLayerDirectories(nuxt)
40
+ const layerCssFiles = findLayerCssFiles(layers.map((layer) => layer.app), existsSync)
41
+
42
+ addTemplate({
43
+ filename: 'pluto.css',
44
+ write: true,
45
+ getContents: () => renderPlutoCss(layerCssFiles, nuxt.options.buildDir),
46
+ })
47
+
48
+ addTemplate({
49
+ filename: 'pluto-entry.css',
50
+ write: true,
51
+ getContents: renderPlutoEntryCss,
52
+ })
53
+
54
+ const entries = []
55
+ for (const entry of nuxt.options.css) {
56
+ const file = typeof entry === 'string' ? await resolvePath(entry).catch(() => undefined) : undefined
57
+ if (!file || !file.endsWith('.css') || !existsSync(file)) {
58
+ continue
59
+ }
60
+ entries.push(
61
+ analyzeCssEntry(
62
+ file,
63
+ (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined),
64
+ resolveRelativeImport
65
+ )
66
+ )
67
+ }
68
+
69
+ const appRoot = nuxt.options.rootDir.replaceAll('\\', '/').replace(/\/?$/, '/')
70
+ const layerRoots = layers.map((layer) => layer.root.replaceAll('\\', '/')).filter((root) => root !== appRoot)
71
+ const report = checkCssSetup(entries, layerRoots)
72
+
73
+ if (report.injectFallback) {
74
+ nuxt.options.css.push('#build/pluto-entry.css')
75
+ if (!nuxt.options._prepare) {
76
+ logger.info('No CSS entry imports Tailwind. Using the Pluto fallback entry (#build/pluto-entry.css).')
77
+ }
78
+ }
79
+
80
+ if (options.check && !nuxt.options._prepare) {
81
+ for (const warning of report.warnings) {
82
+ logger.warn(warning)
83
+ }
84
+ }
85
+ },
86
+ })
package/nuxt.config.ts CHANGED
@@ -40,7 +40,9 @@ export default defineNuxtConfig({
40
40
  },
41
41
  },
42
42
 
43
- css: ['#layers/pluto/app/assets/css/tailwind.css'],
43
+ // No `css` entry: the app's CSS entry owns the one Tailwind build, and
44
+ // modules/pluto-css adds this layer's pluto-layer.css to it through
45
+ // #build/pluto.css. See the single-tailwind-build skill.
44
46
 
45
47
  runtimeConfig: {
46
48
  public: {