diff --git a/packages/mako/src/lessLoader/plugin.ts b/packages/mako/src/lessLoader/plugin.ts new file mode 100644 index 000000000..f34bc4050 --- /dev/null +++ b/packages/mako/src/lessLoader/plugin.ts @@ -0,0 +1,163 @@ +import EnhancedResolve, { type ResolveFunctionAsync } from 'enhanced-resolve'; + +/* eslint-disable class-methods-use-this */ +const trailingSlash = /[/\\]$/; + +// This somewhat changed in Less 3.x. Now the file name comes without the +// automatically added extension whereas the extension is passed in as `options.ext`. +// So, if the file name matches this regexp, we simply ignore the proposed extension. +const IS_SPECIAL_MODULE_IMPORT = /^~[^/]+$/; + +// `[drive_letter]:\` + `\\[server]\[share_name]\` +const IS_NATIVE_WIN32_PATH = /^[a-z]:[/\\]|^\\\\/i; + +// Examples: +// - ~package +// - ~package/ +// - ~@org +// - ~@org/ +// - ~@org/package +// - ~@org/package/ +const IS_MODULE_IMPORT = + /^~([^/]+|[^/]+\/|@[^/]+[/][^/]+|@[^/]+\/?|@[^/]+[/][^/]+\/)$/; +const MODULE_REQUEST_REGEX = /^[^?]*~/; + +export function createLessPlugin(less: LessStatic): Less.Plugin { + const resolve = EnhancedResolve.create({ + conditionNames: ['less', 'style', '...'], + mainFields: ['less', 'style', 'main', '...'], + mainFiles: ['index', '...'], + extensions: ['.less', '.css'], + preferRelative: true, + }); + + class FileManager extends less.FileManager { + supports(filename: string) { + if (filename[0] === '/' || IS_NATIVE_WIN32_PATH.test(filename)) { + return true; + } + + if (this.isPathAbsolute(filename)) { + return false; + } + + return true; + } + + supportsSync() { + return false; + } + + async resolveFilename(filename: string, currentDirectory: string) { + // Less is giving us trailing slashes, but the context should have no trailing slash + const context = currentDirectory.replace(trailingSlash, ''); + + let request = filename; + + // A `~` makes the url an module + if (MODULE_REQUEST_REGEX.test(filename)) { + request = request.replace(MODULE_REQUEST_REGEX, ''); + } + + if (IS_MODULE_IMPORT.test(filename)) { + request = request[request.length - 1] === '/' ? request : `${request}/`; + } + + return this.resolveRequests(context, [...new Set([request, filename])]); + } + + async resolveRequests( + context: string, + possibleRequests: string[], + ): Promise { + if (possibleRequests.length === 0) { + return Promise.reject(); + } + + let result; + + try { + result = await asyncResolve(context, possibleRequests[0], resolve); + } catch (error) { + const [, ...tailPossibleRequests] = possibleRequests; + + if (tailPossibleRequests.length === 0) { + throw error; + } + + result = await this.resolveRequests(context, tailPossibleRequests); + } + + return result; + } + + async loadFile( + filename: string, + currentDirectory: string, + options: Less.LoadFileOptions, + environment: Less.Environment, + ) { + let result; + + try { + if (IS_SPECIAL_MODULE_IMPORT.test(filename)) { + const error = new Error() as any; + error.type = 'Next'; + throw error; + } + + result = await super.loadFile( + filename, + currentDirectory, + options, + environment, + ); + } catch (error: any) { + if (error.type !== 'File' && error.type !== 'Next') { + return Promise.reject(error); + } + + try { + result = await this.resolveFilename(filename, currentDirectory); + } catch (webpackResolveError) { + return Promise.reject(error); + } + + // addDependency(result); + + return super.loadFile(result, currentDirectory, options, environment); + } + + // const absoluteFilename = path.isAbsolute(result.filename) + // ? result.filename + // : path.resolve(".", result.filename); + // addDependency(path.normalize(absoluteFilename)); + + return result; + } + } + + return { + install(_lessInstance, pluginManager) { + pluginManager.addFileManager(new FileManager()); + }, + minVersion: [3, 0, 0], + }; +} + +function asyncResolve( + context: string, + path: string, + resolve: ResolveFunctionAsync, +): Promise { + return new Promise((res, rej) => { + resolve(context, path, (err, result) => { + if (err) { + rej(err); + return; + } + + res(result as string); + }); + }); +} diff --git a/packages/mako/src/lessLoader/render.ts b/packages/mako/src/lessLoader/render.ts index 25a1c58ab..c8269b454 100644 --- a/packages/mako/src/lessLoader/render.ts +++ b/packages/mako/src/lessLoader/render.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import less from 'less'; import { LessLoaderOpts } from '.'; +import { createLessPlugin } from './plugin'; module.exports = async function render(param: { filename: string; @@ -19,6 +20,8 @@ module.exports = async function render(param: { } }); + pluginInstances?.unshift(createLessPlugin(less)); + const result = await less .render(input, { filename: param.filename, diff --git a/packages/mako/src/sassLoader/importer.ts b/packages/mako/src/sassLoader/importer.ts new file mode 100644 index 000000000..051bf336c --- /dev/null +++ b/packages/mako/src/sassLoader/importer.ts @@ -0,0 +1,234 @@ +import fs from 'fs'; +import path from 'path'; +import url from 'url'; +import EnhancedResolve, { type ResolveFunctionAsync } from 'enhanced-resolve'; +import type { Importer, ImporterResult } from 'sass'; + +export function createImporter( + filename: string, + implementation: any, +): Importer { + return { + async canonicalize(originalUrl, context) { + const { fromImport } = context; + const prev = context.containingUrl + ? url.fileURLToPath(context.containingUrl.toString()) + : filename; + + const resolver = getResolver(implementation?.compileStringAsync); + try { + const result = await resolver(prev, originalUrl, fromImport); + return url.pathToFileURL(result) as URL; + } catch (err) { + return null; + } + }, + async load(canonicalUrl) { + const ext = path.extname(canonicalUrl.pathname); + + let syntax; + + if (ext && ext.toLowerCase() === '.scss') { + syntax = 'scss'; + } else if (ext && ext.toLowerCase() === '.sass') { + syntax = 'indented'; + } else if (ext && ext.toLowerCase() === '.css') { + syntax = 'css'; + } else { + // Fallback to default value + syntax = 'scss'; + } + + try { + const contents = await new Promise((resolve, reject) => { + const canonicalPath = url.fileURLToPath(canonicalUrl); + + fs.readFile( + canonicalPath, + { + encoding: 'utf8', + }, + (err, content) => { + if (err) { + reject(err); + return; + } + + resolve(content); + }, + ); + }); + + return { + contents, + syntax, + sourceMapUrl: canonicalUrl, + } as ImporterResult; + } catch (err) { + return null; + } + }, + }; +} + +function getResolver(compileStringAsync: any) { + const isModernSass = typeof compileStringAsync !== 'undefined'; + + const importResolve = EnhancedResolve.create({ + conditionNames: ['sass', 'style', '...'], + mainFields: ['sass', 'style', 'main', '...'], + mainFiles: ['_index.import', '_index', 'index.import', 'index', '...'], + extensions: ['.sass', '.scss', '.css'], + restrictions: [/\.((sa|sc|c)ss)$/i], + preferRelative: true, + }); + const moduleResolve = EnhancedResolve.create({ + conditionNames: ['sass', 'style', '...'], + mainFields: ['sass', 'style', 'main', '...'], + mainFiles: ['_index', 'index', '...'], + extensions: ['.sass', '.scss', '.css'], + restrictions: [/\.((sa|sc|c)ss)$/i], + preferRelative: true, + }); + + return (context: string, request: string, fromImport: boolean) => { + if (!isModernSass && !path.isAbsolute(context)) { + return Promise.reject(); + } + + const originalRequest = request; + const isFileScheme = originalRequest.slice(0, 5).toLowerCase() === 'file:'; + + if (isFileScheme) { + try { + request = url.fileURLToPath(originalRequest); + } catch (error) { + request = request.slice(7); + } + } + + let resolutionMap: any[] = []; + + const webpackPossibleRequests = getPossibleRequests(request, fromImport); + + resolutionMap = resolutionMap.concat({ + resolve: fromImport ? importResolve : moduleResolve, + context: path.dirname(context), + possibleRequests: webpackPossibleRequests, + }); + + return startResolving(resolutionMap); + }; +} + +const MODULE_REQUEST_REGEX = /^[^?]*~/; + +// Examples: +// - ~package +// - ~package/ +// - ~@org +// - ~@org/ +// - ~@org/package +// - ~@org/package/ +const IS_MODULE_IMPORT = + /^~([^/]+|[^/]+\/|@[^/]+[/][^/]+|@[^/]+\/?|@[^/]+[/][^/]+\/)$/; + +const IS_PKG_SCHEME = /^pkg:/i; + +function getPossibleRequests(url: string, fromImport: boolean) { + console.log('getPossibleRequests', url); + let request = url; + + if (MODULE_REQUEST_REGEX.test(url)) { + request = request.replace(MODULE_REQUEST_REGEX, ''); + } + + if (IS_PKG_SCHEME.test(url)) { + request = `${request.slice(4)}`; + + return [...new Set([request, url])]; + } + + if (IS_MODULE_IMPORT.test(url) || IS_PKG_SCHEME.test(url)) { + request = request[request.length - 1] === '/' ? request : `${request}/`; + + return [...new Set([request, url])]; + } + + const extension = path.extname(request).toLowerCase(); + + if (extension === '.css') { + return fromImport ? [] : [url]; + } + + const dirname = path.dirname(request); + const normalizedDirname = dirname === '.' ? '' : `${dirname}/`; + const basename = path.basename(request); + const basenameWithoutExtension = path.basename(request, extension); + + return [ + ...new Set( + ([] as any[]) + .concat( + fromImport + ? [ + `${normalizedDirname}_${basenameWithoutExtension}.import${extension}`, + `${normalizedDirname}${basenameWithoutExtension}.import${extension}`, + ] + : [], + ) + .concat([ + `${normalizedDirname}_${basename}`, + `${normalizedDirname}${basename}`, + ]) + .concat([url]), + ), + ]; +} + +async function startResolving(resolutionMap: any[]) { + if (resolutionMap.length === 0) { + return Promise.reject(); + } + + const [{ possibleRequests }] = resolutionMap; + + if (possibleRequests.length === 0) { + return Promise.reject(); + } + + const [{ resolve, context }] = resolutionMap; + + try { + return await asyncResolve(context, possibleRequests[0], resolve); + } catch (_ignoreError) { + const [, ...tailResult] = possibleRequests; + + if (tailResult.length === 0) { + const [, ...tailResolutionMap] = resolutionMap; + + return startResolving(tailResolutionMap); + } + + resolutionMap[0].possibleRequests = tailResult; + + return startResolving(resolutionMap); + } +} + +function asyncResolve( + context: string, + path: string, + resolve: ResolveFunctionAsync, +): Promise { + return new Promise((res, rej) => { + resolve(context, path, (err, result) => { + if (err) { + rej(err); + return; + } + + res(result as string); + }); + }); +} diff --git a/packages/mako/src/sassLoader/render.ts b/packages/mako/src/sassLoader/render.ts index e1207e1bd..d95d35b44 100644 --- a/packages/mako/src/sassLoader/render.ts +++ b/packages/mako/src/sassLoader/render.ts @@ -1,8 +1,5 @@ -import fs from 'fs'; -import path from 'path'; -import url from 'url'; -import resolve, { type ResolveFunctionAsync } from 'enhanced-resolve'; -import { type ImporterResult, type Options } from 'sass'; +import { type Options } from 'sass'; +import { createImporter } from './importer'; async function render(param: { filename: string; @@ -19,67 +16,7 @@ async function render(param: { const options = { style: 'compressed', ...param.opts }; options.importers = options.importers || []; - options.importers.push({ - async canonicalize(originalUrl, context) { - const { fromImport } = context; - const prev = context.containingUrl - ? url.fileURLToPath(context.containingUrl.toString()) - : param.filename; - - const resolver = getResolver(sass?.compileStringAsync); - try { - const result = await resolver(prev, originalUrl, fromImport); - return url.pathToFileURL(result) as URL; - } catch (err) { - return null; - } - }, - async load(canonicalUrl) { - const ext = path.extname(canonicalUrl.pathname); - - let syntax; - - if (ext && ext.toLowerCase() === '.scss') { - syntax = 'scss'; - } else if (ext && ext.toLowerCase() === '.sass') { - syntax = 'indented'; - } else if (ext && ext.toLowerCase() === '.css') { - syntax = 'css'; - } else { - // Fallback to default value - syntax = 'scss'; - } - - try { - const contents = await new Promise((resolve, reject) => { - const canonicalPath = url.fileURLToPath(canonicalUrl); - - fs.readFile( - canonicalPath, - { - encoding: 'utf8', - }, - (err, content) => { - if (err) { - reject(err); - return; - } - - resolve(content); - }, - ); - }); - - return { - contents, - syntax, - sourceMapUrl: canonicalUrl, - } as ImporterResult; - } catch (err) { - return null; - } - }, - }); + options.importers.push(createImporter(param.filename, sass)); const result = await sass .compileAsync(param.filename, options) @@ -90,165 +27,3 @@ async function render(param: { } export { render }; - -function getResolver(compileStringAsync: any) { - const isModernSass = typeof compileStringAsync !== 'undefined'; - - const importResolve = resolve.create({ - conditionNames: ['sass', 'style', '...'], - mainFields: ['sass', 'style', 'main', '...'], - mainFiles: ['_index.import', '_index', 'index.import', 'index', '...'], - extensions: ['.sass', '.scss', '.css'], - restrictions: [/\.((sa|sc|c)ss)$/i], - preferRelative: true, - }); - const moduleResolve = resolve.create({ - conditionNames: ['sass', 'style', '...'], - mainFields: ['sass', 'style', 'main', '...'], - mainFiles: ['_index', 'index', '...'], - extensions: ['.sass', '.scss', '.css'], - restrictions: [/\.((sa|sc|c)ss)$/i], - preferRelative: true, - }); - - return (context: string, request: string, fromImport: boolean) => { - if (!isModernSass && !path.isAbsolute(context)) { - return Promise.reject(); - } - - const originalRequest = request; - const isFileScheme = originalRequest.slice(0, 5).toLowerCase() === 'file:'; - - if (isFileScheme) { - try { - request = url.fileURLToPath(originalRequest); - } catch (error) { - request = request.slice(7); - } - } - - let resolutionMap: any[] = []; - - const webpackPossibleRequests = getPossibleRequests(request, fromImport); - - resolutionMap = resolutionMap.concat({ - resolve: fromImport ? importResolve : moduleResolve, - context: path.dirname(context), - possibleRequests: webpackPossibleRequests, - }); - - return startResolving(resolutionMap); - }; -} - -const MODULE_REQUEST_REGEX = /^[^?]*~/; - -// Examples: -// - ~package -// - ~package/ -// - ~@org -// - ~@org/ -// - ~@org/package -// - ~@org/package/ -const IS_MODULE_IMPORT = - /^~([^/]+|[^/]+\/|@[^/]+[/][^/]+|@[^/]+\/?|@[^/]+[/][^/]+\/)$/; - -const IS_PKG_SCHEME = /^pkg:/i; - -function getPossibleRequests(url: string, fromImport: boolean) { - console.log('getPossibleRequests', url); - let request = url; - - if (MODULE_REQUEST_REGEX.test(url)) { - request = request.replace(MODULE_REQUEST_REGEX, ''); - } - - if (IS_PKG_SCHEME.test(url)) { - request = `${request.slice(4)}`; - - return [...new Set([request, url])]; - } - - if (IS_MODULE_IMPORT.test(url) || IS_PKG_SCHEME.test(url)) { - request = request[request.length - 1] === '/' ? request : `${request}/`; - - return [...new Set([request, url])]; - } - - const extension = path.extname(request).toLowerCase(); - - if (extension === '.css') { - return fromImport ? [] : [url]; - } - - const dirname = path.dirname(request); - const normalizedDirname = dirname === '.' ? '' : `${dirname}/`; - const basename = path.basename(request); - const basenameWithoutExtension = path.basename(request, extension); - - return [ - ...new Set( - ([] as any[]) - .concat( - fromImport - ? [ - `${normalizedDirname}_${basenameWithoutExtension}.import${extension}`, - `${normalizedDirname}${basenameWithoutExtension}.import${extension}`, - ] - : [], - ) - .concat([ - `${normalizedDirname}_${basename}`, - `${normalizedDirname}${basename}`, - ]) - .concat([url]), - ), - ]; -} - -async function startResolving(resolutionMap: any[]) { - if (resolutionMap.length === 0) { - return Promise.reject(); - } - - const [{ possibleRequests }] = resolutionMap; - - if (possibleRequests.length === 0) { - return Promise.reject(); - } - - const [{ resolve, context }] = resolutionMap; - - try { - return await asyncResolve(context, possibleRequests[0], resolve); - } catch (_ignoreError) { - const [, ...tailResult] = possibleRequests; - - if (tailResult.length === 0) { - const [, ...tailResolutionMap] = resolutionMap; - - return startResolving(tailResolutionMap); - } - - resolutionMap[0].possibleRequests = tailResult; - - return startResolving(resolutionMap); - } -} - -function asyncResolve( - context: string, - path: string, - resolve: ResolveFunctionAsync, -): Promise { - return new Promise((res, rej) => { - resolve(context, path, (err, result) => { - if (err) { - rej(err); - return; - } - - res(result as string); - }); - }); -}