diff --git a/packages/docusaurus-plugin-css-cascade-layers/.npmignore b/packages/docusaurus-plugin-css-cascade-layers/.npmignore new file mode 100644 index 000000000000..03c9ae1e1b54 --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/.npmignore @@ -0,0 +1,3 @@ +.tsbuildinfo* +tsconfig* +__tests__ diff --git a/packages/docusaurus-plugin-css-cascade-layers/README.md b/packages/docusaurus-plugin-css-cascade-layers/README.md new file mode 100644 index 000000000000..94e35be735e2 --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/README.md @@ -0,0 +1,7 @@ +# `@docusaurus/plugin-css-cascade-layers` + +CSS Cascade Layer plugin for Docusaurus + +## Usage + +See [plugin-css-cascade-layers documentation](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-css-cascade-layers). diff --git a/packages/docusaurus-plugin-css-cascade-layers/package.json b/packages/docusaurus-plugin-css-cascade-layers/package.json new file mode 100644 index 000000000000..2d22fba61695 --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/package.json @@ -0,0 +1,29 @@ +{ + "name": "@docusaurus/plugin-css-cascade-layers", + "version": "3.7.0", + "description": "CSS Cascade Layer plugin for Docusaurus.", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "tsc --build", + "watch": "tsc --build --watch" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/facebook/docusaurus.git", + "directory": "packages/docusaurus-plugin-css-cascade-layers" + }, + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.7.0", + "@docusaurus/types": "3.7.0", + "@docusaurus/utils-validation": "3.7.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + } +} diff --git a/packages/docusaurus-plugin-css-cascade-layers/src/__tests__/layers.test.ts b/packages/docusaurus-plugin-css-cascade-layers/src/__tests__/layers.test.ts new file mode 100644 index 000000000000..e7dc82a49dae --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/src/__tests__/layers.test.ts @@ -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'); + }); +}); diff --git a/packages/docusaurus-plugin-css-cascade-layers/src/__tests__/options.test.ts b/packages/docusaurus-plugin-css-cascade-layers/src/__tests__/options.test.ts new file mode 100644 index 000000000000..215ab167f06e --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/src/__tests__/options.test.ts @@ -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, + }); +} + +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"`, + ); + }); + }); +}); diff --git a/packages/docusaurus-plugin-css-cascade-layers/src/index.ts b/packages/docusaurus-plugin-css-cascade-layers/src/index.ts new file mode 100644 index 000000000000..013b61ed0650 --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/src/index.ts @@ -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}; diff --git a/packages/docusaurus-plugin-css-cascade-layers/src/layers.ts b/packages/docusaurus-plugin-css-cascade-layers/src/layers.ts new file mode 100644 index 000000000000..c15d32891756 --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/src/layers.ts @@ -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 +} diff --git a/packages/docusaurus-plugin-css-cascade-layers/src/options.ts b/packages/docusaurus-plugin-css-cascade-layers/src/options.ts new file mode 100644 index 000000000000..180e9d427ebe --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/src/options.ts @@ -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 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 = { + id: 'default', + layers: DEFAULT_LAYERS, +}; + +const pluginOptionsSchema = Joi.object({ + 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): PluginOptions { + return validate(pluginOptionsSchema, options); +} diff --git a/packages/docusaurus-plugin-css-cascade-layers/src/postCssPlugin.ts b/packages/docusaurus-plugin-css-cascade-layers/src/postCssPlugin.ts new file mode 100644 index 000000000000..6b37b78b3d68 --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/src/postCssPlugin.ts @@ -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; diff --git a/packages/docusaurus-plugin-css-cascade-layers/src/types.d.ts b/packages/docusaurus-plugin-css-cascade-layers/src/types.d.ts new file mode 100644 index 000000000000..6f6f99f12793 --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/src/types.d.ts @@ -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. + */ + +/// diff --git a/packages/docusaurus-plugin-css-cascade-layers/tsconfig.json b/packages/docusaurus-plugin-css-cascade-layers/tsconfig.json new file mode 100644 index 000000000000..343f87c70bdc --- /dev/null +++ b/packages/docusaurus-plugin-css-cascade-layers/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": false + }, + "include": ["src"], + "exclude": ["**/__tests__/**"] +} diff --git a/packages/docusaurus-preset-classic/package.json b/packages/docusaurus-preset-classic/package.json index cfb770edaa62..47ed48acff25 100644 --- a/packages/docusaurus-preset-classic/package.json +++ b/packages/docusaurus-preset-classic/package.json @@ -19,6 +19,7 @@ "license": "MIT", "dependencies": { "@docusaurus/core": "3.7.0", + "@docusaurus/plugin-css-cascade-layers": "3.7.0", "@docusaurus/plugin-content-blog": "3.7.0", "@docusaurus/plugin-content-docs": "3.7.0", "@docusaurus/plugin-content-pages": "3.7.0", diff --git a/packages/docusaurus-preset-classic/src/index.ts b/packages/docusaurus-preset-classic/src/index.ts index eac8c461b306..661be7e325c8 100644 --- a/packages/docusaurus-preset-classic/src/index.ts +++ b/packages/docusaurus-preset-classic/src/index.ts @@ -62,6 +62,13 @@ export default function preset( } const plugins: PluginConfig[] = []; + + // TODO Docusaurus v4: temporary due to the opt-in flag + // In v4 we'd like to use layers everywhere natively + if (siteConfig.future.v4.useCssCascadeLayers) { + plugins.push(makePluginConfig('@docusaurus/plugin-css-cascade-layers')); + } + if (docs !== false) { plugins.push(makePluginConfig('@docusaurus/plugin-content-docs', docs)); } diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/Content/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Navbar/Content/styles.module.css index 3cbe701f247e..eee2127b697c 100644 --- a/packages/docusaurus-theme-classic/src/theme/Navbar/Content/styles.module.css +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/Content/styles.module.css @@ -13,3 +13,11 @@ Hide color mode toggle in small viewports display: none; } } + +/* +Restore some Infima style that broke with CSS Cascade Layers +See https://github.com/facebook/docusaurus/pull/11142 + */ +:global(.navbar__items--right) > :last-child { + padding-right: 0; +} diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index 26b7022cf3d9..d77975a06b7f 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -136,6 +136,7 @@ export type FasterConfig = { export type FutureV4Config = { removeLegacyPostBuildHeadAttribute: boolean; + useCssCascadeLayers: boolean; }; export type FutureConfig = { diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap index 1cce07a53a07..bc5e43776718 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap @@ -25,6 +25,7 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], @@ -99,6 +100,7 @@ exports[`loadSiteConfig website with ts + js config 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], @@ -173,6 +175,7 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], @@ -247,6 +250,7 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], @@ -321,6 +325,7 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], @@ -395,6 +400,7 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], @@ -469,6 +475,7 @@ exports[`loadSiteConfig website with valid async config 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], @@ -545,6 +552,7 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], @@ -621,6 +629,7 @@ exports[`loadSiteConfig website with valid config creator function 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], @@ -700,6 +709,7 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap index 91aea83bc33d..3a85ce8cb2c0 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap @@ -99,6 +99,7 @@ exports[`load loads props for site with custom i18n path 1`] = ` }, "v4": { "removeLegacyPostBuildHeadAttribute": false, + "useCssCascadeLayers": false, }, }, "headTags": [], diff --git a/packages/docusaurus/src/server/__tests__/configValidation.test.ts b/packages/docusaurus/src/server/__tests__/configValidation.test.ts index 8d8f5058cc64..861faa8cd52e 100644 --- a/packages/docusaurus/src/server/__tests__/configValidation.test.ts +++ b/packages/docusaurus/src/server/__tests__/configValidation.test.ts @@ -50,6 +50,7 @@ describe('normalizeConfig', () => { future: { v4: { removeLegacyPostBuildHeadAttribute: true, + useCssCascadeLayers: true, }, experimental_faster: { swcJsLoader: true, @@ -754,6 +755,7 @@ describe('future', () => { const future: DocusaurusConfig['future'] = { v4: { removeLegacyPostBuildHeadAttribute: true, + useCssCascadeLayers: true, }, experimental_faster: { swcJsLoader: true, @@ -1861,6 +1863,7 @@ describe('future', () => { it('accepts v4 - full', () => { const v4: FutureV4Config = { removeLegacyPostBuildHeadAttribute: true, + useCssCascadeLayers: true, }; expect( normalizeConfig({ @@ -1976,5 +1979,80 @@ describe('future', () => { `); }); }); + + describe('useCssCascadeLayers', () => { + it('accepts - undefined', () => { + const v4: Partial = { + useCssCascadeLayers: undefined, + }; + expect( + normalizeConfig({ + future: { + v4, + }, + }), + ).toEqual(v4Containing({useCssCascadeLayers: false})); + }); + + it('accepts - true', () => { + const v4: Partial = { + useCssCascadeLayers: true, + }; + expect( + normalizeConfig({ + future: { + v4, + }, + }), + ).toEqual(v4Containing({useCssCascadeLayers: true})); + }); + + it('accepts - false', () => { + const v4: Partial = { + useCssCascadeLayers: false, + }; + expect( + normalizeConfig({ + future: { + v4, + }, + }), + ).toEqual(v4Containing({useCssCascadeLayers: false})); + }); + + it('rejects - null', () => { + const v4: Partial = { + // @ts-expect-error: invalid + useCssCascadeLayers: 42, + }; + expect(() => + normalizeConfig({ + future: { + v4, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.v4.useCssCascadeLayers" must be a boolean + " + `); + }); + + it('rejects - number', () => { + const v4: Partial = { + // @ts-expect-error: invalid + useCssCascadeLayers: 42, + }; + expect(() => + normalizeConfig({ + future: { + v4, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.v4.useCssCascadeLayers" must be a boolean + " + `); + }); + }); }); }); diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index 33d7199143f6..67fe593f2208 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -68,11 +68,13 @@ export const DEFAULT_FASTER_CONFIG_TRUE: FasterConfig = { export const DEFAULT_FUTURE_V4_CONFIG: FutureV4Config = { removeLegacyPostBuildHeadAttribute: false, + useCssCascadeLayers: false, }; // When using the "v4: true" shortcut export const DEFAULT_FUTURE_V4_CONFIG_TRUE: FutureV4Config = { removeLegacyPostBuildHeadAttribute: true, + useCssCascadeLayers: true, }; export const DEFAULT_FUTURE_CONFIG: FutureConfig = { @@ -270,6 +272,9 @@ const FUTURE_V4_SCHEMA = Joi.alternatives() removeLegacyPostBuildHeadAttribute: Joi.boolean().default( DEFAULT_FUTURE_V4_CONFIG.removeLegacyPostBuildHeadAttribute, ), + useCssCascadeLayers: Joi.boolean().default( + DEFAULT_FUTURE_V4_CONFIG.useCssCascadeLayers, + ), }), Joi.boolean() .required() diff --git a/project-words.txt b/project-words.txt index 18363f6eec9c..48a0ac932173 100644 --- a/project-words.txt +++ b/project-words.txt @@ -109,6 +109,7 @@ Héctor héllô IANAD Infima +infima inlines interactiveness Interpolatable diff --git a/website/_dogfooding/_pages tests/index.mdx b/website/_dogfooding/_pages tests/index.mdx index 38f13ae9aea9..0ac188463580 100644 --- a/website/_dogfooding/_pages tests/index.mdx +++ b/website/_dogfooding/_pages tests/index.mdx @@ -41,3 +41,4 @@ import Readme from "../README.mdx" - [Analytics](/tests/pages/analytics) - [History tests](/tests/pages/history-tests) - [Embeds](/tests/pages/embeds) +- [Style Isolation tests](/tests/pages/style-isolation) diff --git a/website/_dogfooding/_pages tests/style-isolation/index.module.css b/website/_dogfooding/_pages tests/style-isolation/index.module.css new file mode 100644 index 000000000000..cfa3773802fd --- /dev/null +++ b/website/_dogfooding/_pages tests/style-isolation/index.module.css @@ -0,0 +1,26 @@ +/** + * 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. + */ + +.exampleContainer { + border: solid thin; +} + +.isolated:not(#a#b) { + &, + * { + @layer docusaurus { + all: revert-layer; + } + /* + Yes, unfortunately we need to revert sub-layers one by one + See https://bsky.app/profile/sebastienlorber.com/post/3lpqzuxat6s2v + */ + @layer docusaurus.infima { + all: revert-layer; + } + } +} diff --git a/website/_dogfooding/_pages tests/style-isolation/index.tsx b/website/_dogfooding/_pages tests/style-isolation/index.tsx new file mode 100644 index 000000000000..96ac21117114 --- /dev/null +++ b/website/_dogfooding/_pages tests/style-isolation/index.tsx @@ -0,0 +1,174 @@ +/** + * 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 React, {type ReactNode} from 'react'; +import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import Layout from '@theme/Layout'; +import Heading from '@theme/Heading'; + +import styles from './index.module.css'; + +/* eslint-disable @docusaurus/prefer-docusaurus-heading */ + +function ExampleContainer({ + isolated, + children, +}: { + isolated?: boolean; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +function ExampleRow({name, children}: {name: string; children: ReactNode}) { + return ( + + {name} + + {children} + + + {children} + + + ); +} + +function ExamplesTable() { + return ( + + + + + + + + + + +

title

+
+ + +

text

+
+ + + {/* eslint-disable-next-line */} + link + + + + code + + +
+            code
+          
+
+ + +
some text
+
+ + + {/* eslint-disable-next-line */} + + + + +
    +
  • item1
  • +
  • item2
  • +
+
+ + +
    +
  1. item1
  2. +
  3. item2
  4. +
+
+ + + kbd + + + +
shadow (KO)
+
+ + +
ExampleNormalIsolated
+ + + + + + + + + + + + + + + + +
Col1Col2
Cell1Cell2
Cell3Cell3
+ + + + {/* eslint-disable-next-line */} + + + + +
danger
+
+ +
success
+
+ + + ); +} + +export default function StyleIsolation(): ReactNode { + return ( + +
+ Style Isolation tests + +

+ This shows how to isolate your components from Docusaurus global + styles. A workaround for{' '} + + this issue + + . +

+ +
+
+ ); +} diff --git a/website/docs/api/docusaurus.config.js.mdx b/website/docs/api/docusaurus.config.js.mdx index 41f5c50a9f77..e746377ba782 100644 --- a/website/docs/api/docusaurus.config.js.mdx +++ b/website/docs/api/docusaurus.config.js.mdx @@ -199,6 +199,7 @@ export default { future: { v4: { removeLegacyPostBuildHeadAttribute: true, + useCssCascadeLayers: true, }, experimental_faster: { swcJsLoader: true, @@ -221,6 +222,7 @@ export default { - `v4`: Permits to opt-in for upcoming Docusaurus v4 breaking changes and features, to prepare your site in advance for this new version. Use `true` as a shorthand to enable all the flags. - [`removeLegacyPostBuildHeadAttribute`](https://github.com/facebook/docusaurus/pull/10435): Removes the legacy `plugin.postBuild({head})` API that prevents us from applying useful SSG optimizations ([explanations](https://github.com/facebook/docusaurus/pull/10850)). + - [`useCssCascadeLayers`](https://github.com/facebook/docusaurus/pull/11142): This enables the [Docusaurus CSS Cascade Layers plugin](./plugins/plugin-css-cascade-layers.mdx) with pre-configured layers that we plan to apply by default for Docusaurus v4. - `experimental_faster`: An object containing feature flags to make the Docusaurus build faster. This requires adding the `@docusaurus/faster` package to your site's dependencies. Use `true` as a shorthand to enable all flags. Read more on the [Docusaurus Faster](https://github.com/facebook/docusaurus/issues/10556) issue. Available feature flags: - [`swcJsLoader`](https://github.com/facebook/docusaurus/pull/10435): Use [SWC](https://swc.rs/) to transpile JS (instead of [Babel](https://babeljs.io/)). - [`swcJsMinimizer`](https://github.com/facebook/docusaurus/pull/10441): Use [SWC](https://swc.rs/) to minify JS (instead of [Terser](https://github.com/terser/terser)). diff --git a/website/docs/api/plugins/overview.mdx b/website/docs/api/plugins/overview.mdx index 23eb70892996..6109d4eb20eb 100644 --- a/website/docs/api/plugins/overview.mdx +++ b/website/docs/api/plugins/overview.mdx @@ -31,3 +31,4 @@ These plugins will add a useful behavior to your Docusaurus site. - [@docusaurus/plugin-google-analytics](./plugin-google-analytics.mdx) - [@docusaurus/plugin-google-gtag](./plugin-google-gtag.mdx) - [@docusaurus/plugin-google-tag-manager](./plugin-google-tag-manager.mdx) +- [@docusaurus/plugin-css-cascade-layers](./plugin-css-cascade-layers.mdx) u diff --git a/website/docs/api/plugins/plugin-css-cascade-layers.mdx b/website/docs/api/plugins/plugin-css-cascade-layers.mdx new file mode 100644 index 000000000000..a155a02260a1 --- /dev/null +++ b/website/docs/api/plugins/plugin-css-cascade-layers.mdx @@ -0,0 +1,95 @@ +--- +sidebar_position: 9 +slug: /api/plugins/@docusaurus/plugin-css-cascade-layers +--- + +# 📦 plugin-css-cascade-layers + +import APITable from '@site/src/components/APITable'; + +:::caution Experimental + +This plugin is mostly designed to be used internally by the classic preset through the [Docusaurus `future.v4.useCssCascadeLayers` flag](../docusaurus.config.js.mdx#future), although it can also be used as a standalone plugin. Please [let us know here](https://github.com/facebook/docusaurus/pull/11142) if you have a use case for it and help us design an API that makes sense for the future of Docusaurus. + +::: + +A plugin for wrapping CSS modules of your Docusaurus site in [CSS Cascade Layers](https://css-tricks.com/css-cascade-layers/). This modern CSS feature is widely supported by all browsers. It allows grouping CSS rules in layers of specificity and gives you more control over the CSS cascade. + +Use this plugin to: + +- apply a top-level `@layer myLayer { ... }` block rule around any CSS module, including un-layered third-party CSS. +- define an explicit layer ordering + +:::caution + +To use this plugin properly, it's recommended to have a solid understanding of [CSS Cascade Layers](https://css-tricks.com/css-cascade-layers/), the [CSS Cascade](https://developer.mozilla.org/docs/Web/CSS/CSS_cascade/Cascade) and [specificity](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Specificity). + +::: + +## Installation {#installation} + +```bash npm2yarn +npm install --save @docusaurus/plugin-css-cascade-layers +``` + +:::tip + +If you use the preset `@docusaurus/preset-classic`, this plugin is automatically configured for you with the [`siteConfig.future.v4.useCssCascadeLayers`](../docusaurus.config.js.mdx#future) flag. + +::: + +## Configuration {#configuration} + +Accepted fields: + +```mdx-code-block + +``` + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `layers` | `Layers` | **Built-in layers** | An object representing all the CSS cascade layers you want to use, and whether the layer should be applied to a given file path. See examples and types below. | + +```mdx-code-block + +``` + +### Types {#types} + +#### `Layers` {#EditUrlFunction} + +```ts +type Layers = Record< + string, // layer name + (filePath: string) => boolean // layer matcher +>; +``` + +The `layers` object is defined by: + +- key: the name of a layer +- value: a function to define if a given CSS module file should be in that layer + +:::caution Order matters + +The object order matters: + +- the keys order defines an explicit CSS layer order +- when multiple layers match a file path, only the first layer will apply + +::: + +### Example configuration {#ex-config} + +You can configure this plugin through plugin options. + +```js +const options = { + layers: { + 'docusaurus.infima': (filePath) => + filePath.includes('/node_modules/infima/dist'), + 'docusaurus.theme-classic': (filePath) => + filePath.includes('/node_modules/@docusaurus/theme-classic/lib'), + }, +}; +```