@kv-systems/ng-packagr
Version:
Compile and package Angular libraries in Angular Package Format (APF)
185 lines (168 loc) • 5.36 kB
text/typescript
import type { Location, OnLoadResult, PluginBuild } from 'esbuild';
import { readFile } from 'node:fs/promises';
import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory';
/**
* The lazy-loaded instance of the less stylesheet preprocessor.
* It is only imported and initialized if a less stylesheet is used.
*/
let lessPreprocessor: typeof import('less') | undefined;
interface LessException extends Error {
filename: string;
line: number;
column: number;
extract?: string[];
}
function isLessException(error: unknown): error is LessException {
return !!error && typeof error === 'object' && 'column' in error;
}
export const LessStylesheetLanguage = Object.freeze<StylesheetLanguage>({
name: 'less',
componentFilter: /^less;/,
fileFilter: /\.less$/,
process(data, file, _, options, build) {
return compileString(
data,
file,
options,
build.resolve.bind(build),
/* unsafeInlineJavaScript */ false,
);
},
});
async function compileString(
data: string,
filename: string,
options: StylesheetPluginOptions,
resolver: PluginBuild['resolve'],
unsafeInlineJavaScript: boolean,
): Promise<OnLoadResult> {
try {
lessPreprocessor ??= (await import('less')).default;
} catch {
return {
errors: [
{
text: 'Unable to load the "less" stylesheet preprocessor.',
location: null,
notes: [
{
text:
'Ensure that the "less" Node.js package is installed within the project. ' +
"If not present, installation via the project's package manager should resolve the error.",
},
],
},
],
};
}
const less = lessPreprocessor;
const resolverPlugin: Less.Plugin = {
install({ FileManager }, pluginManager): void {
const resolverFileManager = new (class extends FileManager {
override supportsSync(): boolean {
return false;
}
override supports(): boolean {
return true;
}
override async loadFile(
filename: string,
currentDirectory: string,
options: Less.LoadFileOptions,
environment: Less.Environment,
): Promise<Less.FileLoadResult> {
// Attempt direct loading as a relative path to avoid resolution overhead
try {
return await super.loadFile(filename, currentDirectory, options, environment);
} catch (error) {
// Attempt a full resolution if not found
const fullResult = await resolver(filename, {
kind: 'import-rule',
resolveDir: currentDirectory,
});
if (fullResult.path) {
return {
filename: fullResult.path,
contents: await readFile(fullResult.path, 'utf-8'),
};
}
// Otherwise error by throwing the failing direct result
throw error;
}
}
})();
pluginManager.addFileManager(resolverFileManager);
},
};
try {
const result = await less.render(data, {
filename,
paths: options.includePaths,
plugins: [resolverPlugin],
rewriteUrls: 'all',
javascriptEnabled: unsafeInlineJavaScript,
sourceMap: options.sourcemap
? {
sourceMapFileInline: true,
outputSourceFiles: true,
}
: undefined,
} as Less.Options);
return {
contents: result.css,
loader: 'css',
watchFiles: [filename, ...result.imports],
};
} catch (error) {
if (isLessException(error)) {
const location = convertExceptionLocation(error);
// Retry with a warning for less files requiring the deprecated inline JavaScript option
if (error.message.includes('Inline JavaScript is not enabled.')) {
const withJsResult = await compileString(
data,
filename,
options,
resolver,
/* unsafeInlineJavaScript */ true,
);
withJsResult.warnings = [
{
text: 'Deprecated inline execution of JavaScript has been enabled ("javascriptEnabled")',
location,
notes: [
{
location: null,
text: 'JavaScript found within less stylesheets may be executed at build time. [https://lesscss.org/usage/#less-options]',
},
{
location: null,
text: 'Support for "javascriptEnabled" may be removed from the Angular CLI starting with Angular v19.',
},
],
},
];
return withJsResult;
}
return {
errors: [
{
text: error.message,
location,
},
],
loader: 'css',
watchFiles: location.file ? [filename, location.file] : [filename],
};
}
throw error;
}
}
function convertExceptionLocation(exception: LessException): Partial<Location> {
return {
file: exception.filename,
line: exception.line,
column: exception.column,
// Middle element represents the line containing the exception
lineText: exception.extract && exception.extract[Math.trunc(exception.extract.length / 2)],
};
}