diff --git a/src/index.test.mts b/src/index.test.mts index d230d32..b284574 100644 --- a/src/index.test.mts +++ b/src/index.test.mts @@ -146,6 +146,56 @@ describe("postcss-assign-layer", () => { ); }); + it("keeps @charset and ICSS :import/:export rules outside the layer", async () => { + await run( + `@charset "UTF-8"; + :import("./tokens.css") { primary: primary; } + a { color: FireBrick; } + :export { primary: primary; } + i { color: WhiteSmoke; }`, + `@charset "UTF-8"; + :import("./tokens.css") { primary: primary; } + :export { primary: primary; } + @layer components { + a { color: FireBrick; } + i { color: WhiteSmoke; } + }`, + undefined, + { from: path.resolve("test/fixtures/component.module.css") } + ); + }); + + it("keeps @namespace outside the layer", async () => { + await run( + `@charset "UTF-8"; + @namespace svg url(http://www.w3.org/2000/svg); + svg|a { color: FireBrick; }`, + `@charset "UTF-8"; + @namespace svg url(http://www.w3.org/2000/svg); + @layer components { + svg|a { color: FireBrick; } + }`, + undefined, + { from: path.resolve("test/fixtures/component.module.css") } + ); + }); + + it("emits a hoisted @charset as the first bytes of the output", async () => { + // Asserts on raw output because prettier would strip the leading whitespace. + const result = await postcss([plugin()]).process( + `/* license */\n@charset "UTF-8";\na { color: FireBrick; }`, + { from: path.resolve("test/fixtures/component.module.css") } + ); + expect(result.css.startsWith(`@charset "UTF-8";`)).toBe(true); + }); + + it("does not hoist ICSS-looking rules out of non-matching files", async () => { + const input = `a { color: FireBrick; } :export { primary: red; }`; + await run(input, input, undefined, { + from: path.resolve("test/fixtures/base.css"), + }); + }); + it("allows multiple patterns and layers", async () => { const basePath = path.resolve("test/fixtures/base.css"); const componentPath = path.resolve("test/fixtures/component.module.css"); diff --git a/src/index.ts b/src/index.ts index b466d3f..01d53a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,21 @@ const plugin: PluginCreator = ( } } + if (layerNames.length === 0) return; + + // `@charset` must be the first thing in a stylesheet, `@namespace` is only valid at + // the top level, and css-loader only reads ICSS `:import`/`:export` rules at the + // root, so these stay outside the layer. + const rootOnly = root.nodes.filter( + (node) => + (node.type === "atrule" && + (node.name === "charset" || node.name === "namespace")) || + (node.type === "rule" && /^:(import|export)\b/.test(node.selector)) + ); + for (const node of rootOnly) node.remove(); + // Browsers only honor `@charset` as the very first bytes of the file. + if (rootOnly[0]) rootOnly[0].raws.before = ""; + for (const layerName of layerNames) { const layer = new AtRule({ name: "layer", @@ -46,6 +61,8 @@ const plugin: PluginCreator = ( root.removeAll(); root.append(layer); } + + root.prepend(rootOnly); }, }; };