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/.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/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..372b1e2c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -764,6 +764,108 @@ 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. + * + * 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 + * @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 +892,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..027a8360 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -292,3 +292,32 @@ 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. + * + * 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 + * @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; +};