Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/aurora/news/199.documentation
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Documented the content styles loader, its authoring rules, and how to override block styles from an add-on. @sneridagh
82 changes: 77 additions & 5 deletions docs/conceptual-guides/add-on-styles-loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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-<type>`, `.category-<category>`, and `.slate-<type>`.
- 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`.
Expand Down
139 changes: 139 additions & 0 deletions packages/registry/__tests__/create-addons-styles-loader.test.js
Original file line number Diff line number Diff line change
@@ -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);",
);
});
});
1 change: 1 addition & 0 deletions packages/registry/news/199.feature
Original file line number Diff line number Diff line change
@@ -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
57 changes: 40 additions & 17 deletions packages/registry/src/addon-registry/create-addons-styles-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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`;
Expand All @@ -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 };
Loading