From ac36c07bec1221bd6ca403403537ac0a50be6e73 Mon Sep 17 00:00:00 2001 From: Caleb Jasik Date: Wed, 28 Oct 2026 15:26:17 -0500 Subject: [PATCH 1/2] Keep @charset and ICSS rules outside the assigned layer @charset must be the first statement in a stylesheet, and css-loader / icss-utils only read :import and :export at the root, so wrapping them in the layer broke the charset and silently dropped CSS Module exports. Refs #11 Co-Authored-By: Claude Opus 5.5 --- src/index.test.mts | 26 ++++++++++++++++++++++++++ src/index.ts | 13 +++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/index.test.mts b/src/index.test.mts index d230d32..5ee175a 100644 --- a/src/index.test.mts +++ b/src/index.test.mts @@ -146,6 +146,32 @@ 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("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..3c861d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,17 @@ const plugin: PluginCreator = ( } } + if (layerNames.length === 0) return; + + // `@charset` must be the first thing in a stylesheet, 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.type === "rule" && /^:(import|export)\b/.test(node.selector)) + ); + for (const node of rootOnly) node.remove(); + for (const layerName of layerNames) { const layer = new AtRule({ name: "layer", @@ -46,6 +57,8 @@ const plugin: PluginCreator = ( root.removeAll(); root.append(layer); } + + root.prepend(rootOnly); }, }; }; From 27f8d227f4a6e02b3707704817eabf13437c7155 Mon Sep 17 00:00:00 2001 From: Caleb Jasik Date: Thu, 29 Oct 2026 12:02:59 -0500 Subject: [PATCH 2/2] Hoist @namespace and emit @charset as the first bytes @namespace is only valid at the top level, so wrapping it in the layer invalidated it. A hoisted @charset kept its raws.before, so a leading comment in the source left a newline ahead of it and browsers ignored it. Co-Authored-By: Claude Opus 5.5 --- src/index.test.mts | 24 ++++++++++++++++++++++++ src/index.ts | 10 +++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/index.test.mts b/src/index.test.mts index 5ee175a..b284574 100644 --- a/src/index.test.mts +++ b/src/index.test.mts @@ -165,6 +165,30 @@ describe("postcss-assign-layer", () => { ); }); + 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, { diff --git a/src/index.ts b/src/index.ts index 3c861d2..01d53a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,14 +39,18 @@ const plugin: PluginCreator = ( if (layerNames.length === 0) return; - // `@charset` must be the first thing in a stylesheet, and css-loader only reads - // ICSS `:import`/`:export` rules at the root, so these stay outside the layer. + // `@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.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({