Files
nrwl--nx/packages/webpack/src/utils/webpack/plugins/postcss-cli-resources.ts
T
2026-07-13 12:38:36 +08:00

217 lines
6.9 KiB
TypeScript

import { interpolateName } from 'loader-utils';
import * as path from 'path';
import { pathToFileURL } from 'url';
import type { Declaration } from 'postcss';
import type { LoaderContext } from 'webpack';
function wrapUrl(url: string): string {
let wrappedUrl;
const hasSingleQuotes = url.indexOf("'") >= 0;
if (hasSingleQuotes) {
wrappedUrl = `"${url}"`;
} else {
wrappedUrl = `'${url}'`;
}
return `url(${wrappedUrl})`;
}
function resolveUrl(from: string, to: string) {
const resolvedUrl = new URL(to, new URL(from, 'resolve://'));
if (resolvedUrl.protocol === 'resolve:') {
// `from` is a relative URL.
const { pathname, search, hash } = resolvedUrl;
return pathname + search + hash;
}
return resolvedUrl.toString();
}
export interface PostcssCliResourcesOptions {
baseHref?: string;
deployUrl?: string;
resourcesOutputPath?: string;
rebaseRootRelative?: boolean;
filename: string;
loader: LoaderContext<unknown>;
publicPath: string;
}
async function resolve(
file: string,
base: string,
resolver: (file: string, base: string) => Promise<boolean | string>
): Promise<boolean | string> {
try {
return await resolver(`./${file}`, base);
} catch {
return resolver(file, base);
}
}
module.exports.postcss = true;
export function PostcssCliResources(options: PostcssCliResourcesOptions) {
const {
deployUrl = '',
baseHref = '',
resourcesOutputPath = '',
rebaseRootRelative = false,
filename,
loader,
publicPath = '',
} = options;
const dedupeSlashes = (url: string) => url.replace(/\/\/+/g, '/');
const process = async (
inputUrl: string,
context: string,
resourceCache: Map<string, string>
) => {
// If root-relative, absolute or protocol relative url, leave as is
if (/^((?:\w+:)?\/\/|data:|chrome:|#)/.test(inputUrl)) {
return inputUrl;
}
if (!rebaseRootRelative && /^\//.test(inputUrl)) {
return inputUrl;
}
// If starts with a caret, remove and return remainder
// this supports bypassing asset processing
if (inputUrl.startsWith('^')) {
return inputUrl.slice(1);
}
const cacheKey = path.resolve(context, inputUrl);
const cachedUrl = resourceCache.get(cacheKey);
if (cachedUrl) {
return cachedUrl;
}
if (inputUrl.startsWith('~')) {
inputUrl = inputUrl.slice(1);
}
if (inputUrl.startsWith('/')) {
let outputUrl = '';
if (deployUrl.match(/:\/\//) || deployUrl.startsWith('/')) {
// If deployUrl is absolute or root relative, ignore baseHref & use deployUrl as is.
outputUrl = `${deployUrl.replace(/\/$/, '')}${inputUrl}`;
} else if (baseHref.match(/:\/\//)) {
// If baseHref contains a scheme, include it as is.
outputUrl =
baseHref.replace(/\/$/, '') +
dedupeSlashes(`/${deployUrl}/${inputUrl}`);
} else {
// Join together base-href, deploy-url and the original URL.
outputUrl = dedupeSlashes(
`/${baseHref}/${deployUrl}/${publicPath}/${inputUrl}`
);
}
resourceCache.set(cacheKey, outputUrl);
return outputUrl;
}
// Separate URL query/hash from the file path before resolving
const [, filePath, urlSuffix] = inputUrl.match(/^([^?#]*)(.*)$/)!;
const resolvedPath = path.resolve(context, filePath.replace(/\\/g, '/'));
const { pathname } = pathToFileURL(resolvedPath);
let hash = '';
let search = '';
if (urlSuffix) {
({ hash, search } = new URL(`file:///dummy${urlSuffix}`));
}
const resolver = (file: string, base: string) =>
new Promise<boolean | string>((resolve, reject) => {
loader.resolve(base, decodeURI(file), (err, result) => {
if (err) {
reject(err);
return;
}
resolve(result);
});
});
const result = await resolve(pathname as string, context, resolver);
return new Promise<boolean | string>((resolve, reject) => {
loader.fs.readFile(result as string, (err: Error, content: Buffer) => {
if (err) {
reject(err);
return;
}
let outputPath = interpolateName(
{ resourcePath: result } as LoaderContext<unknown>,
filename,
{ content }
);
if (resourcesOutputPath) {
outputPath = path.posix.join(resourcesOutputPath, outputPath);
}
loader.addDependency(result as string);
loader.emitFile(outputPath, content, undefined);
let outputUrl = outputPath.replace(/\\/g, '/');
if (hash || search) {
outputUrl = outputUrl + (search || '') + (hash || '');
}
const loaderOptions: any = loader.loaders[loader.loaderIndex].options;
if (deployUrl && loaderOptions.ident !== 'extracted') {
outputUrl = resolveUrl(deployUrl, outputUrl);
}
resourceCache.set(cacheKey, outputUrl);
resolve(outputUrl);
});
});
};
return {
postcssPlugin: 'postcss-cli-resources',
Once(root) {
const urlDeclarations: Array<Declaration> = [];
/**
* TODO: Explore if this can be rewritten using the new `Declaration()`
* listener added in postcss v8
*/
root.walkDecls((decl) => {
if (decl.value && decl.value.includes('url')) {
urlDeclarations.push(decl);
}
});
if (urlDeclarations.length === 0) {
return;
}
const resourceCache = new Map<string, string>();
return Promise.all(
urlDeclarations.map(async (decl) => {
const value = decl.value;
const urlRegex = /url(?:\(\s*(['"]?))(.*?)(?:\1\s*\))/g;
const segments: string[] = [];
let match;
let lastIndex = 0;
let modified = false;
// We want to load it relative to the file that imports
const inputFile = decl.source && decl.source.input.file;
const context =
(inputFile && path.dirname(inputFile)) || loader.context;
while ((match = urlRegex.exec(value))) {
const originalUrl = match[2];
let processedUrl;
try {
processedUrl = await process(originalUrl, context, resourceCache);
} catch (err) {
loader.emitError(decl.error(err.message, { word: originalUrl }));
continue;
}
if (lastIndex < match.index) {
segments.push(value.slice(lastIndex, match.index));
}
if (!processedUrl || originalUrl === processedUrl) {
segments.push(match[0]);
} else {
segments.push(wrapUrl(processedUrl));
modified = true;
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < value.length) {
segments.push(value.slice(lastIndex));
}
if (modified) {
decl.value = segments.join('');
}
})
);
},
};
}