@plutocms/core 0.46.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,22 @@
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
+
4
20
  ## [0.46.0](https://github.com/plutocms/pluto/compare/v0.45.0...v0.46.0) (2026-11-02)
5
21
 
6
22
 
package/FEATURES.md CHANGED
@@ -72,6 +72,10 @@ policies shared by every project here.
72
72
  layer's `pluto-layer.css` (imported as `@plutocms/css` by the admin build and by a Tailwind
73
73
  site), and warns about a Tailwind build in a layer or more than one.
74
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
75
79
  - Git CMS: a standalone CMS with Markdown posts in a Git repo. Out of the box it publishes to the
76
80
  local Git repo (dev only: each save is a local commit, `commit.local`); a GitHub App publishes
77
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,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>
@@ -162,6 +162,25 @@ useHead({
162
162
  --pluto-admin-bar-height: 2.5rem;
163
163
  }
164
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
+
165
184
  /*
166
185
  * The wrappers grow to fill #__nuxt. Plain CSS, not Tailwind classes: a site
167
186
  * may have no Tailwind (the admin has a build of its own).
@@ -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')
@@ -81,6 +104,8 @@ export function renderPlutoAdminCss(): string {
81
104
  '@import "tailwindcss";',
82
105
  '@import "@nuxt/ui";',
83
106
  `@import "${PLUTO_CSS_IMPORT}";`,
107
+ // The admin shows public components too: the post preview, the 3D preview.
108
+ `@import "${PLUTO_BASE_CSS_IMPORT}";`,
84
109
  '',
85
110
  ].join('\n')
86
111
  }
@@ -13,9 +13,12 @@ import {
13
13
  analyzeCssEntry,
14
14
  checkCssSetup,
15
15
  findLayerCssFiles,
16
+ LAYER_BASE_CSS_FILE,
17
+ PLUTO_BASE_CSS_IMPORT,
16
18
  PLUTO_CSS_IMPORT,
17
19
  PLUTO_SITE_CSS_IMPORT,
18
20
  renderPlutoAdminCss,
21
+ renderPlutoBaseCss,
19
22
  renderPlutoCss,
20
23
  renderPlutoSiteCss,
21
24
  resolveRelativeImport,
@@ -49,6 +52,9 @@ export interface PlutoCssOptions {
49
52
  * - `@plutocms/css/site` (opt-in) is an alias of `#build/pluto-site.css`: the
50
53
  * Pluto site theme of core (app/assets/css/pluto-site-theme.css), the look of
51
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.
52
58
  * - The names are aliases, not npm packages, so they resolve with every package
53
59
  * manager, also when the app installs only an outer layer.
54
60
  * - The module warns about a layer that registers its own Tailwind build, and
@@ -84,13 +90,31 @@ export default defineNuxtModule<PlutoCssOptions>({
84
90
  getContents: () => renderPlutoSiteCss(siteTheme, nuxt.options.buildDir),
85
91
  })
86
92
 
87
- // The names that an app's CSS entry imports. Tailwind resolves `@import`
88
- // through the Vite aliases. `/site` comes first: an alias also matches the
89
- // 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.
90
110
  nuxt.options.alias[PLUTO_SITE_CSS_IMPORT] = join(
91
111
  nuxt.options.buildDir,
92
112
  'pluto-site.css'
93
113
  )
114
+ nuxt.options.alias[PLUTO_BASE_CSS_IMPORT] = join(
115
+ nuxt.options.buildDir,
116
+ 'pluto-base.css'
117
+ )
94
118
  nuxt.options.alias[PLUTO_CSS_IMPORT] = join(
95
119
  nuxt.options.buildDir,
96
120
  'pluto.css'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/core",
3
3
  "type": "module",
4
- "version": "0.46.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",