Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 137 additions & 91 deletions packages/ngtools/webpack/src/paths-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,21 +16,98 @@ export interface TypeScriptPathsPluginOptions extends Pick<CompilerOptions, 'pat
// Extract Resolver type from Webpack types since it is not directly exported
type Resolver = Exclude<Exclude<Configuration['resolve'], undefined>['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;
Expand All @@ -50,39 +127,45 @@ 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;
}

const potentialRequest = {
...request,
request: path.resolve(this.options?.baseUrl || '', potential),
request: path.resolve(this.baseUrl ?? '', next.value),
typescriptPathMapped: true,
};

Expand Down Expand Up @@ -110,89 +193,52 @@ export class TypeScriptPathsPlugin {
}
}

function findReplacements(originalRequest: string, paths: MapLike<string[]>): string[] {
function* findReplacements(
originalRequest: string,
patterns: PathPattern[],
): IterableIterator<string> {
// 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;
}
}