@plutocms/core 0.45.0 → 0.47.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,33 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.47.0](https://github.com/plutocms/pluto/compare/v0.46.0...v0.47.0) (2026-11-02)
5
+
6
+
7
+ ### ⚠ BREAKING CHANGES
8
+
9
+ * PlutoHtml has no Tailwind prose classes. A site imports `@plutocms/css/base` for the prose look. A site that adds its own page offset for the admin bar removes it, or the page moves down twice.
10
+
11
+ ### Features
12
+
13
+ * the public components use plain CSS, with an optional @plutocms/css/base ([#353](https://github.com/plutocms/pluto/issues/353)) ([d0fa541](https://github.com/plutocms/pluto/commit/d0fa541b34a672370ebf2d95372a967a1aebde10))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * the admin has its styles in the first frame of a client navigation ([#354](https://github.com/plutocms/pluto/issues/354)) ([f7a4226](https://github.com/plutocms/pluto/commit/f7a4226966e73bf2a91ae599303c35a8daac61a2))
19
+
20
+ ## [0.46.0](https://github.com/plutocms/pluto/compare/v0.45.0...v0.46.0) (2026-11-02)
21
+
22
+
23
+ ### ⚠ BREAKING CHANGES
24
+
25
+ * 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.
26
+
27
+ ### Features
28
+
29
+ * 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))
30
+
4
31
  ## [0.45.0](https://github.com/plutocms/pluto/compare/v0.44.1...v0.45.0) (2026-11-01)
5
32
 
6
33
 
package/FEATURES.md CHANGED
@@ -66,9 +66,16 @@ 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
75
+ - Public components: the components for a public site (`PlutoHtml`, `PlutoImage`, `PlutoLogo`,
76
+ `PlutoRoot`, and those of the layers) use only `pluto-*` classes and work with no Tailwind. Their
77
+ look is plain CSS in each layer's `pluto-base.css`, which a site imports with the opt-in
78
+ `@import '@plutocms/css/base'`. @.claude/skills/public-components/SKILL.md
72
79
  - Git CMS: a standalone CMS with Markdown posts in a Git repo. Out of the box it publishes to the
73
80
  local Git repo (dev only: each save is a local commit, `commit.local`); a GitHub App publishes
74
81
  from a deployed site. The module is `pluto-github` (config
@@ -0,0 +1,228 @@
1
+ /*
2
+ * The look of the public components of core. Opt-in: a site imports it with
3
+ * `@import '@plutocms/css/base'`, with or without Tailwind. Plain CSS only:
4
+ * no Tailwind directive. See the public-components skill.
5
+ *
6
+ * Every selector is in `:where()` (zero specificity) and in the `components`
7
+ * layer. On a Tailwind site, the file joins Tailwind's own layer, so a utility
8
+ * wins. On a site with no Tailwind, any unlayered rule of the site wins.
9
+ *
10
+ * Colors read the Nuxt UI variables, with plain fallbacks: a Nuxt UI site
11
+ * gets its own theme. A site can also set the `--pluto-prose-*` variables.
12
+ */
13
+ @layer theme, base, components, utilities;
14
+
15
+ @layer components {
16
+ /* PlutoHtml, PlutoMarkdown, and the PlutoProse of the blog layer. */
17
+ :where(.pluto-prose) {
18
+ --pluto-prose-heading: var(--ui-text-highlighted, currentColor);
19
+ --pluto-prose-muted: var(
20
+ --ui-text-muted,
21
+ color-mix(in oklab, currentColor 65%, transparent)
22
+ );
23
+ --pluto-prose-link: var(--ui-primary, LinkText);
24
+ --pluto-prose-border: var(
25
+ --ui-border,
26
+ color-mix(in oklab, currentColor 15%, transparent)
27
+ );
28
+ --pluto-prose-border-accented: var(
29
+ --ui-border-accented,
30
+ color-mix(in oklab, currentColor 30%, transparent)
31
+ );
32
+ --pluto-prose-code-bg: var(
33
+ --ui-bg-muted,
34
+ color-mix(in oklab, currentColor 6%, transparent)
35
+ );
36
+ --pluto-prose-th-bg: var(
37
+ --ui-bg-elevated,
38
+ color-mix(in oklab, currentColor 4%, transparent)
39
+ );
40
+ --pluto-prose-radius: calc(var(--ui-radius, 0.25rem) * 1.5);
41
+ --pluto-prose-mono: var(
42
+ --font-mono,
43
+ ui-monospace,
44
+ SFMono-Regular,
45
+ Menlo,
46
+ Consolas,
47
+ monospace
48
+ );
49
+
50
+ width: 100%;
51
+ overflow-wrap: break-word;
52
+ }
53
+
54
+ :where(.pluto-prose) ::selection {
55
+ background-color: color-mix(
56
+ in oklab,
57
+ var(--pluto-prose-link) 20%,
58
+ transparent
59
+ );
60
+ }
61
+
62
+ :where(.pluto-prose) > :where(*) {
63
+ margin-block: 1.25rem;
64
+ }
65
+
66
+ :where(.pluto-prose) > :where(:first-child) {
67
+ margin-block-start: 0;
68
+ }
69
+
70
+ :where(.pluto-prose) > :where(:last-child) {
71
+ margin-block-end: 0;
72
+ }
73
+
74
+ :where(.pluto-prose) :where(p) {
75
+ line-height: 1.75rem;
76
+ }
77
+
78
+ :where(.pluto-prose) :where(a) {
79
+ color: var(--pluto-prose-link);
80
+ font-weight: 500;
81
+ text-decoration: none;
82
+ border-block-end: 1px solid transparent;
83
+ transition: border-color 0.15s;
84
+ }
85
+
86
+ :where(.pluto-prose) :where(a:hover) {
87
+ border-block-end-color: var(--pluto-prose-link);
88
+ }
89
+
90
+ :where(.pluto-prose) :where(a:focus-visible) {
91
+ border-block-end-color: transparent;
92
+ border-radius: 0.125rem;
93
+ outline: 3px solid
94
+ color-mix(in oklab, var(--pluto-prose-link) 25%, transparent);
95
+ }
96
+
97
+ :where(.pluto-prose) :where(h1, h2, h3, h4, h5, h6) {
98
+ color: var(--pluto-prose-heading);
99
+ font-weight: 700;
100
+ }
101
+
102
+ :where(.pluto-prose) :where(h1) {
103
+ font-size: 1.875rem;
104
+ line-height: 2.25rem;
105
+ }
106
+
107
+ :where(.pluto-prose) :where(h2) {
108
+ font-size: 1.5rem;
109
+ line-height: 2rem;
110
+ }
111
+
112
+ :where(.pluto-prose) :where(h3) {
113
+ font-size: 1.25rem;
114
+ line-height: 1.75rem;
115
+ }
116
+
117
+ :where(.pluto-prose) :where(h4) {
118
+ font-size: 1.125rem;
119
+ line-height: 1.75rem;
120
+ }
121
+
122
+ :where(.pluto-prose) :where(h5, h6) {
123
+ font-size: 1rem;
124
+ line-height: 1.5rem;
125
+ }
126
+
127
+ :where(.pluto-prose) :where(blockquote) {
128
+ margin-inline: 0;
129
+ padding-inline-start: 1rem;
130
+ border-inline-start: 4px solid var(--pluto-prose-border-accented);
131
+ font-style: italic;
132
+ }
133
+
134
+ :where(.pluto-prose) :where(hr) {
135
+ border: 0;
136
+ border-block-start: 1px solid var(--pluto-prose-border);
137
+ }
138
+
139
+ :where(.pluto-prose) :where(code) {
140
+ display: inline-block;
141
+ padding: 0.125rem 0.375rem;
142
+ border: 1px solid var(--pluto-prose-border);
143
+ border-radius: var(--pluto-prose-radius);
144
+ background-color: var(--pluto-prose-code-bg);
145
+ color: var(--pluto-prose-heading);
146
+ font-family: var(--pluto-prose-mono);
147
+ font-size: 0.875rem;
148
+ font-weight: 500;
149
+ }
150
+
151
+ :where(.pluto-prose) :where(pre) {
152
+ overflow-x: auto;
153
+ padding: 0.75rem 1rem;
154
+ border: 1px solid var(--pluto-prose-border);
155
+ border-radius: var(--pluto-prose-radius);
156
+ background-color: var(--pluto-prose-code-bg);
157
+ font-family: var(--pluto-prose-mono);
158
+ font-size: 0.875rem;
159
+ line-height: 1.5rem;
160
+ white-space: pre-wrap;
161
+ }
162
+
163
+ :where(.pluto-prose) :where(pre code) {
164
+ display: inline;
165
+ padding: 0;
166
+ border: 0;
167
+ border-radius: 0;
168
+ background-color: transparent;
169
+ color: inherit;
170
+ font-size: inherit;
171
+ }
172
+
173
+ :where(.pluto-prose) :where(ul, ol) {
174
+ padding-inline-start: 1.5rem;
175
+ }
176
+
177
+ :where(.pluto-prose) :where(ul) {
178
+ list-style-type: disc;
179
+ }
180
+
181
+ :where(.pluto-prose) :where(ol) {
182
+ list-style-type: decimal;
183
+ }
184
+
185
+ :where(.pluto-prose) :where(ul > li)::marker {
186
+ color: var(--pluto-prose-border-accented);
187
+ }
188
+
189
+ :where(.pluto-prose) :where(ol > li)::marker {
190
+ color: var(--pluto-prose-muted);
191
+ }
192
+
193
+ :where(.pluto-prose) :where(li) {
194
+ margin-block: 0.375rem;
195
+ padding-inline-start: 0.375rem;
196
+ }
197
+
198
+ :where(.pluto-prose) :where(img) {
199
+ display: block;
200
+ max-width: 100%;
201
+ height: auto;
202
+ border-radius: var(--pluto-prose-radius);
203
+ }
204
+
205
+ :where(.pluto-prose) :where(table) {
206
+ display: block;
207
+ overflow-x: auto;
208
+ width: 100%;
209
+ border-collapse: collapse;
210
+ font-size: 0.875rem;
211
+ }
212
+
213
+ :where(.pluto-prose) :where(th, td) {
214
+ padding: 0.5rem 0.75rem;
215
+ border: 1px solid var(--pluto-prose-border);
216
+ }
217
+
218
+ :where(.pluto-prose) :where(th) {
219
+ background-color: var(--pluto-prose-th-bg);
220
+ color: var(--pluto-prose-heading);
221
+ font-weight: 600;
222
+ text-align: start;
223
+ }
224
+
225
+ :where(.pluto-prose) :where(input[type='checkbox']) {
226
+ margin-inline-end: 0.5rem;
227
+ }
228
+ }
@@ -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>
@@ -1,36 +1,21 @@
1
1
  <script setup lang="ts">
2
- import { tv } from '@nuxt/ui/utils/tv'
3
- import theme from '#build/ui/editor'
4
-
5
2
  // Shows the `html` of a public item (the public API of Pluto), with readable prose styles. It
6
3
  // works with every backend: the Git CMS and `@plutocms/supabase-blog` both send the body as
7
4
  // sanitized HTML, so the string is safe to set as `innerHTML`. This component never parses or
8
5
  // sanitizes. Do not pass any other HTML to it.
9
- const props = withDefaults(
6
+ //
7
+ // The prose look is `.pluto-prose` in app/assets/css/pluto-base.css: plain CSS that a site
8
+ // imports with `@plutocms/css/base`. No Tailwind class here, so the component works on a site
9
+ // with no Tailwind. See the public-components skill.
10
+ withDefaults(
10
11
  defineProps<{
11
12
  html: string
12
13
  tag?: string
13
- class?: any
14
14
  }>(),
15
15
  {
16
16
  tag: 'article',
17
- class: undefined,
18
17
  }
19
18
  )
20
-
21
- // The base of the Nuxt UI editor theme gives readable prose with semantic
22
- // color tokens. The editor adds side padding, so `sm:px-0` removes it.
23
- // The table classes are here because the editor theme has none.
24
- const ui = computed(() => tv({ extend: theme })())
25
-
26
- const classes = computed(() => [
27
- ui.value.base({ class: 'sm:px-0' }),
28
- '[&_table]:block [&_table]:w-full [&_table]:overflow-x-auto [&_table]:text-sm',
29
- '[&_:is(th,td)]:border [&_:is(th,td)]:border-default [&_:is(th,td)]:px-3 [&_:is(th,td)]:py-2',
30
- '[&_th]:bg-elevated [&_th]:text-highlighted [&_th]:text-start [&_th]:font-semibold',
31
- '[&_input[type=checkbox]]:me-2',
32
- props.class,
33
- ])
34
19
  </script>
35
20
 
36
21
  <template>
@@ -38,5 +23,5 @@ const classes = computed(() => [
38
23
  on a dynamic `<component :is>` drops the value in the SSR output. The
39
24
  hyphenated spelling renders a literal `inner-h-t-m-l` attribute. -->
40
25
  <!-- eslint-disable-next-line vue/attribute-hyphenation -- must stay camelCase, see comment above -->
41
- <component :is="tag" v-if="html" :class="classes" :innerHTML="html" />
26
+ <component :is="tag" v-if="html" :innerHTML="html" class="pluto-prose" />
42
27
  </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,58 @@ 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 bar is fixed at the top, so a public page moves down by its height while
167
+ * it shows: the top of the page is never under the bar. A sticky or fixed
168
+ * header of the site still needs `top: var(--pluto-admin-bar-height)`. The
169
+ * admin pages (`data-pluto-admin`) have their own layout. Zero specificity
170
+ * (`:where`), so a rule of the site wins.
171
+ */
172
+ :where(:root[data-pluto-admin-bar]:not([data-pluto-admin])) body {
173
+ padding-top: var(--pluto-admin-bar-height);
174
+ }
175
+
176
+ :where(:root[data-pluto-admin-bar]:not([data-pluto-admin])) {
177
+ scroll-padding-top: var(--pluto-admin-bar-height);
178
+ }
179
+
180
+ :where(:root[data-pluto-admin-bar]:not([data-pluto-admin])) #__nuxt {
181
+ min-height: calc(100dvh - var(--pluto-admin-bar-height));
182
+ }
183
+
184
+ /*
185
+ * The wrappers grow to fill #__nuxt. Plain CSS, not Tailwind classes: a site
186
+ * may have no Tailwind (the admin has a build of its own).
187
+ */
188
+ .pluto-root {
189
+ display: flex;
190
+ flex-direction: column;
191
+ flex-grow: 1;
192
+ width: 100%;
193
+ min-width: 0;
194
+ min-height: 0;
195
+ }
196
+
197
+ .pluto-root-page {
198
+ flex-grow: 1;
199
+ width: 100%;
200
+ min-width: 0;
201
+ min-height: 0;
202
+ }
203
+
152
204
  :root[data-pluto-admin],
153
205
  :root[data-pluto-admin] body,
154
206
  :root[data-pluto-admin] #__nuxt {
@@ -8,9 +8,30 @@
8
8
  *
9
9
  * Call it from every admin layout. `useHead` removes the attribute when the
10
10
  * layout unmounts, so the public site never gets it.
11
+ *
12
+ * `useHead` puts the attribute in the server HTML, but in the browser it
13
+ * writes <html> after the render, a frame late. The admin build is scoped to
14
+ * the attribute, so a client navigation from the public site showed one frame
15
+ * of the admin with no styles. `onMounted` runs in the same task as the DOM
16
+ * change, before the paint, so the attribute is set there too. A count keeps
17
+ * it while another admin layout is mounted.
11
18
  */
19
+ let mountedAdminLayouts = 0
20
+
12
21
  export function usePlutoAdminTheme() {
13
22
  useHead({ htmlAttrs: { 'data-pluto-admin': '' } })
23
+ if (import.meta.client) {
24
+ onMounted(() => {
25
+ mountedAdminLayouts++
26
+ document.documentElement.setAttribute('data-pluto-admin', '')
27
+ })
28
+ onUnmounted(() => {
29
+ mountedAdminLayouts--
30
+ if (mountedAdminLayouts === 0) {
31
+ document.documentElement.removeAttribute('data-pluto-admin')
32
+ }
33
+ })
34
+ }
14
35
  }
15
36
 
16
37
  /**
@@ -11,24 +11,30 @@ export const PLUTO_CSS_IMPORT = '@plutocms/css'
11
11
  /** The opt-in Pluto site theme: an alias of `#build/pluto-site.css`. */
12
12
  export const PLUTO_SITE_CSS_IMPORT = '@plutocms/css/site'
13
13
 
14
+ /** A layer's plain CSS for its public components. See the public-components skill. */
15
+ export const LAYER_BASE_CSS_FILE = 'assets/css/pluto-base.css'
16
+
17
+ /** The opt-in look of the public components: an alias of `#build/pluto-base.css`. */
18
+ export const PLUTO_BASE_CSS_IMPORT = '@plutocms/css/base'
19
+
14
20
  function toPosix(path: string): string {
15
21
  return path.replaceAll('\\', '/')
16
22
  }
17
23
 
18
24
  /**
19
- * Returns the `pluto-layer.css` file of every layer that has one, base
20
- * layer first. Nuxt lists the layers top first (the app, then each layer it
21
- * extends), so the list is reversed: a higher layer's `@theme` then comes
22
- * later in the build and wins.
25
+ * Returns the `file` of every layer that has one, base layer first. Nuxt
26
+ * lists the layers top first (the app, then each layer it extends), so the
27
+ * list is reversed: a higher layer's rules then come later and win.
23
28
  */
24
29
  export function findLayerCssFiles(
25
30
  layerAppDirs: string[],
26
- exists: (path: string) => boolean
31
+ exists: (path: string) => boolean,
32
+ file = LAYER_CSS_FILE
27
33
  ): string[] {
28
34
  return [...layerAppDirs]
29
35
  .reverse()
30
- .map((appDir) => toPosix(join(appDir, LAYER_CSS_FILE)))
31
- .filter((file) => exists(file))
36
+ .map((appDir) => toPosix(join(appDir, file)))
37
+ .filter((path) => exists(path))
32
38
  }
33
39
 
34
40
  /**
@@ -37,9 +43,26 @@ export function findLayerCssFiles(
37
43
  * another drive (Windows) stays absolute.
38
44
  */
39
45
  export function renderPlutoCss(files: string[], buildDir: string): string {
46
+ return renderLayerImports(files, buildDir, LAYER_CSS_FILE)
47
+ }
48
+
49
+ /**
50
+ * Renders `#build/pluto-base.css`: one `@import` for each layer's
51
+ * `pluto-base.css`. The plain CSS of the public components: an app imports
52
+ * it (`@plutocms/css/base`) with or without Tailwind.
53
+ */
54
+ export function renderPlutoBaseCss(files: string[], buildDir: string): string {
55
+ return renderLayerImports(files, buildDir, LAYER_BASE_CSS_FILE)
56
+ }
57
+
58
+ function renderLayerImports(
59
+ files: string[],
60
+ buildDir: string,
61
+ name: string
62
+ ): string {
40
63
  return [
41
64
  '/* Generated by @plutocms/core (modules/pluto-css). Do not edit. */',
42
- "/* Each Pluto layer's app/assets/css/pluto-layer.css, base layer first. */",
65
+ `/* Each Pluto layer's app/${name}, base layer first. */`,
43
66
  ...files.map((file) => cssImport(file, buildDir)),
44
67
  '',
45
68
  ].join('\n')
@@ -70,14 +93,19 @@ function cssImport(file: string, buildDir: string): string {
70
93
  return `@import "${path}";`
71
94
  }
72
95
 
73
- /** The fallback entry, used only when the app has no Tailwind entry. */
74
- export function renderPlutoEntryCss(): string {
96
+ /**
97
+ * Renders `#build/pluto-admin.css`: the Tailwind build of the admin. The module
98
+ * scopes its output to the admin (see scope.ts), and PlutoAdminTheme imports it.
99
+ */
100
+ export function renderPlutoAdminCss(): string {
75
101
  return [
76
102
  '/* Generated by @plutocms/core (modules/pluto-css). Do not edit. */',
77
- '/* Fallback Tailwind entry: the app has no CSS entry that imports Tailwind. */',
103
+ '/* The admin build: scoped to [data-pluto-admin] and [data-pluto-admin-scope]. */',
78
104
  '@import "tailwindcss";',
79
105
  '@import "@nuxt/ui";',
80
106
  `@import "${PLUTO_CSS_IMPORT}";`,
107
+ // The admin shows public components too: the post preview, the 3D preview.
108
+ `@import "${PLUTO_BASE_CSS_IMPORT}";`,
81
109
  '',
82
110
  ].join('\n')
83
111
  }
@@ -111,17 +139,12 @@ export interface CssEntryInfo {
111
139
  file: string
112
140
  /** It imports Tailwind (`@import 'tailwindcss'`), so it is a Tailwind build. */
113
141
  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
142
  }
119
143
 
120
144
  /**
121
145
  * 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.
146
+ * whether it imports Tailwind. `resolveImport` turns a specifier into a file
147
+ * path, or returns `undefined` for a package import, which is not followed.
125
148
  */
126
149
  export function analyzeCssEntry(
127
150
  file: string,
@@ -129,12 +152,7 @@ export function analyzeCssEntry(
129
152
  resolveImport: (specifier: string, fromFile: string) => string | undefined,
130
153
  maxDepth = 3
131
154
  ): CssEntryInfo {
132
- const info: CssEntryInfo = {
133
- file,
134
- tailwind: false,
135
- nuxtUi: false,
136
- plutoCss: false,
137
- }
155
+ const info: CssEntryInfo = { file, tailwind: false }
138
156
  const seen = new Set<string>()
139
157
 
140
158
  function visit(path: string, depth: number) {
@@ -149,13 +167,6 @@ export function analyzeCssEntry(
149
167
  for (const specifier of parseCssImports(css)) {
150
168
  if (specifier === 'tailwindcss') {
151
169
  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
170
  } else {
160
171
  const next = resolveImport(specifier, path)
161
172
  if (next) {
@@ -179,27 +190,18 @@ export function resolveRelativeImport(
179
190
  : undefined
180
191
  }
181
192
 
182
- export interface CssSetupReport {
183
- /** Inject the fallback entry: no CSS entry imports Tailwind. */
184
- injectFallback: boolean
185
- warnings: string[]
186
- }
187
-
188
193
  /**
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.
194
+ * Checks the app's CSS entries and returns the warnings. The admin has a build
195
+ * of its own, so the app's entry is for the site only, and an app with no
196
+ * Tailwind entry is fine. A Tailwind build belongs in the app, not in a layer,
197
+ * and there is one. `layerRoots` are the root dirs of every layer except the app.
191
198
  */
192
199
  export function checkCssSetup(
193
200
  entries: CssEntryInfo[],
194
201
  layerRoots: string[]
195
- ): CssSetupReport {
202
+ ): string[] {
196
203
  const warnings: string[] = []
197
204
  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
205
 
204
206
  for (const entry of tailwindEntries) {
205
207
  const layerRoot = layerRoots.find((root) =>
@@ -218,17 +220,5 @@ export function checkCssSetup(
218
220
  )
219
221
  }
220
222
 
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 }
223
+ return warnings
234
224
  }
@@ -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,
@@ -12,39 +13,52 @@ import {
12
13
  analyzeCssEntry,
13
14
  checkCssSetup,
14
15
  findLayerCssFiles,
16
+ LAYER_BASE_CSS_FILE,
17
+ PLUTO_BASE_CSS_IMPORT,
15
18
  PLUTO_CSS_IMPORT,
16
19
  PLUTO_SITE_CSS_IMPORT,
20
+ renderPlutoAdminCss,
21
+ renderPlutoBaseCss,
17
22
  renderPlutoCss,
18
- renderPlutoEntryCss,
19
23
  renderPlutoSiteCss,
20
24
  resolveRelativeImport,
21
25
  } from './css-entries'
26
+ import { scopeCss } from './scope'
22
27
 
23
28
  export interface PlutoCssOptions {
24
- /** Warn at build time about a CSS setup that breaks the single build. Default true. */
29
+ /** Warn at build time about a Tailwind build in a layer, or more than one. Default true. */
25
30
  check?: boolean
26
31
  }
27
32
 
28
33
  /**
29
- * One Tailwind build per app. The app's CSS entry owns the build:
34
+ * The CSS of Pluto. The admin and the site have separate Tailwind builds:
30
35
  *
31
- * @import 'tailwindcss';
32
- * @import '@nuxt/ui';
33
- * @import '@plutocms/css';
36
+ * - The admin build `#build/pluto-admin.css` (`tailwindcss`, `@nuxt/ui`,
37
+ * `@plutocms/css`). Tailwind compiles it at the app's build, then the
38
+ * `pluto-css:scope-admin` plugin scopes every rule to the admin
39
+ * (`[data-pluto-admin]`, `[data-pluto-admin-scope]`). PlutoAdminTheme imports
40
+ * it, so it loads only with the admin UI.
41
+ * - The site's build is the app's own CSS entry, if the app has one. A site
42
+ * needs no Tailwind. A Tailwind site imports what its pages use:
43
+ *
44
+ * @import 'tailwindcss';
45
+ * @import '@nuxt/ui';
46
+ * @import '@plutocms/css';
34
47
  *
35
48
  * - `@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.
49
+ * each layer's app/assets/css/pluto-layer.css (the Pluto tokens, the layer
50
+ * tokens). Nuxt UI already adds `@source` for every layer's app/ dir, so this
51
+ * file adds no sources.
39
52
  * - `@plutocms/css/site` (opt-in) is an alias of `#build/pluto-site.css`: the
40
53
  * Pluto site theme of core (app/assets/css/pluto-site-theme.css), the look of
41
54
  * the Pluto docs for a site.
55
+ * - `@plutocms/css/base` (opt-in) is an alias of `#build/pluto-base.css`: each
56
+ * layer's app/assets/css/pluto-base.css, the plain CSS look of its public
57
+ * components. It needs no Tailwind. The admin build imports it too.
42
58
  * - The names are aliases, not npm packages, so they resolve with every package
43
59
  * 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.
60
+ * - The module warns about a layer that registers its own Tailwind build, and
61
+ * about more than one build in the app.
48
62
  *
49
63
  * See the single-tailwind-build skill.
50
64
  */
@@ -76,18 +90,96 @@ export default defineNuxtModule<PlutoCssOptions>({
76
90
  getContents: () => renderPlutoSiteCss(siteTheme, nuxt.options.buildDir),
77
91
  })
78
92
 
79
- // The names that an app's CSS entry imports. Tailwind resolves `@import`
80
- // through the Vite aliases. `/site` comes first: an alias also matches the
81
- // paths under its name, so `@plutocms/css` would take `@plutocms/css/site`.
93
+ // The opt-in plain CSS of the public components, from every layer. An
94
+ // app imports `@plutocms/css/base`, with or without Tailwind.
95
+ const layerBaseCssFiles = findLayerCssFiles(
96
+ layers.map((layer) => layer.app),
97
+ existsSync,
98
+ LAYER_BASE_CSS_FILE
99
+ )
100
+ addTemplate({
101
+ filename: 'pluto-base.css',
102
+ write: true,
103
+ getContents: () =>
104
+ renderPlutoBaseCss(layerBaseCssFiles, nuxt.options.buildDir),
105
+ })
106
+
107
+ // The names that an app's CSS entry imports. Tailwind and Vite resolve
108
+ // `@import` through the aliases. `/site` and `/base` come first: an alias
109
+ // also matches the paths under its name, so `@plutocms/css` would take them.
82
110
  nuxt.options.alias[PLUTO_SITE_CSS_IMPORT] = join(
83
111
  nuxt.options.buildDir,
84
112
  'pluto-site.css'
85
113
  )
114
+ nuxt.options.alias[PLUTO_BASE_CSS_IMPORT] = join(
115
+ nuxt.options.buildDir,
116
+ 'pluto-base.css'
117
+ )
86
118
  nuxt.options.alias[PLUTO_CSS_IMPORT] = join(
87
119
  nuxt.options.buildDir,
88
120
  'pluto.css'
89
121
  )
90
122
 
123
+ // The admin build. Not written to disk: on disk, Tailwind IntelliSense
124
+ // could take this entry for the app's design system. Not in `css`:
125
+ // PlutoAdminTheme imports it, so it loads with the admin UI only.
126
+ addTemplate({
127
+ filename: 'pluto-admin.css',
128
+ getContents: renderPlutoAdminCss,
129
+ })
130
+
131
+ // Server HTML links the admin build, not inlines it: inline, every page
132
+ // view of a staff user would carry the full build, which no cache keeps.
133
+ const inline = nuxt.options.features.inlineStyles
134
+ if (inline) {
135
+ nuxt.options.features.inlineStyles = (id) =>
136
+ !id?.includes('PlutoAdminTheme.vue') && (inline === true || inline(id))
137
+ }
138
+
139
+ // Nuxt prefetches the CSS of every async chunk, so each visitor would
140
+ // download the admin build after the page loads. The plugin below finds
141
+ // the CSS files of the client chunk with the build, and this hook turns
142
+ // off their prefetch. The chunks that render admin UI still load them.
143
+ // The file names, without the assets dir: the manifest keys have none.
144
+ const adminCssFiles = new Set<string>()
145
+ nuxt.hook('build:manifest', (manifest) => {
146
+ for (const file of adminCssFiles) {
147
+ if (manifest[file]) {
148
+ manifest[file].prefetch = false
149
+ }
150
+ }
151
+ })
152
+
153
+ // A file path, or a Nuxt virtual id (`virtual:nuxt:…%2Fpluto-admin.css`).
154
+ const adminCssId = /(?:[\\/]|%2F)pluto-admin\.css(?:\?|$)/
155
+ addVitePlugin({
156
+ name: 'pluto-css:scope-admin',
157
+ transform(code, id) {
158
+ if (adminCssId.test(id)) {
159
+ return { code: scopeCss(code), map: null }
160
+ }
161
+ },
162
+ generateBundle(_options, bundle) {
163
+ if (this.environment?.name !== 'client') {
164
+ return
165
+ }
166
+ for (const chunk of Object.values(bundle)) {
167
+ if (
168
+ chunk.type === 'chunk' &&
169
+ chunk.moduleIds.some((id) => adminCssId.test(id))
170
+ ) {
171
+ for (const file of chunk.viteMetadata?.importedCss ?? []) {
172
+ adminCssFiles.add(file.slice(file.lastIndexOf('/') + 1))
173
+ }
174
+ }
175
+ }
176
+ },
177
+ })
178
+
179
+ if (!options.check || nuxt.options._prepare) {
180
+ return
181
+ }
182
+
91
183
  const entries = []
92
184
  for (const entry of nuxt.options.css) {
93
185
  const file =
@@ -112,29 +204,8 @@ export default defineNuxtModule<PlutoCssOptions>({
112
204
  const layerRoots = layers
113
205
  .map((layer) => layer.root.replaceAll('\\', '/'))
114
206
  .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
- }
207
+ for (const warning of checkCssSetup(entries, layerRoots)) {
208
+ logger.warn(warning)
138
209
  }
139
210
  },
140
211
  })
@@ -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.47.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",