Skip to content

Commit abd04a2

Browse files
authored
feat(theme): new CSS cascade layers plugin + built-in v4.useCssCascadeLayers future flag (#11142)
Co-authored-by: slorber <[email protected]>
1 parent a301b24 commit abd04a2

File tree

26 files changed

+894
-0
lines changed

26 files changed

+894
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.tsbuildinfo*
2+
tsconfig*
3+
__tests__
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# `@docusaurus/plugin-css-cascade-layers`
2+
3+
CSS Cascade Layer plugin for Docusaurus
4+
5+
## Usage
6+
7+
See [plugin-css-cascade-layers documentation](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-css-cascade-layers).
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@docusaurus/plugin-css-cascade-layers",
3+
"version": "3.7.0",
4+
"description": "CSS Cascade Layer plugin for Docusaurus.",
5+
"main": "lib/index.js",
6+
"types": "lib/index.d.ts",
7+
"scripts": {
8+
"build": "tsc --build",
9+
"watch": "tsc --build --watch"
10+
},
11+
"publishConfig": {
12+
"access": "public"
13+
},
14+
"repository": {
15+
"type": "git",
16+
"url": "https://github.com/facebook/docusaurus.git",
17+
"directory": "packages/docusaurus-plugin-css-cascade-layers"
18+
},
19+
"license": "MIT",
20+
"dependencies": {
21+
"@docusaurus/core": "3.7.0",
22+
"@docusaurus/types": "3.7.0",
23+
"@docusaurus/utils-validation": "3.7.0",
24+
"tslib": "^2.6.0"
25+
},
26+
"engines": {
27+
"node": ">=18.0"
28+
}
29+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {
9+
generateLayersDeclaration,
10+
findLayer,
11+
isValidLayerName,
12+
} from '../layers';
13+
import type {PluginOptions} from '../options';
14+
15+
describe('isValidLayerName', () => {
16+
it('accepts valid names', () => {
17+
expect(isValidLayerName('layer1')).toBe(true);
18+
expect(isValidLayerName('layer1.layer2')).toBe(true);
19+
expect(isValidLayerName('layer-1.layer_2.layer3')).toBe(true);
20+
});
21+
22+
it('rejects layer with coma', () => {
23+
expect(isValidLayerName('lay,er1')).toBe(false);
24+
});
25+
it('rejects layer with space', () => {
26+
expect(isValidLayerName('lay er1')).toBe(false);
27+
});
28+
});
29+
30+
describe('generateLayersDeclaration', () => {
31+
it('for list of layers', () => {
32+
expect(generateLayersDeclaration(['layer1', 'layer2'])).toBe(
33+
'@layer layer1, layer2;',
34+
);
35+
});
36+
37+
it('for empty list of layers', () => {
38+
// Not useful to generate it, but still valid CSS anyway
39+
expect(generateLayersDeclaration([])).toBe('@layer ;');
40+
});
41+
});
42+
43+
describe('findLayer', () => {
44+
const inputFilePath = 'filePath';
45+
46+
function testFor(layers: PluginOptions['layers']) {
47+
return findLayer(inputFilePath, Object.entries(layers));
48+
}
49+
50+
it('for empty layers', () => {
51+
expect(testFor({})).toBeUndefined();
52+
});
53+
54+
it('for single matching layer', () => {
55+
expect(testFor({layer: (filePath) => filePath === inputFilePath})).toBe(
56+
'layer',
57+
);
58+
});
59+
60+
it('for single non-matching layer', () => {
61+
expect(
62+
testFor({layer: (filePath) => filePath !== inputFilePath}),
63+
).toBeUndefined();
64+
});
65+
66+
it('for multiple matching layers', () => {
67+
expect(
68+
testFor({
69+
layer1: (filePath) => filePath === inputFilePath,
70+
layer2: (filePath) => filePath === inputFilePath,
71+
layer3: (filePath) => filePath === inputFilePath,
72+
}),
73+
).toBe('layer1');
74+
});
75+
76+
it('for multiple non-matching layers', () => {
77+
expect(
78+
testFor({
79+
layer1: (filePath) => filePath !== inputFilePath,
80+
layer2: (filePath) => filePath !== inputFilePath,
81+
layer3: (filePath) => filePath !== inputFilePath,
82+
}),
83+
).toBeUndefined();
84+
});
85+
86+
it('for multiple mixed matching layers', () => {
87+
expect(
88+
testFor({
89+
layer1: (filePath) => filePath !== inputFilePath,
90+
layer2: (filePath) => filePath === inputFilePath,
91+
layer3: (filePath) => filePath !== inputFilePath,
92+
layer4: (filePath) => filePath === inputFilePath,
93+
}),
94+
).toBe('layer2');
95+
});
96+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {normalizePluginOptions} from '@docusaurus/utils-validation';
9+
import {
10+
validateOptions,
11+
type PluginOptions,
12+
type Options,
13+
DEFAULT_OPTIONS,
14+
} from '../options';
15+
import type {Validate} from '@docusaurus/types';
16+
17+
function testValidateOptions(options: Options) {
18+
return validateOptions({
19+
validate: normalizePluginOptions as Validate<Options, PluginOptions>,
20+
options,
21+
});
22+
}
23+
24+
describe('validateOptions', () => {
25+
it('accepts undefined options', () => {
26+
// @ts-expect-error: should error
27+
expect(testValidateOptions(undefined)).toEqual(DEFAULT_OPTIONS);
28+
});
29+
30+
it('accepts empty options', () => {
31+
expect(testValidateOptions({})).toEqual(DEFAULT_OPTIONS);
32+
});
33+
34+
describe('layers', () => {
35+
it('accepts empty layers', () => {
36+
expect(testValidateOptions({layers: {}})).toEqual({
37+
...DEFAULT_OPTIONS,
38+
layers: {},
39+
});
40+
});
41+
42+
it('accepts undefined layers', () => {
43+
const config: Options = {
44+
layers: undefined,
45+
};
46+
expect(testValidateOptions(config)).toEqual(DEFAULT_OPTIONS);
47+
});
48+
49+
it('accepts custom layers', () => {
50+
const config: Options = {
51+
layers: {
52+
layer1: (filePath: string) => {
53+
return !!filePath;
54+
},
55+
layer2: (filePath: string) => {
56+
return !!filePath;
57+
},
58+
},
59+
};
60+
expect(testValidateOptions(config)).toEqual({
61+
...DEFAULT_OPTIONS,
62+
layers: config.layers,
63+
});
64+
});
65+
66+
it('rejects layer with bad name', () => {
67+
const config: Options = {
68+
layers: {
69+
'layer 1': (filePath) => !!filePath,
70+
},
71+
};
72+
expect(() =>
73+
testValidateOptions(config),
74+
).toThrowErrorMatchingInlineSnapshot(`""layers.layer 1" is not allowed"`);
75+
});
76+
77+
it('rejects layer with bad value', () => {
78+
const config: Options = {
79+
layers: {
80+
// @ts-expect-error: should error
81+
layer1: 'bad value',
82+
},
83+
};
84+
expect(() =>
85+
testValidateOptions(config),
86+
).toThrowErrorMatchingInlineSnapshot(
87+
`""layers.layer1" must be of type function"`,
88+
);
89+
});
90+
91+
it('rejects layer with bad function arity', () => {
92+
const config: Options = {
93+
layers: {
94+
// @ts-expect-error: should error
95+
layer1: () => {},
96+
},
97+
};
98+
expect(() =>
99+
testValidateOptions(config),
100+
).toThrowErrorMatchingInlineSnapshot(
101+
`""layers.layer1" must have an arity of 1"`,
102+
);
103+
});
104+
});
105+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import path from 'path';
9+
import {PostCssPluginWrapInLayer} from './postCssPlugin';
10+
import {generateLayersDeclaration} from './layers';
11+
import type {LoadContext, Plugin} from '@docusaurus/types';
12+
import type {PluginOptions, Options} from './options';
13+
14+
const PluginName = 'docusaurus-plugin-css-cascade-layers';
15+
16+
const LayersDeclarationModule = 'layers.css';
17+
18+
function getLayersDeclarationPath(
19+
context: LoadContext,
20+
options: PluginOptions,
21+
) {
22+
const {generatedFilesDir} = context;
23+
const pluginId = options.id;
24+
if (pluginId !== 'default') {
25+
// Since it's only possible to declare a single layer order
26+
// using this plugin twice doesn't really make sense
27+
throw new Error(
28+
'The CSS Cascade Layers plugin does not support multiple instances.',
29+
);
30+
}
31+
return path.join(
32+
generatedFilesDir,
33+
PluginName,
34+
pluginId,
35+
LayersDeclarationModule,
36+
);
37+
}
38+
39+
export default function pluginCssCascadeLayers(
40+
context: LoadContext,
41+
options: PluginOptions,
42+
): Plugin | null {
43+
const layersDeclarationPath = getLayersDeclarationPath(context, options);
44+
45+
return {
46+
name: PluginName,
47+
48+
getClientModules() {
49+
return [layersDeclarationPath];
50+
},
51+
52+
async contentLoaded({actions}) {
53+
await actions.createData(
54+
LayersDeclarationModule,
55+
generateLayersDeclaration(Object.keys(options.layers)),
56+
);
57+
},
58+
59+
configurePostCss(postCssOptions) {
60+
postCssOptions.plugins.push(PostCssPluginWrapInLayer(options));
61+
return postCssOptions;
62+
},
63+
};
64+
}
65+
66+
export {validateOptions} from './options';
67+
68+
export type {PluginOptions, Options};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
export type LayerEntry = [string, (filePath: string) => boolean];
9+
10+
export function isValidLayerName(layer: string): boolean {
11+
// TODO improve validation rule to match spec, not high priority
12+
return !layer.includes(',') && !layer.includes(' ');
13+
}
14+
15+
export function generateLayersDeclaration(layers: string[]): string {
16+
return `@layer ${layers.join(', ')};`;
17+
}
18+
19+
export function findLayer(
20+
filePath: string,
21+
layers: LayerEntry[],
22+
): string | undefined {
23+
// Using find() => layers order matter
24+
// The first layer that matches is used in priority even if others match too
25+
const layerEntry = layers.find((layer) => layer[1](filePath));
26+
return layerEntry?.[0]; // return layer name
27+
}

0 commit comments

Comments
 (0)