@plutocms/core 0.45.0 → 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 CHANGED
@@ -1,6 +1,17 @@
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
+
4
15
  ## [0.45.0](https://github.com/plutocms/pluto/compare/v0.44.1...v0.45.0) (2026-11-01)
5
16
 
6
17
 
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
- - Single Tailwind build: the app owns the only Tailwind build. The pluto-css module generates
70
- `#build/pluto.css` from each layer's `pluto-layer.css` (the app imports it as `@plutocms/css`), adds a fallback entry, and warns
71
- about a wrong CSS setup. @.claude/skills/single-tailwind-build/SKILL.md
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
@@ -1,14 +1,15 @@
1
1
  /*
2
2
  * The pluto layer's CSS. It is not a Tailwind entry.
3
3
  *
4
- * The app's CSS entry owns the one Tailwind build of the app:
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
- * `@plutocms/css` (core generates it) imports this file (and the same file of every other
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` is the height of the admin navbar: 2.5rem (40px)
30
- * at every width. The admin navbar and the admin layout read it. A public site
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
- <PlutoAdminNavbar v-if="visible" />
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="flex min-h-0 w-full min-w-0 grow flex-col">
118
- <UApp :locale="uiLocale" class="flex min-h-0 w-full min-w-0 grow flex-col">
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
- <PlutoNavbarAdminMenu v-if="!isAdmin && adminBar.visible.value" />
128
+ <LazyPlutoNavbarAdminMenu v-if="!isAdmin && adminBar.visible.value" />
129
129
 
130
- <div class="min-h-0 w-full min-w-0 grow">
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 {
@@ -70,11 +70,14 @@ function cssImport(file: string, buildDir: string): string {
70
70
  return `@import "${path}";`
71
71
  }
72
72
 
73
- /** The fallback entry, used only when the app has no Tailwind entry. */
74
- export function renderPlutoEntryCss(): string {
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
- '/* Fallback Tailwind entry: the app has no CSS entry that imports Tailwind. */',
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
- * what it imports. `resolveImport` turns a specifier into a file path, or
123
- * returns `undefined` for a package import (such as `tailwindcss`), which is
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 setup against the single-build rules and returns
190
- * what to do. `layerRoots` are the root dirs of every layer except the app.
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
- ): CssSetupReport {
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
- if (!tailwindEntries.some((entry) => entry.plutoCss)) {
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 CSS setup that breaks the single build. Default true. */
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
- * One Tailwind build per app. The app's CSS entry owns the build:
31
+ * The CSS of Pluto. The admin and the site have separate Tailwind builds:
30
32
  *
31
- * @import 'tailwindcss';
32
- * @import '@nuxt/ui';
33
- * @import '@plutocms/css';
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 (tokens, the admin theme), so one
37
- * import covers every layer of the app. Nuxt UI already adds `@source` for
38
- * every layer's app/ dir, so this file adds no sources.
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
- * - An app with no Tailwind entry (a layer repo run on its own, or an app
45
- * with no CSS) gets the fallback entry `#build/pluto-entry.css`.
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 report = checkCssSetup(entries, layerRoots)
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: the app's CSS entry owns the one Tailwind build, and
45
- // modules/pluto-css adds this layer's pluto-layer.css to it through
46
- // @plutocms/css (#build/pluto.css). See the single-tailwind-build skill.
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.45.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",