diff --git a/packages/ngtools/webpack/src/paths-plugin.ts b/packages/ngtools/webpack/src/paths-plugin.ts index e9aab5831f24..60f845ccd140 100644 --- a/packages/ngtools/webpack/src/paths-plugin.ts +++ b/packages/ngtools/webpack/src/paths-plugin.ts @@ -7,7 +7,7 @@ */ import * as path from 'path'; -import { CompilerOptions, MapLike } from 'typescript'; +import { CompilerOptions } from 'typescript'; import type { Configuration } from 'webpack'; // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -16,21 +16,98 @@ export interface TypeScriptPathsPluginOptions extends Pick['resolver'], undefined>; +interface PathPattern { + starIndex: number; + prefix: string; + suffix?: string; + potentials: { hasStar: boolean; prefix: string; suffix?: string }[]; +} + export class TypeScriptPathsPlugin { - constructor(private options?: TypeScriptPathsPluginOptions) {} + private baseUrl?: string; + private patterns?: PathPattern[]; + + constructor(options?: TypeScriptPathsPluginOptions) { + if (options) { + this.update(options); + } + } + /** + * Update the plugin with new path mapping option values. + * The options will also be preprocessed to reduce the overhead of individual resolve actions + * during a build. + * + * @param options The `paths` and `baseUrl` options from TypeScript's `CompilerOptions`. + */ update(options: TypeScriptPathsPluginOptions): void { - this.options = options; + this.baseUrl = options.baseUrl; + this.patterns = undefined; + + if (options.paths) { + for (const [pattern, potentials] of Object.entries(options.paths)) { + // Ignore any entries that would not result in a new mapping + if (potentials.length === 0 || potentials.every((potential) => potential === '*')) { + continue; + } + + const starIndex = pattern.indexOf('*'); + let prefix = pattern; + let suffix; + if (starIndex > -1) { + prefix = pattern.slice(0, starIndex); + if (starIndex < pattern.length - 1) { + suffix = pattern.slice(starIndex + 1); + } + } + + this.patterns ??= []; + this.patterns.push({ + starIndex, + prefix, + suffix, + potentials: potentials.map((potential) => { + const potentialStarIndex = potential.indexOf('*'); + if (potentialStarIndex === -1) { + return { hasStar: false, prefix: potential }; + } + + return { + hasStar: true, + prefix: potential.slice(0, potentialStarIndex), + suffix: + potentialStarIndex < potential.length - 1 + ? potential.slice(potentialStarIndex + 1) + : undefined, + }; + }), + }); + } + + // Sort patterns so that exact matches take priority then largest prefix match + this.patterns?.sort((a, b) => { + if (a.starIndex === -1) { + return -1; + } else if (b.starIndex === -1) { + return 1; + } else { + return b.starIndex - a.starIndex; + } + }); + } } apply(resolver: Resolver): void { const target = resolver.ensureHook('resolve'); + // To support synchronous resolvers this hook cannot be promise based. + // Webpack supports synchronous resolution with `tap` and `tapAsync` hooks. resolver.getHook('described-resolve').tapAsync( 'TypeScriptPathsPlugin', // eslint-disable-next-line @typescript-eslint/no-explicit-any (request: any, resolveContext, callback) => { - if (!this.options) { + // Preprocessing of the options will ensure that `patterns` is either undefined or has elements to check + if (!this.patterns) { callback(); return; @@ -50,31 +127,37 @@ export class TypeScriptPathsPlugin { } // Only work on Javascript/TypeScript issuers. - if (!request.context.issuer || !request.context.issuer.match(/\.[jt]sx?$/)) { + if (!request.context.issuer || !request.context.issuer.match(/\.[cm]?[jt]sx?$/)) { callback(); return; } - // Relative or absolute requests are not mapped - if (originalRequest.startsWith('.') || originalRequest.startsWith('/')) { - callback(); - - return; - } - - // Ignore all webpack special requests - if (originalRequest.startsWith('!!')) { - callback(); + switch (originalRequest[0]) { + case '.': + case '/': + // Relative or absolute requests are not mapped + callback(); - return; + return; + case '!': + // Ignore all webpack special requests + if (originalRequest.length > 1 && originalRequest[1] === '!') { + callback(); + + return; + } + break; } - const replacements = findReplacements(originalRequest, this.options.paths || {}); + // A generator is used to limit the amount of replacements that need to be created. + // For example, if the first one resolves, any others are not needed and do not need + // to be created. + const replacements = findReplacements(originalRequest, this.patterns); const tryResolve = () => { - const potential = replacements.shift(); - if (!potential) { + const next = replacements.next(); + if (next.done) { callback(); return; @@ -82,7 +165,7 @@ export class TypeScriptPathsPlugin { const potentialRequest = { ...request, - request: path.resolve(this.options?.baseUrl || '', potential), + request: path.resolve(this.baseUrl ?? '', next.value), typescriptPathMapped: true, }; @@ -110,89 +193,52 @@ export class TypeScriptPathsPlugin { } } -function findReplacements(originalRequest: string, paths: MapLike): string[] { +function* findReplacements( + originalRequest: string, + patterns: PathPattern[], +): IterableIterator { // check if any path mapping rules are relevant - const pathMapOptions = []; - for (const pattern in paths) { - // get potentials and remove duplicates; JS Set maintains insertion order - const potentials = Array.from(new Set(paths[pattern])); - if (potentials.length === 0) { - // no potential replacements so skip - continue; - } + for (const { starIndex, prefix, suffix, potentials } of patterns) { + let partial; - // can only contain zero or one - const starIndex = pattern.indexOf('*'); if (starIndex === -1) { - if (pattern === originalRequest) { - pathMapOptions.push({ - starIndex, - partial: '', - potentials, - }); - } - } else if (starIndex === 0 && pattern.length === 1) { - if (potentials.length === 1 && potentials[0] === '*') { - // identity mapping -> noop - continue; + // No star means an exact match is required + if (prefix === originalRequest) { + partial = ''; } - pathMapOptions.push({ - starIndex, - partial: originalRequest, - potentials, - }); - } else if (starIndex === pattern.length - 1) { - if (originalRequest.startsWith(pattern.slice(0, -1))) { - pathMapOptions.push({ - starIndex, - partial: originalRequest.slice(pattern.length - 1), - potentials, - }); + } else if (starIndex === 0 && !suffix) { + // Everything matches a single wildcard pattern ("*") + partial = originalRequest; + } else if (!suffix) { + // No suffix means the star is at the end of the pattern + if (originalRequest.startsWith(prefix)) { + partial = originalRequest.slice(prefix.length); } } else { - const [prefix, suffix] = pattern.split('*'); + // Star was in the middle of the pattern if (originalRequest.startsWith(prefix) && originalRequest.endsWith(suffix)) { - pathMapOptions.push({ - starIndex, - partial: originalRequest.slice(prefix.length).slice(0, -suffix.length), - potentials, - }); + partial = originalRequest.substring(prefix.length, originalRequest.length - suffix.length); } } - } - - if (pathMapOptions.length === 0) { - return []; - } - // exact matches take priority then largest prefix match - pathMapOptions.sort((a, b) => { - if (a.starIndex === -1) { - return -1; - } else if (b.starIndex === -1) { - return 1; - } else { - return b.starIndex - a.starIndex; + // If request was not matched, move on to the next pattern + if (partial === undefined) { + continue; } - }); - - const replacements: string[] = []; - pathMapOptions.forEach((option) => { - for (const potential of option.potentials) { - let replacement; - const starIndex = potential.indexOf('*'); - if (starIndex === -1) { - replacement = potential; - } else if (starIndex === potential.length - 1) { - replacement = potential.slice(0, -1) + option.partial; - } else { - const [prefix, suffix] = potential.split('*'); - replacement = prefix + option.partial + suffix; + + // Create the full replacement values based on the original request and the potentials + // for the successfully matched pattern. + for (const { hasStar, prefix, suffix } of potentials) { + let replacement = prefix; + + if (hasStar) { + replacement += partial; + if (suffix) { + replacement += suffix; + } } - replacements.push(replacement); + yield replacement; } - }); - - return replacements; + } }