From c52432b3084a85df3fa7f5d7665c129f41888ebe Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:25:49 +0000 Subject: [PATCH] fix(@angular/build): re-analyze exports on package.json change in library builder When `package.json` changes in watch mode, the `exports` field is now re-analyzed to detect any added, removed, or modified entry points. New entry points are registered into watched files and trigger a re-compilation, while removed entry points are pruned from cached bundle results. --- .../build/src/builders/library/builder.ts | 25 +- .../build/src/builders/library/options.ts | 200 +---- .../src/builders/library/pipeline/assets.ts | 2 +- .../builders/library/pipeline/build-action.ts | 24 +- .../src/builders/library/pipeline/bundler.ts | 13 +- .../builders/library/pipeline/compilation.ts | 19 +- .../builders/library/pipeline/entry-points.ts | 270 ++++++- .../library/pipeline/entry-points_spec.ts | 706 ++++++++++++++++++ .../library/pipeline/package-manifests.ts | 2 +- .../pipeline/package-manifests_spec.ts | 2 +- .../library/pipeline/stylesheet-bundler.ts | 2 +- .../library/tests/behavior/watch_spec.ts | 115 +++ .../build/src/builders/library/types.ts | 133 ++++ 13 files changed, 1237 insertions(+), 276 deletions(-) create mode 100644 packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts create mode 100644 packages/angular/build/src/builders/library/types.ts diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts index 6eb450879ed7..b809c5de3342 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -24,14 +24,11 @@ import { purgeStaleBuildCache } from '../../utils/purge-cache'; import { getSupportedBrowsers } from '../../utils/supported-browsers'; import { assertCompatibleAngularVersion } from '../../utils/version'; import type { BuildWatcher } from '../../utils/watcher'; -import { - type NormalizedLibraryOptions, - type PackageJsonData, - normalizeLibraryOptions, -} from './options'; -import type { SingleBuildState } from './pipeline/build-action'; +import { normalizeLibraryOptions } from './options'; +import { updateWatchedEntryPoints } from './pipeline/entry-points'; import type { createComponentStylesheetBundlerForLibrary } from './pipeline/stylesheet-bundler'; import type { Schema as LibraryBuilderOptions } from './schema'; +import type { NormalizedLibraryOptions, PackageJsonData, SingleBuildState } from './types'; /** * Executes the library builder to compile, bundle, and package an Angular library into the Angular Package Format (APF). @@ -275,6 +272,19 @@ async function* runWatchLoop( try { const packageJson = await loadPackageJson(packageJsonPath); options.packageJson = packageJson; + if (!packageJson.name) { + throw new Error(`The package.json at '${packageJsonPath}' must contain a 'name'.`); + } + options.packageName = packageJson.name; + + updateWatchedEntryPoints( + packageJson, + options, + buildState, + allWatchedFiles, + packageJsonPath, + ); + hasPackageJsonChanges = true; } catch (error) { assertIsError(error); @@ -290,7 +300,8 @@ async function* runWatchLoop( const hasSourceChanges = !buildState.singleProgramCache || - Boolean(buildState.hasCompilationError) || + buildState.hasCompilationError || + buildState.hasEntryPointsChanges || hasModifiedWatchedFile(changedFiles, allWatchedFiles, posixPackageJsonPath); if ( diff --git a/packages/angular/build/src/builders/library/options.ts b/packages/angular/build/src/builders/library/options.ts index 7f74954de60b..fdf5354f7fa7 100644 --- a/packages/angular/build/src/builders/library/options.ts +++ b/packages/angular/build/src/builders/library/options.ts @@ -14,66 +14,16 @@ import { normalizeAssetPatterns } from '../../utils'; import { supportColor } from '../../utils/color'; import { assertIsError } from '../../utils/error'; import { normalizeCacheOptions } from '../../utils/normalize-cache'; -import { isSubDirectory, toPosixPath } from '../../utils/path'; +import { isSubDirectory } from '../../utils/path'; import { - type PostcssConfiguration, generateSearchDirectories, getTailwindConfig, loadPostcssConfiguration, } from '../../utils/postcss-configuration'; import { getProjectRootPaths } from '../../utils/project-metadata'; -import { type NormalizedEntryPoint, getEntryPointBundleName } from './pipeline/entry-points'; +import { normalizeEntryPoints } from './pipeline/entry-points'; import type { Schema as LibraryBuilderOptions } from './schema'; - -export type { NormalizedEntryPoint } from './pipeline/entry-points'; - -export interface PackageJsonData { - name: string; - version?: string; - type?: string; - main?: string; - module?: string; - typings?: string; - types?: string; - sideEffects?: boolean | string[]; - exports?: string | Record; - scripts?: Record; - workspaces?: unknown; - dependencies?: Record; - optionalDependencies?: Record; - peerDependencies?: Record; - peerDependenciesMeta?: Record; - [key: string]: unknown; -} - -export interface NormalizedLibraryOptions { - workspaceRoot: string; - projectRoot: string; - packageName: string; - packageJson: PackageJsonData; - outputPath: string; - deleteOutputPath: boolean; - packageJsonPath: string; - tsConfigPath: string; - entryPoints: Map; - inlineStyleLanguage: 'css' | 'less' | 'sass' | 'scss'; - styleIncludePaths: string[]; - sass?: StylesheetPluginsass; - assets: ReturnType; - compilationMode: 'partial' | 'full'; - declarationMap: boolean; - allowedNonPeerDependencies: RegExp[]; - keepLifecycleScripts: boolean; - watch: boolean; - poll?: number; - preserveSymlinks: boolean; - progress: boolean; - clearScreen?: boolean; - cacheOptions: ReturnType; - postcssConfiguration?: { config: PostcssConfiguration; configPath: string }; - tailwindConfiguration?: { file: string; package: string }; - colors: boolean; -} +import type { NormalizedLibraryOptions, PackageJsonData } from './types'; export async function normalizeLibraryOptions( context: BuilderContext, @@ -217,147 +167,3 @@ export async function normalizeLibraryOptions( tailwindConfiguration, }; } - -/** - * Normalizes a single entry point specification. - * - * @param key The entry point key from package.json exports (e.g. '.' or './testing'). - * @param posixKey Normalized POSIX key without trailing slashes. - * @param isPrimary Whether this is the primary entry point. - * @param targetPath The relative file path string from exports. - * @param projectRoot The library project root directory. - * @param packageName The root package name (e.g. `@my/lib`). - * @returns The normalized entry point descriptor. - */ -function normalizeEntryPoint( - key: string, - posixKey: string, - isPrimary: boolean, - targetPath: string, - projectRoot: string, - packageName: string, -): NormalizedEntryPoint { - const name = isPrimary - ? '.' - : posixKey[0] === '.' && posixKey[1] === '/' - ? posixKey.slice(2) - : posixKey; - - if (name !== '.' && (path.posix.isAbsolute(name) || name.includes('..'))) { - throw new Error( - `Invalid entry point key '${key}'. Entry point keys must be relative subpaths without '..' (e.g. './testing').`, - ); - } - - const subpath = isPrimary ? '.' : `./${name}`; - const displayName = isPrimary ? packageName : `${packageName}/${name}`; - const bundleName = getEntryPointBundleName(packageName, name); - - const entryFilePath = path.resolve(projectRoot, targetPath); - - if (!/(? { - if (!rawExports || (typeof rawExports !== 'string' && typeof rawExports !== 'object')) { - throw new Error( - `The 'package.json' at '${packageJsonPath}' must contain an 'exports' field defining the primary entry point ('.').`, - ); - } - - const exportsRecord = typeof rawExports === 'string' ? { '.': rawExports } : rawExports; - - const entryPoints = new Map(); - let hasPrimary = false; - - for (const [key, value] of Object.entries(exportsRecord)) { - let target: string | undefined; - - if (typeof value === 'string') { - target = value; - } else if ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - typeof (value as Record)['default'] === 'string' - ) { - target = (value as Record)['default'] as string; - } - - const posixKey = toPosixPath(key).replace(/\/+$/, ''); - const isPrimary = posixKey === '.' || posixKey === ''; - - if (!target) { - if (isPrimary) { - throw new Error( - `The primary entry point '.' in '${packageJsonPath}' must specify a string path ` + - `or a 'default' condition pointing to a TypeScript file.`, - ); - } - - // Non-JS/TS conditional export (e.g., sass/style-only subpath); preserve in package.json without compiling. - continue; - } - - if (!isPrimary && !/\.m?ts$/.test(target)) { - // Static asset, stylesheet, or package.json export; preserve in package.json without compiling. - continue; - } - - const entryPoint = normalizeEntryPoint( - key, - posixKey, - isPrimary, - target, - projectRoot, - packageName, - ); - - if (entryPoints.has(entryPoint.name)) { - throw new Error( - `Duplicate entry point detected: '${key}' resolves to the same name ('${entryPoint.name}') as an existing entry point.`, - ); - } - - entryPoints.set(entryPoint.name, entryPoint); - - if (entryPoint.isPrimary) { - hasPrimary = true; - } - } - - if (!hasPrimary) { - throw new Error( - `The 'exports' field in '${packageJsonPath}' must contain a primary entry point with key '.'.`, - ); - } - - return entryPoints; -} diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts index 27688ab0b634..8cd85d4e8fae 100644 --- a/packages/angular/build/src/builders/library/pipeline/assets.ts +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -11,7 +11,7 @@ import path from 'node:path'; import picomatch from 'picomatch'; import { toPosixPath } from '../../../utils/path'; import { DEFAULT_ASSET_IGNORE, resolveAssets } from '../../../utils/resolve-assets'; -import type { NormalizedLibraryOptions } from '../options'; +import type { NormalizedLibraryOptions } from '../types'; import { type DiskOutputFile, createDiskOutputFile } from './utils'; /** diff --git a/packages/angular/build/src/builders/library/pipeline/build-action.ts b/packages/angular/build/src/builders/library/pipeline/build-action.ts index 2cc3b185ec70..93bbf0ba59a3 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -11,28 +11,14 @@ import { constants, copyFile, mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { emitFilesToDisk } from '../../../tools/esbuild/utils'; import { toPosixPath } from '../../../utils/path'; -import type { NormalizedLibraryOptions } from '../options'; +import type { NormalizedLibraryOptions, SingleBuildState } from '../types'; import { collectAssetsToEmit } from './assets'; -import { type BundleEntryPointInput, type BundleResult, bundleEntryPoints } from './bundler'; -import { type SingleProgramCache, compileLibrary } from './compilation'; +import { type BundleEntryPointInput, bundleEntryPoints } from './bundler'; +import { compileLibrary } from './compilation'; import { generatePackageManifests } from './package-manifests'; import type { createComponentStylesheetBundlerForLibrary } from './stylesheet-bundler'; import type { OutputFile } from './utils'; -/** - * State preserved across incremental builds in watch mode. - */ -export interface SingleBuildState { - singleProgramCache?: SingleProgramCache; - previousBundleResults: Map; - pendingChangedEsmFiles: Set; - pendingChangedDtsFiles: Set; - hasCompilationError?: boolean; - hasEmittedManifests?: boolean; - hasEmittedAssets?: boolean; - directoryExists: Set; -} - /** * Creates a fresh {@link SingleBuildState} instance. */ @@ -86,7 +72,8 @@ export async function buildAction(actionContext: BuildActionContext): Promise 0 || pendingChangedDtsFiles.size > 0 || hasModifiedWatchedFile(modifiedFiles, allWatchedFiles, posixPackageJsonPath); @@ -100,6 +87,7 @@ export async function buildAction(actionContext: BuildActionContext): Promise; - - /** Exact set of virtual DTS module IDs bundled into this entry point. */ - dtsModuleIds: ReadonlySet; -} - /** * Output of the entry point bundling process. */ diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts index 49356bbb3a90..b8b89fc41e24 100644 --- a/packages/angular/build/src/builders/library/pipeline/compilation.ts +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -15,28 +15,11 @@ import { import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; import { useTypeChecking } from '../../../utils/environment-options'; import { toPosixPath } from '../../../utils/path'; -import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions, SingleProgramCache } from '../types'; import { isDeclarationFile, isDeclarationSourceMapFile } from './utils'; const EMITTED_EXTENSIONS = ['.js', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts']; -/** - * Cached state for the single unified library compilation. - */ -export interface SingleProgramCache { - /** The active Angular compilation instance. */ - readonly compilationInstance: AngularCompilation; - - /** In-memory map of emitted JavaScript files keyed by relative output path. */ - readonly esmFiles: Map; - - /** In-memory map of emitted TypeScript declaration files keyed by relative output path. */ - readonly dtsFiles: Map; - - /** Set of file paths that failed during stylesheet bundling or compilation. */ - readonly failedFiles?: ReadonlySet; -} - /** * Output of the unified library compilation step. */ diff --git a/packages/angular/build/src/builders/library/pipeline/entry-points.ts b/packages/angular/build/src/builders/library/pipeline/entry-points.ts index de381828c755..76d647359184 100644 --- a/packages/angular/build/src/builders/library/pipeline/entry-points.ts +++ b/packages/angular/build/src/builders/library/pipeline/entry-points.ts @@ -6,25 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ -export interface NormalizedEntryPoint { - /** The subpath in package.json exports (e.g. '.' or './testing'). */ - subpath: string; - - /** Subpath name without leading './' (e.g. '.' or 'testing'). */ - name: string; - - /** Display name of the entry point (e.g. '@my/lib' or '@my/lib/testing'). */ - displayName: string; - - /** Base name of the output bundle (e.g. 'my-lib' or 'my-lib-testing'). */ - bundleName: string; - - /** Absolute path to entry file. */ - entryFilePath: string; - - /** Is this the primary entry point ('.')? */ - isPrimary: boolean; -} +import path from 'node:path'; +import { toPosixPath } from '../../../utils/path'; +import type { + NormalizedEntryPoint, + NormalizedLibraryOptions, + PackageJsonData, + SingleBuildState, +} from '../types'; /** * Computes the base bundle file name for an entry point. @@ -38,5 +27,246 @@ export function getEntryPointBundleName(packageName: string, entryPointName = '. const pkgName = packageName[0] === '@' ? packageName.slice(1) : packageName; const epName = isPrimary ? pkgName : `${pkgName}-${entryPointName}`; - return epName.replaceAll('/', '-'); + return epName.replaceAll('/', '-').toLowerCase(); +} + +/** + * Normalizes a single entry point specification. + * + * @param key The entry point key from package.json exports (e.g. '.' or './testing'). + * @param posixKey Normalized POSIX key without trailing slashes. + * @param isPrimary Whether this is the primary entry point. + * @param targetPath The relative file path string from exports. + * @param projectRoot The library project root directory. + * @param packageName The root package name (e.g. `@my/lib`). + * @returns The normalized entry point descriptor. + */ +function normalizeEntryPoint( + key: string, + posixKey: string, + isPrimary: boolean, + targetPath: string, + projectRoot: string, + packageName: string, +): NormalizedEntryPoint { + const name = isPrimary + ? '.' + : posixKey[0] === '.' && posixKey[1] === '/' + ? posixKey.slice(2) + : posixKey; + + if (name !== '.' && (path.posix.isAbsolute(name) || name.includes('..'))) { + throw new Error( + `Invalid entry point key '${key}'. Entry point keys must be relative subpaths without '..' (e.g. './testing').`, + ); + } + + const subpath = isPrimary ? '.' : `./${name}`; + const displayName = (isPrimary ? packageName : `${packageName}/${name}`).toLowerCase(); + const bundleName = getEntryPointBundleName(packageName, name); + const entryFilePath = path.resolve(projectRoot, targetPath); + + if (!/(?)['default']); + } + + return undefined; +} + +/** + * Normalizes all entry points from the library's `package.json` `exports` field. + * + * @param rawExports The `exports` field from `package.json`. + * @param projectRoot The library project root directory. + * @param packageJsonPath Path to `package.json` for error reporting. + * @param packageName The root package name (e.g. `@my/lib`). + * @returns A Map of normalized entry points keyed by name. + */ +export function normalizeEntryPoints( + rawExports: unknown, + projectRoot: string, + packageJsonPath: string, + packageName: string, +): Map { + if (!rawExports || (typeof rawExports !== 'string' && typeof rawExports !== 'object')) { + throw new Error( + `The 'package.json' at '${packageJsonPath}' must contain an 'exports' field defining the primary entry point ('.').`, + ); + } + + let exportsRecord: Record; + if (typeof rawExports === 'string' || Array.isArray(rawExports)) { + exportsRecord = { '.': rawExports }; + } else { + // If an object has no keys starting with '.', Node.js treats the entire object as the '.' entry point. + const hasSubpathKeys = Object.keys(rawExports as object).some((k) => k.startsWith('.')); + exportsRecord = hasSubpathKeys ? (rawExports as Record) : { '.': rawExports }; + } + + const entryPoints = new Map(); + const usedBundleNames = new Map(); + let hasPrimary = false; + + for (const [key, value] of Object.entries(exportsRecord)) { + const target = resolveTarget(value); + + const posixKey = toPosixPath(key).replace(/\/+$/, ''); + const isPrimary = posixKey === '.' || posixKey === ''; + + if (!target) { + if (isPrimary) { + throw new Error( + `The primary entry point '.' in '${packageJsonPath}' must specify a string path ` + + `or a 'default' condition pointing to a TypeScript file.`, + ); + } + + // Non-JS/TS conditional export (e.g., sass/style-only subpath); preserve in package.json without compiling. + continue; + } + + if (!isPrimary && !/\.m?ts$/.test(target)) { + // Static asset, stylesheet, or package.json export; preserve in package.json without compiling. + continue; + } + + const entryPoint = normalizeEntryPoint( + key, + posixKey, + isPrimary, + target, + projectRoot, + packageName, + ); + + const existingKey = usedBundleNames.get(entryPoint.bundleName); + if (existingKey) { + throw new Error( + `Duplicate entry point detected: '${key}' resolves to the same bundle name ('${entryPoint.bundleName}') ` + + `as existing entry point '${existingKey}'. ` + + `Entry points must be unique and cannot differ only by case, slashes, or hyphens.`, + ); + } + + usedBundleNames.set(entryPoint.bundleName, key); + entryPoints.set(entryPoint.name, entryPoint); + + if (entryPoint.isPrimary) { + hasPrimary = true; + } + } + + if (!hasPrimary) { + throw new Error( + `The 'exports' field in '${packageJsonPath}' must contain a primary entry point with key '.'.`, + ); + } + + return entryPoints; +} + +/** + * Determines whether two sets of normalized entry points differ in keys, paths, or subpaths. + */ +export function haveEntryPointsChanged( + oldEntryPoints: ReadonlyMap, + newEntryPoints: ReadonlyMap, +): boolean { + if (oldEntryPoints.size !== newEntryPoints.size) { + return true; + } + + for (const [name, oldEp] of oldEntryPoints) { + const newEp = newEntryPoints.get(name); + if ( + !newEp || + newEp.entryFilePath !== oldEp.entryFilePath || + newEp.subpath !== oldEp.subpath || + newEp.bundleName !== oldEp.bundleName || + newEp.displayName !== oldEp.displayName + ) { + return true; + } + } + + return false; +} + +/** + * Updates watched files, entry points in options, and cached bundle results when package.json entry points change. + */ +export function updateWatchedEntryPoints( + packageJson: PackageJsonData, + options: NormalizedLibraryOptions, + buildState: SingleBuildState, + allWatchedFiles: Set, + packageJsonPath: string, +): void { + const newEntryPoints = normalizeEntryPoints( + packageJson.exports, + options.projectRoot, + packageJsonPath, + options.packageName, + ); + + if (haveEntryPointsChanged(options.entryPoints, newEntryPoints)) { + for (const entryPoint of newEntryPoints.values()) { + allWatchedFiles.add(toPosixPath(entryPoint.entryFilePath)); + } + + for (const name of buildState.previousBundleResults.keys()) { + const oldEp = options.entryPoints.get(name); + const newEp = newEntryPoints.get(name); + if ( + !newEp || + !oldEp || + newEp.entryFilePath !== oldEp.entryFilePath || + newEp.bundleName !== oldEp.bundleName + ) { + buildState.previousBundleResults.delete(name); + } + } + + options.entryPoints = newEntryPoints; + buildState.hasEntryPointsChanges = true; + } } diff --git a/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts b/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts new file mode 100644 index 000000000000..8472ec35ad34 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts @@ -0,0 +1,706 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join, resolve } from 'node:path'; +import { toPosixPath } from '../../../utils/path'; +import type { + BundleResult, + NormalizedEntryPoint, + NormalizedLibraryOptions, + PackageJsonData, + SingleBuildState, +} from '../types'; +import { + getEntryPointBundleName, + haveEntryPointsChanged, + normalizeEntryPoints, + updateWatchedEntryPoints, +} from './entry-points'; + +describe('entry-points pipeline', () => { + const projectRoot = resolve('/workspace/projects/my-lib'); + const packageJsonPath = join(projectRoot, 'package.json'); + const packageName = '@my-scope/my-lib'; + + describe('getEntryPointBundleName', () => { + it('should compute bundle name for primary entry point without scope', () => { + expect(getEntryPointBundleName('my-lib')).toBe('my-lib'); + expect(getEntryPointBundleName('my-lib', '.')).toBe('my-lib'); + }); + + it('should compute bundle name for primary entry point with scope', () => { + expect(getEntryPointBundleName('@my-scope/my-lib')).toBe('my-scope-my-lib'); + expect(getEntryPointBundleName('@my-scope/my-lib', '.')).toBe('my-scope-my-lib'); + }); + + it('should compute bundle name for secondary entry point without scope', () => { + expect(getEntryPointBundleName('my-lib', 'testing')).toBe('my-lib-testing'); + }); + + it('should compute bundle name for secondary entry point with scope', () => { + expect(getEntryPointBundleName('@my-scope/my-lib', 'testing')).toBe( + 'my-scope-my-lib-testing', + ); + }); + + it('should replace nested slashes with dashes in secondary entry point name', () => { + expect(getEntryPointBundleName('@my-scope/my-lib', 'testing/sub/deep')).toBe( + 'my-scope-my-lib-testing-sub-deep', + ); + }); + + it('should lowercase bundle name', () => { + expect(getEntryPointBundleName('@My-Scope/My-Lib', 'Testing/Sub')).toBe( + 'my-scope-my-lib-testing-sub', + ); + }); + }); + + describe('normalizeEntryPoints', () => { + it('should parse string shorthand exports', () => { + const entryPoints = normalizeEntryPoints( + './src/public-api.ts', + projectRoot, + packageJsonPath, + packageName, + ); + + expect(entryPoints).toHaveSize(1); + const primary = entryPoints.get('.'); + expect(primary).toEqual({ + subpath: '.', + name: '.', + displayName: packageName, + bundleName: 'my-scope-my-lib', + entryFilePath: join(projectRoot, 'src/public-api.ts'), + isPrimary: true, + }); + }); + + it('should parse array shorthand exports', () => { + const entryPoints = normalizeEntryPoints( + ['./src/public-api.ts'], + projectRoot, + packageJsonPath, + packageName, + ); + + expect(entryPoints).toHaveSize(1); + const primary = entryPoints.get('.'); + expect(primary).toEqual({ + subpath: '.', + name: '.', + displayName: packageName, + bundleName: 'my-scope-my-lib', + entryFilePath: join(projectRoot, 'src/public-api.ts'), + isPrimary: true, + }); + }); + + it('should parse array shorthand exports with conditional objects', () => { + const entryPoints = normalizeEntryPoints( + [{ default: './src/public-api.ts' }], + projectRoot, + packageJsonPath, + packageName, + ); + + expect(entryPoints).toHaveSize(1); + const primary = entryPoints.get('.'); + expect(primary).toEqual({ + subpath: '.', + name: '.', + displayName: packageName, + bundleName: 'my-scope-my-lib', + entryFilePath: join(projectRoot, 'src/public-api.ts'), + isPrimary: true, + }); + }); + + it('should parse root conditional exports without leading "."', () => { + const entryPoints = normalizeEntryPoints( + { default: './src/public-api.ts' }, + projectRoot, + packageJsonPath, + packageName, + ); + + expect(entryPoints).toHaveSize(1); + const primary = entryPoints.get('.'); + expect(primary).toEqual({ + subpath: '.', + name: '.', + displayName: packageName, + bundleName: 'my-scope-my-lib', + entryFilePath: join(projectRoot, 'src/public-api.ts'), + isPrimary: true, + }); + }); + + it('should parse object exports with array targets', () => { + const rawExports = { + '.': ['./src/public-api.ts'], + './testing': [{ default: './testing/src/public-api.ts' }], + }; + + const entryPoints = normalizeEntryPoints( + rawExports, + projectRoot, + packageJsonPath, + packageName, + ); + + expect(entryPoints).toHaveSize(2); + expect(entryPoints.get('.')?.entryFilePath).toBe(join(projectRoot, 'src/public-api.ts')); + expect(entryPoints.get('testing')?.entryFilePath).toBe( + join(projectRoot, 'testing/src/public-api.ts'), + ); + }); + + it('should parse object exports with primary and secondary entry points', () => { + const rawExports = { + '.': './src/public-api.ts', + './testing': './testing/src/public-api.ts', + './testing/sub': './testing/sub/src/public-api.mts', + }; + + const entryPoints = normalizeEntryPoints( + rawExports, + projectRoot, + packageJsonPath, + packageName, + ); + + expect(entryPoints).toHaveSize(3); + + const primary = entryPoints.get('.'); + expect(primary).toEqual({ + subpath: '.', + name: '.', + displayName: packageName, + bundleName: 'my-scope-my-lib', + entryFilePath: join(projectRoot, 'src/public-api.ts'), + isPrimary: true, + }); + + const secondary = entryPoints.get('testing'); + expect(secondary).toEqual({ + subpath: './testing', + name: 'testing', + displayName: `${packageName}/testing`, + bundleName: 'my-scope-my-lib-testing', + entryFilePath: join(projectRoot, 'testing/src/public-api.ts'), + isPrimary: false, + }); + + const nested = entryPoints.get('testing/sub'); + expect(nested).toEqual({ + subpath: './testing/sub', + name: 'testing/sub', + displayName: `${packageName}/testing/sub`, + bundleName: 'my-scope-my-lib-testing-sub', + entryFilePath: join(projectRoot, 'testing/sub/src/public-api.mts'), + isPrimary: false, + }); + }); + + it('should handle conditional exports with "default"', () => { + const rawExports = { + '.': { default: './src/public-api.ts' }, + './testing': { default: './testing/src/public-api.ts' }, + }; + + const entryPoints = normalizeEntryPoints( + rawExports, + projectRoot, + packageJsonPath, + packageName, + ); + + expect(entryPoints).toHaveSize(2); + expect(entryPoints.get('.')?.entryFilePath).toBe(join(projectRoot, 'src/public-api.ts')); + expect(entryPoints.get('testing')?.entryFilePath).toBe( + join(projectRoot, 'testing/src/public-api.ts'), + ); + }); + + it('should ignore non-TypeScript exports for secondary subpaths', () => { + const rawExports = { + '.': './src/public-api.ts', + './package.json': './package.json', + './styles.css': './styles.css', + './theme': { sass: './theme.scss' }, + }; + + const entryPoints = normalizeEntryPoints( + rawExports, + projectRoot, + packageJsonPath, + packageName, + ); + + expect(entryPoints).toHaveSize(1); + expect(entryPoints.has('.')).toBeTrue(); + expect(entryPoints.has('package.json')).toBeFalse(); + expect(entryPoints.has('styles.css')).toBeFalse(); + expect(entryPoints.has('theme')).toBeFalse(); + }); + + it('should throw when exports is missing or not an object/string', () => { + expect(() => + normalizeEntryPoints(undefined, projectRoot, packageJsonPath, packageName), + ).toThrowError(/must contain an 'exports' field defining the primary entry point/); + + expect(() => + normalizeEntryPoints(null, projectRoot, packageJsonPath, packageName), + ).toThrowError(/must contain an 'exports' field defining the primary entry point/); + + expect(() => + normalizeEntryPoints(123, projectRoot, packageJsonPath, packageName), + ).toThrowError(/must contain an 'exports' field defining the primary entry point/); + }); + + it('should throw when primary entry point "." is missing', () => { + const rawExports = { + './testing': './testing/src/public-api.ts', + }; + + expect(() => + normalizeEntryPoints(rawExports, projectRoot, packageJsonPath, packageName), + ).toThrowError(/must contain a primary entry point with key '\.'/); + }); + + it('should throw when primary entry point does not have a target path or default', () => { + const rawExports = { + '.': { require: './src/public-api.js' }, + }; + + expect(() => + normalizeEntryPoints(rawExports, projectRoot, packageJsonPath, packageName), + ).toThrowError(/must specify a string path or a 'default' condition/); + + expect(() => + normalizeEntryPoints([], projectRoot, packageJsonPath, packageName), + ).toThrowError(/must specify a string path or a 'default' condition/); + }); + + it('should throw when entry point target is not a TypeScript file', () => { + expect(() => + normalizeEntryPoints( + { '.': './src/public-api.js' }, + projectRoot, + packageJsonPath, + packageName, + ), + ).toThrowError(/must be a TypeScript file \('\.ts' or '\.mts'\)/); + + expect(() => + normalizeEntryPoints( + { '.': './src/public-api.cts' }, + projectRoot, + packageJsonPath, + packageName, + ), + ).toThrowError(/must be a TypeScript file \('\.ts' or '\.mts'\)/); + + expect(() => + normalizeEntryPoints( + { '.': './src/public-api.d.ts' }, + projectRoot, + packageJsonPath, + packageName, + ), + ).toThrowError(/must be a TypeScript file \('\.ts' or '\.mts'\)/); + }); + + it('should throw when entry point key contains ".." or is absolute', () => { + expect(() => + normalizeEntryPoints( + { '.': './src/public-api.ts', './../evil': './evil/src/public-api.ts' }, + projectRoot, + packageJsonPath, + packageName, + ), + ).toThrowError(/Entry point keys must be relative subpaths without '\.\.'/); + + expect(() => + normalizeEntryPoints( + { '.': './src/public-api.ts', '/abs': './evil/src/public-api.ts' }, + projectRoot, + packageJsonPath, + packageName, + ), + ).toThrowError(/Entry point keys must be relative subpaths without '\.\.'/); + }); + + it('should throw when duplicate entry point names resolve', () => { + expect(() => + normalizeEntryPoints( + { + '.': './src/public-api.ts', + './testing': './testing/src/public-api.ts', + 'testing': './testing2/src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ), + ).toThrowError(/Duplicate entry point detected/); + }); + + it('should throw when entry point names differ only by slashes vs hyphens', () => { + expect(() => + normalizeEntryPoints( + { + '.': './src/public-api.ts', + './zz/child': './zz/child/src/public-api.ts', + './zz-child': './zz-child/src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ), + ).toThrowError( + /Duplicate entry point detected: '\.\/zz-child' resolves to the same bundle name \('my-scope-my-lib-zz-child'\)/, + ); + }); + + it('should throw when entry point names differ only by case', () => { + expect(() => + normalizeEntryPoints( + { + '.': './src/public-api.ts', + './testing': './testing/src/public-api.ts', + './Testing': './testing2/src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ), + ).toThrowError( + /Duplicate entry point detected: '\.\/Testing' resolves to the same bundle name \('my-scope-my-lib-testing'\)/, + ); + }); + + it('should throw when entry point names differ by case and slashes vs hyphens', () => { + expect(() => + normalizeEntryPoints( + { + '.': './src/public-api.ts', + './ZZ/Child': './zz/child/src/public-api.ts', + './zz-child': './zz-child/src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ), + ).toThrowError( + /Duplicate entry point detected: '\.\/zz-child' resolves to the same bundle name \('my-scope-my-lib-zz-child'\)/, + ); + }); + }); + + describe('haveEntryPointsChanged', () => { + function createMap(items: NormalizedEntryPoint[]): Map { + return new Map(items.map((item) => [item.name, item])); + } + + const primaryEp: NormalizedEntryPoint = { + subpath: '.', + name: '.', + displayName: 'my-lib', + bundleName: 'my-lib', + entryFilePath: join(projectRoot, 'src/public-api.ts'), + isPrimary: true, + }; + + const secondaryEp: NormalizedEntryPoint = { + subpath: './testing', + name: 'testing', + displayName: 'my-lib/testing', + bundleName: 'my-lib-testing', + entryFilePath: join(projectRoot, 'testing/src/public-api.ts'), + isPrimary: false, + }; + + it('should return false when entry points are identical', () => { + const mapA = createMap([primaryEp, secondaryEp]); + const mapB = createMap([{ ...primaryEp }, { ...secondaryEp }]); + + expect(haveEntryPointsChanged(mapA, mapB)).toBeFalse(); + }); + + it('should return true when sizes differ', () => { + const mapA = createMap([primaryEp]); + const mapB = createMap([primaryEp, secondaryEp]); + + expect(haveEntryPointsChanged(mapA, mapB)).toBeTrue(); + expect(haveEntryPointsChanged(mapB, mapA)).toBeTrue(); + }); + + it('should return true when an entry point file path changes', () => { + const mapA = createMap([primaryEp]); + const mapB = createMap([ + { + ...primaryEp, + entryFilePath: join(projectRoot, 'src/other.ts'), + }, + ]); + + expect(haveEntryPointsChanged(mapA, mapB)).toBeTrue(); + }); + + it('should return true when an entry point subpath changes', () => { + const mapA = createMap([secondaryEp]); + const mapB = createMap([ + { + ...secondaryEp, + subpath: './other-subpath', + }, + ]); + + expect(haveEntryPointsChanged(mapA, mapB)).toBeTrue(); + }); + + it('should return true when keys differ with the same size', () => { + const mapA = createMap([secondaryEp]); + const mapB = createMap([ + { + ...secondaryEp, + name: 'other', + }, + ]); + + expect(haveEntryPointsChanged(mapA, mapB)).toBeTrue(); + }); + + it('should return true when an entry point bundleName changes', () => { + const mapA = createMap([primaryEp]); + const mapB = createMap([ + { + ...primaryEp, + bundleName: 'my-scope-my-lib-renamed', + }, + ]); + + expect(haveEntryPointsChanged(mapA, mapB)).toBeTrue(); + }); + + it('should return true when an entry point displayName changes', () => { + const mapA = createMap([primaryEp]); + const mapB = createMap([ + { + ...primaryEp, + displayName: '@my-scope/renamed-lib', + }, + ]); + + expect(haveEntryPointsChanged(mapA, mapB)).toBeTrue(); + }); + }); + + describe('updateWatchedEntryPoints', () => { + it('should update options, watched files, build state, and prune removed bundles when entry points change', () => { + const initialEntryPoints = normalizeEntryPoints( + { + '.': './src/public-api.ts', + './old-feature': './old-feature/src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ); + + const options = { + entryPoints: initialEntryPoints, + projectRoot, + packageName, + } as unknown as NormalizedLibraryOptions; + + const buildState = { + previousBundleResults: new Map([ + ['.', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], + ['old-feature', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], + ]), + hasEntryPointsChanges: false, + } as unknown as SingleBuildState; + + const allWatchedFiles = new Set(); + + const newPackageJson: PackageJsonData = { + name: packageName, + exports: { + '.': './src/public-api.ts', + './new-feature': './new-feature/src/public-api.ts', + }, + }; + + updateWatchedEntryPoints( + newPackageJson, + options, + buildState, + allWatchedFiles, + packageJsonPath, + ); + + expect(buildState.hasEntryPointsChanges).toBeTrue(); + expect(options.entryPoints.has('new-feature')).toBeTrue(); + expect(options.entryPoints.has('old-feature')).toBeFalse(); + expect(buildState.previousBundleResults.has('old-feature')).toBeFalse(); + expect(buildState.previousBundleResults.has('.')).toBeTrue(); + expect(allWatchedFiles.has(toPosixPath(join(projectRoot, 'src/public-api.ts')))).toBeTrue(); + expect( + allWatchedFiles.has(toPosixPath(join(projectRoot, 'new-feature/src/public-api.ts'))), + ).toBeTrue(); + }); + + it('should not mutate buildState or options when entry points do not change', () => { + const initialEntryPoints = normalizeEntryPoints( + { + '.': './src/public-api.ts', + './feature': './feature/src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ); + + const options = { + entryPoints: initialEntryPoints, + projectRoot, + packageName, + } as unknown as NormalizedLibraryOptions; + + const buildState = { + previousBundleResults: new Map([ + ['.', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], + ['feature', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], + ]), + hasEntryPointsChanges: false, + } as unknown as SingleBuildState; + + const allWatchedFiles = new Set(); + + const samePackageJson: PackageJsonData = { + name: packageName, + exports: { + '.': './src/public-api.ts', + './feature': './feature/src/public-api.ts', + }, + }; + + updateWatchedEntryPoints( + samePackageJson, + options, + buildState, + allWatchedFiles, + packageJsonPath, + ); + + expect(buildState.hasEntryPointsChanges).toBeFalse(); + expect(options.entryPoints).toBe(initialEntryPoints); + expect(allWatchedFiles).toHaveSize(0); + expect(buildState.previousBundleResults).toHaveSize(2); + }); + + it('should prune previousBundleResults when an entry point path changes', () => { + const initialEntryPoints = normalizeEntryPoints( + { + '.': './src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ); + + const options = { + entryPoints: initialEntryPoints, + projectRoot, + packageName, + } as unknown as NormalizedLibraryOptions; + + const buildState = { + previousBundleResults: new Map([ + ['.', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], + ]), + hasEntryPointsChanges: false, + } as unknown as SingleBuildState; + + const allWatchedFiles = new Set(); + + const updatedPackageJson: PackageJsonData = { + name: packageName, + exports: { + '.': './src/other-api.ts', + }, + }; + + updateWatchedEntryPoints( + updatedPackageJson, + options, + buildState, + allWatchedFiles, + packageJsonPath, + ); + + expect(buildState.hasEntryPointsChanges).toBeTrue(); + expect(buildState.previousBundleResults.has('.')).toBeFalse(); + expect(options.entryPoints.get('.')?.entryFilePath).toBe( + join(projectRoot, 'src/other-api.ts'), + ); + expect(allWatchedFiles.has(toPosixPath(join(projectRoot, 'src/other-api.ts')))).toBeTrue(); + }); + + it('should prune previousBundleResults when an entry point bundleName changes', () => { + const initialEntryPoints = normalizeEntryPoints( + { + '.': './src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ); + + const options = { + entryPoints: initialEntryPoints, + projectRoot, + packageName, + } as unknown as NormalizedLibraryOptions; + + const buildState = { + previousBundleResults: new Map([ + ['.', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], + ]), + hasEntryPointsChanges: false, + } as unknown as SingleBuildState; + + const allWatchedFiles = new Set(); + + const updatedPackageJson: PackageJsonData = { + name: '@renamed-scope/my-lib', + exports: { + '.': './src/public-api.ts', + }, + }; + + options.packageName = updatedPackageJson.name; + + updateWatchedEntryPoints( + updatedPackageJson, + options, + buildState, + allWatchedFiles, + packageJsonPath, + ); + + expect(buildState.hasEntryPointsChanges).toBeTrue(); + expect(buildState.previousBundleResults.has('.')).toBeFalse(); + expect(options.entryPoints.get('.')?.bundleName).toBe('renamed-scope-my-lib'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/pipeline/package-manifests.ts b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts index 8b5c7708bcec..8a3772e0e43e 100644 --- a/packages/angular/build/src/builders/library/pipeline/package-manifests.ts +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts @@ -7,7 +7,7 @@ */ import path from 'node:path'; -import type { NormalizedLibraryOptions, PackageJsonData } from '../options'; +import type { NormalizedLibraryOptions, PackageJsonData } from '../types'; import { FESM_OUTPUT_DIR, type MemoryOutputFile, diff --git a/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts b/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts index 112234ca89d3..822af1d536f9 100644 --- a/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts @@ -8,7 +8,7 @@ import assert from 'node:assert'; import { join } from 'node:path'; -import type { NormalizedEntryPoint, NormalizedLibraryOptions, PackageJsonData } from '../options'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions, PackageJsonData } from '../types'; import { getEntryPointBundleName } from './entry-points'; import { generatePackageManifests } from './package-manifests'; import type { MemoryOutputFile } from './utils'; diff --git a/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts b/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts index b5f8823b4100..74c44f3a1491 100644 --- a/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts @@ -8,7 +8,7 @@ import { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; import type { BundleStylesheetOptions } from '../../../tools/esbuild/stylesheets/bundle-options'; -import type { NormalizedLibraryOptions } from '../options'; +import type { NormalizedLibraryOptions } from '../types'; export type LibraryStylesheetBundlerOptions = Pick< NormalizedLibraryOptions, diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts index d78ae7e2b731..03213842a30c 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -260,6 +260,121 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => ]); }); + it('should re-analyze exports and compile new entry point when package.json is modified in watch mode', async () => { + await harness.writeFile( + 'projects/lib/secondary/src/public-api.ts', + `export const secondary = 'secondary-initial';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs')).toBeFalse(); + + // Add secondary entry point to package.json exports + const pkg = JSON.parse(harness.readFile('projects/lib/package.json')); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + await harness.writeFile('projects/lib/package.json', JSON.stringify(pkg, null, 2)); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs')).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'secondary-initial', + ); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.exports['./secondary']).toBeDefined(); + + // Update secondary entry point source file to ensure it is being watched + await harness.writeFile( + 'projects/lib/secondary/src/public-api.ts', + `export const secondary = 'secondary-updated';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'secondary-updated', + ); + }, + ]); + }); + + it('should re-analyze exports and recompile when entry point path is changed in package.json in watch mode', async () => { + await harness.writeFile( + 'projects/lib/src/alternative-api.ts', + `export const value = 'alternative-api-content';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('LibComponent'); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).not.toContain( + 'alternative-api-content', + ); + + // Change primary entry point path in package.json + const pkg = JSON.parse(harness.readFile('projects/lib/package.json')); + pkg.exports = { + '.': './src/alternative-api.ts', + }; + await harness.writeFile('projects/lib/package.json', JSON.stringify(pkg, null, 2)); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain( + 'alternative-api-content', + ); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).not.toContain('LibComponent'); + }, + ]); + }); + + it('should fail with error when name is removed from package.json in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Remove name from package.json + const pkg = JSON.parse(harness.readFile('projects/lib/package.json')); + delete pkg.name; + await harness.writeFile('projects/lib/package.json', JSON.stringify(pkg, null, 2)); + }, + async ({ result }) => { + expect(result?.success).toBeFalse(); + expect(result?.error).toContain("must contain a 'name'"); + + // Restore name in package.json + const pkg = JSON.parse(harness.readFile('projects/lib/package.json')); + pkg.name = 'lib'; + await harness.writeFile('projects/lib/package.json', JSON.stringify(pkg, null, 2)); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + }, + ]); + }); + it('should recover from compilation errors in watch mode', async () => { await harness.writeFile( 'projects/lib/src/public-api.ts', diff --git a/packages/angular/build/src/builders/library/types.ts b/packages/angular/build/src/builders/library/types.ts new file mode 100644 index 000000000000..7eb7c2feea38 --- /dev/null +++ b/packages/angular/build/src/builders/library/types.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { AngularCompilation } from '../../tools/angular/compilation'; +import type { StylesheetPluginsass } from '../../tools/esbuild/stylesheets/stylesheet-plugin-factory'; +import type { normalizeAssetPatterns } from '../../utils'; +import type { normalizeCacheOptions } from '../../utils/normalize-cache'; +import type { PostcssConfiguration } from '../../utils/postcss-configuration'; + +/** + * Normalized representation of an entry point in the library. + */ +export interface NormalizedEntryPoint { + /** The subpath in package.json exports (e.g. '.' or './testing'). */ + subpath: string; + + /** Subpath name without leading './' (e.g. '.' or 'testing'). */ + name: string; + + /** Display name of the entry point (e.g. '@my/lib' or '@my/lib/testing'). */ + displayName: string; + + /** Base name of the output bundle (e.g. 'my-lib' or 'my-lib-testing'). */ + bundleName: string; + + /** Absolute path to entry file. */ + entryFilePath: string; + + /** Is this the primary entry point ('.')? */ + isPrimary: boolean; +} + +/** + * Partial package.json data structure for library packaging. + */ +export interface PackageJsonData { + name: string; + version?: string; + type?: string; + main?: string; + module?: string; + typings?: string; + types?: string; + sideEffects?: boolean | string[]; + exports?: string | unknown[] | Record; + scripts?: Record; + workspaces?: unknown; + dependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + [key: string]: unknown; +} + +/** + * Normalized options for the library builder. + */ +export interface NormalizedLibraryOptions { + workspaceRoot: string; + projectRoot: string; + packageName: string; + packageJson: PackageJsonData; + outputPath: string; + deleteOutputPath: boolean; + packageJsonPath: string; + tsConfigPath: string; + entryPoints: Map; + inlineStyleLanguage: 'css' | 'less' | 'sass' | 'scss'; + styleIncludePaths: string[]; + sass?: StylesheetPluginsass; + assets: ReturnType; + compilationMode: 'partial' | 'full'; + declarationMap: boolean; + allowedNonPeerDependencies: RegExp[]; + keepLifecycleScripts: boolean; + watch: boolean; + poll?: number; + preserveSymlinks: boolean; + progress: boolean; + clearScreen?: boolean; + cacheOptions: ReturnType; + postcssConfiguration?: { config: PostcssConfiguration; configPath: string }; + tailwindConfiguration?: { file: string; package: string }; + colors: boolean; +} + +/** + * Cached module ID sets for a bundled entry point. + */ +export interface BundleResult { + /** Exact set of virtual ESM module IDs bundled into this entry point. */ + esmModuleIds: ReadonlySet; + + /** Exact set of virtual DTS module IDs bundled into this entry point. */ + dtsModuleIds: ReadonlySet; +} + +/** + * Cached state for the single unified library compilation. + */ +export interface SingleProgramCache { + /** The active Angular compilation instance. */ + readonly compilationInstance: AngularCompilation; + + /** In-memory map of emitted JavaScript files keyed by relative output path. */ + readonly esmFiles: Map; + + /** In-memory map of emitted TypeScript declaration files keyed by relative output path. */ + readonly dtsFiles: Map; + + /** Set of file paths that failed during stylesheet bundling or compilation. */ + readonly failedFiles?: ReadonlySet; +} + +/** + * State preserved across incremental builds in watch mode. + */ +export interface SingleBuildState { + singleProgramCache?: SingleProgramCache; + previousBundleResults: Map; + pendingChangedEsmFiles: Set; + pendingChangedDtsFiles: Set; + hasCompilationError?: boolean; + hasEmittedManifests?: boolean; + hasEmittedAssets?: boolean; + hasEntryPointsChanges?: boolean; + directoryExists: Set; +}