@4c/fast-sass-loader
Version:
fast sass loader for webpack
360 lines (297 loc) • 8.96 kB
JavaScript
/* eslint-disable global-require */
/* eslint-disable no-await-in-loop */
const path = require('path');
const preview = require('cli-source-preview');
const { promisify, debuglog } = require('util');
const loaderUtils = require('loader-utils');
const Cache = require('./cache');
const utils = require('./utils');
const replaceAsync = require('./replace');
const getSassImplementation = require('./getSassImplementation');
const BOM_HEADER = '\uFEFF';
const EXT_PRECEDENCE = ['.scss', '.sass', '.css'];
const MATCH_URL_ALL = /url\(\s*(['"]?)([^ '"()]+)(\1)\s*\)/g;
const MATCH_IMPORTS = /@import\s+(['"])([^,;'"]+)(\1)(\s*,\s*(['"])([^,;'"]+)(\1))*\s*(;|$)/g;
const MATCH_FILES = /(['"])([^,;'"]+)(\1)/g;
const debug = debuglog('fast-sass-loader');
class FastSassLoaderError extends Error {
constructor(source, err) {
super();
const { message } = err;
this.name = 'FastSassLoaderError';
this.message = `${message}\n\n${preview(source, err, { offset: 5 })}\n`;
Error.captureStackTrace(this, this.constructor);
}
}
function getImportsToResolve(original, includePaths, transformers) {
const extname = path.extname(original);
let basename = path.basename(original, extname);
const dirname = path.dirname(original);
const imports = [];
let names = [basename];
let exts = [extname];
const extensionPrecedence = [
...EXT_PRECEDENCE,
...Object.keys(transformers),
];
if (!extname) {
exts = extensionPrecedence;
}
if (extname && extensionPrecedence.indexOf(extname) === -1) {
basename = path.basename(original);
names = [basename];
exts = extensionPrecedence;
}
if (basename[0] !== '_') {
names.push(`_${basename}`);
}
for (let i = 0; i < names.length; i++) {
for (let j = 0; j < exts.length; j++) {
// search relative to original file
imports.push(path.join(dirname, names[i] + exts[j]));
// search in includePaths
for (const includePath of includePaths) {
imports.push(path.join(includePath, dirname, names[i] + exts[j]));
}
}
}
return imports;
}
function createTransformersMap(transformers) {
if (!transformers) return {};
// return map of extension strings to transformer functions
return transformers.reduce((acc, transformer) => {
transformer.extensions.forEach(ext => {
acc[ext] = transformer.transform;
});
return acc;
}, {});
}
function getLoaderConfig(ctx) {
const options = loaderUtils.getOptions(ctx) || {};
const includePaths = options.includePaths || [];
const basedir =
ctx.rootContext || options.context || ctx.options.context || process.cwd();
const transformers = createTransformersMap(options.transformers);
// convert relative to absolute
for (let i = 0; i < includePaths.length; i++) {
if (!path.isAbsolute(includePaths[i])) {
includePaths[i] = path.join(basedir, includePaths[i]);
}
}
return {
basedir,
includePaths,
transformers,
baseEntryDir: path.dirname(ctx.resourcePath),
root: options.root,
data: options.data,
};
}
async function mergeSources(
opts,
entry,
resolveModule,
dependencies,
fs,
level,
) {
level = level || 0;
dependencies = dependencies || [];
const { includePaths, transformers } = opts;
let content = false;
function readFile(file) {
return new Promise((resolve, reject) =>
fs.readFile(file, (err, result) => {
if (err) reject(err);
else resolve(result.toString('utf8'));
}),
);
}
function existsSync(file) {
try {
return !!fs.statSync(file);
} catch (err) {
return false;
}
}
if (typeof entry === 'object') {
content = entry.content;
entry = entry.file;
} else {
content = await readFile(entry);
// fix BOM issue (only on windows)
if (content.startsWith(BOM_HEADER)) {
content = content.substring(BOM_HEADER.length);
}
}
const ext = path.extname(entry);
if (transformers[ext]) {
content = transformers[ext](content);
}
if (opts.data) {
content = `${opts.data}\n${content}`;
}
const entryDir = path.dirname(entry);
// replace url(...)
content = content.replace(MATCH_URL_ALL, (total, left, file, right) => {
if (loaderUtils.isUrlRequest(file)) {
// handle url(<loader>!<file>)
const pos = file.lastIndexOf('!');
if (pos >= 0) {
left += file.substring(0, pos + 1);
file = file.substring(pos + 1);
}
// test again
if (loaderUtils.isUrlRequest(file)) {
const absoluteFile = path.normalize(path.resolve(entryDir, file));
let relativeFile = path
.relative(opts.baseEntryDir, absoluteFile)
.replace(/\\/g, '/'); // fix for windows path
if (relativeFile[0] !== '.') {
relativeFile = `./${relativeFile}`;
}
return `url(${left}${relativeFile}${right})`;
}
return total;
}
return total;
});
// find comments should after content.replace(...), otherwise the comments offset will be incorrect
const commentRanges = utils.findComments(content);
// replace @import "..."
async function importReplacer(total) {
// if current import is in comments, then skip it
const range = this;
const finded = commentRanges.find(
commentRange =>
range.start >= commentRange[0] && range.end <= commentRange[1],
);
if (finded) {
return total;
}
const contents = [];
let matched;
// must reset lastIndex
MATCH_FILES.lastIndex = 0;
// eslint-disable-next-line
while ((matched = MATCH_FILES.exec(total))) {
// eslint-disable-line
const originalImport = matched[2].trim();
if (!originalImport) {
const err = new Error(
`import file cannot be empty: "${total}" @${entry}`,
);
err.file = entry;
throw err;
}
const imports = getImportsToResolve(
originalImport,
includePaths,
transformers,
);
let resolvedImport;
for (let i = 0; i < imports.length; i++) {
// if imports[i] is absolute path, then use it directly
if (path.isAbsolute(imports[i]) && existsSync(imports[i])) {
resolvedImport = imports[i];
} else {
try {
const reqFile = loaderUtils.urlToRequest(imports[i], opts.root);
resolvedImport = await resolveModule(entryDir, reqFile);
break;
} catch (err) {
// skip
}
}
}
if (!resolvedImport) {
const err = new Error(
`import file cannot be resolved: "${total}" @${entry}`,
);
err.file = entry;
throw err;
}
resolvedImport = path.normalize(resolvedImport);
if (dependencies.indexOf(resolvedImport) < 0) {
dependencies.push(resolvedImport);
contents.push(
await mergeSources(
opts,
resolvedImport,
resolveModule,
dependencies,
fs,
level + 1,
),
);
}
}
return contents.join('\n');
}
return replaceAsync(content, MATCH_IMPORTS, importReplacer);
}
module.exports = async function loader(content) {
const { fs, resourcePath: entry } = this;
const callback = this.async();
const options = getLoaderConfig(this);
const resolve = promisify(this.resolve.bind(this));
const cache = new Cache(entry, fs);
// for webpack 1
if (this.cacheable) {
this.cacheable();
}
const dependencies = [];
if (cache.isValid()) {
cache.getDependencies().forEach(file => {
this.dependency(file);
});
return callback(null, cache.read());
}
debug('cache miss for %s', entry);
let merged;
try {
merged = await mergeSources(
options,
{ file: entry, content },
resolve,
dependencies,
fs,
);
dependencies.forEach(file => {
this.dependency(file);
});
} catch (err) {
// disabled cache
cache.markInvalid();
// add error file as deps, so if file changed next time sass-loader will be noticed
if (err.file) this.dependency(err.file);
return callback(err);
}
const [renderSass, isDartSass] = getSassImplementation(
options.implementation,
);
const { sassOptions = {} } = options;
if (isDartSass && sassOptions.fiber == null) {
try {
// eslint-disable-next-line import/no-dynamic-require
options.fiber = require(require.resolve('fibers'));
} catch (err) {
/* ignore */
}
}
let css;
try {
const result = await renderSass({
indentedSyntax: entry.endsWith('.sass'),
file: entry,
data: merged,
});
css = result.css.toString();
cache.write(dependencies, css);
} catch (err) {
// console.log('HERE', err);
return callback(new FastSassLoaderError(merged, err));
}
return callback(null, css);
};