Skip to content

Commit

Permalink
refactor: export a decoupled Sass importer (#874)
Browse files Browse the repository at this point in the history
  • Loading branch information
vvanpo committed Aug 24, 2020
1 parent a838317 commit d2b532c
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 56 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -77,6 +77,7 @@
"css-loader": "^4.2.0",
"del": "^5.1.0",
"del-cli": "^3.0.1",
"enhanced-resolve": "^4.3.0",
"eslint": "^7.6.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.21.2",
Expand Down
166 changes: 110 additions & 56 deletions src/utils.js
Expand Up @@ -24,6 +24,11 @@ function getDefaultSassImplementation() {
return require(sassImplPkg);
}

/**
* @public
* This function is not Webpack-specific and can be used by tools wishing to
* mimic `sass-loader`'s behaviour, so its signature should not be changed.
*/
function getSassImplementation(implementation) {
let resolvedImplementation = implementation;

Expand Down Expand Up @@ -211,22 +216,19 @@ const isModuleImport = /^~([^/]+|[^/]+\/|@[^/]+[/][^/]+|@[^/]+\/?|@[^/]+[/][^/]+
*
* @param {string} url
* @param {boolean} forWebpackResolver
* @param {Object} loaderContext
* @param {string} rootContext
* @returns {Array<string>}
*/
export default function getPossibleRequests(
loaderContext,
function getPossibleRequests(
// eslint-disable-next-line no-shadow
url,
forWebpackResolver = false
forWebpackResolver = false,
rootContext = false
) {
const request = urlToRequest(
url,
// Maybe it is server-relative URLs
forWebpackResolver && url.charAt(0) === '/'
? loaderContext.rootContext
: // eslint-disable-next-line no-undefined
undefined
forWebpackResolver && rootContext
);

// In case there is module request, send this to webpack resolver
Expand Down Expand Up @@ -262,23 +264,53 @@ export default function getPossibleRequests(
];
}

const matchCss = /\.css$/i;
function promiseResolve(callbackResolve) {
return (context, request) =>
new Promise((resolve, reject) => {
callbackResolve(context, request, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
}

const isSpecialModuleImport = /^~[^/]+$/;
// `[drive_letter]:\` + `\\[server]\[sharename]\`
const isNativeWin32Path = /^[a-zA-Z]:[/\\]|^\\\\/i;

function getWebpackImporter(loaderContext, implementation, includePaths) {
/**
* @public
* Create the resolve function used in the custom Sass importer.
*
* Can be used by external tools to mimic how `sass-loader` works, for example
* in a Jest transform. Such usages will want to wrap `resolve.create` from
* [`enhanced-resolve`]{@link https://github.com/webpack/enhanced-resolve} to
* pass as the `resolverFactory` argument.
*
* @param {Function} resolverFactory - A factory function for creating a Webpack
* resolver.
* @param {Object} implementation - The imported Sass implementation, both
* `sass` (Dart Sass) and `node-sass` are supported.
* @param {string[]} [includePaths] - The list of include paths passed to Sass.
* @param {boolean} [rootContext] - The configured Webpack root context.
*
* @throws If a compatible Sass implementation cannot be found.
*/
function getWebpackResolver(
resolverFactory,
implementation,
includePaths = [],
rootContext = false
) {
async function startResolving(resolutionMap) {
if (resolutionMap.length === 0) {
return Promise.reject();
}

const [{ resolve, context, possibleRequests }] = resolutionMap;

let result;

try {
result = await resolve(context, possibleRequests[0]);
return await resolve(context, possibleRequests[0]);
} catch (_ignoreError) {
const [, ...tailResult] = possibleRequests;

Expand All @@ -293,46 +325,43 @@ function getWebpackImporter(loaderContext, implementation, includePaths) {

return startResolving(resolutionMap);
}

// Add the result as dependency.
// Although we're also using stats.includedFiles, this might come in handy when an error occurs.
// In this case, we don't get stats.includedFiles from node-sass/sass.
loaderContext.addDependency(path.normalize(result));

// By removing the CSS file extension, we trigger node-sass to include the CSS file instead of just linking it.
return { file: result.replace(matchCss, '') };
}

const sassResolve = loaderContext.getResolve({
alias: [],
aliasFields: [],
conditionNames: [],
descriptionFiles: [],
extensions: ['.sass', '.scss', '.css'],
exportsFields: [],
mainFields: [],
mainFiles: ['_index', 'index'],
modules: [],
restrictions: [/\.((sa|sc|c)ss)$/i],
});
const webpackResolve = loaderContext.getResolve({
conditionNames: ['sass', 'style'],
mainFields: ['sass', 'style', 'main', '...'],
mainFiles: ['_index', 'index', '...'],
extensions: ['.sass', '.scss', '.css'],
restrictions: [/\.((sa|sc|c)ss)$/i],
});

return (originalUrl, prev, done) => {
let request = originalUrl;
const isDartSass = implementation.info.includes('dart-sass');
const sassResolve = promiseResolve(
resolverFactory({
alias: [],
aliasFields: [],
conditionNames: [],
descriptionFiles: [],
extensions: ['.sass', '.scss', '.css'],
exportsFields: [],
mainFields: [],
mainFiles: ['_index', 'index'],
modules: [],
restrictions: [/\.((sa|sc|c)ss)$/i],
})
);
const webpackResolve = promiseResolve(
resolverFactory({
conditionNames: ['sass', 'style'],
mainFields: ['sass', 'style', 'main', '...'],
mainFiles: ['_index', 'index', '...'],
extensions: ['.sass', '.scss', '.css'],
restrictions: [/\.((sa|sc|c)ss)$/i],
})
);

const isFileScheme = originalUrl.slice(0, 5).toLowerCase() === 'file:';
return (context, request) => {
const originalRequest = request;
const isFileScheme = originalRequest.slice(0, 5).toLowerCase() === 'file:';

if (isFileScheme) {
try {
// eslint-disable-next-line no-param-reassign
request = url.fileURLToPath(originalUrl);
request = url.fileURLToPath(originalRequest);
} catch (ignoreError) {
// eslint-disable-next-line no-param-reassign
request = request.slice(7);
}
}
Expand All @@ -347,8 +376,8 @@ function getWebpackImporter(loaderContext, implementation, includePaths) {
// - Server-relative URLs - `<context>/path/to/file.ext` (where `<context>` is root context)
// - Absolute path - `/full/path/to/file.ext` or `C:\\full\path\to\file.ext`
!isFileScheme &&
!originalUrl.startsWith('/') &&
!isNativeWin32Path.test(originalUrl);
!originalRequest.startsWith('/') &&
!isNativeWin32Path.test(originalRequest);

if (includePaths.length > 0 && needEmulateSassResolver) {
// The order of import precedence is as follows:
Expand All @@ -360,19 +389,19 @@ function getWebpackImporter(loaderContext, implementation, includePaths) {
// 5. Filesystem imports relative to a `SASS_PATH` path.
//
// Because `sass`/`node-sass` run custom importers before `3`, `4` and `5` points, we need to emulate this behavior to avoid wrong resolution.
const sassPossibleRequests = getPossibleRequests(loaderContext, request);
const isDartSass = implementation.info.includes('dart-sass');
const sassPossibleRequests = getPossibleRequests(request);

// `node-sass` calls our importer before `1. Filesystem imports relative to the base file.`, so we need emulate this too
if (!isDartSass) {
resolutionMap = resolutionMap.concat({
resolve: sassResolve,
context: path.dirname(prev),
context: path.dirname(context),
possibleRequests: sassPossibleRequests,
});
}

resolutionMap = resolutionMap.concat(
// eslint-disable-next-line no-shadow
includePaths.map((context) => ({
resolve: sassResolve,
context,
Expand All @@ -382,21 +411,45 @@ function getWebpackImporter(loaderContext, implementation, includePaths) {
}

const webpackPossibleRequests = getPossibleRequests(
loaderContext,
request,
true
true,
rootContext
);

resolutionMap = resolutionMap.concat({
resolve: webpackResolve,
context: path.dirname(prev),
context: path.dirname(context),
possibleRequests: webpackPossibleRequests,
});

startResolving(resolutionMap)
return startResolving(resolutionMap);
};
}

const matchCss = /\.css$/i;

function getWebpackImporter(loaderContext, implementation, includePaths) {
const resolve = getWebpackResolver(
loaderContext.getResolve,
implementation,
includePaths,
loaderContext.rootContext
);

return (originalUrl, prev, done) => {
resolve(prev, originalUrl)
.then((result) => {
// Add the result as dependency.
// Although we're also using stats.includedFiles, this might come in handy when an error occurs.
// In this case, we don't get stats.includedFiles from node-sass/sass.
loaderContext.addDependency(path.normalize(result));
// By removing the CSS file extension, we trigger node-sass to include the CSS file instead of just linking it.
done({ file: result.replace(matchCss, '') });
})
// Catch all resolving errors, return the original file and pass responsibility back to other custom importers
.catch(() => ({ file: originalUrl }))
.then((result) => done(result));
.catch(() => {
done({ file: originalUrl });
});
};
}

Expand Down Expand Up @@ -433,6 +486,7 @@ function getRenderFunctionFromSassImplementation(implementation) {
export {
getSassImplementation,
getSassOptions,
getWebpackResolver,
getWebpackImporter,
getRenderFunctionFromSassImplementation,
};
44 changes: 44 additions & 0 deletions test/resolver.test.js
@@ -0,0 +1,44 @@
import { fileURLToPath } from 'url';

import enhanced from 'enhanced-resolve';
import sass from 'sass';

import { getWebpackResolver } from '../src/utils';

/**
* Because `getWebpackResolver` is a public function that can be imported by
* external packages, we want to test it separately to ensure its API does not
* change unexpectedly.
*/
describe('getWebpackResolver', () => {
const resolve = (request, ...options) =>
getWebpackResolver(enhanced.create, sass, ...options)(__filename, request);

it('should resolve .scss from node_modules', async () => {
expect(await resolve('scss/style')).toMatch(/style\.scss$/);
});

it('should resolve from passed `includePaths`', async () => {
expect(await resolve('empty', [`${__dirname}/scss`])).toMatch(
/empty\.scss$/
);
});

it('should reject when file cannot be resolved', async () => {
await expect(resolve('foo/bar/baz')).rejects.toBe();
});

if (process.platform !== 'win32') {
// a `file:` URI with two `/`s indicates the next segment is a hostname,
// which Node restricts to `localhost` on Unix platforms. Because it is
// nevertheless commonly used, the resolver converts it to a relative path.
// Node does allow specifying remote hosts in the Windows environment, so
// this test is restricted to Unix platforms.
it('should convert an invalid file URL with an erroneous hostname to a relative path', async () => {
const invalidFileURL = 'file://scss/empty';

expect(() => fileURLToPath(invalidFileURL)).toThrow();
expect(await resolve(invalidFileURL)).toMatch(/empty\.scss$/);
});
}
});

0 comments on commit d2b532c

Please sign in to comment.