diff --git a/packages/core/integration-tests/test/integration/sass-include-paths-import/.sassrc.js b/packages/core/integration-tests/test/integration/sass-include-paths-import/.sassrc.js index aa1249d05c4..6858711bdef 100644 --- a/packages/core/integration-tests/test/integration/sass-include-paths-import/.sassrc.js +++ b/packages/core/integration-tests/test/integration/sass-include-paths-import/.sassrc.js @@ -3,5 +3,6 @@ const path = require('path') module.exports = { includePaths: [ path.join(__dirname, "include-path") - ] + ], + silenceDeprecations: ['legacy-js-api'] } diff --git a/packages/core/integration-tests/test/integration/sass-load-paths-import/.sassrc.js b/packages/core/integration-tests/test/integration/sass-load-paths-import/.sassrc.js new file mode 100644 index 00000000000..1ef9e547f27 --- /dev/null +++ b/packages/core/integration-tests/test/integration/sass-load-paths-import/.sassrc.js @@ -0,0 +1,7 @@ +const path = require('path') + +module.exports = { + loadPaths: [ + path.join(__dirname, "include-path") + ] +} diff --git a/packages/core/integration-tests/test/integration/sass-load-paths-import/include-path/style.sass b/packages/core/integration-tests/test/integration/sass-load-paths-import/include-path/style.sass new file mode 100644 index 00000000000..ca863e0d32e --- /dev/null +++ b/packages/core/integration-tests/test/integration/sass-load-paths-import/include-path/style.sass @@ -0,0 +1,2 @@ +.included + color: red \ No newline at end of file diff --git a/packages/core/integration-tests/test/integration/sass-load-paths-import/index.sass b/packages/core/integration-tests/test/integration/sass-load-paths-import/index.sass new file mode 100644 index 00000000000..0013a0044a9 --- /dev/null +++ b/packages/core/integration-tests/test/integration/sass-load-paths-import/index.sass @@ -0,0 +1 @@ +@import "style.sass" \ No newline at end of file diff --git a/packages/core/integration-tests/test/integration/sass-load-paths-import/package.json b/packages/core/integration-tests/test/integration/sass-load-paths-import/package.json new file mode 100644 index 00000000000..83a4a452279 --- /dev/null +++ b/packages/core/integration-tests/test/integration/sass-load-paths-import/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} \ No newline at end of file diff --git a/packages/core/integration-tests/test/integration/sass-load-paths-import/yarn.lock b/packages/core/integration-tests/test/integration/sass-load-paths-import/yarn.lock new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/core/integration-tests/test/integration/scss-global-data/.sassrc.js b/packages/core/integration-tests/test/integration/scss-global-data/.sassrc.js index 019eef685ec..c13727b874c 100644 --- a/packages/core/integration-tests/test/integration/scss-global-data/.sassrc.js +++ b/packages/core/integration-tests/test/integration/scss-global-data/.sassrc.js @@ -1,3 +1,4 @@ module.exports = { - data: "$color: red;" + data: "$color: red;", + silenceDeprecations: ['legacy-js-api'] } diff --git a/packages/core/integration-tests/test/sass.js b/packages/core/integration-tests/test/sass.js index c2879c33052..ec642949dba 100644 --- a/packages/core/integration-tests/test/sass.js +++ b/packages/core/integration-tests/test/sass.js @@ -284,7 +284,7 @@ describe('sass', function () { assert(css.includes('.external')); }); - it('should support imports from includePaths', async function () { + it('should support imports from includePaths (legacy)', async function () { let b = await bundle( path.join(__dirname, '/integration/sass-include-paths-import/index.sass'), ); @@ -300,6 +300,22 @@ describe('sass', function () { assert(css.includes('.included')); }); + it('should support imports from loadPaths (modern)', async function () { + let b = await bundle( + path.join(__dirname, '/integration/sass-load-paths-import/index.sass'), + ); + + assertBundles(b, [ + { + name: 'index.css', + assets: ['index.sass'], + }, + ]); + + let css = await outputFS.readFile(path.join(distDir, 'index.css'), 'utf8'); + assert(css.includes('.included')); + }); + it('should support package.json exports', async function () { let b = await bundle( path.join(__dirname, '/integration/sass-exports/index.sass'), diff --git a/packages/transformers/sass/package.json b/packages/transformers/sass/package.json index f2826ab8609..5b43e2372d8 100644 --- a/packages/transformers/sass/package.json +++ b/packages/transformers/sass/package.json @@ -22,6 +22,6 @@ "dependencies": { "@parcel/plugin": "2.12.0", "@parcel/source-map": "^2.1.1", - "sass": "^1.38.0" + "sass": "^1.79.4" } } diff --git a/packages/transformers/sass/src/SassTransformer.js b/packages/transformers/sass/src/SassTransformer.js index 179af651d1f..aef16561dde 100644 --- a/packages/transformers/sass/src/SassTransformer.js +++ b/packages/transformers/sass/src/SassTransformer.js @@ -1,13 +1,8 @@ // @flow import {Transformer} from '@parcel/plugin'; import path from 'path'; -import {EOL} from 'os'; -import SourceMap from '@parcel/source-map'; -import sass from 'sass'; -import {promisify} from 'util'; - -// E.g: ~library/file.sass -const NODE_MODULE_ALIAS_RE = /^~[^/\\]/; +import {transformLegacy} from './legacy'; +import {transformModern} from './modern'; export default (new Transformer({ async loadConfig({config, options}) { @@ -27,171 +22,102 @@ export default (new Transformer({ configResult = {}; } - // Resolve relative paths from config file - if (configFile && configResult.includePaths) { - configResult.includePaths = configResult.includePaths.map(p => - path.resolve(path.dirname(configFile.filePath), p), - ); - } - - if (configResult.importer === undefined) { - configResult.importer = []; - } else if (!Array.isArray(configResult.importer)) { - configResult.importer = [configResult.importer]; - } - - // Always emit sourcemap - configResult.sourceMap = true; - // sources are created relative to the directory of outFile - configResult.outFile = path.join(options.projectRoot, 'style.css.map'); - configResult.omitSourceMapUrl = true; - configResult.sourceMapContents = false; + let version = detectVersion(configResult); - return configResult; - }, + if (version === 'legacy') { + // Resolve relative paths from config file + if (configFile && configResult.includePaths) { + configResult.includePaths = configResult.includePaths.map(p => + path.resolve(path.dirname(configFile.filePath), p), + ); + } - async transform({asset, options, config, resolve}) { - let rawConfig = config ?? {}; - let sassRender = promisify(sass.render.bind(sass)); - let css; - try { - let code = await asset.getCode(); - let result = await sassRender({ - ...rawConfig, - file: asset.filePath, - data: rawConfig.data ? rawConfig.data + EOL + code : code, - importer: [ - ...rawConfig.importer, - resolvePathImporter({ - asset, - resolve, - includePaths: rawConfig.includePaths, - options, - }), - ], - indentedSyntax: - typeof rawConfig.indentedSyntax === 'boolean' - ? rawConfig.indentedSyntax - : asset.type === 'sass', - }); - - css = result.css; - for (let included of result.stats.includedFiles) { - if (included !== asset.filePath) { - asset.invalidateOnFileChange(included); - } + if (configResult.importer === undefined) { + configResult.importer = []; + } else if (!Array.isArray(configResult.importer)) { + configResult.importer = [configResult.importer]; } - if (result.map != null) { - let map = new SourceMap(options.projectRoot); - map.addVLQMap(JSON.parse(result.map)); - asset.setMap(map); + // Always emit sourcemap + configResult.sourceMap = true; + // sources are created relative to the directory of outFile + configResult.outFile = path.join(options.projectRoot, 'style.css.map'); + configResult.omitSourceMapUrl = true; + configResult.sourceMapContents = false; + } else if (version === 'modern') { + // Resolve relative paths from config file + if (configFile && configResult.loadPaths) { + configResult.loadPaths = configResult.loadPaths.map(p => + path.resolve(path.dirname(configFile.filePath), p), + ); } - } catch (err) { - // Adapt the Error object for the reporter. - err.fileName = err.file; - err.loc = { - line: err.line, - column: err.column, - }; - - throw err; + + // Always emit sourcemap + configResult.sourceMap = true; } - asset.type = 'css'; - asset.setCode(css); - return [asset]; + return {version, config: configResult}; }, -}): Transformer); -function resolvePathImporter({asset, resolve, includePaths, options}) { - // This is a reimplementation of the Sass resolution algorithm that uses Parcel's - // FS and tracks all tried files so they are watched for creation. - async function resolvePath( - url, - prev, - ): Promise<{filePath: string, contents: string, ...} | void> { - /* - Imports are resolved by trying, in order: - * Loading a file relative to the file in which the `@import` appeared. - * Each custom importer. - * Loading a file relative to the current working directory (This rule doesn't really make sense for Parcel). - * Each load path in `includePaths` - * Each load path specified in the `SASS_PATH` environment variable, which should be semicolon-separated on Windows and colon-separated elsewhere. - - See: https://sass-lang.com/documentation/js-api#importer - See also: https://github.com/sass/dart-sass/blob/006e6aa62f2417b5267ad5cdb5ba050226fab511/lib/src/importer/node/implementation.dart - */ - - let paths = [path.dirname(prev)]; - if (includePaths) { - paths.push(...includePaths); + async transform({asset, options, config: {version, config}, resolve}) { + if (version === 'legacy') { + await transformLegacy(asset, config, resolve, options); + } else { + await transformModern(asset, config, resolve, options); } - asset.invalidateOnEnvChange('SASS_PATH'); - if (options.env.SASS_PATH) { - paths.push( - ...options.env.SASS_PATH.split( - process.platform === 'win32' ? ';' : ':', - ).map(p => path.resolve(options.projectRoot, p)), - ); - } + return [asset]; + }, +}): Transformer); - const urls = [url]; - const urlFileName = path.basename(url); - if (urlFileName[0] !== '_') { - urls.push(path.join(path.dirname(url), `_${urlFileName}`)); +function detectVersion(config: any) { + for (let legacyOption of [ + 'data', + 'indentType', + 'indentWidth', + 'linefeed', + 'outputStyle', + 'importer', + 'pkgImporter', + 'includePaths', + 'omitSourceMapUrl', + 'outFile', + 'sourceMapContents', + 'sourceMapEmbed', + 'sourceMapRoot', + ]) { + if (config[legacyOption] != null) { + return 'legacy'; } + } - if (url[0] !== '~') { - for (let p of paths) { - for (let u of urls) { - const filePath = path.resolve(p, u); - try { - const contents = await asset.fs.readFile(filePath, 'utf8'); - return { - filePath, - contents, - }; - } catch (err) { - asset.invalidateOnFileCreate({filePath}); - } - } - } + for (let modernOption of [ + 'loadPaths', + 'sourceMapIncludeSources', + 'style', + 'importers', + ]) { + if (config[modernOption] != null) { + return 'modern'; } + } - // If none of the default sass rules apply, try Parcel's resolver. - for (let u of urls) { - if (NODE_MODULE_ALIAS_RE.test(u)) { - u = u.slice(1); - } - try { - const filePath = await resolve(prev, u, { - packageConditions: ['sass', 'style'], - }); - if (filePath) { - const contents = await asset.fs.readFile(filePath, 'utf8'); - return {filePath, contents}; - } - } catch (err) { - continue; + if (typeof config.sourceMap === 'string') { + return 'legacy'; + } + + if ( + config.functions && + typeof config.functions === 'object' && + Object.keys(config.functions).length > 0 + ) { + for (let key in config.functions) { + let fn = config.functions[key]; + if (typeof fn === 'function' && fn.length > 1) { + return 'legacy'; } } } - return function (rawUrl, prev, done) { - const url = rawUrl.replace(/^file:\/\//, ''); - resolvePath(url, prev) - .then(resolved => { - if (resolved) { - done({ - file: resolved.filePath, - contents: resolved.contents, - }); - } else { - done(); - } - }) - .catch(done); - }; + return 'modern'; } diff --git a/packages/transformers/sass/src/legacy.js b/packages/transformers/sass/src/legacy.js new file mode 100644 index 00000000000..d3518368b3f --- /dev/null +++ b/packages/transformers/sass/src/legacy.js @@ -0,0 +1,159 @@ +// @flow +import type {MutableAsset, ResolveFn, PluginOptions} from '@parcel/types'; +import path from 'path'; +import {EOL} from 'os'; +import SourceMap from '@parcel/source-map'; +import sass from 'sass'; +import {promisify} from 'util'; + +// E.g: ~library/file.sass +const NODE_MODULE_ALIAS_RE = /^~[^/\\]/; + +export async function transformLegacy( + asset: MutableAsset, + config: any, + resolve: ResolveFn, + options: PluginOptions, +) { + let rawConfig = config ?? {}; + let sassRender = promisify(sass.render.bind(sass)); + let css; + try { + let code = await asset.getCode(); + let result = await sassRender({ + ...rawConfig, + file: asset.filePath, + data: rawConfig.data ? rawConfig.data + EOL + code : code, + importer: [ + ...rawConfig.importer, + resolvePathImporter({ + asset, + resolve, + includePaths: rawConfig.includePaths, + options, + }), + ], + indentedSyntax: + typeof rawConfig.indentedSyntax === 'boolean' + ? rawConfig.indentedSyntax + : asset.type === 'sass', + }); + + css = result.css; + for (let included of result.stats.includedFiles) { + if (included !== asset.filePath) { + asset.invalidateOnFileChange(included); + } + } + + if (result.map != null) { + let map = new SourceMap(options.projectRoot); + map.addVLQMap(JSON.parse(result.map)); + asset.setMap(map); + } + } catch (err) { + // Adapt the Error object for the reporter. + err.fileName = err.file; + err.loc = { + line: err.line, + column: err.column, + }; + + throw err; + } + + asset.type = 'css'; + asset.setCode(css); +} + +function resolvePathImporter({asset, resolve, includePaths, options}) { + // This is a reimplementation of the Sass resolution algorithm that uses Parcel's + // FS and tracks all tried files so they are watched for creation. + async function resolvePath( + url, + prev, + ): Promise<{filePath: string, contents: string, ...} | void> { + /* + Imports are resolved by trying, in order: + * Loading a file relative to the file in which the `@import` appeared. + * Each custom importer. + * Loading a file relative to the current working directory (This rule doesn't really make sense for Parcel). + * Each load path in `includePaths` + * Each load path specified in the `SASS_PATH` environment variable, which should be semicolon-separated on Windows and colon-separated elsewhere. + + See: https://sass-lang.com/documentation/js-api#importer + See also: https://github.com/sass/dart-sass/blob/006e6aa62f2417b5267ad5cdb5ba050226fab511/lib/src/importer/node/implementation.dart + */ + + let paths = [path.dirname(prev)]; + if (includePaths) { + paths.push(...includePaths); + } + + asset.invalidateOnEnvChange('SASS_PATH'); + if (options.env.SASS_PATH) { + paths.push( + ...options.env.SASS_PATH.split( + process.platform === 'win32' ? ';' : ':', + ).map(p => path.resolve(options.projectRoot, p)), + ); + } + + const urls = [url]; + const urlFileName = path.basename(url); + if (urlFileName[0] !== '_') { + urls.push(path.join(path.dirname(url), `_${urlFileName}`)); + } + + if (url[0] !== '~') { + for (let p of paths) { + for (let u of urls) { + const filePath = path.resolve(p, u); + try { + const contents = await asset.fs.readFile(filePath, 'utf8'); + return { + filePath, + contents, + }; + } catch (err) { + asset.invalidateOnFileCreate({filePath}); + } + } + } + } + + // If none of the default sass rules apply, try Parcel's resolver. + for (let u of urls) { + if (NODE_MODULE_ALIAS_RE.test(u)) { + u = u.slice(1); + } + try { + const filePath = await resolve(prev, u, { + packageConditions: ['sass', 'style'], + }); + if (filePath) { + const contents = await asset.fs.readFile(filePath, 'utf8'); + return {filePath, contents}; + } + } catch (err) { + continue; + } + } + } + + return function (rawUrl, prev, done) { + const url = rawUrl.replace(/^file:\/\//, ''); + resolvePath(url, prev) + .then(resolved => { + if (resolved) { + done({ + file: resolved.filePath, + contents: resolved.contents, + }); + } else { + done(); + } + }) + .catch(done); + }; +} diff --git a/packages/transformers/sass/src/modern.js b/packages/transformers/sass/src/modern.js new file mode 100644 index 00000000000..10aaf04d36d --- /dev/null +++ b/packages/transformers/sass/src/modern.js @@ -0,0 +1,160 @@ +// @flow +import type {MutableAsset, ResolveFn, PluginOptions} from '@parcel/types'; +import path from 'path'; +import {extname} from 'path'; +import SourceMap from '@parcel/source-map'; +import sass from 'sass'; +import {fileURLToPath, pathToFileURL} from 'url'; + +// E.g: ~library/file.sass +const NODE_MODULE_ALIAS_RE = /^~[^/\\]/; + +export async function transformModern( + asset: MutableAsset, + config: any, + resolve: ResolveFn, + options: PluginOptions, +) { + let rawConfig = config ?? {}; + let css; + try { + let code = await asset.getCode(); + let indentedSyntax = + rawConfig.syntax === 'indented' || + typeof rawConfig.indentedSyntax === 'boolean' + ? rawConfig.indentedSyntax + : undefined; + let result = await sass.compileStringAsync(code, { + ...rawConfig, + loadPaths: undefined, + url: pathToFileURL(asset.filePath), + importers: [ + ...(rawConfig.importers || []), + resolvePathImporter({ + asset, + resolve, + loadPaths: rawConfig.loadPaths, + indentedSyntax, + options, + }), + ], + syntax: (indentedSyntax != null ? indentedSyntax : asset.type === 'sass') + ? 'indented' + : 'scss', + sourceMap: !!asset.env.sourceMap, + }); + + css = result.css; + for (let included of result.loadedUrls) { + let file = fileURLToPath(included); + if (file !== asset.filePath) { + asset.invalidateOnFileChange(file); + } + } + + if (result.sourceMap != null) { + let map = new SourceMap(options.projectRoot); + map.addVLQMap(result.sourceMap); + asset.setMap(map); + } + } catch (err) { + // Adapt the Error object for the reporter. + err.fileName = err.file; + err.loc = { + line: err.line, + column: err.column, + }; + + throw err; + } + + asset.type = 'css'; + asset.setCode(css); +} + +function resolvePathImporter({ + asset, + resolve, + loadPaths, + indentedSyntax, + options, +}) { + return { + // This is a reimplementation of the Sass resolution algorithm that uses Parcel's + // FS and tracks all tried files so they are watched for creation. + async canonicalize(url, {containingUrl}) { + /* + Imports are resolved by trying, in order: + * Loading a file relative to the file in which the `@import` appeared. + * Each custom importer. + * Loading a file relative to the current working directory (This rule doesn't really make sense for Parcel). + * Each load path in `includePaths` + * Each load path specified in the `SASS_PATH` environment variable, which should be semicolon-separated on Windows and colon-separated elsewhere. + + See: https://sass-lang.com/documentation/js-api#importer + See also: https://github.com/sass/dart-sass/blob/006e6aa62f2417b5267ad5cdb5ba050226fab511/lib/src/importer/node/implementation.dart + */ + + let containingPath = fileURLToPath(containingUrl); + let paths = [path.dirname(containingPath)]; + if (loadPaths) { + paths.push(...loadPaths); + } + + asset.invalidateOnEnvChange('SASS_PATH'); + if (options.env.SASS_PATH) { + paths.push( + ...options.env.SASS_PATH.split( + process.platform === 'win32' ? ';' : ':', + ).map(p => path.resolve(options.projectRoot, p)), + ); + } + + const urls = [url]; + const urlFileName = path.basename(url); + if (urlFileName[0] !== '_') { + urls.push(path.join(path.dirname(url), `_${urlFileName}`)); + } + + if (url[0] !== '~') { + for (let p of paths) { + for (let u of urls) { + const filePath = path.resolve(p, u); + if (await asset.fs.exists(filePath)) { + return pathToFileURL(filePath); + } + + asset.invalidateOnFileCreate({filePath}); + } + } + } + + // If none of the default sass rules apply, try Parcel's resolver. + for (let u of urls) { + if (NODE_MODULE_ALIAS_RE.test(u)) { + u = u.slice(1); + } + try { + const filePath = await resolve(containingPath, u, { + packageConditions: ['sass', 'style'], + }); + return pathToFileURL(filePath); + } catch (err) { + continue; + } + } + }, + async load(url) { + let path = fileURLToPath(url); + const contents = await asset.fs.readFile(path, 'utf8'); + return { + contents, + syntax: ( + indentedSyntax != null ? indentedSyntax : extname(path) === '.sass' + ) + ? 'indented' + : 'scss', + }; + }, + }; +} diff --git a/yarn.lock b/yarn.lock index 1f3949c35ca..8400bd7eb05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12911,7 +12911,7 @@ safe-regex@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sass@^1.38.0: +sass@^1.79.4: version "1.79.4" resolved "https://registry.yarnpkg.com/sass/-/sass-1.79.4.tgz#f9c45af35fbeb53d2c386850ec842098d9935267" integrity sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg==