mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-09 06:12:28 +02:00
feat(theme): new CSS cascade layers plugin + built-in v4.useCssCascadeLayers
future flag (#11142)
Co-authored-by: slorber <749374+slorber@users.noreply.github.com>
This commit is contained in:
parent
a301b24d64
commit
abd04a2b71
26 changed files with 894 additions and 0 deletions
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {
|
||||
generateLayersDeclaration,
|
||||
findLayer,
|
||||
isValidLayerName,
|
||||
} from '../layers';
|
||||
import type {PluginOptions} from '../options';
|
||||
|
||||
describe('isValidLayerName', () => {
|
||||
it('accepts valid names', () => {
|
||||
expect(isValidLayerName('layer1')).toBe(true);
|
||||
expect(isValidLayerName('layer1.layer2')).toBe(true);
|
||||
expect(isValidLayerName('layer-1.layer_2.layer3')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects layer with coma', () => {
|
||||
expect(isValidLayerName('lay,er1')).toBe(false);
|
||||
});
|
||||
it('rejects layer with space', () => {
|
||||
expect(isValidLayerName('lay er1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateLayersDeclaration', () => {
|
||||
it('for list of layers', () => {
|
||||
expect(generateLayersDeclaration(['layer1', 'layer2'])).toBe(
|
||||
'@layer layer1, layer2;',
|
||||
);
|
||||
});
|
||||
|
||||
it('for empty list of layers', () => {
|
||||
// Not useful to generate it, but still valid CSS anyway
|
||||
expect(generateLayersDeclaration([])).toBe('@layer ;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLayer', () => {
|
||||
const inputFilePath = 'filePath';
|
||||
|
||||
function testFor(layers: PluginOptions['layers']) {
|
||||
return findLayer(inputFilePath, Object.entries(layers));
|
||||
}
|
||||
|
||||
it('for empty layers', () => {
|
||||
expect(testFor({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('for single matching layer', () => {
|
||||
expect(testFor({layer: (filePath) => filePath === inputFilePath})).toBe(
|
||||
'layer',
|
||||
);
|
||||
});
|
||||
|
||||
it('for single non-matching layer', () => {
|
||||
expect(
|
||||
testFor({layer: (filePath) => filePath !== inputFilePath}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('for multiple matching layers', () => {
|
||||
expect(
|
||||
testFor({
|
||||
layer1: (filePath) => filePath === inputFilePath,
|
||||
layer2: (filePath) => filePath === inputFilePath,
|
||||
layer3: (filePath) => filePath === inputFilePath,
|
||||
}),
|
||||
).toBe('layer1');
|
||||
});
|
||||
|
||||
it('for multiple non-matching layers', () => {
|
||||
expect(
|
||||
testFor({
|
||||
layer1: (filePath) => filePath !== inputFilePath,
|
||||
layer2: (filePath) => filePath !== inputFilePath,
|
||||
layer3: (filePath) => filePath !== inputFilePath,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('for multiple mixed matching layers', () => {
|
||||
expect(
|
||||
testFor({
|
||||
layer1: (filePath) => filePath !== inputFilePath,
|
||||
layer2: (filePath) => filePath === inputFilePath,
|
||||
layer3: (filePath) => filePath !== inputFilePath,
|
||||
layer4: (filePath) => filePath === inputFilePath,
|
||||
}),
|
||||
).toBe('layer2');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {normalizePluginOptions} from '@docusaurus/utils-validation';
|
||||
import {
|
||||
validateOptions,
|
||||
type PluginOptions,
|
||||
type Options,
|
||||
DEFAULT_OPTIONS,
|
||||
} from '../options';
|
||||
import type {Validate} from '@docusaurus/types';
|
||||
|
||||
function testValidateOptions(options: Options) {
|
||||
return validateOptions({
|
||||
validate: normalizePluginOptions as Validate<Options, PluginOptions>,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
describe('validateOptions', () => {
|
||||
it('accepts undefined options', () => {
|
||||
// @ts-expect-error: should error
|
||||
expect(testValidateOptions(undefined)).toEqual(DEFAULT_OPTIONS);
|
||||
});
|
||||
|
||||
it('accepts empty options', () => {
|
||||
expect(testValidateOptions({})).toEqual(DEFAULT_OPTIONS);
|
||||
});
|
||||
|
||||
describe('layers', () => {
|
||||
it('accepts empty layers', () => {
|
||||
expect(testValidateOptions({layers: {}})).toEqual({
|
||||
...DEFAULT_OPTIONS,
|
||||
layers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts undefined layers', () => {
|
||||
const config: Options = {
|
||||
layers: undefined,
|
||||
};
|
||||
expect(testValidateOptions(config)).toEqual(DEFAULT_OPTIONS);
|
||||
});
|
||||
|
||||
it('accepts custom layers', () => {
|
||||
const config: Options = {
|
||||
layers: {
|
||||
layer1: (filePath: string) => {
|
||||
return !!filePath;
|
||||
},
|
||||
layer2: (filePath: string) => {
|
||||
return !!filePath;
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(testValidateOptions(config)).toEqual({
|
||||
...DEFAULT_OPTIONS,
|
||||
layers: config.layers,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects layer with bad name', () => {
|
||||
const config: Options = {
|
||||
layers: {
|
||||
'layer 1': (filePath) => !!filePath,
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
testValidateOptions(config),
|
||||
).toThrowErrorMatchingInlineSnapshot(`""layers.layer 1" is not allowed"`);
|
||||
});
|
||||
|
||||
it('rejects layer with bad value', () => {
|
||||
const config: Options = {
|
||||
layers: {
|
||||
// @ts-expect-error: should error
|
||||
layer1: 'bad value',
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
testValidateOptions(config),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`""layers.layer1" must be of type function"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects layer with bad function arity', () => {
|
||||
const config: Options = {
|
||||
layers: {
|
||||
// @ts-expect-error: should error
|
||||
layer1: () => {},
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
testValidateOptions(config),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`""layers.layer1" must have an arity of 1"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
68
packages/docusaurus-plugin-css-cascade-layers/src/index.ts
Normal file
68
packages/docusaurus-plugin-css-cascade-layers/src/index.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import {PostCssPluginWrapInLayer} from './postCssPlugin';
|
||||
import {generateLayersDeclaration} from './layers';
|
||||
import type {LoadContext, Plugin} from '@docusaurus/types';
|
||||
import type {PluginOptions, Options} from './options';
|
||||
|
||||
const PluginName = 'docusaurus-plugin-css-cascade-layers';
|
||||
|
||||
const LayersDeclarationModule = 'layers.css';
|
||||
|
||||
function getLayersDeclarationPath(
|
||||
context: LoadContext,
|
||||
options: PluginOptions,
|
||||
) {
|
||||
const {generatedFilesDir} = context;
|
||||
const pluginId = options.id;
|
||||
if (pluginId !== 'default') {
|
||||
// Since it's only possible to declare a single layer order
|
||||
// using this plugin twice doesn't really make sense
|
||||
throw new Error(
|
||||
'The CSS Cascade Layers plugin does not support multiple instances.',
|
||||
);
|
||||
}
|
||||
return path.join(
|
||||
generatedFilesDir,
|
||||
PluginName,
|
||||
pluginId,
|
||||
LayersDeclarationModule,
|
||||
);
|
||||
}
|
||||
|
||||
export default function pluginCssCascadeLayers(
|
||||
context: LoadContext,
|
||||
options: PluginOptions,
|
||||
): Plugin | null {
|
||||
const layersDeclarationPath = getLayersDeclarationPath(context, options);
|
||||
|
||||
return {
|
||||
name: PluginName,
|
||||
|
||||
getClientModules() {
|
||||
return [layersDeclarationPath];
|
||||
},
|
||||
|
||||
async contentLoaded({actions}) {
|
||||
await actions.createData(
|
||||
LayersDeclarationModule,
|
||||
generateLayersDeclaration(Object.keys(options.layers)),
|
||||
);
|
||||
},
|
||||
|
||||
configurePostCss(postCssOptions) {
|
||||
postCssOptions.plugins.push(PostCssPluginWrapInLayer(options));
|
||||
return postCssOptions;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export {validateOptions} from './options';
|
||||
|
||||
export type {PluginOptions, Options};
|
27
packages/docusaurus-plugin-css-cascade-layers/src/layers.ts
Normal file
27
packages/docusaurus-plugin-css-cascade-layers/src/layers.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
export type LayerEntry = [string, (filePath: string) => boolean];
|
||||
|
||||
export function isValidLayerName(layer: string): boolean {
|
||||
// TODO improve validation rule to match spec, not high priority
|
||||
return !layer.includes(',') && !layer.includes(' ');
|
||||
}
|
||||
|
||||
export function generateLayersDeclaration(layers: string[]): string {
|
||||
return `@layer ${layers.join(', ')};`;
|
||||
}
|
||||
|
||||
export function findLayer(
|
||||
filePath: string,
|
||||
layers: LayerEntry[],
|
||||
): string | undefined {
|
||||
// Using find() => layers order matter
|
||||
// The first layer that matches is used in priority even if others match too
|
||||
const layerEntry = layers.find((layer) => layer[1](filePath));
|
||||
return layerEntry?.[0]; // return layer name
|
||||
}
|
87
packages/docusaurus-plugin-css-cascade-layers/src/options.ts
Normal file
87
packages/docusaurus-plugin-css-cascade-layers/src/options.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
import {Joi} from '@docusaurus/utils-validation';
|
||||
import {isValidLayerName} from './layers';
|
||||
import type {OptionValidationContext} from '@docusaurus/types';
|
||||
|
||||
export type PluginOptions = {
|
||||
id: string; // plugin id
|
||||
layers: Record<string, (filePath: string) => boolean>;
|
||||
};
|
||||
|
||||
export type Options = {
|
||||
layers?: PluginOptions['layers'];
|
||||
};
|
||||
|
||||
// Not ideal to compute layers using "filePath.includes()"
|
||||
// But this is mostly temporary until we add first-class layers everywhere
|
||||
function layerFor(...params: string[]) {
|
||||
return (filePath: string) => params.some((p) => filePath.includes(p));
|
||||
}
|
||||
|
||||
// Object order matters, it defines the layer order
|
||||
export const DEFAULT_LAYERS: PluginOptions['layers'] = {
|
||||
'docusaurus.infima': layerFor('node_modules/infima/dist'),
|
||||
'docusaurus.theme-common': layerFor(
|
||||
'packages/docusaurus-theme-common/lib',
|
||||
'node_modules/@docusaurus/theme-common/lib',
|
||||
),
|
||||
'docusaurus.theme-classic': layerFor(
|
||||
'packages/docusaurus-theme-classic/lib',
|
||||
'node_modules/@docusaurus/theme-classic/lib',
|
||||
),
|
||||
'docusaurus.core': layerFor(
|
||||
'packages/docusaurus/lib',
|
||||
'node_modules/@docusaurus/core/lib',
|
||||
),
|
||||
'docusaurus.plugin-debug': layerFor(
|
||||
'packages/docusaurus-plugin-debug/lib',
|
||||
'node_modules/@docusaurus/plugin-debug/lib',
|
||||
),
|
||||
'docusaurus.theme-mermaid': layerFor(
|
||||
'packages/docusaurus-theme-mermaid/lib',
|
||||
'node_modules/@docusaurus/theme-mermaid/lib',
|
||||
),
|
||||
'docusaurus.theme-live-codeblock': layerFor(
|
||||
'packages/docusaurus-theme-live-codeblock/lib',
|
||||
'node_modules/@docusaurus/theme-live-codeblock/lib',
|
||||
),
|
||||
'docusaurus.theme-search-algolia.docsearch': layerFor(
|
||||
'node_modules/@docsearch/css/dist',
|
||||
),
|
||||
'docusaurus.theme-search-algolia': layerFor(
|
||||
'packages/docusaurus-theme-search-algolia/lib',
|
||||
'node_modules/@docusaurus/theme-search-algolia/lib',
|
||||
),
|
||||
// docusaurus.website layer ? (declare it, even if empty?)
|
||||
};
|
||||
|
||||
export const DEFAULT_OPTIONS: Partial<PluginOptions> = {
|
||||
id: 'default',
|
||||
layers: DEFAULT_LAYERS,
|
||||
};
|
||||
|
||||
const pluginOptionsSchema = Joi.object<PluginOptions>({
|
||||
layers: Joi.object()
|
||||
.pattern(
|
||||
Joi.custom((val, helpers) => {
|
||||
if (!isValidLayerName(val)) {
|
||||
return helpers.error('any.invalid');
|
||||
}
|
||||
return val;
|
||||
}),
|
||||
Joi.function().arity(1).required(),
|
||||
)
|
||||
.default(DEFAULT_LAYERS),
|
||||
});
|
||||
|
||||
export function validateOptions({
|
||||
validate,
|
||||
options,
|
||||
}: OptionValidationContext<Options, PluginOptions>): PluginOptions {
|
||||
return validate(pluginOptionsSchema, options);
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {findLayer} from './layers';
|
||||
import type {Root, PluginCreator} from 'postcss';
|
||||
import type {PluginOptions} from './options';
|
||||
|
||||
function wrapCssRootInLayer(root: Root, layer: string): void {
|
||||
const rootBefore = root.clone();
|
||||
root.removeAll();
|
||||
root.append({
|
||||
type: 'atrule',
|
||||
name: 'layer',
|
||||
params: layer,
|
||||
nodes: rootBefore.nodes,
|
||||
});
|
||||
}
|
||||
|
||||
export const PostCssPluginWrapInLayer: PluginCreator<{
|
||||
layers: PluginOptions['layers'];
|
||||
}> = (options) => {
|
||||
if (!options) {
|
||||
throw new Error('PostCssPluginWrapInLayer options are mandatory');
|
||||
}
|
||||
const layers = Object.entries(options.layers);
|
||||
return {
|
||||
postcssPlugin: 'postcss-wrap-in-layer',
|
||||
Once(root) {
|
||||
const filePath = root.source?.input.file;
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
const layer = findLayer(filePath, layers);
|
||||
if (layer) {
|
||||
wrapCssRootInLayer(root, layer);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
PostCssPluginWrapInLayer.postcss = true;
|
8
packages/docusaurus-plugin-css-cascade-layers/src/types.d.ts
vendored
Normal file
8
packages/docusaurus-plugin-css-cascade-layers/src/types.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/// <reference types="@docusaurus/module-type-aliases" />
|
Loading…
Add table
Add a link
Reference in a new issue