@plutocms/core 0.44.1 → 0.46.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 +18 -0
- package/FEATURES.md +7 -4
- package/app/assets/css/pluto-layer.css +6 -10
- package/app/components/Pluto/Admin/PlutoAdminNavbarProvider.vue +2 -1
- package/app/components/Pluto/Admin/PlutoAdminTheme.vue +4 -0
- package/app/components/Pluto/PlutoRoot.vue +37 -4
- package/app/composables/internal/admin-shell.ts +35 -0
- package/app/middleware/pluto-admin-auth.global.ts +15 -8
- package/app/pages/admin/not-configured.vue +22 -0
- package/i18n/locales/en.json +4 -0
- package/i18n/locales/pt-BR.json +4 -0
- package/modules/pluto-css/css-entries.ts +15 -50
- package/modules/pluto-css/index.ts +83 -36
- package/modules/pluto-css/scope.ts +117 -0
- package/nuxt.config.ts +4 -3
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## [0.46.0](https://github.com/plutocms/pluto/compare/v0.45.0...v0.46.0) (2026-11-02)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
### ⚠ BREAKING CHANGES
|
|
8
|
+
|
|
9
|
+
* an app with no Tailwind entry no longer gets one from Pluto. A site whose pages use Tailwind or Nuxt UI adds its own entry.
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* the admin has its own Tailwind build, and a site needs no Tailwind ([#343](https://github.com/plutocms/pluto/issues/343)) ([6d6ae4a](https://github.com/plutocms/pluto/commit/6d6ae4a59f14289abf2da421d028dba920cacd76))
|
|
14
|
+
|
|
15
|
+
## [0.45.0](https://github.com/plutocms/pluto/compare/v0.44.1...v0.45.0) (2026-11-01)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* in production, the admin shows a not-configured page when no auth driver is registered ([#346](https://github.com/plutocms/pluto/issues/346)) ([6f49cb8](https://github.com/plutocms/pluto/commit/6f49cb814d1d3614d8b3b7b92722081ba82549ae))
|
|
21
|
+
|
|
4
22
|
## [0.44.1](https://github.com/plutocms/pluto/compare/v0.44.0...v0.44.1) (2026-11-01)
|
|
5
23
|
|
|
6
24
|
|
package/FEATURES.md
CHANGED
|
@@ -66,9 +66,12 @@ policies shared by every project here.
|
|
|
66
66
|
@.claude/skills/i18n/SKILL.md
|
|
67
67
|
- Settings pages: `/admin/settings` is a section with tabs. A layer adds a tab with a `settingsPages` entry and a page file at `pages/admin/settings/<x>.vue`. The tabs show with 2 or more pages, the sidebar and the command palette list them, and a tab can need a capability (`'*'` is admin only). The capability picker groups by layer. @.claude/skills/extension-registry/SKILL.md
|
|
68
68
|
- Mail: a mail driver contract (`sendPlutoMail`, `registerMailDriver`) with Resend, dev (console and Mailpit) and none drivers, plain-TypeScript email templates (test, invite) translated by a server translator, and a guided `Settings > Email` page (admin only) that checks the key, adds and verifies a domain, and tests the setup. Secrets stay in the environment. @.claude/skills/mail/SKILL.md
|
|
69
|
-
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
- Admin and site CSS: the admin has its own Tailwind build, scoped to the admin and loaded only
|
|
70
|
+
with the admin UI, so a site needs no Tailwind and a site's theme does not reach the admin. A
|
|
71
|
+
Tailwind site owns its one build. The pluto-css module generates `#build/pluto.css` from each
|
|
72
|
+
layer's `pluto-layer.css` (imported as `@plutocms/css` by the admin build and by a Tailwind
|
|
73
|
+
site), and warns about a Tailwind build in a layer or more than one.
|
|
74
|
+
@.claude/skills/single-tailwind-build/SKILL.md
|
|
72
75
|
- Git CMS: a standalone CMS with Markdown posts in a Git repo. Out of the box it publishes to the
|
|
73
76
|
local Git repo (dev only: each save is a local commit, `commit.local`); a GitHub App publishes
|
|
74
77
|
from a deployed site. The module is `pluto-github` (config
|
|
@@ -101,7 +104,7 @@ policies shared by every project here.
|
|
|
101
104
|
`defineAsyncComponent`, shows a skeleton per field, widget and settings panel while a chunk
|
|
102
105
|
loads, and warns in dev about a registered component that is not async.
|
|
103
106
|
@.claude/skills/extension-registry/SKILL.md
|
|
104
|
-
- Admin shell: core owns the `/admin` parent route, the `/admin/login` page, the route guard, the admin banners, the navbar and the user menu. A backend layer registers an auth driver, account menu items, site info and link actions. The admin is for staff only: a signed-in user with no capability goes to `/admin/no-access`. The bar shows on `/admin/**` for staff, and on public pages for staff only. A route hides the bar with the page meta `plutoAdminBar: false`, and `isPlutoPreviewPath` marks the preview path. The bar height is the token `--pluto-admin-bar-height`, and `<html>` gets `data-pluto-admin-bar` while the bar shows. A hint cookie puts the bar frame in the server HTML of a public page for staff (the actions and the user menu load after the mount). Content types with `adminBar` get "New <type>" and "Edit <type>" buttons (`usePlutoEditLink`). With two or more types, one "New" menu lists them, grouped by `navGroup`. A `navGroup` also puts related types under one sidebar item (registry bucket `navGroups`, core group `pages`). @.claude/skills/admin-shell/SKILL.md
|
|
107
|
+
- Admin shell: core owns the `/admin` parent route, the `/admin/login` page, the route guard, the admin banners, the navbar and the user menu. A backend layer registers an auth driver, account menu items, site info and link actions. The admin is for staff only: a signed-in user with no capability goes to `/admin/no-access`. In production, an app with no auth driver (the backend is missing or did not load) sends every admin path to `/admin/not-configured`; in dev the admin opens. The bar shows on `/admin/**` for staff, and on public pages for staff only. A route hides the bar with the page meta `plutoAdminBar: false`, and `isPlutoPreviewPath` marks the preview path. The bar height is the token `--pluto-admin-bar-height`, and `<html>` gets `data-pluto-admin-bar` while the bar shows. A hint cookie puts the bar frame in the server HTML of a public page for staff (the actions and the user menu load after the mount). Content types with `adminBar` get "New <type>" and "Edit <type>" buttons (`usePlutoEditLink`). With two or more types, one "New" menu lists them, grouped by `navGroup`. A `navGroup` also puts related types under one sidebar item (registry bucket `navGroups`, core group `pages`). @.claude/skills/admin-shell/SKILL.md
|
|
105
108
|
- Local layers helper: `plutoLayer(name)` from `@plutocms/core/layers` gives a Nuxt `extends` entry.
|
|
106
109
|
With `PLUTO_LOCAL_ROOT`, it uses the local checkouts of the Pluto layers. @.claude/skills/local-layers/SKILL.md
|
|
107
110
|
- Pluto logo: `PlutoLogo` draws the logo inline. The wordmark takes the text color, so the logo works
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* The pluto layer's CSS. It is not a Tailwind entry.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* `@plutocms/css` (core generates it) imports this file (and the same file of every other
|
|
5
|
+
* Pluto layer). Two builds import `@plutocms/css`: the admin build of core
|
|
6
|
+
* (scoped to the admin), and the app's own CSS entry of a Tailwind site:
|
|
5
7
|
*
|
|
6
8
|
* @import 'tailwindcss';
|
|
7
9
|
* @import '@nuxt/ui';
|
|
8
10
|
* @import '@plutocms/css';
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
* Pluto layer). Rules for this file:
|
|
12
|
+
* Rules for this file:
|
|
12
13
|
* - Do not import tailwindcss or @nuxt/ui here.
|
|
13
14
|
* - Use only @theme, @custom-variant, @utility, unlayered rules, and
|
|
14
15
|
* imports of plain CSS files next to this one.
|
|
@@ -26,12 +27,8 @@
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/*
|
|
29
|
-
* `--pluto-admin-bar-height`
|
|
30
|
-
*
|
|
31
|
-
* that shows the admin navbar to a logged-in admin offsets its own header by
|
|
32
|
-
* the same value. Core sets `data-pluto-admin-bar` on <html> while the bar
|
|
33
|
-
* shows, so a site can offset its header only then:
|
|
34
|
-
* `:root[data-pluto-admin-bar] .site-header { top: var(--pluto-admin-bar-height) }`.
|
|
30
|
+
* `--pluto-admin-bar-height` (the admin navbar height) is in PlutoRoot, in
|
|
31
|
+
* plain CSS, so a site with no Tailwind has it too.
|
|
35
32
|
*
|
|
36
33
|
* `--ui-header-height` stays 2.5rem for existing apps. Set it outside a layer.
|
|
37
34
|
* This overrides Nuxt UI's own `@layer theme` value for the same variable. A
|
|
@@ -39,7 +36,6 @@
|
|
|
39
36
|
* `--ui-header-height`, and the admin keeps `--pluto-admin-bar-height`.
|
|
40
37
|
*/
|
|
41
38
|
:root {
|
|
42
|
-
--pluto-admin-bar-height: 2.5rem;
|
|
43
39
|
--ui-header-height: 2.5rem;
|
|
44
40
|
}
|
|
45
41
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// Decides if the core admin navbar shows. The rule lives in `usePlutoAdminBar`.
|
|
3
|
+
// The navbar is lazy, so its code and the admin CSS load only when it shows.
|
|
3
4
|
const { visible } = usePlutoAdminBar()
|
|
4
5
|
</script>
|
|
5
6
|
|
|
6
7
|
<template>
|
|
7
|
-
<
|
|
8
|
+
<LazyPlutoAdminNavbar v-if="visible" />
|
|
8
9
|
</template>
|
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
PLUTO_ADMIN_UI,
|
|
9
9
|
PLUTO_ADMIN_UI_PROPS,
|
|
10
10
|
} from '../../../composables/pluto-admin-theme'
|
|
11
|
+
// The admin's own Tailwind build, scoped to the admin. Only the async chunks of
|
|
12
|
+
// the admin UI import this component, so a visitor who sees no admin UI does
|
|
13
|
+
// not load the build. See the pluto-css module.
|
|
14
|
+
import '#build/pluto-admin.css'
|
|
11
15
|
</script>
|
|
12
16
|
|
|
13
17
|
<template>
|
|
@@ -114,8 +114,8 @@ useHead({
|
|
|
114
114
|
</script>
|
|
115
115
|
|
|
116
116
|
<template>
|
|
117
|
-
<div class="
|
|
118
|
-
<UApp :locale="uiLocale"
|
|
117
|
+
<div class="pluto-root">
|
|
118
|
+
<UApp :locale="uiLocale">
|
|
119
119
|
<NuxtRouteAnnouncer />
|
|
120
120
|
<NuxtLoadingIndicator />
|
|
121
121
|
|
|
@@ -125,9 +125,9 @@ useHead({
|
|
|
125
125
|
Public pages have no admin sidebar. The admin navbar menu button
|
|
126
126
|
opens this slideover there instead.
|
|
127
127
|
-->
|
|
128
|
-
<
|
|
128
|
+
<LazyPlutoNavbarAdminMenu v-if="!isAdmin && adminBar.visible.value" />
|
|
129
129
|
|
|
130
|
-
<div class="
|
|
130
|
+
<div class="pluto-root-page">
|
|
131
131
|
<NuxtLayout>
|
|
132
132
|
<slot />
|
|
133
133
|
</NuxtLayout>
|
|
@@ -149,6 +149,39 @@ useHead({
|
|
|
149
149
|
flex-direction: column;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/*
|
|
153
|
+
* `--pluto-admin-bar-height` is the height of the admin navbar: 2.5rem (40px)
|
|
154
|
+
* at every width. The admin navbar and the admin layout read it. A public site
|
|
155
|
+
* that shows the bar to a logged-in admin offsets its own header by the same
|
|
156
|
+
* value. Core sets `data-pluto-admin-bar` on <html> while the bar shows, so a
|
|
157
|
+
* site can offset its header only then:
|
|
158
|
+
* `:root[data-pluto-admin-bar] .site-header { top: var(--pluto-admin-bar-height) }`.
|
|
159
|
+
* It is here, in plain CSS, so a site with no Tailwind has it too.
|
|
160
|
+
*/
|
|
161
|
+
:root {
|
|
162
|
+
--pluto-admin-bar-height: 2.5rem;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/*
|
|
166
|
+
* The wrappers grow to fill #__nuxt. Plain CSS, not Tailwind classes: a site
|
|
167
|
+
* may have no Tailwind (the admin has a build of its own).
|
|
168
|
+
*/
|
|
169
|
+
.pluto-root {
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-direction: column;
|
|
172
|
+
flex-grow: 1;
|
|
173
|
+
width: 100%;
|
|
174
|
+
min-width: 0;
|
|
175
|
+
min-height: 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.pluto-root-page {
|
|
179
|
+
flex-grow: 1;
|
|
180
|
+
width: 100%;
|
|
181
|
+
min-width: 0;
|
|
182
|
+
min-height: 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
152
185
|
:root[data-pluto-admin],
|
|
153
186
|
:root[data-pluto-admin] body,
|
|
154
187
|
:root[data-pluto-admin] #__nuxt {
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
export const ADMIN_LOGIN_PATH = '/admin/login'
|
|
10
10
|
export const ADMIN_HOME_PATH = '/admin/home'
|
|
11
11
|
export const ADMIN_NO_ACCESS_PATH = '/admin/no-access'
|
|
12
|
+
export const ADMIN_NOT_CONFIGURED_PATH = '/admin/not-configured'
|
|
12
13
|
|
|
13
14
|
const FAKE_ORIGIN = 'http://pluto.invalid'
|
|
14
15
|
|
|
@@ -111,6 +112,40 @@ function firstString(value: unknown): unknown {
|
|
|
111
112
|
return Array.isArray(value) ? value[0] : value
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
export interface DriverInput {
|
|
116
|
+
path: string
|
|
117
|
+
/** An auth driver is registered. */
|
|
118
|
+
hasDriver: boolean
|
|
119
|
+
/** The app runs in dev (`import.meta.dev`). */
|
|
120
|
+
dev: boolean
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Decides what the route guard does about the auth driver, before the sign-in
|
|
125
|
+
* rules. It returns `undefined` when a driver is registered and the sign-in
|
|
126
|
+
* rules apply. It never touches a path outside `/admin`.
|
|
127
|
+
*
|
|
128
|
+
* - No driver in production: the backend is missing or did not load (both
|
|
129
|
+
* backends register a driver). Every admin path goes to the not-configured
|
|
130
|
+
* page, so the admin pages do not show to everyone.
|
|
131
|
+
* - No driver in dev: allow, so admin pages can be built with no backend.
|
|
132
|
+
* - A driver, on the not-configured page: go to the admin home.
|
|
133
|
+
*/
|
|
134
|
+
export function driverDecision(input: DriverInput): GuardDecision | undefined {
|
|
135
|
+
const { path, hasDriver, dev } = input
|
|
136
|
+
if (!isAdminPath(path)) {
|
|
137
|
+
return { action: 'allow' }
|
|
138
|
+
}
|
|
139
|
+
const onPage = trimSlash(path) === ADMIN_NOT_CONFIGURED_PATH
|
|
140
|
+
if (hasDriver) {
|
|
141
|
+
return onPage ? { action: 'redirect', to: ADMIN_HOME_PATH } : undefined
|
|
142
|
+
}
|
|
143
|
+
if (dev || onPage) {
|
|
144
|
+
return { action: 'allow' }
|
|
145
|
+
}
|
|
146
|
+
return { action: 'redirect', to: ADMIN_NOT_CONFIGURED_PATH }
|
|
147
|
+
}
|
|
148
|
+
|
|
114
149
|
/**
|
|
115
150
|
* Decides what the route guard does. It never touches a path outside `/admin`.
|
|
116
151
|
* A signed-out user on a private admin path goes to the login page, with the
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
+
driverDecision,
|
|
2
3
|
guardDecision,
|
|
3
|
-
isAdminPath,
|
|
4
4
|
matchPublicPath,
|
|
5
5
|
staffDecision,
|
|
6
6
|
} from '../composables/internal/admin-shell'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* The route guard of the admin. It
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* admin path goes to the login page. A
|
|
13
|
-
* goes to the admin home. The driver's own `guard` runs first, so a backend
|
|
9
|
+
* The route guard of the admin. It never touches a path outside `/admin`.
|
|
10
|
+
* With no auth driver, `driverDecision` decides: in production every admin
|
|
11
|
+
* path goes to the not-configured page, in dev the admin opens. With a driver,
|
|
12
|
+
* a signed-out user on a private admin path goes to the login page. A
|
|
13
|
+
* signed-in user on a public auth path goes to the admin home. The driver's own `guard` runs first, so a backend
|
|
14
14
|
* can send the user somewhere else (supabase: the first setup). Then a signed-in
|
|
15
15
|
* user on a private path must be staff (at least one capability, or no
|
|
16
16
|
* permissions driver). Other users go to `/admin/no-access`. The public auth
|
|
@@ -19,8 +19,15 @@ import {
|
|
|
19
19
|
*/
|
|
20
20
|
export default defineNuxtRouteMiddleware(async (to) => {
|
|
21
21
|
const auth = usePlutoAuth()
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
const driver = driverDecision({
|
|
23
|
+
path: to.path,
|
|
24
|
+
hasDriver: auth.hasDriver.value,
|
|
25
|
+
dev: import.meta.dev,
|
|
26
|
+
})
|
|
27
|
+
if (driver) {
|
|
28
|
+
return driver.action === 'redirect'
|
|
29
|
+
? navigateTo(driver.to, { replace: true })
|
|
30
|
+
: undefined
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
try {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The page for an admin with no auth driver in production: the backend is
|
|
3
|
+
// missing or did not load. The route guard sends every admin path here (see
|
|
4
|
+
// `driverDecision`), so the admin pages do not show to everyone. The frame is
|
|
5
|
+
// PlutoAdminAuthCard. See the admin-shell skill.
|
|
6
|
+
definePageMeta({ layout: 'auth' })
|
|
7
|
+
|
|
8
|
+
const { t } = usePlutoI18n()
|
|
9
|
+
|
|
10
|
+
useHead({ title: () => t('pluto.notConfigured.title') })
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<PlutoAdminAuthCard :title="t('pluto.notConfigured.title')">
|
|
15
|
+
<UAlert
|
|
16
|
+
:description="t('pluto.notConfigured.description')"
|
|
17
|
+
color="warning"
|
|
18
|
+
icon="lucide:plug-zap"
|
|
19
|
+
variant="subtle"
|
|
20
|
+
/>
|
|
21
|
+
</PlutoAdminAuthCard>
|
|
22
|
+
</template>
|
package/i18n/locales/en.json
CHANGED
|
@@ -597,6 +597,10 @@
|
|
|
597
597
|
"goToSite": "Go to the site",
|
|
598
598
|
"signOut": "Sign out"
|
|
599
599
|
},
|
|
600
|
+
"notConfigured": {
|
|
601
|
+
"title": "The admin is not set up",
|
|
602
|
+
"description": "This site has no sign-in for the admin. If you run this site, make sure that the server can load its content backend, and check the environment variables of the server."
|
|
603
|
+
},
|
|
600
604
|
"post": {
|
|
601
605
|
"previewTitle": "Post preview",
|
|
602
606
|
"previewBadge": "Preview",
|
package/i18n/locales/pt-BR.json
CHANGED
|
@@ -597,6 +597,10 @@
|
|
|
597
597
|
"goToSite": "Ir para o site",
|
|
598
598
|
"signOut": "Sair"
|
|
599
599
|
},
|
|
600
|
+
"notConfigured": {
|
|
601
|
+
"title": "O painel não está configurado",
|
|
602
|
+
"description": "Este site não tem login para o painel. Se você administra este site, confira se o servidor consegue carregar o backend de conteúdo e verifique as variáveis de ambiente do servidor."
|
|
603
|
+
},
|
|
600
604
|
"post": {
|
|
601
605
|
"previewTitle": "Prévia do post",
|
|
602
606
|
"previewBadge": "Prévia",
|
|
@@ -70,11 +70,14 @@ function cssImport(file: string, buildDir: string): string {
|
|
|
70
70
|
return `@import "${path}";`
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Renders `#build/pluto-admin.css`: the Tailwind build of the admin. The module
|
|
75
|
+
* scopes its output to the admin (see scope.ts), and PlutoAdminTheme imports it.
|
|
76
|
+
*/
|
|
77
|
+
export function renderPlutoAdminCss(): string {
|
|
75
78
|
return [
|
|
76
79
|
'/* Generated by @plutocms/core (modules/pluto-css). Do not edit. */',
|
|
77
|
-
'/*
|
|
80
|
+
'/* The admin build: scoped to [data-pluto-admin] and [data-pluto-admin-scope]. */',
|
|
78
81
|
'@import "tailwindcss";',
|
|
79
82
|
'@import "@nuxt/ui";',
|
|
80
83
|
`@import "${PLUTO_CSS_IMPORT}";`,
|
|
@@ -111,17 +114,12 @@ export interface CssEntryInfo {
|
|
|
111
114
|
file: string
|
|
112
115
|
/** It imports Tailwind (`@import 'tailwindcss'`), so it is a Tailwind build. */
|
|
113
116
|
tailwind: boolean
|
|
114
|
-
/** It imports `@nuxt/ui`. */
|
|
115
|
-
nuxtUi: boolean
|
|
116
|
-
/** It imports `@plutocms/css` (or the generated `#build/pluto.css` itself). */
|
|
117
|
-
plutoCss: boolean
|
|
118
117
|
}
|
|
119
118
|
|
|
120
119
|
/**
|
|
121
120
|
* Follows the local `@import`s of a CSS entry, to `maxDepth`, and reports
|
|
122
|
-
*
|
|
123
|
-
* returns `undefined` for a package import
|
|
124
|
-
* not followed.
|
|
121
|
+
* whether it imports Tailwind. `resolveImport` turns a specifier into a file
|
|
122
|
+
* path, or returns `undefined` for a package import, which is not followed.
|
|
125
123
|
*/
|
|
126
124
|
export function analyzeCssEntry(
|
|
127
125
|
file: string,
|
|
@@ -129,12 +127,7 @@ export function analyzeCssEntry(
|
|
|
129
127
|
resolveImport: (specifier: string, fromFile: string) => string | undefined,
|
|
130
128
|
maxDepth = 3
|
|
131
129
|
): CssEntryInfo {
|
|
132
|
-
const info: CssEntryInfo = {
|
|
133
|
-
file,
|
|
134
|
-
tailwind: false,
|
|
135
|
-
nuxtUi: false,
|
|
136
|
-
plutoCss: false,
|
|
137
|
-
}
|
|
130
|
+
const info: CssEntryInfo = { file, tailwind: false }
|
|
138
131
|
const seen = new Set<string>()
|
|
139
132
|
|
|
140
133
|
function visit(path: string, depth: number) {
|
|
@@ -149,13 +142,6 @@ export function analyzeCssEntry(
|
|
|
149
142
|
for (const specifier of parseCssImports(css)) {
|
|
150
143
|
if (specifier === 'tailwindcss') {
|
|
151
144
|
info.tailwind = true
|
|
152
|
-
} else if (specifier === '@nuxt/ui') {
|
|
153
|
-
info.nuxtUi = true
|
|
154
|
-
} else if (
|
|
155
|
-
specifier === PLUTO_CSS_IMPORT ||
|
|
156
|
-
(/(?:^|\/)pluto\.css$/.test(specifier) && specifier.includes('#build'))
|
|
157
|
-
) {
|
|
158
|
-
info.plutoCss = true
|
|
159
145
|
} else {
|
|
160
146
|
const next = resolveImport(specifier, path)
|
|
161
147
|
if (next) {
|
|
@@ -179,27 +165,18 @@ export function resolveRelativeImport(
|
|
|
179
165
|
: undefined
|
|
180
166
|
}
|
|
181
167
|
|
|
182
|
-
export interface CssSetupReport {
|
|
183
|
-
/** Inject the fallback entry: no CSS entry imports Tailwind. */
|
|
184
|
-
injectFallback: boolean
|
|
185
|
-
warnings: string[]
|
|
186
|
-
}
|
|
187
|
-
|
|
188
168
|
/**
|
|
189
|
-
* Checks the app's CSS
|
|
190
|
-
*
|
|
169
|
+
* Checks the app's CSS entries and returns the warnings. The admin has a build
|
|
170
|
+
* of its own, so the app's entry is for the site only, and an app with no
|
|
171
|
+
* Tailwind entry is fine. A Tailwind build belongs in the app, not in a layer,
|
|
172
|
+
* and there is one. `layerRoots` are the root dirs of every layer except the app.
|
|
191
173
|
*/
|
|
192
174
|
export function checkCssSetup(
|
|
193
175
|
entries: CssEntryInfo[],
|
|
194
176
|
layerRoots: string[]
|
|
195
|
-
):
|
|
177
|
+
): string[] {
|
|
196
178
|
const warnings: string[] = []
|
|
197
179
|
const tailwindEntries = entries.filter((entry) => entry.tailwind)
|
|
198
|
-
const snippet = ` @import 'tailwindcss';\n @import '@nuxt/ui';\n @import '${PLUTO_CSS_IMPORT}';`
|
|
199
|
-
|
|
200
|
-
if (tailwindEntries.length === 0) {
|
|
201
|
-
return { injectFallback: true, warnings }
|
|
202
|
-
}
|
|
203
180
|
|
|
204
181
|
for (const entry of tailwindEntries) {
|
|
205
182
|
const layerRoot = layerRoots.find((root) =>
|
|
@@ -218,17 +195,5 @@ export function checkCssSetup(
|
|
|
218
195
|
)
|
|
219
196
|
}
|
|
220
197
|
|
|
221
|
-
|
|
222
|
-
warnings.push(
|
|
223
|
-
`No CSS entry imports ${PLUTO_CSS_IMPORT}. The admin theme and layer tokens are missing. Start the app's CSS entry with:\n${snippet}`
|
|
224
|
-
)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (!tailwindEntries.some((entry) => entry.nuxtUi)) {
|
|
228
|
-
warnings.push(
|
|
229
|
-
`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}`
|
|
230
|
-
)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return { injectFallback: false, warnings }
|
|
198
|
+
return warnings
|
|
234
199
|
}
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import {
|
|
5
5
|
addTemplate,
|
|
6
|
+
addVitePlugin,
|
|
6
7
|
defineNuxtModule,
|
|
7
8
|
getLayerDirectories,
|
|
8
9
|
resolvePath,
|
|
@@ -14,37 +15,44 @@ import {
|
|
|
14
15
|
findLayerCssFiles,
|
|
15
16
|
PLUTO_CSS_IMPORT,
|
|
16
17
|
PLUTO_SITE_CSS_IMPORT,
|
|
18
|
+
renderPlutoAdminCss,
|
|
17
19
|
renderPlutoCss,
|
|
18
|
-
renderPlutoEntryCss,
|
|
19
20
|
renderPlutoSiteCss,
|
|
20
21
|
resolveRelativeImport,
|
|
21
22
|
} from './css-entries'
|
|
23
|
+
import { scopeCss } from './scope'
|
|
22
24
|
|
|
23
25
|
export interface PlutoCssOptions {
|
|
24
|
-
/** Warn at build time about a
|
|
26
|
+
/** Warn at build time about a Tailwind build in a layer, or more than one. Default true. */
|
|
25
27
|
check?: boolean
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
|
-
*
|
|
31
|
+
* The CSS of Pluto. The admin and the site have separate Tailwind builds:
|
|
30
32
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
33
|
+
* - The admin build `#build/pluto-admin.css` (`tailwindcss`, `@nuxt/ui`,
|
|
34
|
+
* `@plutocms/css`). Tailwind compiles it at the app's build, then the
|
|
35
|
+
* `pluto-css:scope-admin` plugin scopes every rule to the admin
|
|
36
|
+
* (`[data-pluto-admin]`, `[data-pluto-admin-scope]`). PlutoAdminTheme imports
|
|
37
|
+
* it, so it loads only with the admin UI.
|
|
38
|
+
* - The site's build is the app's own CSS entry, if the app has one. A site
|
|
39
|
+
* needs no Tailwind. A Tailwind site imports what its pages use:
|
|
40
|
+
*
|
|
41
|
+
* @import 'tailwindcss';
|
|
42
|
+
* @import '@nuxt/ui';
|
|
43
|
+
* @import '@plutocms/css';
|
|
34
44
|
*
|
|
35
45
|
* - `@plutocms/css` is an alias of the generated `#build/pluto.css`. It imports
|
|
36
|
-
* each layer's app/assets/css/pluto-layer.css (
|
|
37
|
-
*
|
|
38
|
-
*
|
|
46
|
+
* each layer's app/assets/css/pluto-layer.css (the Pluto tokens, the layer
|
|
47
|
+
* tokens). Nuxt UI already adds `@source` for every layer's app/ dir, so this
|
|
48
|
+
* file adds no sources.
|
|
39
49
|
* - `@plutocms/css/site` (opt-in) is an alias of `#build/pluto-site.css`: the
|
|
40
50
|
* Pluto site theme of core (app/assets/css/pluto-site-theme.css), the look of
|
|
41
51
|
* the Pluto docs for a site.
|
|
42
52
|
* - The names are aliases, not npm packages, so they resolve with every package
|
|
43
53
|
* manager, also when the app installs only an outer layer.
|
|
44
|
-
* -
|
|
45
|
-
*
|
|
46
|
-
* - The module warns about a layer that registers its own Tailwind build,
|
|
47
|
-
* more than one build, or a missing import.
|
|
54
|
+
* - The module warns about a layer that registers its own Tailwind build, and
|
|
55
|
+
* about more than one build in the app.
|
|
48
56
|
*
|
|
49
57
|
* See the single-tailwind-build skill.
|
|
50
58
|
*/
|
|
@@ -88,6 +96,66 @@ export default defineNuxtModule<PlutoCssOptions>({
|
|
|
88
96
|
'pluto.css'
|
|
89
97
|
)
|
|
90
98
|
|
|
99
|
+
// The admin build. Not written to disk: on disk, Tailwind IntelliSense
|
|
100
|
+
// could take this entry for the app's design system. Not in `css`:
|
|
101
|
+
// PlutoAdminTheme imports it, so it loads with the admin UI only.
|
|
102
|
+
addTemplate({
|
|
103
|
+
filename: 'pluto-admin.css',
|
|
104
|
+
getContents: renderPlutoAdminCss,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Server HTML links the admin build, not inlines it: inline, every page
|
|
108
|
+
// view of a staff user would carry the full build, which no cache keeps.
|
|
109
|
+
const inline = nuxt.options.features.inlineStyles
|
|
110
|
+
if (inline) {
|
|
111
|
+
nuxt.options.features.inlineStyles = (id) =>
|
|
112
|
+
!id?.includes('PlutoAdminTheme.vue') && (inline === true || inline(id))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Nuxt prefetches the CSS of every async chunk, so each visitor would
|
|
116
|
+
// download the admin build after the page loads. The plugin below finds
|
|
117
|
+
// the CSS files of the client chunk with the build, and this hook turns
|
|
118
|
+
// off their prefetch. The chunks that render admin UI still load them.
|
|
119
|
+
// The file names, without the assets dir: the manifest keys have none.
|
|
120
|
+
const adminCssFiles = new Set<string>()
|
|
121
|
+
nuxt.hook('build:manifest', (manifest) => {
|
|
122
|
+
for (const file of adminCssFiles) {
|
|
123
|
+
if (manifest[file]) {
|
|
124
|
+
manifest[file].prefetch = false
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// A file path, or a Nuxt virtual id (`virtual:nuxt:…%2Fpluto-admin.css`).
|
|
130
|
+
const adminCssId = /(?:[\\/]|%2F)pluto-admin\.css(?:\?|$)/
|
|
131
|
+
addVitePlugin({
|
|
132
|
+
name: 'pluto-css:scope-admin',
|
|
133
|
+
transform(code, id) {
|
|
134
|
+
if (adminCssId.test(id)) {
|
|
135
|
+
return { code: scopeCss(code), map: null }
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
generateBundle(_options, bundle) {
|
|
139
|
+
if (this.environment?.name !== 'client') {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
for (const chunk of Object.values(bundle)) {
|
|
143
|
+
if (
|
|
144
|
+
chunk.type === 'chunk' &&
|
|
145
|
+
chunk.moduleIds.some((id) => adminCssId.test(id))
|
|
146
|
+
) {
|
|
147
|
+
for (const file of chunk.viteMetadata?.importedCss ?? []) {
|
|
148
|
+
adminCssFiles.add(file.slice(file.lastIndexOf('/') + 1))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
if (!options.check || nuxt.options._prepare) {
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
91
159
|
const entries = []
|
|
92
160
|
for (const entry of nuxt.options.css) {
|
|
93
161
|
const file =
|
|
@@ -112,29 +180,8 @@ export default defineNuxtModule<PlutoCssOptions>({
|
|
|
112
180
|
const layerRoots = layers
|
|
113
181
|
.map((layer) => layer.root.replaceAll('\\', '/'))
|
|
114
182
|
.filter((root) => root !== appRoot)
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
// Only an app with no Tailwind entry gets the fallback file. In an app with
|
|
118
|
-
// its own entry, the file would be a second Tailwind entry on disk, and
|
|
119
|
-
// Tailwind IntelliSense could take it for the app's design system.
|
|
120
|
-
if (report.injectFallback) {
|
|
121
|
-
addTemplate({
|
|
122
|
-
filename: 'pluto-entry.css',
|
|
123
|
-
write: true,
|
|
124
|
-
getContents: renderPlutoEntryCss,
|
|
125
|
-
})
|
|
126
|
-
nuxt.options.css.push('#build/pluto-entry.css')
|
|
127
|
-
if (!nuxt.options._prepare) {
|
|
128
|
-
logger.info(
|
|
129
|
-
'No CSS entry imports Tailwind. Using the Pluto fallback entry (#build/pluto-entry.css).'
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (options.check && !nuxt.options._prepare) {
|
|
135
|
-
for (const warning of report.warnings) {
|
|
136
|
-
logger.warn(warning)
|
|
137
|
-
}
|
|
183
|
+
for (const warning of checkCssSetup(entries, layerRoots)) {
|
|
184
|
+
logger.warn(warning)
|
|
138
185
|
}
|
|
139
186
|
},
|
|
140
187
|
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Scopes a compiled stylesheet to the Pluto admin: every rule matches only
|
|
2
|
+
// inside a scope root. Pure (no `nuxt/kit`), so the unit tests call it directly.
|
|
3
|
+
// See the single-tailwind-build skill, "The admin build".
|
|
4
|
+
import type { AtRule, Rule } from 'postcss'
|
|
5
|
+
import postcss from 'postcss'
|
|
6
|
+
import selectorParser from 'postcss-selector-parser'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The elements that start the admin scope: <html> on an admin page
|
|
10
|
+
* (`data-pluto-admin`), and the admin bar and its portals on a public page
|
|
11
|
+
* (`data-pluto-admin-scope`).
|
|
12
|
+
*/
|
|
13
|
+
export const PLUTO_ADMIN_SCOPE_ROOTS = [
|
|
14
|
+
'[data-pluto-admin]',
|
|
15
|
+
'[data-pluto-admin-scope]',
|
|
16
|
+
] as const
|
|
17
|
+
|
|
18
|
+
/** Selectors that mean the document root. They become the scope roots. */
|
|
19
|
+
const ROOT_SELECTORS = new Set([':root', ':host', 'html'])
|
|
20
|
+
|
|
21
|
+
/** A pseudo-element: the scope goes before it. */
|
|
22
|
+
const PSEUDO_ELEMENT = /^(?:::|:(?:before|after|first-line|first-letter)$)/
|
|
23
|
+
|
|
24
|
+
/** Scopes each selector of a selector list. */
|
|
25
|
+
function scopeSelectorList(selector: string, roots: readonly string[]): string {
|
|
26
|
+
// An element in the scope: a root itself, or anything inside a root.
|
|
27
|
+
const inScope = `:is(${[...roots, ...roots.map((root) => `${root} *`)].join(', ')})`
|
|
28
|
+
const rootNames = roots.map((root) => root.slice(1, -1))
|
|
29
|
+
const out: string[] = []
|
|
30
|
+
|
|
31
|
+
selectorParser((list) => {
|
|
32
|
+
list.each((sel) => {
|
|
33
|
+
const text = sel.toString().trim()
|
|
34
|
+
if (rootNames.some((name) => text.includes(name))) {
|
|
35
|
+
out.push(text)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
if (ROOT_SELECTORS.has(text)) {
|
|
39
|
+
// A doubled root, (0,2,0): on <html> of an admin page it beats the
|
|
40
|
+
// app's own `:root` (0,1,0), whatever order the two files load in.
|
|
41
|
+
out.push(...roots.map((root) => `${root}${root}`))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
// The subject is the last compound: `.a .b::before` becomes
|
|
45
|
+
// `.a .b:is(…)::before`.
|
|
46
|
+
const nodes = sel.nodes
|
|
47
|
+
let start = 0
|
|
48
|
+
nodes.forEach((node, index) => {
|
|
49
|
+
if (node.type === 'combinator') {
|
|
50
|
+
start = index + 1
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
const pseudoElement = nodes.findIndex(
|
|
54
|
+
(node, index) =>
|
|
55
|
+
index >= start &&
|
|
56
|
+
node.type === 'pseudo' &&
|
|
57
|
+
PSEUDO_ELEMENT.test(node.value)
|
|
58
|
+
)
|
|
59
|
+
const scope = selectorParser.pseudo({ value: inScope })
|
|
60
|
+
if (pseudoElement === -1) {
|
|
61
|
+
sel.append(scope)
|
|
62
|
+
} else {
|
|
63
|
+
// The space before the pseudo-element (`, ::after`) moves to the
|
|
64
|
+
// scope. Left in place, it would be a descendant combinator.
|
|
65
|
+
const target = nodes[pseudoElement]!
|
|
66
|
+
scope.spaces.before = target.spaces.before
|
|
67
|
+
target.spaces.before = ''
|
|
68
|
+
sel.insertBefore(target, scope)
|
|
69
|
+
}
|
|
70
|
+
out.push(sel.toString().trim())
|
|
71
|
+
})
|
|
72
|
+
}).processSync(selector)
|
|
73
|
+
|
|
74
|
+
return out.join(', ')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when the rule is in `@keyframes`, or nested in another rule. */
|
|
78
|
+
function keepAsIs(rule: Rule): boolean {
|
|
79
|
+
let parent = rule.parent
|
|
80
|
+
while (parent && parent.type !== 'root') {
|
|
81
|
+
if (parent.type === 'rule') {
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
parent.type === 'atrule' &&
|
|
86
|
+
/keyframes$/i.test((parent as AtRule).name)
|
|
87
|
+
) {
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
parent = parent.parent
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Returns `css` with every top-level rule scoped to `roots`:
|
|
97
|
+
*
|
|
98
|
+
* - `:root`, `:host`, and `html` become the roots, doubled for (0,2,0), so the
|
|
99
|
+
* theme variables and the base of the document apply from each root down,
|
|
100
|
+
* over the app's own `:root` rules.
|
|
101
|
+
* - Any other selector gets `:is(<root>, <root> *)` on its subject. It then
|
|
102
|
+
* matches only in the scope, with (0,1,0) more specificity.
|
|
103
|
+
* - A selector that names a root already stays.
|
|
104
|
+
* - Rules nested in rules (they follow their parent) and `@keyframes` stay.
|
|
105
|
+
*/
|
|
106
|
+
export function scopeCss(
|
|
107
|
+
css: string,
|
|
108
|
+
roots: readonly string[] = PLUTO_ADMIN_SCOPE_ROOTS
|
|
109
|
+
): string {
|
|
110
|
+
const ast = postcss.parse(css)
|
|
111
|
+
ast.walkRules((rule) => {
|
|
112
|
+
if (!keepAsIs(rule)) {
|
|
113
|
+
rule.selector = scopeSelectorList(rule.selector, roots)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
return ast.toString()
|
|
117
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -41,9 +41,10 @@ export default defineNuxtConfig({
|
|
|
41
41
|
},
|
|
42
42
|
},
|
|
43
43
|
|
|
44
|
-
// No `css` entry:
|
|
45
|
-
//
|
|
46
|
-
// @plutocms/css (#build/pluto.css). See the
|
|
44
|
+
// No `css` entry: modules/pluto-css gives the admin a Tailwind build of its
|
|
45
|
+
// own, and the site's build is the app's CSS entry. Both get this layer's
|
|
46
|
+
// pluto-layer.css through @plutocms/css (#build/pluto.css). See the
|
|
47
|
+
// single-tailwind-build skill.
|
|
47
48
|
|
|
48
49
|
runtimeConfig: {
|
|
49
50
|
// Private mail settings. Empty defaults on purpose: Nuxt reads
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/core",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.46.0",
|
|
5
5
|
"description": "The base layer every Pluto app extends: the admin, the content model, and the Git CMS",
|
|
6
6
|
"author": "Victor Ribeiro",
|
|
7
7
|
"license": "MIT",
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
"@vueuse/nuxt": "^15.0.0",
|
|
48
48
|
"image-meta": "^0.3.0",
|
|
49
49
|
"marked": "^18.1.1",
|
|
50
|
+
"postcss": "^8.5.30",
|
|
51
|
+
"postcss-selector-parser": "^7.1.4",
|
|
50
52
|
"reka-ui": "^2.11.0",
|
|
51
53
|
"tailwind-merge": "^3.7.0",
|
|
52
54
|
"ultrahtml": "^1.7.0",
|