Skip to content

Commit ebdaf77

Browse files
committed
feat(@angular/build): support patterns in library entryPoints
A key and its entry file path in `entryPoints` can each contain one `*`, as with subpath patterns in package.json `exports`. For example `"./*": "projects/lib/*/public-api.ts"` adds an entry point for every matching file, with the matched part of the path, which can include `/`, used in the key. Explicit entry points take precedence over a pattern match with the same name or the same entry file, and a pattern that matches no files is an error. Patterns are expanded when the build starts.
1 parent 005e05e commit ebdaf77

3 files changed

Lines changed: 196 additions & 12 deletions

File tree

packages/angular/build/src/builders/library/options.ts

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import type { BuilderContext } from '@angular-devkit/architect';
1010
import fs from 'node:fs/promises';
1111
import path from 'node:path';
12+
import { escapePath, glob } from 'tinyglobby';
1213
import type { StylesheetPluginsass } from '../../tools/esbuild/stylesheets/stylesheet-plugin-factory';
1314
import { normalizeAssetPatterns } from '../../utils';
1415
import { supportColor } from '../../utils/color';
@@ -154,7 +155,7 @@ export async function normalizeLibraryOptions(
154155
throw new Error(`The package.json at '${packageJsonPath}' must contain a 'name'.`);
155156
}
156157

157-
const entryPoints = normalizeEntryPoints(
158+
const entryPoints = await normalizeEntryPoints(
158159
rawEntryPoints,
159160
workspaceRoot,
160161
resolvedTsConfigPath,
@@ -303,31 +304,31 @@ function normalizeEntryPoint(
303304
/**
304305
* Normalizes all entry points for the library project.
305306
*
307+
* A key and its entry file path can each contain a single '*', the same as subpath patterns
308+
* in package.json `exports`. The '*' in the path matches any non-empty string, including '/',
309+
* and adds an entry point for every matching file, with the matched value substituted into
310+
* the key. Explicit entry points take precedence over pattern matches with the same name or
311+
* the same entry file.
312+
*
306313
* @param rawEntryPoints The raw entryPoints dictionary from schema options.
307314
* @param workspaceRoot The workspace root directory.
308315
* @param defaultTsConfigPath The default tsConfig path for the project.
309316
* @param projectName The project name used in error reporting.
310317
* @param packageName The root package name (e.g. `@my/lib`).
311318
* @returns A Map of normalized entry points keyed by name.
312319
*/
313-
function normalizeEntryPoints(
320+
async function normalizeEntryPoints(
314321
rawEntryPoints: LibraryBuilderOptions['entryPoints'],
315322
workspaceRoot: string,
316323
defaultTsConfigPath: string,
317324
projectName: string,
318325
packageName: string,
319-
): Map<string, NormalizedEntryPoint> {
326+
): Promise<Map<string, NormalizedEntryPoint>> {
320327
const entryPoints = new Map<string, NormalizedEntryPoint>();
328+
const patterns: [string, LibraryBuilderOptions['entryPoints'][string]][] = [];
321329
let hasPrimary = false;
322330

323-
for (const [key, value] of Object.entries(rawEntryPoints)) {
324-
const entryPoint = normalizeEntryPoint(
325-
key,
326-
value,
327-
workspaceRoot,
328-
defaultTsConfigPath,
329-
packageName,
330-
);
331+
const addEntryPoint = (key: string, entryPoint: NormalizedEntryPoint) => {
331332
if (entryPoints.has(entryPoint.name)) {
332333
throw new Error(
333334
`Duplicate entry point detected: '${key}' resolves to the same name ('${entryPoint.name}') as an existing entry point.`,
@@ -337,6 +338,44 @@ function normalizeEntryPoints(
337338
if (entryPoint.isPrimary) {
338339
hasPrimary = true;
339340
}
341+
};
342+
343+
for (const [key, value] of Object.entries(rawEntryPoints)) {
344+
const entryFile = typeof value === 'string' ? value : value.entryPoint;
345+
if (key.includes('*') || entryFile.includes('*')) {
346+
patterns.push([key, value]);
347+
continue;
348+
}
349+
350+
addEntryPoint(
351+
key,
352+
normalizeEntryPoint(key, value, workspaceRoot, defaultTsConfigPath, packageName),
353+
);
354+
}
355+
356+
const explicitNames = new Set(entryPoints.keys());
357+
const explicitFiles = new Set(Array.from(entryPoints.values(), (e) => e.entryFilePath));
358+
359+
for (const [key, value] of patterns) {
360+
const entryFile = typeof value === 'string' ? value : value.entryPoint;
361+
for (const [matchedKey, matchedFile] of await expandEntryPointPattern(
362+
key,
363+
entryFile,
364+
workspaceRoot,
365+
)) {
366+
const entryPoint = normalizeEntryPoint(
367+
matchedKey,
368+
typeof value === 'string' ? matchedFile : { ...value, entryPoint: matchedFile },
369+
workspaceRoot,
370+
defaultTsConfigPath,
371+
packageName,
372+
);
373+
if (explicitNames.has(entryPoint.name) || explicitFiles.has(entryPoint.entryFilePath)) {
374+
continue;
375+
}
376+
377+
addEntryPoint(matchedKey, entryPoint);
378+
}
340379
}
341380

342381
if (!hasPrimary) {
@@ -347,3 +386,59 @@ function normalizeEntryPoints(
347386

348387
return entryPoints;
349388
}
389+
390+
/**
391+
* Expands an entry point pattern into the key and entry file of every matching file,
392+
* sorted by key.
393+
*
394+
* @param key The entry point key containing a single '*'.
395+
* @param entryFile The entry file path containing a single '*'.
396+
* @param workspaceRoot The workspace root directory.
397+
* @returns The expanded keys and absolute entry file paths.
398+
*/
399+
async function expandEntryPointPattern(
400+
key: string,
401+
entryFile: string,
402+
workspaceRoot: string,
403+
): Promise<[string, string][]> {
404+
if (key.split('*').length !== 2 || entryFile.split('*').length !== 2) {
405+
throw new Error(
406+
`Invalid entry point pattern '${key}': the key and the entry file path must each contain exactly one '*'.`,
407+
);
408+
}
409+
410+
const pattern = toPosixPath(path.resolve(workspaceRoot, entryFile));
411+
const starIndex = pattern.indexOf('*');
412+
const prefix = pattern.slice(0, starIndex);
413+
const suffix = pattern.slice(starIndex + 1);
414+
const baseDir = prefix.slice(0, prefix.lastIndexOf('/') + 1);
415+
416+
const files = await glob(`**/*${escapePath(suffix.slice(suffix.lastIndexOf('/') + 1))}`, {
417+
cwd: baseDir,
418+
ignore: ['**/node_modules/**'],
419+
});
420+
421+
const [keyPrefix, keySuffix] = key.split('*');
422+
const matches: [string, string][] = [];
423+
for (const file of files) {
424+
const filePath = baseDir + file;
425+
if (
426+
filePath.length <= prefix.length + suffix.length ||
427+
!filePath.startsWith(prefix) ||
428+
!filePath.endsWith(suffix) ||
429+
!/\.m?ts$/.test(filePath) ||
430+
/\.d\.m?ts$/.test(filePath)
431+
) {
432+
continue;
433+
}
434+
435+
const match = filePath.slice(prefix.length, filePath.length - suffix.length);
436+
matches.push([keyPrefix + match + keySuffix, filePath]);
437+
}
438+
439+
if (matches.length === 0) {
440+
throw new Error(`Entry point pattern '${key}' did not match any files: '${entryFile}'.`);
441+
}
442+
443+
return matches.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
444+
}

packages/angular/build/src/builders/library/schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"properties": {
77
"entryPoints": {
88
"type": "object",
9-
"description": "Map of package entry points. The '.' key represents the primary entry point; other keys define secondary subpath entry points.",
9+
"description": "Map of package entry points. The '.' key represents the primary entry point; other keys define secondary subpath entry points. A key and its entry file path can each contain one '*' (e.g. './*': 'projects/lib/*/public-api.ts') to add an entry point for every matching file, as with subpath patterns in package.json 'exports'. Patterns are expanded when the build starts.",
1010
"required": ["."],
1111
"additionalProperties": {
1212
"oneOf": [

packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,94 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) =>
7272
expect(error).toBeDefined();
7373
expect((error as Error).message).toMatch(/must be a TypeScript file \('\.ts' or '\.mts'\)/);
7474
});
75+
76+
it('should add an entry point for every file matched by a pattern', async () => {
77+
await harness.writeFiles({
78+
'projects/lib/feature/src/public-api.ts': 'export const FEATURE = 1;\n',
79+
'projects/lib/nested/child/src/public-api.ts': 'export const CHILD = 2;\n',
80+
});
81+
82+
harness.useTarget('build', {
83+
...BASE_OPTIONS,
84+
entryPoints: {
85+
'.': 'projects/lib/src/public-api.ts',
86+
'./*': 'projects/lib/*/src/public-api.ts',
87+
},
88+
});
89+
90+
const { result } = await harness.executeOnce();
91+
expect(result?.success).toBeTrue();
92+
93+
const { exports } = JSON.parse(harness.readFile('dist/lib/package.json'));
94+
expect(Object.keys(exports).sort()).toEqual([
95+
'.',
96+
'./feature',
97+
'./nested/child',
98+
'./package.json',
99+
]);
100+
harness.expectFile('dist/lib/fesm2022/lib-feature.mjs').content.toContain('FEATURE');
101+
harness.expectFile('dist/lib/fesm2022/lib-nested-child.mjs').content.toContain('CHILD');
102+
});
103+
104+
it('should prefer an explicit entry point over a pattern match for the same file', async () => {
105+
await harness.writeFiles({
106+
'projects/lib/feature/src/public-api.ts': 'export const FEATURE = 1;\n',
107+
'projects/lib/other/src/public-api.ts': 'export const OTHER = 2;\n',
108+
});
109+
110+
harness.useTarget('build', {
111+
...BASE_OPTIONS,
112+
entryPoints: {
113+
'.': 'projects/lib/src/public-api.ts',
114+
'./*': 'projects/lib/*/src/public-api.ts',
115+
'./renamed': 'projects/lib/other/src/public-api.ts',
116+
},
117+
});
118+
119+
const { result } = await harness.executeOnce();
120+
expect(result?.success).toBeTrue();
121+
122+
const { exports } = JSON.parse(harness.readFile('dist/lib/package.json'));
123+
expect(Object.keys(exports).sort()).toEqual([
124+
'.',
125+
'./feature',
126+
'./package.json',
127+
'./renamed',
128+
]);
129+
});
130+
131+
it('should fail when a pattern does not match any files', async () => {
132+
harness.useTarget('build', {
133+
...BASE_OPTIONS,
134+
entryPoints: {
135+
'.': 'projects/lib/src/public-api.ts',
136+
'./*': 'projects/lib/*/missing.ts',
137+
},
138+
});
139+
140+
const { result, error } = await harness.executeOnce({
141+
outputLogsOnException: false,
142+
outputLogsOnFailure: false,
143+
});
144+
expect(result).toBeUndefined();
145+
expect((error as Error).message).toMatch(/did not match any files/);
146+
});
147+
148+
it('should fail when only the key or the path contains a pattern', async () => {
149+
harness.useTarget('build', {
150+
...BASE_OPTIONS,
151+
entryPoints: {
152+
'.': 'projects/lib/src/public-api.ts',
153+
'./*': 'projects/lib/src/public-api.ts',
154+
},
155+
});
156+
157+
const { result, error } = await harness.executeOnce({
158+
outputLogsOnException: false,
159+
outputLogsOnFailure: false,
160+
});
161+
expect(result).toBeUndefined();
162+
expect((error as Error).message).toMatch(/must each contain exactly one '\*'/);
163+
});
75164
});
76165
});

0 commit comments

Comments
 (0)