diff --git a/README.md b/README.md index 8f32b5a..a4a1ac1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,22 @@ module.exports = { ## Options +### `layers` + +```ts +type Layer = { + path?: string | string[]; + exclude?: string | string[]; + name: string; +}; +``` + +An array of layer configurations. Each layer can have: + +- `name` (required): The name of the CSS cascade layer +- `path` (optional): A glob pattern or array of glob patterns to match files that should be wrapped in this layer. If omitted, the layer will only appear in the layer order declaration. +- `exclude` (optional): A glob pattern or array of glob patterns to exclude files from being wrapped in this layer. + ### `injectOrderAs` ```ts @@ -87,6 +103,12 @@ module.exports = { exclude: "**/notification.module.scss", name: "ui-shared", }, + { + // Multiple patterns can be provided as arrays + path: ["**/src/**/*.css", "**/lib/**/*.scss"], + exclude: ["**/*.test.css", "**/*.spec.scss"], + name: "utilities", + }, ], injectOrderAs: "link", publicPath: "/static/css/layers.css", diff --git a/src/loader.ts b/src/loader.ts index 2b48280..d109da6 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -16,12 +16,18 @@ export const OPTIONS_SCHEMA: JSONSchema7 = { description: "All files that are matched with this value using minimatch package will be wrapped with this layer." + "If undefined layer will only be included in layer order declaration (can be used for preexisting layers).", - type: "string", + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], }, exclude: { description: "All files matched with this value using minimatch package will be excluded from layer wrapping.", - type: "string", + oneOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], }, name: { description: "Name of layer", @@ -34,8 +40,8 @@ export const OPTIONS_SCHEMA: JSONSchema7 = { }; export type Layer = { - path?: string; - exclude?: string; + path?: string | string[]; + exclude?: string | string[]; name: string; }; @@ -47,11 +53,20 @@ function loader(this: LoaderContext<{ layers: Layer[] }>, source: string) { for (const layer of layers) { const { path, name, exclude } = layer; - if ( - path && - minimatch(this.resourcePath, path) && - (exclude === undefined || !minimatch(this.resourcePath, exclude)) - ) { + + const pathMatches = path + ? Array.isArray(path) + ? path.some((pattern) => minimatch(this.resourcePath, pattern)) + : minimatch(this.resourcePath, path) + : false; + + const isExcluded = exclude + ? Array.isArray(exclude) + ? exclude.some((pattern) => minimatch(this.resourcePath, pattern)) + : minimatch(this.resourcePath, exclude) + : false; + + if (pathMatches && !isExcluded) { return wrapSourceInLayer(source, name); } } diff --git a/tests/css-layering-plugin.css.integration.test.ts b/tests/css-layering-plugin.css.integration.test.ts index 0d98bb0..46fc699 100644 --- a/tests/css-layering-plugin.css.integration.test.ts +++ b/tests/css-layering-plugin.css.integration.test.ts @@ -8,116 +8,115 @@ function normalizeCss(css: string): string { describe("CSSLayeringPlugin CSS transformation integration", () => { const cases = [ - // Basic case: single CSS file matched by path { + name: "basic CSS file wrapping", fixture: "css-basic", outputName: "css", - cssFile: "styles.css", + cssFiles: ["styles.css"], layers: [{ name: "components", path: "**/styles.css" }], }, - // Exclude behavior: one file wrapped, one left untouched { + name: "exclude pattern", fixture: "css-exclude", outputName: "css", - cssFile: "styles.css", + cssFiles: ["styles.css", "styles.ignore.css"], layers: [ { name: "components", path: "**/*.css", exclude: "**/*.ignore.css" }, ], }, { - fixture: "css-exclude", - outputName: "css", - cssFile: "styles.ignore.css", - layers: [ - { name: "components", path: "**/*.css", exclude: "**/*.ignore.css" }, - ], - }, - // Multiple layers with different paths - { - fixture: "css-multi-layers", - outputName: "css", - cssFile: "reset.css", - layers: [ - { name: "base", path: "**/reset.css" }, - { name: "components", path: "**/components.css" }, - ], - }, - { + name: "multiple layers with different paths", fixture: "css-multi-layers", outputName: "css", - cssFile: "components.css", + cssFiles: ["reset.css", "components.css"], layers: [ { name: "base", path: "**/reset.css" }, { name: "components", path: "**/components.css" }, ], }, - // Layers without path should not be used for wrapping { + name: "preexisting layers without path", fixture: "css-preexisting-layer", outputName: "css", - cssFile: "styles.css", + cssFiles: ["styles.css"], layers: [ { name: "preexisting" }, { name: "components", path: "**/styles.css" }, ], }, - // Non-matching path pattern should leave CSS unchanged { + name: "non-matching path pattern", fixture: "css-no-match", outputName: "css", - cssFile: "plain.css", + cssFiles: ["plain.css"], layers: [{ name: "components", path: "**/styles.css" }], }, - // Use ordering and comments: @use lines hoisted, others wrapped { + name: "@use line hoisting", fixture: "css-use-ordering", outputName: "css", - cssFile: "styles.css", + cssFiles: ["styles.css"], layers: [{ name: "components", path: "**/styles.css" }], }, - // SCSS file handling { + name: "SCSS file handling", fixture: "css-scss", outputName: "css", - cssFile: "styles.scss", + cssFiles: ["styles.scss"], layers: [{ name: "components", path: "**/*.scss" }], }, - // Complex SCSS file with nesting, extends, variables, media queries { + name: "complex SCSS with nesting and variables", fixture: "css-scss-complex", outputName: "css", - cssFile: "styles-complex.scss", + cssFiles: ["styles-complex.scss"], layers: [{ name: "components", path: "**/styles-complex.scss" }], }, - // Multiple layers affecting the same file: first matching layer should be used { + name: "first matching layer wins", fixture: "css-multi-match", outputName: "css", - cssFile: "styles.css", + cssFiles: ["styles.css"], layers: [ { name: "base", path: "**/styles.css" }, { name: "components", path: "**/*.css" }, ], }, - // Single layer applied to multiple files via glob { + name: "single layer applied to multiple files", fixture: "css-multi-file", outputName: "css", - cssFile: "one.css", + cssFiles: ["one.css", "two.css"], layers: [{ name: "shared", path: "**/*.css" }], }, { - fixture: "css-multi-file", + name: "array path patterns", + fixture: "css-array-path", outputName: "css", - cssFile: "two.css", - layers: [{ name: "shared", path: "**/*.css" }], + cssFiles: ["button.css", "input.scss"], + layers: [ + { name: "components", path: ["**/button.css", "**/input.scss"] }, + ], + }, + { + name: "array exclude patterns", + fixture: "css-array-exclude", + outputName: "css", + cssFiles: ["app.css", "app.test.css", "app.spec.css"], + layers: [ + { + name: "components", + path: "**/*.css", + exclude: ["**/*.test.css", "**/*.spec.css"], + }, + ], }, ] as const; for (const testCase of cases) { - const { fixture, outputName, cssFile, layers } = testCase; + const { name, fixture, outputName, cssFiles, layers } = testCase; - it(`wraps CSS for fixture ${fixture} with configured layers`, async () => { + it(name, async () => { const compiler = createCompiler( fixture, outputName, @@ -141,16 +140,19 @@ describe("CSSLayeringPlugin CSS transformation integration", () => { const { outDir, fixturesDir } = getPaths(fixture, outputName); - const actualCss = await fs.readFile( - path.join(outDir, "css", cssFile), - "utf8", - ); - const expectedCss = await fs.readFile( - path.join(fixturesDir, "expected", cssFile), - "utf8", - ); + // Check all CSS files for this fixture + for (const cssFile of cssFiles) { + const actualCss = await fs.readFile( + path.join(outDir, "css", cssFile), + "utf8", + ); + const expectedCss = await fs.readFile( + path.join(fixturesDir, "expected", cssFile), + "utf8", + ); - expect(normalizeCss(actualCss)).toBe(normalizeCss(expectedCss)); + expect(normalizeCss(actualCss)).toBe(normalizeCss(expectedCss)); + } }); } }); diff --git a/tests/fixtures/css-array-exclude/app.css b/tests/fixtures/css-array-exclude/app.css new file mode 100644 index 0000000..11d21f8 --- /dev/null +++ b/tests/fixtures/css-array-exclude/app.css @@ -0,0 +1,4 @@ +.app { + width: 100%; + height: 100vh; +} diff --git a/tests/fixtures/css-array-exclude/app.spec.css b/tests/fixtures/css-array-exclude/app.spec.css new file mode 100644 index 0000000..210dfdf --- /dev/null +++ b/tests/fixtures/css-array-exclude/app.spec.css @@ -0,0 +1,3 @@ +.spec { + color: orange; +} diff --git a/tests/fixtures/css-array-exclude/app.test.css b/tests/fixtures/css-array-exclude/app.test.css new file mode 100644 index 0000000..e71fd38 --- /dev/null +++ b/tests/fixtures/css-array-exclude/app.test.css @@ -0,0 +1,3 @@ +.test { + color: green; +} diff --git a/tests/fixtures/css-array-exclude/entry.ts b/tests/fixtures/css-array-exclude/entry.ts new file mode 100644 index 0000000..6759c42 --- /dev/null +++ b/tests/fixtures/css-array-exclude/entry.ts @@ -0,0 +1,7 @@ +import "./app.css"; +import "./app.test.css"; +import "./app.spec.css"; + +export function main() { + // Entry is only here to make webpack process CSS files +} diff --git a/tests/fixtures/css-array-exclude/expected/app.css b/tests/fixtures/css-array-exclude/expected/app.css new file mode 100644 index 0000000..48245c4 --- /dev/null +++ b/tests/fixtures/css-array-exclude/expected/app.css @@ -0,0 +1,6 @@ +@layer components { + .app { + width: 100%; + height: 100vh; +} +} diff --git a/tests/fixtures/css-array-exclude/expected/app.spec.css b/tests/fixtures/css-array-exclude/expected/app.spec.css new file mode 100644 index 0000000..210dfdf --- /dev/null +++ b/tests/fixtures/css-array-exclude/expected/app.spec.css @@ -0,0 +1,3 @@ +.spec { + color: orange; +} diff --git a/tests/fixtures/css-array-exclude/expected/app.test.css b/tests/fixtures/css-array-exclude/expected/app.test.css new file mode 100644 index 0000000..e71fd38 --- /dev/null +++ b/tests/fixtures/css-array-exclude/expected/app.test.css @@ -0,0 +1,3 @@ +.test { + color: green; +} diff --git a/tests/fixtures/css-array-exclude/index.html b/tests/fixtures/css-array-exclude/index.html new file mode 100644 index 0000000..afb85bd --- /dev/null +++ b/tests/fixtures/css-array-exclude/index.html @@ -0,0 +1,10 @@ + + + + + Webpack App + + +

Hello world!

+ + diff --git a/tests/fixtures/css-array-path/button.css b/tests/fixtures/css-array-path/button.css new file mode 100644 index 0000000..f1a0cf6 --- /dev/null +++ b/tests/fixtures/css-array-path/button.css @@ -0,0 +1,4 @@ +.button { + padding: 10px; + background: blue; +} diff --git a/tests/fixtures/css-array-path/entry.ts b/tests/fixtures/css-array-path/entry.ts new file mode 100644 index 0000000..5b797c1 --- /dev/null +++ b/tests/fixtures/css-array-path/entry.ts @@ -0,0 +1,6 @@ +import "./button.css"; +import "./input.scss"; + +export function main() { + // Entry is only here to make webpack process CSS files +} diff --git a/tests/fixtures/css-array-path/expected/button.css b/tests/fixtures/css-array-path/expected/button.css new file mode 100644 index 0000000..29af3f2 --- /dev/null +++ b/tests/fixtures/css-array-path/expected/button.css @@ -0,0 +1,6 @@ +@layer components { + .button { + padding: 10px; + background: blue; +} +} diff --git a/tests/fixtures/css-array-path/expected/input.scss b/tests/fixtures/css-array-path/expected/input.scss new file mode 100644 index 0000000..3013ed7 --- /dev/null +++ b/tests/fixtures/css-array-path/expected/input.scss @@ -0,0 +1,6 @@ +@layer components { + .input { + border: 1px solid gray; + padding: 5px; +} +} diff --git a/tests/fixtures/css-array-path/index.html b/tests/fixtures/css-array-path/index.html new file mode 100644 index 0000000..afb85bd --- /dev/null +++ b/tests/fixtures/css-array-path/index.html @@ -0,0 +1,10 @@ + + + + + Webpack App + + +

Hello world!

+ + diff --git a/tests/fixtures/css-array-path/input.scss b/tests/fixtures/css-array-path/input.scss new file mode 100644 index 0000000..8761539 --- /dev/null +++ b/tests/fixtures/css-array-path/input.scss @@ -0,0 +1,4 @@ +.input { + border: 1px solid gray; + padding: 5px; +}