From fecb59f6c1b4d51193bd463d2b0e3d823b27f88d Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 29 Aug 2026 13:38:18 +0000 Subject: [PATCH 1/2] fix: remove the BOM from the compiled CSS `dart-sass` prepends a byte order mark to the compiled CSS when the `charset` option is enabled (by default), the `style` option is `compressed` (which the loader sets automatically in the `production` mode) and the CSS contains non ASCII characters. A BOM is only meaningful at the very beginning of a file, but the loader result is just a string for webpack. Tools like `css-loader` move `@import` at-rules above it, so the BOM ended up in the middle of the generated CSS, where browsers read it as a part of the following selector and broke that rule. Sass counts the BOM as the first column of the first line, so the generated column of the first mapping is shifted by one when it is removed - the segments after it are relative to the previous one and stay untouched. Closes #1335 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013PtW7eezwuQP5epLFMrAky --- .changeset/remove-bom.md | 5 + src/index.js | 5 +- src/utils.js | 96 ++++++++ test/__snapshots__/loader.test.js.snap | 104 ++++++++- .../sassOptions-option.test.js.snap | 8 +- .../sourceMap-options.test.js.snap | 216 ++++++++++++++++++ test/loader.test.js | 28 ++- test/sassOptions-option.test.js | 7 +- test/sourceMap-options.test.js | 35 +++ types/utils.d.ts | 22 ++ 10 files changed, 515 insertions(+), 11 deletions(-) create mode 100644 .changeset/remove-bom.md diff --git a/.changeset/remove-bom.md b/.changeset/remove-bom.md new file mode 100644 index 00000000..0dcd6adb --- /dev/null +++ b/.changeset/remove-bom.md @@ -0,0 +1,5 @@ +--- +"sass-loader": patch +--- + +Remove the byte order mark (BOM) from the compiled CSS. `dart-sass` prepends it when the compiled CSS contains non ASCII characters and the `style` option is `compressed` (the default for the `production` mode), but a BOM is only valid at the very beginning of a file - tools like `css-loader` move `@import` at-rules above it, so it ended up in the middle of the generated CSS and broke the rule after it. Source maps are shifted accordingly. diff --git a/src/index.js b/src/index.js index beb13cb0..125dfa46 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import { getSassImplementation, getSassOptions, normalizeSourceMap, + removeBOM, } from "./utils.js"; /** @typedef {import("webpack").LoaderContext} LoaderContext */ @@ -89,6 +90,8 @@ async function loader(content) { map = normalizeSourceMap(map, this.rootContext); } + const { css, map: cssMap } = removeBOM(result.css.toString(), map); + if (typeof result.loadedUrls !== "undefined") { for (const includedFile of result.loadedUrls.filter( (loadedUrl) => loadedUrl.protocol === "file:", @@ -102,7 +105,7 @@ async function loader(content) { } } - callback(null, result.css.toString(), map || undefined); + callback(null, css, cssMap || undefined); } export default loader; diff --git a/src/utils.js b/src/utils.js index 6ee5736a..612f235d 100644 --- a/src/utils.js +++ b/src/utils.js @@ -764,6 +764,101 @@ function normalizeSourceMap(map, rootContext) { return newMap; } +const BASE64_CHARACTERS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +const VLQ_BASE_SHIFT = 5; +const VLQ_BASE = 1 << VLQ_BASE_SHIFT; +const VLQ_BASE_MASK = VLQ_BASE - 1; +const VLQ_CONTINUATION_BIT = VLQ_BASE; + +/** + * Decodes the first Base64 VLQ value of the given `mappings`. + * @param {string} mappings mappings of a source map + * @returns {{ value: number, length: number } | null} the decoded value and how many characters it takes, `null` when `mappings` doesn't start with a value + */ +function decodeVLQ(mappings) { + let value = 0; + let shift = 0; + let length = 0; + let digit; + + do { + digit = BASE64_CHARACTERS.indexOf(mappings[length]); + + if (digit === -1) { + return null; + } + + value += (digit & VLQ_BASE_MASK) << shift; + shift += VLQ_BASE_SHIFT; + length += 1; + } while ((digit & VLQ_CONTINUATION_BIT) !== 0); + + const isNegative = (value & 1) === 1; + + value >>= 1; + + return { value: isNegative ? -value : value, length }; +} + +/** + * @param {number} value value + * @returns {string} the Base64 VLQ encoded value + */ +function encodeVLQ(value) { + let encoded = ""; + let vlq = value < 0 ? (-value << 1) | 1 : value << 1; + + do { + let digit = vlq & VLQ_BASE_MASK; + + vlq >>>= VLQ_BASE_SHIFT; + + if (vlq > 0) { + digit |= VLQ_CONTINUATION_BIT; + } + + encoded += BASE64_CHARACTERS[digit]; + } while (vlq > 0); + + return encoded; +} + +/** + * Removes the byte order mark (BOM) from the compiled CSS. + * + * `dart-sass` prepends a BOM when the `charset` option is enabled (by default), + * the `style` option is `compressed` (the default for the `production` mode) and + * the compiled CSS contains non ASCII characters. + * A BOM is only meaningful at the very beginning of a file, but the loader result + * is just a string for webpack, and tools like `css-loader` move `@import` at-rules + * above it, so the BOM ends up in the middle of the generated CSS and breaks the + * first rule after it. + * @see https://github.com/webpack/sass-loader/issues/1335 + * @param {string} css compiled CSS + * @param {RawSourceMap=} map source map + * @returns {{ css: string, map: RawSourceMap | undefined }} the CSS without the BOM and the source map adjusted to it + */ +function removeBOM(css, map) { + if (css.charCodeAt(0) !== 0xfe_ff) { + return { css, map }; + } + + // Sass counts the BOM as the first column of the first line, so all mappings + // of this line need to be shifted by one column. + // Only the first segment has to be updated - the generated column of the + // segments after it is relative to the previous one. + if (map && typeof map.mappings === "string") { + const decoded = decodeVLQ(map.mappings); + + if (decoded && decoded.value > 0) { + map.mappings = `${encodeVLQ(decoded.value - 1)}${map.mappings.slice(decoded.length)}`; + } + } + + return { css: css.slice(1), map }; +} + /** * @param {Error | SassError} error the original sass error * @returns {Error} a new error @@ -790,4 +885,5 @@ export { getSassOptions, getWebpackResolver, normalizeSourceMap, + removeBOM, }; diff --git a/test/__snapshots__/loader.test.js.snap b/test/__snapshots__/loader.test.js.snap index 34e4873d..45373bef 100644 --- a/test/__snapshots__/loader.test.js.snap +++ b/test/__snapshots__/loader.test.js.snap @@ -1446,6 +1446,102 @@ exports[`loader > should prefer relative import ('sass-embedded', 'modern-compil [] `; +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern' API, 'sass' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern' API, 'sass' syntax) 2`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern' API, 'sass' syntax) 3`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern' API, 'scss' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern' API, 'scss' syntax) 2`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern' API, 'scss' syntax) 3`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern-compiler' API, 'sass' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern-compiler' API, 'sass' syntax) 2`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern-compiler' API, 'sass' syntax) 3`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern-compiler' API, 'scss' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern-compiler' API, 'scss' syntax) 2`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('dart-sass', 'modern-compiler' API, 'scss' syntax) 3`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern' API, 'sass' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern' API, 'sass' syntax) 2`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern' API, 'sass' syntax) 3`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern' API, 'scss' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern' API, 'scss' syntax) 2`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern' API, 'scss' syntax) 3`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern-compiler' API, 'sass' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern-compiler' API, 'sass' syntax) 2`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern-compiler' API, 'sass' syntax) 3`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern-compiler' API, 'scss' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern-compiler' API, 'scss' syntax) 2`] = ` +[] +`; + +exports[`loader > should remove the BOM from the compiled CSS ('sass-embedded', 'modern-compiler' API, 'scss' syntax) 3`] = ` +[] +`; + exports[`loader > should resolve absolute paths ('dart-sass', 'modern' API, 'sass' syntax) 1`] = ` "@charset \\"UTF-8\\";\\n@import \\"./file.css\\";\\nbody {\\n font: 100% Helvetica, sans-serif;\\n color: #333;\\n}\\n\\nnav ul {\\n margin: 0;\\n padding: 0;\\n list-style: none;\\n}\\nnav li {\\n display: inline-block;\\n}\\nnav a {\\n display: block;\\n padding: 6px 12px;\\n text-decoration: none;\\n}\\n\\n.box {\\n -webkit-border-radius: 10px;\\n -moz-border-radius: 10px;\\n -ms-border-radius: 10px;\\n border-radius: 10px;\\n}\\n\\n.message, .warning, .error, .success {\\n border: 1px solid #ccc;\\n padding: 10px;\\n color: #333;\\n}\\n\\n.success {\\n border-color: green;\\n}\\n\\n.error {\\n border-color: red;\\n}\\n\\n.warning {\\n border-color: yellow;\\n}\\n\\n.foo:before {\\n content: \\"\\\\e0c6\\";\\n}\\n\\n.bar:before {\\n content: \\"∑\\";\\n}" `; @@ -3327,7 +3423,7 @@ exports[`loader > should work and output deprecation message (sass-embedded) 9`] `; exports[`loader > should work and output the \"compressed\" outputStyle when \"mode\" is production ('dart-sass', 'modern' API, 'sass' syntax) 1`] = ` -"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.message,.warning,.error,.success{border:1px solid #ccc;padding:10px;color:#333}.success{border-color:green}.error{border-color:red}.warning{border-color:#ff0}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" +"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.message,.warning,.error,.success{border:1px solid #ccc;padding:10px;color:#333}.success{border-color:green}.error{border-color:red}.warning{border-color:#ff0}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" `; exports[`loader > should work and output the \"compressed\" outputStyle when \"mode\" is production ('dart-sass', 'modern' API, 'sass' syntax) 2`] = ` @@ -3339,7 +3435,7 @@ exports[`loader > should work and output the \"compressed\" outputStyle when \"m `; exports[`loader > should work and output the \"compressed\" outputStyle when \"mode\" is production ('dart-sass', 'modern' API, 'scss' syntax) 1`] = ` -"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" +"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" `; exports[`loader > should work and output the \"compressed\" outputStyle when \"mode\" is production ('dart-sass', 'modern' API, 'scss' syntax) 2`] = ` @@ -3351,7 +3447,7 @@ exports[`loader > should work and output the \"compressed\" outputStyle when \"m `; exports[`loader > should work and output the \"compressed\" outputStyle when \"mode\" is production ('dart-sass', 'modern-compiler' API, 'sass' syntax) 1`] = ` -"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.message,.warning,.error,.success{border:1px solid #ccc;padding:10px;color:#333}.success{border-color:green}.error{border-color:red}.warning{border-color:#ff0}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" +"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.message,.warning,.error,.success{border:1px solid #ccc;padding:10px;color:#333}.success{border-color:green}.error{border-color:red}.warning{border-color:#ff0}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" `; exports[`loader > should work and output the \"compressed\" outputStyle when \"mode\" is production ('dart-sass', 'modern-compiler' API, 'sass' syntax) 2`] = ` @@ -3363,7 +3459,7 @@ exports[`loader > should work and output the \"compressed\" outputStyle when \"m `; exports[`loader > should work and output the \"compressed\" outputStyle when \"mode\" is production ('dart-sass', 'modern-compiler' API, 'scss' syntax) 1`] = ` -"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" +"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" `; exports[`loader > should work and output the \"compressed\" outputStyle when \"mode\" is production ('dart-sass', 'modern-compiler' API, 'scss' syntax) 2`] = ` diff --git a/test/__snapshots__/sassOptions-option.test.js.snap b/test/__snapshots__/sassOptions-option.test.js.snap index 534c3def..519fd5eb 100644 --- a/test/__snapshots__/sassOptions-option.test.js.snap +++ b/test/__snapshots__/sassOptions-option.test.js.snap @@ -287,7 +287,7 @@ exports[`sassOptions option > should respect the \"style\" option ('sass-embedde `; exports[`sassOptions option > should use \"compressed\" output style in the \"production\" mode ('dart-sass', 'modern' API, 'sass' syntax) 1`] = ` -"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.message,.warning,.error,.success{border:1px solid #ccc;padding:10px;color:#333}.success{border-color:green}.error{border-color:red}.warning{border-color:#ff0}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" +"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.message,.warning,.error,.success{border:1px solid #ccc;padding:10px;color:#333}.success{border-color:green}.error{border-color:red}.warning{border-color:#ff0}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" `; exports[`sassOptions option > should use \"compressed\" output style in the \"production\" mode ('dart-sass', 'modern' API, 'sass' syntax) 2`] = ` @@ -299,7 +299,7 @@ exports[`sassOptions option > should use \"compressed\" output style in the \"pr `; exports[`sassOptions option > should use \"compressed\" output style in the \"production\" mode ('dart-sass', 'modern' API, 'scss' syntax) 1`] = ` -"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" +"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" `; exports[`sassOptions option > should use \"compressed\" output style in the \"production\" mode ('dart-sass', 'modern' API, 'scss' syntax) 2`] = ` @@ -311,7 +311,7 @@ exports[`sassOptions option > should use \"compressed\" output style in the \"pr `; exports[`sassOptions option > should use \"compressed\" output style in the \"production\" mode ('dart-sass', 'modern-compiler' API, 'sass' syntax) 1`] = ` -"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.message,.warning,.error,.success{border:1px solid #ccc;padding:10px;color:#333}.success{border-color:green}.error{border-color:red}.warning{border-color:#ff0}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" +"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.message,.warning,.error,.success{border:1px solid #ccc;padding:10px;color:#333}.success{border-color:green}.error{border-color:red}.warning{border-color:#ff0}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" `; exports[`sassOptions option > should use \"compressed\" output style in the \"production\" mode ('dart-sass', 'modern-compiler' API, 'sass' syntax) 2`] = ` @@ -323,7 +323,7 @@ exports[`sassOptions option > should use \"compressed\" output style in the \"pr `; exports[`sassOptions option > should use \"compressed\" output style in the \"production\" mode ('dart-sass', 'modern-compiler' API, 'scss' syntax) 1`] = ` -"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" +"@import\\"./file.css\\";body{font:100% Helvetica,sans-serif;color:#333}nav ul{margin:0;padding:0;list-style:none}nav li{display:inline-block}nav a{display:block;padding:6px 12px;text-decoration:none}.box{-webkit-border-radius:10px;-moz-border-radius:10px;-ms-border-radius:10px;border-radius:10px}.foo:before{content:\\"\\"}.bar:before{content:\\"∑\\"}" `; exports[`sassOptions option > should use \"compressed\" output style in the \"production\" mode ('dart-sass', 'modern-compiler' API, 'scss' syntax) 2`] = ` diff --git a/test/__snapshots__/sourceMap-options.test.js.snap b/test/__snapshots__/sourceMap-options.test.js.snap index 6698ac30..2655af2f 100644 --- a/test/__snapshots__/sourceMap-options.test.js.snap +++ b/test/__snapshots__/sourceMap-options.test.js.snap @@ -1,3 +1,219 @@ +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern' API, 'sass' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern' API, 'sass' syntax) 2`] = ` +{ + "version": 3, + "sourceRoot": "", + "sources": [ + "test/sass/charset-utf-8.sass" + ], + "names": [], + "mappings": "AAEA,EACE", + "sourcesContent": [ + "@charset \\"UTF-8\\"\\n\\np\\n content: \\"é\\"\\n" + ] +} +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern' API, 'sass' syntax) 3`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern' API, 'sass' syntax) 4`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern' API, 'scss' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern' API, 'scss' syntax) 2`] = ` +{ + "version": 3, + "sourceRoot": "", + "sources": [ + "test/scss/charset-utf-8.scss" + ], + "names": [], + "mappings": "AAEA,EACE", + "sourcesContent": [ + "@charset \\"UTF-8\\";\\n\\np { \\n content: \\"é\\"; \\n}\\n" + ] +} +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern' API, 'scss' syntax) 3`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern' API, 'scss' syntax) 4`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern-compiler' API, 'sass' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern-compiler' API, 'sass' syntax) 2`] = ` +{ + "version": 3, + "sourceRoot": "", + "sources": [ + "test/sass/charset-utf-8.sass" + ], + "names": [], + "mappings": "AAEA,EACE", + "sourcesContent": [ + "@charset \\"UTF-8\\"\\n\\np\\n content: \\"é\\"\\n" + ] +} +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern-compiler' API, 'sass' syntax) 3`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern-compiler' API, 'sass' syntax) 4`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern-compiler' API, 'scss' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern-compiler' API, 'scss' syntax) 2`] = ` +{ + "version": 3, + "sourceRoot": "", + "sources": [ + "test/scss/charset-utf-8.scss" + ], + "names": [], + "mappings": "AAEA,EACE", + "sourcesContent": [ + "@charset \\"UTF-8\\";\\n\\np { \\n content: \\"é\\"; \\n}\\n" + ] +} +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern-compiler' API, 'scss' syntax) 3`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('dart-sass', 'modern-compiler' API, 'scss' syntax) 4`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern' API, 'sass' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern' API, 'sass' syntax) 2`] = ` +{ + "version": 3, + "sourceRoot": "", + "sources": [ + "test/sass/charset-utf-8.sass" + ], + "names": [], + "mappings": "CAEA,EACE", + "sourcesContent": [ + "@charset \\"UTF-8\\"\\n\\np\\n content: \\"é\\"\\n" + ] +} +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern' API, 'sass' syntax) 3`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern' API, 'sass' syntax) 4`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern' API, 'scss' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern' API, 'scss' syntax) 2`] = ` +{ + "version": 3, + "sourceRoot": "", + "sources": [ + "test/scss/charset-utf-8.scss" + ], + "names": [], + "mappings": "CAEA,EACE", + "sourcesContent": [ + "@charset \\"UTF-8\\";\\n\\np { \\n content: \\"é\\"; \\n}\\n" + ] +} +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern' API, 'scss' syntax) 3`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern' API, 'scss' syntax) 4`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern-compiler' API, 'sass' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern-compiler' API, 'sass' syntax) 2`] = ` +{ + "version": 3, + "sourceRoot": "", + "sources": [ + "test/sass/charset-utf-8.sass" + ], + "names": [], + "mappings": "CAEA,EACE", + "sourcesContent": [ + "@charset \\"UTF-8\\"\\n\\np\\n content: \\"é\\"\\n" + ] +} +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern-compiler' API, 'sass' syntax) 3`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern-compiler' API, 'sass' syntax) 4`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern-compiler' API, 'scss' syntax) 1`] = ` +"p{content:\\"é\\"}" +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern-compiler' API, 'scss' syntax) 2`] = ` +{ + "version": 3, + "sourceRoot": "", + "sources": [ + "test/scss/charset-utf-8.scss" + ], + "names": [], + "mappings": "CAEA,EACE", + "sourcesContent": [ + "@charset \\"UTF-8\\";\\n\\np { \\n content: \\"é\\"; \\n}\\n" + ] +} +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern-compiler' API, 'scss' syntax) 3`] = ` +[] +`; + +exports[`sourceMap option > should generate source maps for the \"compressed\" style without the removed BOM ('sass-embedded', 'modern-compiler' API, 'scss' syntax) 4`] = ` +[] +`; + exports[`sourceMap option > should generate source maps when value has \"false\" value, but the \"sassOptions.sourceMap\" has the \"true\" value ('dart-sass', 'modern' API, 'sass' syntax) 1`] = ` "@charset \\"UTF-8\\";\\n@import \\"./file.css\\";\\nbody {\\n font: 100% Helvetica, sans-serif;\\n color: #333;\\n}\\n\\nnav ul {\\n margin: 0;\\n padding: 0;\\n list-style: none;\\n}\\nnav li {\\n display: inline-block;\\n}\\nnav a {\\n display: block;\\n padding: 6px 12px;\\n text-decoration: none;\\n}\\n\\n.box {\\n -webkit-border-radius: 10px;\\n -moz-border-radius: 10px;\\n -ms-border-radius: 10px;\\n border-radius: 10px;\\n}\\n\\n.message, .warning, .error, .success {\\n border: 1px solid #ccc;\\n padding: 10px;\\n color: #333;\\n}\\n\\n.success {\\n border-color: green;\\n}\\n\\n.error {\\n border-color: red;\\n}\\n\\n.warning {\\n border-color: yellow;\\n}\\n\\n.foo:before {\\n content: \\"\\\\e0c6\\";\\n}\\n\\n.bar:before {\\n content: \\"∑\\";\\n}" `; diff --git a/test/loader.test.js b/test/loader.test.js index 86e603eb..a7a3ed93 100644 --- a/test/loader.test.js +++ b/test/loader.test.js @@ -938,7 +938,12 @@ describe("loader", () => { sassOptions: { style: "compressed" }, }); - assert.strictEqual(codeFromBundle.css, codeFromSass.css); + // `dart-sass` prepends a BOM to the `compressed` output when it contains + // non ASCII characters, the loader removes it + assert.strictEqual( + codeFromBundle.css, + codeFromSass.css.replace(/^\uFEFF/, ""), + ); t.assert.snapshot(codeFromBundle.css); t.assert.snapshot(getWarnings(stats)); t.assert.snapshot(getErrors(stats)); @@ -1332,6 +1337,27 @@ describe("loader", () => { await close(compiler); }); + it(`should remove the BOM from the compiled CSS ('${implementationName}', '${api}' API, '${syntax}' syntax)`, async (t) => { + const testId = getTestId("charset-utf-8", syntax); + const options = { + implementation, + api, + // `dart-sass` prepends a BOM to the `compressed` output when it + // contains non ASCII characters + sassOptions: { style: "compressed" }, + }; + const compiler = getCompiler(testId, { loader: { options } }); + const stats = await compile(compiler); + const codeFromBundle = getCodeFromBundle(stats, compiler); + + assert.notStrictEqual(codeFromBundle.css.charCodeAt(0), 0xfe_ff); + t.assert.snapshot(codeFromBundle.css); + t.assert.snapshot(getWarnings(stats)); + t.assert.snapshot(getErrors(stats)); + + await close(compiler); + }); + it(`should work ('${implementationName}', '${api}' API, '${syntax}' syntax) to disable "@charset "UTF-8";" generation`, async (t) => { const testId = getTestId("charset-utf-8", syntax); const options = { diff --git a/test/sassOptions-option.test.js b/test/sassOptions-option.test.js index 8ba34ffd..bfa29109 100644 --- a/test/sassOptions-option.test.js +++ b/test/sassOptions-option.test.js @@ -337,7 +337,12 @@ describe("sassOptions option", () => { sassOptions: { style: "compressed" }, }); - assert.strictEqual(codeFromBundle.css, codeFromSass.css); + // `dart-sass` prepends a BOM to the `compressed` output when it contains + // non ASCII characters, the loader removes it + assert.strictEqual( + codeFromBundle.css, + codeFromSass.css.replace(/^\uFEFF/, ""), + ); t.assert.snapshot(codeFromBundle.css); t.assert.snapshot(getWarnings(stats)); t.assert.snapshot(getErrors(stats)); diff --git a/test/sourceMap-options.test.js b/test/sourceMap-options.test.js index 41ca6f6f..0dd0ae66 100644 --- a/test/sourceMap-options.test.js +++ b/test/sourceMap-options.test.js @@ -94,6 +94,41 @@ describe("sourceMap option", () => { await close(compiler); }); + it(`should generate source maps for the "compressed" style without the removed BOM ('${implementationName}', '${api}' API, '${syntax}' syntax)`, async (t) => { + const testId = getTestId("charset-utf-8", syntax); + const options = { + implementation, + api, + sourceMap: true, + // `dart-sass` prepends a BOM to the `compressed` output when it + // contains non ASCII characters and counts it as the first column of + // the first line, so mappings have to be shifted when it is removed + sassOptions: { style: "compressed" }, + }; + const compiler = getCompiler(testId, { + devtool: "source-map", + loader: { options }, + }); + const stats = await compile(compiler); + const { css, sourceMap } = getCodeFromBundle(stats, compiler); + + assert.notStrictEqual(css.charCodeAt(0), 0xfe_ff); + + sourceMap.sourceRoot = ""; + sourceMap.sources = sourceMap.sources.map((source) => + path + .relative(path.resolve(__dirname, ".."), source) + .replaceAll("\\", "/"), + ); + + t.assert.snapshot(css); + t.assert.snapshot(getSourceMap(sourceMap)); + t.assert.snapshot(getWarnings(stats)); + t.assert.snapshot(getErrors(stats)); + + await close(compiler); + }); + it(`should generate source maps when value has "true" value and the "devtool" option has "false" value ('${implementationName}', '${api}' API, '${syntax}' syntax)`, async (t) => { const testId = getTestId("language", syntax); const options = { implementation, api, sourceMap: true }; diff --git a/types/utils.d.ts b/types/utils.d.ts index fa566390..37b645a5 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -292,3 +292,25 @@ export function normalizeSourceMap( map: RawSourceMap, rootContext: string, ): RawSourceMap; +/** + * Removes the byte order mark (BOM) from the compiled CSS. + * + * `dart-sass` prepends a BOM when the `charset` option is enabled (by default), + * the `style` option is `compressed` (the default for the `production` mode) and + * the compiled CSS contains non ASCII characters. + * A BOM is only meaningful at the very beginning of a file, but the loader result + * is just a string for webpack, and tools like `css-loader` move `@import` at-rules + * above it, so the BOM ends up in the middle of the generated CSS and breaks the + * first rule after it. + * @see https://github.com/webpack/sass-loader/issues/1335 + * @param {string} css compiled CSS + * @param {RawSourceMap=} map source map + * @returns {{ css: string, map: RawSourceMap | undefined }} the CSS without the BOM and the source map adjusted to it + */ +export function removeBOM( + css: string, + map?: RawSourceMap | undefined, +): { + css: string; + map: RawSourceMap | undefined; +}; From f5146bf1080ca89edd4276d5715370beb730a65a Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 30 Aug 2026 14:14:04 +0000 Subject: [PATCH 2/2] docs: note the upstream fixes that make the BOM removal a no-op Webpack removes a loader-produced BOM since #21857 and adjusts the source map with it since #21861. Record when this can be dropped: once the minimum supported webpack carries both and `rspack` handles a string result too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013PtW7eezwuQP5epLFMrAky --- .cspell.json | 3 ++- src/utils.js | 7 +++++++ types/utils.d.ts | 7 +++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index afc7d374..cb744d09 100644 --- a/.cspell.json +++ b/.cspell.json @@ -18,7 +18,8 @@ "commitlint", "bgcolor", "autocrlf", - "eslintcache" + "eslintcache", + "rspack" ], "ignorePaths": [ "CHANGELOG.md", diff --git a/src/utils.js b/src/utils.js index 612f235d..372b1e2c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -834,6 +834,13 @@ function encodeVLQ(value) { * is just a string for webpack, and tools like `css-loader` move `@import` at-rules * above it, so the BOM ends up in the middle of the generated CSS and breaks the * first rule after it. + * + * Webpack removes a BOM a loader produced itself since + * https://github.com/webpack/webpack/pull/21857 and keeps the source map in sync + * with it since https://github.com/webpack/webpack/pull/21861, which makes this a + * no-op there. It is still needed for the webpack versions the `peerDependencies` + * range allows and for `rspack`, which passes a string result through untouched. + * Remove it once both handle this in the minimum version we support. * @see https://github.com/webpack/sass-loader/issues/1335 * @param {string} css compiled CSS * @param {RawSourceMap=} map source map diff --git a/types/utils.d.ts b/types/utils.d.ts index 37b645a5..027a8360 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -302,6 +302,13 @@ export function normalizeSourceMap( * is just a string for webpack, and tools like `css-loader` move `@import` at-rules * above it, so the BOM ends up in the middle of the generated CSS and breaks the * first rule after it. + * + * Webpack removes a BOM a loader produced itself since + * https://github.com/webpack/webpack/pull/21857 and keeps the source map in sync + * with it since https://github.com/webpack/webpack/pull/21861, which makes this a + * no-op there. It is still needed for the webpack versions the `peerDependencies` + * range allows and for `rspack`, which passes a string result through untouched. + * Remove it once both handle this in the minimum version we support. * @see https://github.com/webpack/sass-loader/issues/1335 * @param {string} css compiled CSS * @param {RawSourceMap=} map source map