From f19391c29fc346880ae5295d6bbe781e547e1702 Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Fri, 2 Oct 2026 19:20:50 +0200 Subject: [PATCH] Add the styles/content.css entry point to the add-on styles loader Step A2 of #199. Every add-on's styles/content.css is aggregated into a generated .plone/content.css, which both the Public UI and the CMSUI loaders import first, inside the plone-content cascade layer. Add-ons write block styles once and get them in both user interfaces. Documents the convention, its authoring rules and how to override block styles. --- apps/aurora/news/199.documentation | 1 + .../conceptual-guides/add-on-styles-loader.md | 82 ++++++++++- .../create-addons-styles-loader.test.js | 139 ++++++++++++++++++ packages/registry/news/199.feature | 1 + .../create-addons-styles-loader.ts | 57 ++++--- 5 files changed, 258 insertions(+), 22 deletions(-) create mode 100644 apps/aurora/news/199.documentation create mode 100644 packages/registry/__tests__/create-addons-styles-loader.test.js create mode 100644 packages/registry/news/199.feature diff --git a/apps/aurora/news/199.documentation b/apps/aurora/news/199.documentation new file mode 100644 index 000000000..b06ffd37d --- /dev/null +++ b/apps/aurora/news/199.documentation @@ -0,0 +1 @@ +Documented the content styles loader, its authoring rules, and how to override block styles from an add-on. @sneridagh diff --git a/docs/conceptual-guides/add-on-styles-loader.md b/docs/conceptual-guides/add-on-styles-loader.md index 95013ee83..8de19d6b4 100644 --- a/docs/conceptual-guides/add-on-styles-loader.md +++ b/docs/conceptual-guides/add-on-styles-loader.md @@ -10,7 +10,7 @@ myst: # Add-ons styles loader Add-ons that are compatible with the `@plone/registry` may declare styles that should be loaded by the app. -Currently the loader loads styles for both the end user interface ({term}`Public UI`) part, which displays content to both authenticated and anonymous users, and the content management system user interface ({term}`CMSUI`) part of the app. +The loader loads styles for the end user interface ({term}`Public UI`) part, which displays content to both authenticated and anonymous users, for the content management system user interface ({term}`CMSUI`) part of the app, and for the block content, which both of them render. ## Public UI styles @@ -22,11 +22,83 @@ This file is a `.css` file containing the styles that you want your app to load Similar to the Public UI, you can create a file {file}`styles/cmsui.css` at the root of your add-on package to serve as the entry point for the CMSUI styles. This file is also a CSS file containing the styles that you want your app to load for the CMSUI. -`@plone/registry` has a helper utility `createAddonsStyleLoader` which generates an add-ons loader file. -That file contains the aggregated files from all the registered add-ons, keeping the order in which they were defined. +## Content styles -This loader is also a `.css` file and is placed in the {file}`.plone` directory in the root of your application. -By default, it's called {file}`publicui.css` for the Public UI and {file}`cmsui.css` for the CMSUI. +Blocks render in both the Public UI and the CMSUI editor, and they must look the same in both. +To style block content, create a file {file}`styles/content.css` at the root of your add-on package. +The app loads it in both the Public UI and the CMSUI, so you write block styles once. + +The content styles of all add-ons are loaded first, inside the `plone-content` cascade layer. +This layer sits above the reset in `base` and below `utilities` and site customizations in `custom`. +The full layer order is set by `@plone/theming` in `config.settings.cssLayers`. + +A theme that ships its own reset should load it into the `base` layer, for example with `@import 'modern-normalize.css' layer(base);`. +Tailwind already places its preflight there. +A reset loaded without a layer, or in a layer that isn't declared in `config.settings.cssLayers`, wins over the content styles. + +### Authoring rules + +Follow these rules in {file}`styles/content.css`, so the same file works under any reset and in both user interfaces. + +- Set every property your rules rely on, such as margins, padding, list style, or image display. + Don't assume a particular reset: in the Public UI, the reset depends on the theme, which may use Tailwind's preflight, another reset, or none. +- Don't declare a `@layer` in the file. + The loader assigns the `plone-content` layer to the whole file. +- Write framework block styles inside `:where()`, so they have zero specificity and any override wins. +- Don't use CSS Modules or Tailwind utilities. + Use the block anatomy classnames instead, such as `.block`, `.block-`, `.category-`, and `.slate-`. +- Read values from custom properties with a fallback, such as `var(--block-caption-color, var(--muted-foreground))`, and don't declare those properties in framework styles. + Themes then change a value by setting the property. +- Don't target `:root`, `html`, `body`, or bare element selectors such as `h1` or `figure img`. + Declare content tokens on the `.content-area` element, which wraps the block content in both user interfaces. + +### Override block styles from an add-on + +A theme or any other add-on overrides block styles in its own {file}`styles/content.css`. +The override ends up in the same `plone-content` layer as the framework styles, so it applies in both the Public UI and the editor. + +The framework styles use zero specificity: + +```css +/* @plone/plate/styles/content.css */ +:where(.block-image .block-image__caption) { + color: var(--block-caption-color, var(--muted-foreground)); +} +``` + +A theme can either change a token, which is the preferred way, or override the rule: + +```css +/* my-theme/styles/content.css */ +.content-area { + --block-caption-color: var(--accent-color); +} + +.block-image .block-image__caption { + font-style: italic; +} +``` + +The theme's rule wins because its specificity is higher. +With equal specificity, the add-on loaded later wins. + +```{warning} +Don't wrap the rules of {file}`styles/content.css` in a `@layer`, not even `@layer custom`. +Because the loader imports the whole file into `plone-content`, the wrapped rules end up in a sub-layer, such as `plone-content.custom`. +Within a layer, rules placed directly in it win over its sub-layers, so your overrides would silently lose against the framework styles. +``` + +Styles in {file}`styles/publicui.css`, even inside `@layer custom`, only apply to the Public UI. +Use them for page-level styling, such as the header, the footer, or the layout, and use {file}`styles/content.css` for anything that targets blocks. + +## Generated loaders + +`@plone/registry` has a helper utility `createAddonsStyleLoader` which generates the add-ons loader files. +Each file contains the aggregated files from all the registered add-ons, keeping the order in which they were defined. + +These loaders are also `.css` files and are placed in the {file}`.plone` directory in the root of your application. +They're called {file}`publicui.css` for the Public UI, {file}`cmsui.css` for the CMSUI, and {file}`content.css` for the content styles. +Both {file}`publicui.css` and {file}`cmsui.css` import {file}`content.css` first, inside the `plone-content` layer. ```{important} This file is generated and maintained by `@plone/registry`. diff --git a/packages/registry/__tests__/create-addons-styles-loader.test.js b/packages/registry/__tests__/create-addons-styles-loader.test.js new file mode 100644 index 000000000..f164f53ea --- /dev/null +++ b/packages/registry/__tests__/create-addons-styles-loader.test.js @@ -0,0 +1,139 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, test } from 'vitest'; +import { + buildContentLoaderCode, + buildLoaderCode, + createAddonsStyleLoader, +} from '../src/addon-registry/create-addons-styles-loader'; + +const tmpDirs = []; + +afterEach(() => { + tmpDirs.splice(0).forEach((dir) => fs.rmSync(dir, { recursive: true })); +}); + +/** + * A minimal stand-in for `AddonRegistry`: `styles` maps each stylesheet path + * to the add-ons that ship it, in add-on order. + */ +function fakeRegistry({ styles = {}, tailwind = [] } = {}) { + const projectRootPath = fs.mkdtempSync( + path.join(os.tmpdir(), 'registry-styles-'), + ); + tmpDirs.push(projectRootPath); + fs.mkdirSync(path.join(projectRootPath, '.plone')); + + const addons = [...new Set(Object.values(styles).flat())].map((name) => { + const basePath = path.join(projectRootPath, 'node_modules', name); + let packageJson; + if (tailwind.includes(name)) { + fs.mkdirSync(basePath, { recursive: true }); + packageJson = path.join(basePath, 'package.json'); + fs.writeFileSync( + packageJson, + JSON.stringify({ name, dependencies: { tailwindcss: '*' } }), + ); + } + return { name, basePath, packageJson }; + }); + + return { + projectRootPath, + getAddons: () => addons, + getAddonStyles: (styleSheetPath) => styles[styleSheetPath] ?? [], + }; +} + +describe('buildContentLoaderCode', () => { + test('without add-ons shipping content styles, only has the header', () => { + const code = buildContentLoaderCode(fakeRegistry()); + + expect(code).toContain('Add a ./styles/content.css in your add-on'); + expect(code).not.toContain('@import'); + }); + + test('imports every add-on content stylesheet, in add-on order', () => { + const code = buildContentLoaderCode( + fakeRegistry({ + styles: { + 'styles/content.css': ['@plone/plate', '@plone/blocks', 'my-theme'], + }, + }), + ); + + expect(code.match(/^@import .*$/gm)).toEqual([ + "@import '@plone/plate/styles/content.css';", + "@import '@plone/blocks/styles/content.css';", + "@import 'my-theme/styles/content.css';", + ]); + }); + + test('emits no Tailwind sources, even for add-ons using Tailwind', () => { + const code = buildContentLoaderCode( + fakeRegistry({ + styles: { 'styles/content.css': ['@plone/plate'] }, + tailwind: ['@plone/plate'], + }), + ); + + expect(code).not.toContain('@source'); + }); +}); + +describe('buildLoaderCode', () => { + test.each(['styles/publicui.css', 'styles/cmsui.css'])( + '%s imports the content styles first, in the plone-content layer', + (styleSheetPath) => { + const code = buildLoaderCode( + fakeRegistry({ styles: { [styleSheetPath]: ['@plone/layout'] } }), + styleSheetPath, + ); + + expect(code.match(/^@import .*$/gm)).toEqual([ + "@import './content.css' layer(plone-content);", + `@import '@plone/layout/${styleSheetPath}';`, + ]); + }, + ); + + test('still adds a Tailwind source for add-ons using Tailwind', () => { + const registry = fakeRegistry({ + styles: { 'styles/publicui.css': ['@plone/agave'] }, + tailwind: ['@plone/agave'], + }); + + const code = buildLoaderCode(registry, 'styles/publicui.css'); + + expect(code).toContain(`@source '${registry.getAddons()[0].basePath}';`); + }); +}); + +describe('createAddonsStyleLoader', () => { + test('writes the content, Public UI and CMSUI loaders', () => { + const registry = fakeRegistry({ + styles: { + 'styles/content.css': ['@plone/plate'], + 'styles/publicui.css': ['@plone/layout'], + 'styles/cmsui.css': ['@plone/cmsui'], + }, + }); + + createAddonsStyleLoader(registry); + + const read = (file) => + fs.readFileSync(path.join(registry.projectRootPath, '.plone', file), { + encoding: 'utf-8', + }); + expect(read('content.css')).toContain( + "@import '@plone/plate/styles/content.css';", + ); + expect(read('publicui.css')).toContain( + "@import './content.css' layer(plone-content);", + ); + expect(read('cmsui.css')).toContain( + "@import './content.css' layer(plone-content);", + ); + }); +}); diff --git a/packages/registry/news/199.feature b/packages/registry/news/199.feature new file mode 100644 index 000000000..5443b59f4 --- /dev/null +++ b/packages/registry/news/199.feature @@ -0,0 +1 @@ +Added the `styles/content.css` add-on styles convention: every add-on's content styles are aggregated into `.plone/content.css`, which both the Public UI and the CMSUI loaders import first, inside the `plone-content` cascade layer. @sneridagh diff --git a/packages/registry/src/addon-registry/create-addons-styles-loader.ts b/packages/registry/src/addon-registry/create-addons-styles-loader.ts index 8865cea2a..44598665e 100644 --- a/packages/registry/src/addon-registry/create-addons-styles-loader.ts +++ b/packages/registry/src/addon-registry/create-addons-styles-loader.ts @@ -2,6 +2,12 @@ import fs from 'fs'; import path from 'path'; import type { AddonRegistry } from './addon-registry'; +// Block content styles shared by the Public UI and the CMSUI. Every add-on's +// `styles/content.css` is aggregated into `.plone/content.css`, which both +// UI loaders import first, inside the `plone-content` cascade layer. +export const CONTENT_STYLESHEET = 'styles/content.css'; +export const CONTENT_LAYER = 'plone-content'; + function getAddonInfo(addon: string, registry: AddonRegistry) { return registry.getAddons().find((a) => a.name === addon); } @@ -17,16 +23,22 @@ function hasTailwind(addon: string, registry: AddonRegistry) { return false; } -function buildLoaderCode(registry: AddonRegistry, styleSheetPath: string) { - const addonsStylesInfo = registry.getAddonStyles(styleSheetPath); - - let buf = `/* +function header(styleSheetPath: string) { + return `/* Don't change this file manually. It is autogenerated by @plone/registry. Add a ./${styleSheetPath} in your add-on to load your add-on styles in the app. */ `; +} + +function buildLoaderCode(registry: AddonRegistry, styleSheetPath: string) { + const addonsStylesInfo = registry.getAddonStyles(styleSheetPath); + + let buf = header(styleSheetPath); + buf += `@import './content.css' layer(${CONTENT_LAYER});\n`; + addonsStylesInfo.forEach((addon) => { const customization = `${addon}/${styleSheetPath}`; const line = `@import '${customization}';\n`; @@ -43,23 +55,34 @@ function buildLoaderCode(registry: AddonRegistry, styleSheetPath: string) { return buf; } +// Block content CSS is plain CSS by contract, so no `@source` entries are +// emitted, and the files don't declare a layer: the UI loaders assign it. +function buildContentLoaderCode(registry: AddonRegistry) { + const addonsStylesInfo = registry.getAddonStyles(CONTENT_STYLESHEET); + + let buf = header(CONTENT_STYLESHEET); + addonsStylesInfo.forEach((addon) => { + buf += `@import '${addon}/${CONTENT_STYLESHEET}';\n`; + }); + + return buf; +} + export function createAddonsStyleLoader(registry: AddonRegistry) { - const publicUIStyles = path.join( - registry.projectRootPath, - '.plone', - 'publicui.css', - ); - const cmsUIStyles = path.join( - registry.projectRootPath, - '.plone', - 'cmsui.css', - ); + const ploneDir = path.join(registry.projectRootPath, '.plone'); fs.writeFileSync( - publicUIStyles, + path.join(ploneDir, 'content.css'), + buildContentLoaderCode(registry), + ); + fs.writeFileSync( + path.join(ploneDir, 'publicui.css'), buildLoaderCode(registry, 'styles/publicui.css'), ); - fs.writeFileSync(cmsUIStyles, buildLoaderCode(registry, 'styles/cmsui.css')); + fs.writeFileSync( + path.join(ploneDir, 'cmsui.css'), + buildLoaderCode(registry, 'styles/cmsui.css'), + ); } -export { buildLoaderCode }; +export { buildLoaderCode, buildContentLoaderCode };