@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.
- package/CHANGELOG.md +36 -0
- package/FEATURES.md +13 -0
- package/README.md +30 -0
- package/app/assets/css/admin-theme.css +216 -0
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/ColorModeButton.vue +4 -2
- package/app/components/Pluto/Content/PlutoContentForm.vue +1 -0
- package/app/components/Pluto/Content/PlutoContentList.vue +122 -20
- package/app/components/Pluto/Dashboard/PlutoDashboardContentActivity.vue +114 -0
- package/app/components/Pluto/Dashboard/PlutoDashboardContentOverview.vue +103 -0
- package/app/components/Pluto/Dashboard/PlutoDashboardQuickActions.vue +70 -0
- package/app/components/Pluto/Navbar/PlutoNavbarAdmin.vue +9 -1
- package/app/components/Pluto/View/PlutoViewAdminPanel.vue +105 -19
- package/app/components/Pluto/View/PlutoViewAside.vue +26 -9
- package/app/components/modal/Modal.vue +1 -1
- package/app/composables/pluto-admin-theme.ts +14 -0
- package/app/composables/pluto-command-palette.ts +59 -0
- package/app/composables/pluto-content-activity.ts +37 -0
- package/app/composables/pluto-content-list.ts +88 -7
- package/app/composables/pluto-content-stats.ts +78 -0
- package/app/composables/pluto-view.ts +9 -0
- package/app/layouts/admin.vue +30 -6
- package/app/layouts/auth.vue +4 -2
- package/app/pages/admin/home.vue +34 -7
- package/app/plugins/pluto-core-registrations.ts +34 -0
- package/modules/pluto-css/css-entries.ts +182 -0
- package/modules/pluto-css/index.ts +86 -0
- package/nuxt.config.ts +3 -1
- package/package.json +2 -1
- package/scripts/generate-admin-theme.cjs +110 -0
- package/server/api/_pluto/content/[type]/index.get.ts +4 -1
- package/server/api/_pluto/content-activity/[type].get.ts +15 -0
- package/server/utils/pluto-content-handlers.ts +65 -1
- package/shared/types/content.ts +24 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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')
|
package/app/layouts/admin.vue
CHANGED
|
@@ -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
|
-
:
|
|
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
|
|
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: '
|
|
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
|
|
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>
|
package/app/layouts/auth.vue
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
<script setup lang="ts"
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
usePlutoAdminTheme()
|
|
3
|
+
</script>
|
|
2
4
|
|
|
3
5
|
<template>
|
|
4
|
-
<div class="
|
|
6
|
+
<div class="flex min-h-dvh grow flex-col bg-(--pluto-admin-canvas)">
|
|
5
7
|
<slot />
|
|
6
8
|
</div>
|
|
7
9
|
</template>
|
package/app/pages/admin/home.vue
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
useHead({
|
|
3
|
-
title:
|
|
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
|
-
<
|
|
9
|
-
<
|
|
10
|
-
<
|
|
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
|
-
|
|
13
|
-
|
|
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:
|
|
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: {
|