From 25859e72eb72774ad3b55b648836f5efcdbc71b7 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:20:05 +0000 Subject: [PATCH 1/8] feat(@angular/build): add library builder Add a new native `@angular/build:library` builder providing a modern, high-performance compilation and packaging pipeline. --- packages/angular/build/BUILD.bazel | 40 ++ packages/angular/build/builders.json | 5 + packages/angular/build/package.json | 1 + .../build/src/builders/library/builder.ts | 411 +++++++++++++++ .../build/src/builders/library/index.ts | 17 + .../build/src/builders/library/options.ts | 349 +++++++++++++ .../src/builders/library/pipeline/assets.ts | 100 ++++ .../builders/library/pipeline/build-action.ts | 325 ++++++++++++ .../src/builders/library/pipeline/bundler.ts | 483 ++++++++++++++++++ .../builders/library/pipeline/compilation.ts | 268 ++++++++++ .../library/pipeline/compiler-worker.ts | 132 +++++ .../library/pipeline/entry-point-graph.ts | 342 +++++++++++++ .../library/pipeline/entry-point-scanner.ts | 256 ++++++++++ .../library/pipeline/package-manifests.ts | 167 ++++++ .../pipeline/package-manifests_spec.ts | 339 ++++++++++++ .../library/pipeline/stylesheet-bundler.ts | 66 +++ .../src/builders/library/pipeline/types.d.ts | 54 ++ .../src/builders/library/pipeline/utils.ts | 142 +++++ .../build/src/builders/library/schema.json | 199 ++++++++ .../library/tests/behavior/apf_spec.ts | 106 ++++ .../library/tests/behavior/build_spec.ts | 51 ++ .../library/tests/behavior/core_spec.ts | 101 ++++ .../library/tests/behavior/secondary_spec.ts | 146 ++++++ .../library/tests/behavior/styles_spec.ts | 145 ++++++ .../library/tests/behavior/watch_spec.ts | 392 ++++++++++++++ .../allowed-non-peer-dependencies_spec.ts | 68 +++ .../library/tests/options/assets_spec.ts | 54 ++ .../tests/options/compilation-mode_spec.ts | 41 ++ .../tests/options/declaration-map_spec.ts | 43 ++ .../tests/options/delete-output-path_spec.ts | 43 ++ .../tests/options/entry-points_spec.ts | 76 +++ .../options/keep-lifecycle-scripts_spec.ts | 63 +++ .../library/tests/options/output-path_spec.ts | 36 ++ .../build/src/builders/library/tests/setup.ts | 78 +++ .../build/src/builders/unit-test/builder.ts | 33 +- .../tests/behavior/library-target_spec.ts | 76 +++ .../tests/behavior/vitest-zone-init_spec.ts | 37 ++ .../compilation/angular-compilation.ts | 1 + .../src/tools/angular/compilation/index.ts | 1 + .../compilation/library-compilation.ts | 451 ++++++++++++++++ .../compilation/typescript-compilation.ts | 4 +- .../cli/lib/config/workspace-schema.json | 23 + pnpm-lock.yaml | 294 +++++++++++ 43 files changed, 6057 insertions(+), 2 deletions(-) create mode 100644 packages/angular/build/src/builders/library/builder.ts create mode 100644 packages/angular/build/src/builders/library/index.ts create mode 100644 packages/angular/build/src/builders/library/options.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/assets.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/build-action.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/bundler.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/compilation.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/compiler-worker.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/entry-point-scanner.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/package-manifests.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/types.d.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/utils.ts create mode 100644 packages/angular/build/src/builders/library/schema.json create mode 100644 packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/build_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/core_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/styles_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/allowed-non-peer-dependencies_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/assets_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/compilation-mode_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/declaration-map_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/delete-output-path_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/output-path_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/setup.ts create mode 100644 packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts create mode 100644 packages/angular/build/src/tools/angular/compilation/library-compilation.ts diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index d648331b70de..19eee52bcf7c 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -24,6 +24,11 @@ ts_json_schema( src = "src/builders/extract-i18n/schema.json", ) +ts_json_schema( + name = "library_schema", + src = "src/builders/library/schema.json", +) + ts_json_schema( name = "ng_karma_schema", src = "src/builders/karma/schema.json", @@ -72,6 +77,7 @@ ts_project( "//packages/angular/build:src/builders/dev-server/schema.ts", "//packages/angular/build:src/builders/extract-i18n/schema.ts", "//packages/angular/build:src/builders/karma/schema.ts", + "//packages/angular/build:src/builders/library/schema.ts", "//packages/angular/build:src/builders/ng-packagr/schema.ts", "//packages/angular/build:src/builders/unit-test/schema.ts", ], @@ -104,6 +110,7 @@ ts_project( ":node_modules/piscina", ":node_modules/postcss", ":node_modules/rolldown", + ":node_modules/rolldown-plugin-dts", ":node_modules/rollup", ":node_modules/sass", ":node_modules/sass-embedded", @@ -287,6 +294,32 @@ ts_project( ], ) +ts_project( + name = "library_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/library/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # Base dependencies for the library in hello-world-lib. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + ], +) + jasmine_test( name = "application_integration_tests", size = "medium", @@ -328,6 +361,13 @@ jasmine_test( shard_count = 5, ) +jasmine_test( + name = "library_integration_tests", + size = "medium", + data = [":library_integration_test_lib"], + shard_count = 4, +) + genrule( name = "license", srcs = ["//:LICENSE"], diff --git a/packages/angular/build/builders.json b/packages/angular/build/builders.json index 7be59263804c..d73c7a18fe58 100644 --- a/packages/angular/build/builders.json +++ b/packages/angular/build/builders.json @@ -20,6 +20,11 @@ "schema": "./src/builders/karma/schema.json", "description": "Run Karma unit tests." }, + "library": { + "implementation": "./src/builders/library/index", + "schema": "./src/builders/library/schema.json", + "description": "Build an Angular library package conforming to the Angular Package Format (APF)." + }, "ng-packagr": { "implementation": "./src/builders/ng-packagr/index", "schema": "./src/builders/ng-packagr/schema.json", diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index 1e22cf0aae7b..10ebcf9dc93a 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -38,6 +38,7 @@ "picomatch": "4.0.7", "piscina": "5.3.2", "rolldown": "1.2.11", + "rolldown-plugin-dts": "0.28.5", "sass": "1.105.0", "sass-embedded": "1.105.0", "semver": "7.8.5", diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts new file mode 100644 index 000000000000..eb39c06e3d72 --- /dev/null +++ b/packages/angular/build/src/builders/library/builder.ts @@ -0,0 +1,411 @@ +/** + * @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 { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import type { logging } from '@angular-devkit/core'; +import assert from 'node:assert'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import type ts from 'typescript'; +import { logCumulativeDurations } from '../../tools/esbuild/profiling'; +import { + resetSassWorkerPoolCaches, + shutdownSassWorkerPool, +} from '../../tools/esbuild/stylesheets/sass-language'; +import { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target'; +import { withNoProgress, withSpinner } from '../../tools/esbuild/utils'; +import type { BuildWatcher } from '../../tools/esbuild/watcher'; +import { deleteOutputDir } from '../../utils/delete-output-dir'; +import { maxWorkers } from '../../utils/environment-options'; +import { assertIsError } from '../../utils/error'; +import { initializeHash } from '../../utils/hash'; +import { toPosixPath } from '../../utils/path'; +import { purgeStaleBuildCache } from '../../utils/purge-cache'; +import { getSupportedBrowsers } from '../../utils/supported-browsers'; +import { assertCompatibleAngularVersion } from '../../utils/version'; +import { WorkerPool } from '../../utils/worker-pool'; +import { + type NormalizedLibraryOptions, + type PackageJsonData, + normalizeLibraryOptions, +} from './options'; +import type { EntryPointGraph, EntryPointNode } from './pipeline/entry-point-graph'; +import type { createComponentStylesheetBundlerForLibrary } from './pipeline/stylesheet-bundler'; +import type { Schema as LibraryBuilderOptions } from './schema'; + +/** + * Executes the library builder to compile, bundle, and package an Angular library into the Angular Package Format (APF). + * + * @param options The raw builder schema options. + * @param context The architect builder execution context. + * @returns An async iterator yielding builder output results. + */ +export async function* executeLibraryBuilder( + options: LibraryBuilderOptions, + context: BuilderContext & { signal?: AbortSignal }, +): AsyncIterableIterator { + assertCompatibleAngularVersion(context.workspaceRoot); + await initializeHash(); + + // Purge old build disk cache + await purgeStaleBuildCache(context); + + const projectName = context.target?.project; + if (!projectName) { + yield { success: false, error: 'The library builder requires a target.' }; + + return; + } + + const normalizedOptions = await normalizeLibraryOptions(context, projectName, options); + const { + workspaceRoot, + projectRoot, + outputPath, + deleteOutputPath, + packageJsonPath, + tsConfigPath, + watch: isWatchMode, + poll, + cacheOptions, + preserveSymlinks, + progress, + } = normalizedOptions; + + let signal = context.signal; + if (!signal) { + const controller = new AbortController(); + signal = controller.signal; + context.addTeardown?.(() => controller.abort('builder-teardown')); + } + + const { logger } = context; + + const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; + + // Clean output directory + if (deleteOutputPath) { + await deleteOutputDir(workspaceRoot, outputPath); + } + + // Dynamically lazy-loaded to prevent importing dependencies at the top level. + const [ + { buildAction }, + { buildEntryPointGraph }, + { createComponentStylesheetBundlerForLibrary }, + ] = await Promise.all([ + import('./pipeline/build-action'), + import('./pipeline/entry-point-graph'), + import('./pipeline/stylesheet-bundler'), + ]); + + let graph: EntryPointGraph; + let batches: EntryPointNode[][]; + + try { + const { packageName, entryPoints } = normalizedOptions; + graph = await buildEntryPointGraph(entryPoints.values(), packageName, outputPath); + batches = graph.topologicalSortBatches(); + } catch (error) { + assertIsError(error); + yield { success: false, error: error.message }; + + return; + } + + let stylesheetBundler: ReturnType | undefined; + let compilerWorkerPool: WorkerPool | undefined; + let watcher: BuildWatcher | undefined; + const sourceFileCache = new Map(); + + try { + const browsers = getSupportedBrowsers(projectRoot, logger); + const target = transformSupportedBrowsersToTargets(browsers); + stylesheetBundler = createComponentStylesheetBundlerForLibrary( + normalizedOptions, + isWatchMode, + target, + ); + + if (!isWatchMode) { + // TODO: Convert to import.meta usage during ESM transition + const localRequire = createRequire(__filename); + + compilerWorkerPool = new WorkerPool({ + maxThreads: maxWorkers, + idleTimeout: 4_000, + filename: localRequire.resolve('./pipeline/compiler-worker'), + }); + } + + // Track all referenced files for watch mode + const allWatchedFiles = new Set([tsConfigPath, packageJsonPath]); + + for (const { entryPoint } of graph.nodes.values()) { + allWatchedFiles.add(entryPoint.entryFilePath); + allWatchedFiles.add(entryPoint.tsConfigPath); + } + + if (isWatchMode) { + if (progress) { + logger.info('Watch mode enabled. Watching for file changes...'); + } + + const { setupWatcher } = await import('../../tools/esbuild/watcher'); + watcher = await setupWatcher({ + workspaceRoot, + projectRoot, + outputPath, + cacheOptions, + poll, + preserveSymlinks, + signal, + watchFiles: allWatchedFiles, + }); + + context.addTeardown?.(() => void watcher?.close()); + } + + // Execute initial build + const startTime = process.hrtime.bigint(); + try { + await withProgress('Building...', () => { + assert(stylesheetBundler); + + return buildAction({ + options: normalizedOptions, + graph, + batches, + stylesheetBundler, + allWatchedFiles, + isWatchMode, + context, + compilerWorkerPool, + target, + signal, + sourceFileCache, + }); + }); + + logBuildResult(logger, startTime, true); + logCumulativeDurations(); + + watcher?.add(Array.from(allWatchedFiles)); + + yield { success: true }; + } catch (error) { + assertIsError(error); + logBuildResult(logger, startTime, false); + + watcher?.add(Array.from(allWatchedFiles)); + + yield { success: false, error: error.message }; + + if (!isWatchMode) { + return; + } + } + + if (!isWatchMode || !watcher) { + return; + } + + yield* runWatchLoop( + watcher, + normalizedOptions, + graph, + batches, + stylesheetBundler, + allWatchedFiles, + context, + withProgress, + target, + signal, + sourceFileCache, + ); + } finally { + logCumulativeDurations(); + shutdownSassWorkerPool(); + + await Promise.allSettled([ + watcher?.close(), + stylesheetBundler?.dispose(), + compilerWorkerPool?.destroy(), + ]); + } +} + +/** + * Runs the watch loop, rebuilding the library as watched files are modified. + * + * @param watcher The build watcher instance. + * @param options The normalized library options. + * @param graph The entry points dependency graph. + * @param batches The topologically sorted entry point batches. + * @param stylesheetBundler The component stylesheet bundler instance. + * @param allWatchedFiles Set of all watched file paths. + * @param context The architect builder context. + * @param withProgress Function to wrap build actions with progress reporting. + * @param target The esbuild target environments derived from browserslist. + * @param signal Optional abort signal to cancel the watch loop. + * @param sourceFileCache Optional shared cache of TypeScript source files across entry points. + * @returns An async generator yielding builder outputs. + */ +async function* runWatchLoop( + watcher: BuildWatcher, + options: NormalizedLibraryOptions, + graph: EntryPointGraph, + batches: EntryPointNode[][], + stylesheetBundler: ReturnType, + allWatchedFiles: Set, + context: BuilderContext, + withProgress: typeof withSpinner, + target: string[], + signal?: AbortSignal, + sourceFileCache?: Map, +): AsyncIterableIterator { + // Dynamically lazy-loaded to prevent importing dependencies at the top level. + const [{ buildAction }, { checkAssetChanges }] = await Promise.all([ + import('./pipeline/build-action'), + import('./pipeline/assets'), + ]); + + const { logger } = context; + const { workspaceRoot, packageJsonPath, assets, clearScreen } = options; + + for await (const changes of watcher) { + if (signal?.aborted) { + break; + } + + if (clearScreen) { + // eslint-disable-next-line no-console + console.clear(); + } + + const changedFiles = new Set(changes.all.map(toPosixPath)); + + if (sourceFileCache) { + for (const file of changedFiles) { + sourceFileCache.delete(file); + } + } + + // Check if package.json was modified + let hasPackageJsonChanges = false; + const posixPackageJsonPath = toPosixPath(packageJsonPath); + if (changedFiles.has(posixPackageJsonPath)) { + try { + const packageJson = await loadPackageJson(packageJsonPath); + options.packageJson = packageJson; + hasPackageJsonChanges = true; + } catch (error) { + assertIsError(error); + yield { + success: false, + error: `Failed to reload 'package.json': ${error.message}`, + }; + continue; + } + } + + const hasNodeChanges = graph.markAffectedNodes(changedFiles); + + if ( + !hasNodeChanges && + !hasPackageJsonChanges && + !checkAssetChanges(assets, workspaceRoot, changedFiles) + ) { + continue; + } + + const hasSassChanges = changes.all.some((f) => /\.(scss|sass|css)$/i.test(f)); + if (hasSassChanges) { + resetSassWorkerPoolCaches(); + } + + stylesheetBundler.invalidate(changedFiles); + + const startTime = process.hrtime.bigint(); + + try { + await withProgress('Changes detected. Rebuilding...', () => + buildAction({ + options, + graph, + batches, + stylesheetBundler, + allWatchedFiles, + isWatchMode: true, + context, + modifiedFiles: changedFiles, + target, + signal, + sourceFileCache, + }), + ); + + logBuildResult(logger, startTime, true); + watcher.add(Array.from(allWatchedFiles)); + + yield { success: true }; + } catch (error) { + assertIsError(error); + logBuildResult(logger, startTime, false); + + watcher.add(Array.from(allWatchedFiles)); + + yield { success: false, error: error.message }; + } + } +} + +/** + * Loads and validates the package.json file for the library project. + * + * @param packageJsonPath Path to the package.json file. + * @returns The parsed PackageJsonData. + */ +async function loadPackageJson(packageJsonPath: string): Promise { + let packageJson: PackageJsonData; + try { + const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8'); + packageJson = JSON.parse(packageJsonContent) as PackageJsonData; + } catch (error) { + assertIsError(error); + throw new Error(`Failed to read 'package.json' at '${packageJsonPath}': ${error.message}`, { + cause: error, + }); + } + + const { name: packageName } = packageJson; + if (!packageName) { + throw new Error(`The package.json at '${packageJsonPath}' must contain a 'name'.`); + } + + return packageJson; +} + +/** + * Logs the completion or failure message for a library build iteration. + * + * @param logger The builder context logger. + * @param startTime The high-resolution start time of the build iteration. + * @param success Whether the build iteration succeeded. + */ +function logBuildResult(logger: logging.LoggerApi, startTime: bigint, success: boolean): void { + const buildDuration = Number(process.hrtime.bigint() - startTime) / 10 ** 9; + const status = success ? 'complete' : 'failed'; + const message = `\nLibrary bundle generation ${status}. [${buildDuration.toFixed(3)} seconds] - ${new Date().toISOString()}\n`; + + if (success) { + logger.info(message); + } else { + logger.error(message); + } +} diff --git a/packages/angular/build/src/builders/library/index.ts b/packages/angular/build/src/builders/library/index.ts new file mode 100644 index 000000000000..6a6ddf7a1ce7 --- /dev/null +++ b/packages/angular/build/src/builders/library/index.ts @@ -0,0 +1,17 @@ +/** + * @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 { Builder, createBuilder } from '@angular-devkit/architect'; +import { executeLibraryBuilder } from './builder'; +import type { Schema as LibraryBuilderOptions } from './schema'; + +export { type LibraryBuilderOptions, executeLibraryBuilder, executeLibraryBuilder as execute }; + +const builder: Builder = createBuilder(executeLibraryBuilder); + +export default builder; diff --git a/packages/angular/build/src/builders/library/options.ts b/packages/angular/build/src/builders/library/options.ts new file mode 100644 index 000000000000..632fc8e67190 --- /dev/null +++ b/packages/angular/build/src/builders/library/options.ts @@ -0,0 +1,349 @@ +/** + * @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 { BuilderContext } from '@angular-devkit/architect'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { StylesheetPluginsass } from '../../tools/esbuild/stylesheets/stylesheet-plugin-factory'; +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 { + type PostcssConfiguration, + generateSearchDirectories, + getTailwindConfig, + loadPostcssConfiguration, +} from '../../utils/postcss-configuration'; +import { getProjectRootPaths } from '../../utils/project-metadata'; +import { getEntryPointBundleName } from './pipeline/utils'; +import type { Schema as LibraryBuilderOptions } from './schema'; + +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; + + /** Absolute path to tsConfig file for this entry point. */ + tsConfigPath: string; + + /** Is this the primary entry point ('.')? */ + isPrimary: boolean; +} + +export interface PackageJsonData { + name: string; + version?: string; + type?: string; + main?: string; + module?: string; + typings?: string; + types?: string; + sideEffects?: boolean | string[]; + exports?: Record; + scripts?: Record; + workspaces?: unknown; + dependencies?: 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; +} + +export async function normalizeLibraryOptions( + context: BuilderContext, + projectName: string, + options: LibraryBuilderOptions, +): Promise { + const { workspaceRoot } = context; + const projectMetadata = await context.getProjectMetadata(projectName); + const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata); + + const outputPath = options.outputPath ?? path.join(workspaceRoot, 'dist', projectName); + const resolvedOutputPath = path.resolve(workspaceRoot, outputPath); + if ( + resolvedOutputPath === projectRoot || + isSubDirectory(resolvedOutputPath, projectRoot) || + isSubDirectory(projectRoot, resolvedOutputPath) + ) { + throw new Error( + `The 'outputPath' (${resolvedOutputPath}) cannot be the project root, ` + + `contain the project root, or be located within the project root.`, + ); + } + + const { + tsConfig, + entryPoints: rawEntryPoints, + assets: rawAssets, + stylePreprocessorOptions, + inlineStyleLanguage = 'css', + compilationMode = 'partial', + declarationMap = false, + allowedNonPeerDependencies: rawAllowedNonPeerDependencies = [], + keepLifecycleScripts = false, + watch = false, + poll, + preserveSymlinks = process.execArgv.includes('--preserve-symlinks'), + deleteOutputPath = true, + progress = true, + clearScreen, + } = options; + + const resolvedTsConfigPath = path.resolve(workspaceRoot, tsConfig); + const packageJsonPath = path.join(projectRoot, 'package.json'); + + let packageJson: PackageJsonData; + try { + const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8'); + packageJson = JSON.parse(packageJsonContent) as PackageJsonData; + } catch (error) { + assertIsError(error); + throw new Error(`Failed to read 'package.json' at '${packageJsonPath}': ${error.message}`, { + cause: error, + }); + } + + const { name: packageName } = packageJson; + if (!packageName) { + throw new Error(`The package.json at '${packageJsonPath}' must contain a 'name'.`); + } + + const entryPoints = normalizeEntryPoints( + rawEntryPoints, + workspaceRoot, + resolvedTsConfigPath, + projectName, + packageName, + ); + + const allowedNonPeerDependencies: RegExp[] = []; + for (const pattern of rawAllowedNonPeerDependencies) { + try { + allowedNonPeerDependencies.push(new RegExp(pattern)); + } catch (error) { + assertIsError(error); + throw new Error( + `Invalid regular expression '${pattern}' in 'allowedNonPeerDependencies' for project '${projectName}': ${error.message}`, + { cause: error }, + ); + } + } + + const defaultAssets: (string | { glob: string; input: string; output: string })[] = [ + { glob: 'LICENSE*', input: projectRoot, output: '.' }, + { glob: 'README.md', input: projectRoot, output: '.' }, + ]; + + for (const entryPoint of entryPoints.values()) { + if (entryPoint.isPrimary) { + continue; + } + defaultAssets.push({ + glob: 'README.md', + input: path.dirname(entryPoint.entryFilePath), + output: entryPoint.name, + }); + } + + const combinedAssets = [...defaultAssets, ...(rawAssets ?? [])]; + const assets = combinedAssets.length + ? normalizeAssetPatterns(combinedAssets, workspaceRoot, projectRoot, projectSourceRoot) + : []; + + const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot); + + const styleIncludePaths = (stylePreprocessorOptions?.includePaths ?? []).map((p: string) => + path.resolve(workspaceRoot, p), + ); + + const searchDirectories = await generateSearchDirectories([projectRoot, workspaceRoot]); + const postcssConfiguration = await loadPostcssConfiguration(searchDirectories); + const tailwindConfiguration = postcssConfiguration + ? undefined + : await getTailwindConfig(searchDirectories, workspaceRoot, context.logger); + + return { + workspaceRoot, + projectRoot, + packageName, + packageJson, + outputPath: resolvedOutputPath, + deleteOutputPath, + packageJsonPath, + tsConfigPath: resolvedTsConfigPath, + entryPoints, + inlineStyleLanguage, + styleIncludePaths, + sass: stylePreprocessorOptions?.sass as unknown as StylesheetPluginsass | undefined, + assets, + compilationMode, + declarationMap, + allowedNonPeerDependencies, + keepLifecycleScripts, + watch, + poll, + preserveSymlinks, + progress, + clearScreen, + cacheOptions, + colors: supportColor(), + postcssConfiguration, + tailwindConfiguration, + }; +} + +/** + * Normalizes a single entry point specification. + * + * @param key The entry point key from configuration (e.g. '.' or './testing'). + * @param value The entry point file path string or object with entryPoint and tsConfig. + * @param workspaceRoot The workspace root directory. + * @param defaultTsConfigPath The default tsConfig path for the project. + * @param packageName The root package name (e.g. `@my/lib`). + * @returns The normalized entry point descriptor. + */ +function normalizeEntryPoint( + key: string, + value: LibraryBuilderOptions['entryPoints'][string], + workspaceRoot: string, + defaultTsConfigPath: string, + packageName: string, +): NormalizedEntryPoint { + const posixKey = toPosixPath(key).replace(/\/+$/, ''); + const isPrimary = posixKey === '.' || posixKey === ''; + 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' or 'testing').`, + ); + } + + const subpath = isPrimary ? '.' : `./${name}`; + const displayName = isPrimary ? packageName : `${packageName}/${name}`; + const bundleName = getEntryPointBundleName(packageName, name, isPrimary); + + const entryFilePath = path.resolve( + workspaceRoot, + typeof value === 'string' ? value : value.entryPoint, + ); + + if (!/\.(?:ts|mts)$/.test(entryFilePath) || /\.d\.(?:ts|mts)$/.test(entryFilePath)) { + throw new Error( + `Entry point '${key}' file path must be a TypeScript file ('.ts' or '.mts'): '${entryFilePath}'.`, + ); + } + + const tsConfigPath = + typeof value !== 'string' && value.tsConfig + ? path.resolve(workspaceRoot, value.tsConfig) + : defaultTsConfigPath; + + return { + subpath, + name, + displayName, + bundleName, + entryFilePath, + tsConfigPath, + isPrimary, + }; +} + +/** + * Normalizes all entry points for the library project. + * + * @param rawEntryPoints The raw entryPoints dictionary from schema options. + * @param workspaceRoot The workspace root directory. + * @param defaultTsConfigPath The default tsConfig path for the project. + * @param projectName The project name used in error reporting. + * @param packageName The root package name (e.g. `@my/lib`). + * @returns A Map of normalized entry points keyed by name. + */ +function normalizeEntryPoints( + rawEntryPoints: LibraryBuilderOptions['entryPoints'], + workspaceRoot: string, + defaultTsConfigPath: string, + projectName: string, + packageName: string, +): Map { + const entryPoints = new Map(); + let hasPrimary = false; + + for (const [key, value] of Object.entries(rawEntryPoints)) { + const entryPoint = normalizeEntryPoint( + key, + value, + workspaceRoot, + defaultTsConfigPath, + 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 'entryPoints' option in project '${projectName}' 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 new file mode 100644 index 000000000000..fe372af9a0f3 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -0,0 +1,100 @@ +/** + * @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 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 DiskOutputFile, createDiskOutputFile } from './utils'; + +/** + * Resolves and collects configured library assets to be emitted to disk, + * and registers their source paths with the watch set. + * + * @param assets The normalized asset patterns. + * @param workspaceRoot The workspace root directory path. + * @param allWatchedFiles Set collecting all watched file paths for watch mode. + * @param modifiedFiles Optional set of modified file paths for incremental copying in watch mode. + * @returns An array of disk file emission descriptors. + */ +export async function collectAssetsToEmit( + assets: NormalizedLibraryOptions['assets'], + workspaceRoot: string, + allWatchedFiles: Set, + modifiedFiles?: ReadonlySet, +): Promise { + if (assets.length === 0) { + return []; + } + + const hasModifiedFiles = !!modifiedFiles?.size; + + if (hasModifiedFiles && !checkAssetChanges(assets, workspaceRoot, modifiedFiles)) { + return []; + } + + const resolvedAssets = await resolveAssets(assets, workspaceRoot); + const filesToEmit: DiskOutputFile[] = []; + + for (const { source, destination } of resolvedAssets) { + if (hasModifiedFiles && !modifiedFiles.has(toPosixPath(source))) { + continue; + } + + filesToEmit.push(createDiskOutputFile(source, destination)); + allWatchedFiles.add(source); + } + + return filesToEmit; +} + +/** + * Checks whether any configured library assets were modified. + * + * @param assets The normalized asset patterns. + * @param workspaceRoot The workspace root directory path. + * @param changedFiles Set of changed file paths. + * @returns True if any asset file was modified. + */ +export function checkAssetChanges( + assets: NormalizedLibraryOptions['assets'], + workspaceRoot: string, + changedFiles: ReadonlySet, +): boolean { + if (assets.length === 0 || changedFiles.size === 0) { + return false; + } + + const matchers = assets.map((asset) => { + const absInput = path.resolve(workspaceRoot, asset.input); + const posixInput = toPosixPath(absInput).replace(/\/+$/, ''); + const isMatch = picomatch(asset.glob, { + dot: true, + ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])], + }); + + return { posixInputPrefix: `${posixInput}/`, isMatch }; + }); + + for (const file of changedFiles) { + const resolvedFile = path.isAbsolute(file) ? file : path.resolve(workspaceRoot, file); + const posixFile = toPosixPath(resolvedFile); + + for (const { posixInputPrefix, isMatch } of matchers) { + if (posixFile.startsWith(posixInputPrefix)) { + const relative = posixFile.slice(posixInputPrefix.length); + if (isMatch(relative)) { + return true; + } + } + } + } + + return false; +} diff --git a/packages/angular/build/src/builders/library/pipeline/build-action.ts b/packages/angular/build/src/builders/library/pipeline/build-action.ts new file mode 100644 index 000000000000..8549e1b8d67d --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -0,0 +1,325 @@ +/** + * @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 { BuilderContext } from '@angular-devkit/architect'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type ts from 'typescript'; +import { emitFilesToDisk } from '../../../tools/esbuild/utils'; +import { runConcurrent } from '../../../utils/concurrency'; +import { maxWorkers } from '../../../utils/environment-options'; +import { toPosixPath } from '../../../utils/path'; +import type { WorkerPool } from '../../../utils/worker-pool'; +import type { NormalizedLibraryOptions, PackageJsonData } from '../options'; +import { collectAssetsToEmit } from './assets'; +import { type BundleResult, bundleEntryPoint } from './bundler'; +import { type CompilationOutput, compileEntryPoint } from './compilation'; +import { compileEntryPointInWorker } from './compiler-worker'; +import { type EntryPointGraph, type EntryPointNode } from './entry-point-graph'; +import { generatePackageManifests } from './package-manifests'; +import type { createComponentStylesheetBundlerForLibrary } from './stylesheet-bundler'; +import { type OutputFile, getFileText, isDeclarationFile } from './utils'; + +/** + * Context object containing all dependencies and state required to execute a build action. + */ +export interface BuildActionContext { + options: NormalizedLibraryOptions; + graph: EntryPointGraph; + batches: EntryPointNode[][]; + stylesheetBundler: ReturnType; + allWatchedFiles: Set; + isWatchMode: boolean; + context: BuilderContext; + compilerWorkerPool?: WorkerPool; + modifiedFiles?: Set; + signal?: AbortSignal; + target: string[]; + sourceFileCache?: Map; +} + +/** + * Core build pipeline that executes compilation, bundling, package.json generation, and asset copying. + * + * @param actionContext The build action context containing options, graph, and dependencies. + */ +export async function buildAction(actionContext: BuildActionContext): Promise { + const { + options, + graph, + batches, + stylesheetBundler, + allWatchedFiles, + isWatchMode, + context, + compilerWorkerPool, + modifiedFiles, + signal, + target, + sourceFileCache, + } = actionContext; + + signal?.throwIfAborted?.(); + + const { + outputPath, + assets, + workspaceRoot, + packageJson: rawPackageJson, + allowedNonPeerDependencies, + packageJsonPath, + } = options; + + // Validate allowed non-peer dependencies + validateDependencies(rawPackageJson, allowedNonPeerDependencies); + + // Collect cached declaration files across all entry points in the graph. + // This provides in-memory declaration file access for incremental builds. + const upstreamDtsFiles = collectCachedDtsFiles(graph, outputPath); + const filesToEmit: OutputFile[] = []; + const successfulBundles: Array<{ node: EntryPointNode; bundleResult: BundleResult }> = []; + + // Process batches in topological order. Within each batch, entry points are compiled concurrently up to maxWorkers. + for (const batch of batches) { + signal?.throwIfAborted?.(); + + await runConcurrent(batch, maxWorkers, async (node) => { + signal?.throwIfAborted?.(); + + const { entryPoint, isDirty } = node; + if (!isDirty) { + return; + } + + const epStartTime = process.hrtime.bigint(); + const { displayName, entryFilePath } = entryPoint; + + context.logger.info(`Compiling ${displayName}...`); + + try { + let compilation: CompilationOutput; + // In watch mode, compilation runs on the main thread to reuse the in-memory incremental + // program cache (`node.cachedProgram`). TypeScript Program and compiler instances contain + // ASTs, closures, and circular references that cannot be serialized or transferred across + // worker threads via structured clone (`postMessage`). + if (compilerWorkerPool && !isWatchMode) { + compilation = await compileEntryPointInWorker( + compilerWorkerPool, + entryPoint, + options, + target, + graph.upstreamDtsPaths, + upstreamDtsFiles, + modifiedFiles ? Array.from(modifiedFiles) : undefined, + ); + } else { + const result = await compileEntryPoint( + entryPoint, + options, + stylesheetBundler, + graph.upstreamDtsPaths, + node.cachedProgram, + modifiedFiles, + upstreamDtsFiles, + sourceFileCache, + ); + compilation = result.compilation; + node.cachedProgram = result.cachedProgram; + } + + if (compilation.warnings?.length) { + for (const warning of compilation.warnings) { + context.logger.warn(warning); + } + } + + // Track referenced source files for watch mode + node.referencedFiles.clear(); + for (const ref of compilation.referencedFiles) { + node.referencedFiles.add(toPosixPath(ref)); + } + + // Bundle compiled JavaScript and declaration files with Rolldown + const bundleResult = await bundleEntryPoint( + entryPoint, + compilation, + options, + node.lastBundleResult, + ); + + filesToEmit.push(...bundleResult.filesToEmit); + + for (const file of bundleResult.files) { + if (isDeclarationFile(file.path)) { + const posixPath = toPosixPath(path.join(outputPath, file.path)); + const text = getFileText(file.contents); + upstreamDtsFiles.set(posixPath, text); + if (sourceFileCache && sourceFileCache.get(posixPath)?.text !== text) { + sourceFileCache.delete(posixPath); + } + } + } + + // Invalidate downstream dependents if the public type declarations changed + if (node.lastDtsHash !== bundleResult.dtsHash) { + for (const dependent of node.dependents) { + dependent.isDirty = true; + } + } + node.lastDtsHash = bundleResult.dtsHash; + successfulBundles.push({ node, bundleResult }); + + const epDuration = Number(process.hrtime.bigint() - epStartTime) / 10 ** 9; + context.logger.info(`Compiled ${displayName} [${epDuration.toFixed(3)} seconds]`); + } finally { + // Ensure referenced files are watched even if compilation or bundling fails + for (const ref of node.referencedFiles) { + allWatchedFiles.add(ref); + } + allWatchedFiles.add(entryFilePath); + } + }); + } + + signal?.throwIfAborted?.(); + + // Copy assets if configured (collectAssetsToEmit handles incremental filtering in watch mode) + if (assets.length > 0) { + const resolvedAssets = await collectAssetsToEmit( + assets, + workspaceRoot, + allWatchedFiles, + modifiedFiles, + ); + + filesToEmit.push(...resolvedAssets); + } + + // Generate package.json and .npmignore files only on initial build or when package.json was modified. + if (!modifiedFiles || modifiedFiles.has(toPosixPath(packageJsonPath))) { + const manifestFiles = await generatePackageManifests(options, graph, isWatchMode); + filesToEmit.push(...manifestFiles); + } + + // Emit all files (FESM, DTS, sourcemaps, assets, package.json manifests, .npmignore) with a single emitFilesToDisk call + if (filesToEmit.length > 0) { + signal?.throwIfAborted?.(); + await emitOutputsToDisk(outputPath, filesToEmit); + } + + for (const { node, bundleResult } of successfulBundles) { + node.lastBundleResult = bundleResult; + node.isDirty = false; + } +} + +async function emitOutputsToDisk( + outputPath: string, + filesToEmit: readonly OutputFile[], +): Promise { + const createdDirectories = new Set(); + const directoryCreationPromises = new Map>(); + + await emitFilesToDisk(filesToEmit, async (file) => { + const isInMemoryFile = file.type === 'memory'; + const dest = path.join(outputPath, isInMemoryFile ? file.path : file.destination); + const destDir = path.dirname(dest); + + if (!createdDirectories.has(destDir)) { + let createPromise = directoryCreationPromises.get(destDir); + if (!createPromise) { + createPromise = fs + .mkdir(destDir, { recursive: true }) + .then(() => { + let current = destDir; + while (current) { + createdDirectories.add(current); + const parent = path.dirname(current); + if (parent === current || createdDirectories.has(parent)) { + break; + } + current = parent; + } + }) + .finally(() => { + directoryCreationPromises.delete(destDir); + }); + + directoryCreationPromises.set(destDir, createPromise); + } + + await createPromise; + } + + if (isInMemoryFile) { + await fs.writeFile(dest, file.contents); + } else { + await fs.copyFile(file.source, dest, fs.constants.COPYFILE_FICLONE); + } + }); +} + +/** + * Collects bundled declaration files from previous build runs across the graph + * to seed the in-memory declaration file cache for downstream dependency resolution. + * + * @param graph The entry point dependency graph. + * @returns A map of POSIX declaration file paths to their text contents. + */ +function collectCachedDtsFiles(graph: EntryPointGraph, outputPath: string): Map { + const upstreamDtsFiles = new Map(); + + for (const node of graph.nodes.values()) { + if (!node.lastBundleResult) { + continue; + } + + for (const file of node.lastBundleResult.files) { + if (isDeclarationFile(file.path)) { + upstreamDtsFiles.set( + toPosixPath(path.join(outputPath, file.path)), + getFileText(file.contents), + ); + } + } + } + + return upstreamDtsFiles; +} + +/** + * Validate that the package.json dependencies only contain allowed dependencies. + * @param pkg The package.json data. + * @param allowedPatterns Array of regex patterns for allowed dependencies. + */ +function validateDependencies(pkg: PackageJsonData, allowedPatterns: RegExp[]): void { + const { dependencies } = pkg; + if (!dependencies) { + return; + } + + const invalidDeps: string[] = []; + + for (const dep of Object.keys(dependencies)) { + if (dep === 'tslib') { + continue; + } + + const isAllowed = allowedPatterns.some((pattern) => pattern.test(dep)); + if (!isAllowed) { + invalidDeps.push(dep); + } + } + + if (invalidDeps.length > 0) { + throw new Error( + `Package.json contains dependencies not listed in 'allowedNonPeerDependencies': ${invalidDeps.join(', ')}. ` + + `Third-party dependencies must usually be 'peerDependencies' in Angular libraries.`, + ); + } +} diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts new file mode 100644 index 000000000000..2ed1833156f6 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -0,0 +1,483 @@ +/** + * @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 path from 'node:path'; +import { + type OutputOptions, + type Plugin, + type RolldownOptions, + type RolldownPluginOption, + rolldown, +} from 'rolldown'; +import { dts } from 'rolldown-plugin-dts'; +import { calculateHash } from '../../../utils/hash'; +import { toPosixPath } from '../../../utils/path'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; +import type { CompilationOutput } from './compilation'; +import { + FESM_OUTPUT_DIR, + type MemoryOutputFile, + TYPES_OUTPUT_DIR, + createMemoryOutputFile, + getFileText, + isDeclarationFile, +} from './utils'; + +/** + * Result of bundling an entry point. + */ +export interface BundleResult { + /** Hash of the declaration file content used for downstream invalidation. */ + dtsHash: string; + + /** All current output files for this entry point (chunks, sourcemaps, etc.). */ + files: MemoryOutputFile[]; + + /** Newly generated files that need to be written to disk in this build iteration. */ + filesToEmit: MemoryOutputFile[]; +} + +const ESM_EXTENSIONS = ['.js', '.mjs', '/index.js'] as const; +const DTS_EXTENSIONS = ['.d.ts', '.d.mts', '/index.d.ts'] as const; + +/** + * Bundles the compiled in-memory JavaScript and declaration files for an entry point using Rolldown. + * + * @param entryPoint The normalized entry point being bundled. + * @param compilation The in-memory compilation output containing emitted JavaScript and declaration files. + * @param options The normalized library builder options. + * @param previousBundleResult Optional bundle result from a previous compilation run. + * @returns The bundle result containing file paths and DTS content hash. + */ +export async function bundleEntryPoint( + entryPoint: NormalizedEntryPoint, + compilation: CompilationOutput, + options: NormalizedLibraryOptions, + previousBundleResult?: BundleResult, +): Promise { + const { entryFilePath, bundleName } = entryPoint; + const { preserveSymlinks } = options; + const { esmFiles, dtsFiles, dtsSourcemap, hasDtsChanges, hasEsmChanges } = compilation; + + const entryBase = entryFilePath.replace(/\.m?ts$/, ''); + const jsEntry = entryFilePath.endsWith('.mts') ? `${entryBase}.mjs` : `${entryBase}.js`; + const dtsEntry = entryFilePath.endsWith('.mts') ? `${entryBase}.d.mts` : `${entryBase}.d.ts`; + + const isExternal = createExternalDependencyPredicate(entryPoint, options); + + const [esmResult, dtsResult] = await Promise.all([ + bundleEsm( + jsEntry, + bundleName, + esmFiles, + isExternal, + preserveSymlinks, + hasEsmChanges, + previousBundleResult, + ), + bundleDts( + dtsEntry, + bundleName, + dtsFiles, + dtsSourcemap, + isExternal, + preserveSymlinks, + hasDtsChanges, + previousBundleResult, + ), + ]); + + return { + dtsHash: dtsResult.dtsHash, + files: [...esmResult.files, ...dtsResult.files], + filesToEmit: [...esmResult.filesToEmit, ...dtsResult.filesToEmit], + }; +} + +/** + * Creates an external dependency predicate that prevents relative imports across entry point boundaries. + * + * @param entryPoint The normalized entry point being bundled. + * @param options The normalized library options. + * @returns A predicate function for Rolldown. + */ +function createExternalDependencyPredicate( + entryPoint: NormalizedEntryPoint, + options: NormalizedLibraryOptions, +): (moduleId: string, importer?: string) => boolean { + const { name: epName } = entryPoint; + const { entryPoints } = options; + + const entryPointBases = new Map(); + const entryPointsByDirLength = Array.from(entryPoints.values()) + .map((ep) => { + const epDir = toPosixPath(path.dirname(ep.entryFilePath)); + const epEntryBase = toPosixPath(ep.entryFilePath).replace(/\.(?:d\.)?[cm]?[jt]s$/, ''); + entryPointBases.set(epEntryBase, ep); + + return { + ep, + epDir, + epDirSlash: epDir.endsWith('/') ? epDir : `${epDir}/`, + epDirLength: epDir.length, + }; + }) + .sort((a, b) => { + if (b.epDirLength !== a.epDirLength) { + return b.epDirLength - a.epDirLength; + } + + if (a.ep.name === epName) { + return -1; + } + + if (b.ep.name === epName) { + return 1; + } + + return 0; + }); + + const predicateCache = new Map(); + + return (moduleId: string, importer?: string): boolean => { + if (moduleId[0] === '.' || path.isAbsolute(moduleId)) { + if (importer) { + const cacheKey = `${importer}\0${moduleId}`; + const cached = predicateCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const resolved = toPosixPath(path.resolve(path.dirname(importer), moduleId)); + const resolvedBase = resolved.replace(/\.(?:d\.)?[cm]?[jt]s$/, ''); + + let owner = entryPointBases.get(resolvedBase); + if (!owner) { + for (const { ep, epDir, epDirSlash } of entryPointsByDirLength) { + if (resolved === epDir || resolved.startsWith(epDirSlash)) { + owner = ep; + break; + } + } + } + + if (owner && owner.name !== epName) { + throw new Error( + `Entry point '${epName}' cannot import '${moduleId}' from sibling entry point directly. ` + + `Import using the entry point package name instead.`, + ); + } + + predicateCache.set(cacheKey, false); + } + + return false; + } + + return true; + }; +} + +/** + * Creates the Rolldown options shared across ESM and DTS bundling. + * + * @param input Entry file path in memory. + * @param plugins Array of Rolldown plugins. + * @param isExternal Predicate determining if a module specifier is external. + * @param preserveSymlinks Whether to preserve symlinks when resolving dependencies. + * @returns Rolldown options configuration. + */ +function createRolldownOptions( + input: string, + plugins: RolldownPluginOption[], + isExternal: (moduleId: string, parentId?: string) => boolean, + preserveSymlinks: boolean, +): RolldownOptions { + return { + context: 'this', + input, + external: isExternal, + plugins, + treeshake: false, // APF preserves top-level exports without treeshaking + resolve: { symlinks: preserveSymlinks }, + checks: { circularDependency: false }, + experimental: { + attachDebugInfo: 'none', + }, + }; +} + +interface BundleOutputOptions { + dir: string; + bundleName: string; + extension: 'mjs' | 'd.ts'; + sourcemap: boolean; + comments: OutputOptions['comments']; +} + +/** + * Executes a Rolldown build and generates the output bundle in memory. + * + * @param inputOptions Rolldown input options. + * @param outputOptions Output configuration for generating the bundle. + * @returns An object containing the primary output file path, emitted code, and all generated files. + */ +async function executeBundle( + inputOptions: RolldownOptions, + outputOptions: BundleOutputOptions, +): Promise { + const bundle = await rolldown(inputOptions); + + try { + const { dir, bundleName, extension, sourcemap, comments } = outputOptions; + const { output } = await bundle.generate({ + format: 'es', + dir, + entryFileNames: `${bundleName}.${extension}`, + chunkFileNames: `${bundleName}-[name]-[hash].${extension}`, + sourcemap, + hoistTransitiveImports: false, + comments, + }); + + return output.map((item) => + createMemoryOutputFile( + path.join(dir, item.fileName), + 'code' in item ? item.code : item.source, + ), + ); + } finally { + await bundle.close(); + } +} + +/** + * Bundles the compiled in-memory JavaScript into a flattened FESM module. + * + * @param jsEntry Absolute path to the JavaScript entry file in memory. + * @param bundleName Base name of the output bundle. + * @param esmFiles Map of in-memory JavaScript files and sourcemaps. + * @param isExternal Predicate determining if a module specifier is external. + * @param preserveSymlinks Whether to preserve symlinks when resolving dependencies. + * @param hasChanges Whether the compiled JavaScript files changed in this compilation. + * @param previousBundleResult Optional bundle result from a previous compilation run. + * @returns All generated or cached FESM files, and files that need to be emitted to disk. + */ +async function bundleEsm( + jsEntry: string, + bundleName: string, + esmFiles: Map, + isExternal: (moduleId: string, parentId?: string) => boolean, + preserveSymlinks: boolean, + hasChanges: boolean, + previousBundleResult?: BundleResult, +): Promise<{ files: MemoryOutputFile[]; filesToEmit: MemoryOutputFile[] }> { + if (!hasChanges && previousBundleResult) { + // If compiled JavaScript hasn't changed, skip Rolldown bundling and disk writes. + // Preserving previous ESM files maintains a complete file list in BundleResult.files. + return { + files: previousBundleResult.files.filter((f) => f.path.startsWith(FESM_OUTPUT_DIR)), + filesToEmit: [], + }; + } + + const files = await executeBundle( + createRolldownOptions( + jsEntry, + [createMemoryFileLoaderPlugin(esmFiles, false, true)], + isExternal, + preserveSymlinks, + ), + { + dir: FESM_OUTPUT_DIR, + bundleName, + extension: 'mjs', + sourcemap: true, + comments: { + legal: true, + annotation: true, + }, + }, + ); + + return { files, filesToEmit: files }; +} + +/** + * Bundles compiled in-memory declaration files (.d.ts) into a single declaration file. + * + * @param dtsEntry Absolute path to the declaration entry file in memory. + * @param bundleName Base name of the output bundle. + * @param dtsFiles Map of in-memory declaration files and sourcemaps. + * @param dtsSourcemap Whether declaration sourcemaps are enabled. + * @param isExternal Predicate determining if a module specifier is external. + * @param preserveSymlinks Whether to preserve symlinks when resolving dependencies. + * @param hasChanges Whether the compiled declaration files changed in this compilation. + * @param previousBundleResult Optional bundle result from a previous compilation run. + * @returns An object containing the content hash, all generated or cached files, and files to emit. + */ +async function bundleDts( + dtsEntry: string, + bundleName: string, + dtsFiles: Map, + dtsSourcemap: boolean, + isExternal: (moduleId: string, parentId?: string) => boolean, + preserveSymlinks: boolean, + hasChanges: boolean, + previousBundleResult?: BundleResult, +): Promise<{ dtsHash: string; files: MemoryOutputFile[]; filesToEmit: MemoryOutputFile[] }> { + if (!hasChanges && previousBundleResult) { + // If declaration files (.d.ts) haven't changed, skip Rolldown DTS bundling and disk writes. + // Retaining the previous `dtsHash` signals to the build pipeline that downstream dependents + // do not need to be marked dirty or recompiled. + // Crucially, `previousDtsFiles` are preserved in `files` so downstream entry points can continue + // to resolve this entry point's type declarations in memory via `collectUpstreamDts`. + return { + dtsHash: previousBundleResult.dtsHash, + files: previousBundleResult.files.filter((f) => f.path.startsWith(TYPES_OUTPUT_DIR)), + filesToEmit: [], + }; + } + + const files = await executeBundle( + createRolldownOptions( + dtsEntry, + [ + createMemoryFileLoaderPlugin(dtsFiles, true, dtsSourcemap), + dts({ + dtsInput: true, + tsconfig: false, + generator: 'oxc', + sourcemap: dtsSourcemap, + }), + ], + isExternal, + preserveSymlinks, + ), + { + dir: TYPES_OUTPUT_DIR, + bundleName, + extension: 'd.ts', + sourcemap: dtsSourcemap, + comments: { + legal: true, + jsdoc: true, + }, + }, + ); + + // Compute hash from all declaration chunks (excluding sourcemaps) sorted by path for determinism + const dtsFilesOnly = files + .filter((f) => isDeclarationFile(f.path)) + .sort((a, b) => a.path.localeCompare(b.path)); + const dtsHash = + dtsFilesOnly.length > 0 + ? calculateHash(dtsFilesOnly.map(({ contents }) => getFileText(contents)).join('\0')) + : ''; + + return { + dtsHash, + files, + filesToEmit: files, + }; +} + +/** + * Resolves a file specifier against in-memory virtual files. + * + * @param id The import specifier or file path. + * @param importer The path of the importing file, if any. + * @param files Map of virtual files. + * @param extensions Array of candidate extensions to search. + * @returns The resolved virtual file path, or undefined if not found. + */ +function resolveFile( + id: string, + importer: string | undefined, + files: Map, + extensions: readonly string[], +): string | undefined { + if (importer && id[0] !== '.' && id[0] !== '/' && !path.isAbsolute(id)) { + return undefined; + } + + const resolved = toPosixPath( + importer ? path.resolve(path.dirname(importer), id) : path.resolve(id), + ); + if (files.has(resolved)) { + return resolved; + } + + const base = resolved.replace(/\.m?js$/, ''); + for (const extension of extensions) { + const candidate = base + extension; + if (files.has(candidate)) { + return candidate; + } + } + + return undefined; +} + +/** + * Creates a Rolldown plugin to load virtual files from in-memory maps. + * + * @param files Map of virtual files and their hashes. + * @param dtsMode Whether the plugin is operating in declaration file mode. + * @param includeMap Whether to include sourcemaps when loading virtual files. + * @returns A Rolldown plugin. + */ +function createMemoryFileLoaderPlugin( + files: Map, + dtsMode: boolean, + includeMap = true, +): Plugin { + const extensions = dtsMode ? DTS_EXTENSIONS : ESM_EXTENSIONS; + const resolutionCache = new Map(); + + return { + name: 'memory-file-loader', + resolveId: (id, importer) => { + const cacheKey = importer ? `${importer}\0${id}` : id; + if (resolutionCache.has(cacheKey)) { + return resolutionCache.get(cacheKey); + } + + const resolved = resolveFile(id, importer, files, extensions); + resolutionCache.set(cacheKey, resolved); + + return resolved; + }, + load: (id) => { + const normalizedId = toPosixPath(id); + let file = files.get(normalizedId); + let fileKey = normalizedId; + + if (file === undefined) { + const dtsMatch = /\.d\.m?ts$/.exec(normalizedId); + const ext = dtsMatch ? dtsMatch[0] : path.extname(normalizedId); + const base = ext.length > 0 ? normalizedId.slice(0, -ext.length) : normalizedId; + const fallback = dtsMode ? `${base}.d.ts` : `${base}.js`; + file = files.get(fallback); + if (file !== undefined) { + fileKey = fallback; + } + } + + if (file === undefined) { + return null; + } + + return { + code: file, + map: includeMap ? files.get(`${fileKey}.map`) : undefined, + }; + }, + }; +} diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts new file mode 100644 index 000000000000..f70b59442081 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -0,0 +1,268 @@ +/** + * @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 PartialMessage, formatMessages } from 'esbuild'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import type ts from 'typescript'; +import type { AngularHostOptions } from '../../../tools/angular/angular-host'; +import { LibraryCompilation } from '../../../tools/angular/compilation'; +import { 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 { isDeclarationFile, isDeclarationSourceMapFile } from './utils'; + +const EMITTED_EXTENSIONS = [ + '.js', + '.js.map', + '.mjs', + '.mjs.map', + '.d.ts', + '.d.ts.map', + '.d.mts', + '.d.mts.map', +] as const; + +/** + * Cached compilation instance for incremental rebuilds in watch mode. + */ +export interface CachedProgram { + compilationInstance: LibraryCompilation; + esmFiles: Map; + dtsFiles: Map; +} + +/** + * In-memory compilation output containing emitted JavaScript and declaration files. + */ +export interface CompilationOutput { + /** Map of emitted JavaScript files and sourcemaps keyed by absolute path. */ + esmFiles: Map; + + /** Map of emitted declaration files and sourcemaps keyed by absolute path. */ + dtsFiles: Map; + + /** Set of all referenced source, template, and stylesheet file paths. */ + referencedFiles: Set; + + /** Whether declaration sourcemaps are enabled. */ + dtsSourcemap: boolean; + + /** Formatted compiler warning diagnostics, if any. */ + warnings?: string[]; + + /** Whether any declaration files were added, modified, or removed in this compilation run. */ + hasDtsChanges: boolean; + + /** Whether any JavaScript or ESM files were added, modified, or removed in this compilation run. */ + hasEsmChanges: boolean; +} + +/** + * Result of compiling an entry point, including compilation output and updated program cache. + */ +export interface CompilationResult { + compilation: CompilationOutput; + cachedProgram?: CachedProgram; +} + +/** + * Interface representing the stylesheet bundler operations needed during compilation. + */ +export interface StylesheetBundlerAdapter { + bundleFile: ComponentStylesheetBundler['bundleFile']; + bundleInline: ComponentStylesheetBundler['bundleInline']; +} + +export type CompileEntryPointOptions = Pick< + NormalizedLibraryOptions, + | 'compilationMode' + | 'declarationMap' + | 'packageName' + | 'cacheOptions' + | 'inlineStyleLanguage' + | 'preserveSymlinks' + | 'colors' +>; + +/** + * Compiles an entry point with the Angular Compiler (Ngtsc) and TypeScript using LibraryCompilation. + * Emits JavaScript and .d.ts files into in-memory maps. + * + * @param entryPoint The normalized entry point to compile. + * @param options The compilation options for this entry point. + * @param stylesheetBundler The component stylesheet bundler instance or adapter. + * @param upstreamDtsPaths Map of upstream entry point names to their emitted .d.ts file paths. + * @param cachedProgram Cached program from a previous compilation run, if available. + * @param modifiedFiles Set of modified file paths for incremental rebuilding in watch mode. + * @returns The compilation result containing in-memory files, referenced file paths, and updated program cache. + */ +export async function compileEntryPoint( + entryPoint: NormalizedEntryPoint, + options: CompileEntryPointOptions, + stylesheetBundler: StylesheetBundlerAdapter, + upstreamDtsPaths: Record, + cachedProgram?: CachedProgram, + modifiedFiles?: Set, + upstreamDtsFiles?: Map, + sourceFileCache?: Map, +): Promise { + const { entryFilePath, tsConfigPath, bundleName } = entryPoint; + const { + compilationMode, + declarationMap, + cacheOptions, + inlineStyleLanguage, + preserveSymlinks, + colors, + } = options; + const basePath = path.dirname(entryFilePath); + + const tsBuildInfoFile = cacheOptions.enabled + ? path.join(cacheOptions.path, 'tsbuildinfo', `${bundleName}.tsbuildinfo`) + : undefined; + + const compilationInstance = + cachedProgram?.compilationInstance ?? + new LibraryCompilation({ + entryFilePath, + compilationMode, + declarationMap, + upstreamDtsPaths, + upstreamDtsFiles, + basePath, + tsBuildInfoFile, + sourceFileCache, + }); + + if (cachedProgram) { + compilationInstance.updateLibraryOptions({ upstreamDtsPaths, upstreamDtsFiles }); + } + + let stylesheetReferencedFiles: string[] = []; + const stylesheetWarnings: PartialMessage[] = []; + const hostOptions: AngularHostOptions = { + modifiedFiles, + transformStylesheet: async (data: string, containingFile: string, stylesheetFile?: string) => { + const result = stylesheetFile + ? await stylesheetBundler.bundleFile(stylesheetFile) + : await stylesheetBundler.bundleInline(data, containingFile, inlineStyleLanguage); + + const { + contents, + referencedFiles: bundleReferencedFiles, + errors: bundleErrors, + warnings: bundleWarnings, + } = result; + + if (bundleWarnings?.length) { + stylesheetWarnings.push(...bundleWarnings); + } + + if (bundleReferencedFiles?.size) { + stylesheetReferencedFiles = [...bundleReferencedFiles]; + } + + if (bundleErrors?.length) { + const errorMessages = bundleErrors.map((e) => e.text).join('\n'); + throw new Error( + `Failed to bundle stylesheet in '${stylesheetFile ?? containingFile}':\n${errorMessages}`, + ); + } + + return contents; + }, + processWebWorker: () => '', + }; + + const { compilerOptions, referencedFiles } = await compilationInstance.initialize( + tsConfigPath, + hostOptions, + { + preserveSymlinks, + cachePath: cacheOptions.enabled ? cacheOptions.path : undefined, + }, + ); + + let formattedWarnings: string[] | undefined; + if (useTypeChecking) { + const { errors, warnings } = await compilationInstance.diagnoseFiles(); + if (errors?.length) { + const errorMessages = await formatMessages(errors, { kind: 'error', color: colors }); + throw new Error(`Compilation failed with errors:\n${errorMessages.join('\n')}`); + } + + if (warnings?.length) { + formattedWarnings = await formatMessages(warnings, { kind: 'warning', color: colors }); + } + } + + if (stylesheetWarnings.length > 0) { + const formattedStyleWarnings = await formatMessages(stylesheetWarnings, { + kind: 'warning', + color: colors, + }); + formattedWarnings = [...(formattedWarnings ?? []), ...formattedStyleWarnings]; + } + + const emittedFiles = compilationInstance.emitAffectedFiles(); + const esmFiles = new Map(cachedProgram?.esmFiles); + const dtsFiles = new Map(cachedProgram?.dtsFiles); + let hasDtsChanges = !cachedProgram; + let hasEsmChanges = !cachedProgram; + + if (modifiedFiles) { + for (const modifiedFile of modifiedFiles) { + const posixModified = toPosixPath(modifiedFile); + if (existsSync(posixModified)) { + continue; + } + + const basePathWithoutExt = posixModified.replace(/\.[cm]?[jt]sx?$/, ''); + for (const ext of EMITTED_EXTENSIONS) { + const outputPath = `${basePathWithoutExt}${ext}`; + + if (esmFiles.delete(outputPath)) { + hasEsmChanges = true; + } + + if (dtsFiles.delete(outputPath)) { + hasDtsChanges = true; + } + } + } + } + + for (const { filename, contents } of emittedFiles) { + const normalized = toPosixPath(filename); + const isDts = isDeclarationFile(normalized); + const isDtsMap = !isDts && isDeclarationSourceMapFile(normalized); + + if (isDts || isDtsMap) { + hasDtsChanges ||= dtsFiles.get(normalized) !== contents; + dtsFiles.set(normalized, contents); + } else { + hasEsmChanges ||= esmFiles.get(normalized) !== contents; + esmFiles.set(normalized, contents); + } + } + + return { + compilation: { + esmFiles, + dtsFiles, + referencedFiles: new Set([...referencedFiles, ...stylesheetReferencedFiles]), + dtsSourcemap: !!compilerOptions.declarationMap, + warnings: formattedWarnings, + hasDtsChanges, + hasEsmChanges, + }, + cachedProgram: { compilationInstance, esmFiles, dtsFiles }, + }; +} diff --git a/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts b/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts new file mode 100644 index 000000000000..1e08b657ceda --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts @@ -0,0 +1,132 @@ +/** + * @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 { initializeHash } from '../../../utils/hash'; +import { toPosixPath } from '../../../utils/path'; +import type { WorkerPool } from '../../../utils/worker-pool'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; +import { + type CompilationOutput, + type CompileEntryPointOptions, + compileEntryPoint, +} from './compilation'; +import { createComponentStylesheetBundlerForLibrary } from './stylesheet-bundler'; + +export type CompileWorkerOptions = CompileEntryPointOptions & { + workspaceRoot: string; + styleIncludePaths: string[]; + sass?: NormalizedLibraryOptions['sass']; + postcssConfiguration?: NormalizedLibraryOptions['postcssConfiguration']; + tailwindConfiguration?: NormalizedLibraryOptions['tailwindConfiguration']; + target: string[]; +}; + +export interface CompileWorkerRequest { + entryPoint: NormalizedEntryPoint; + options: CompileWorkerOptions; + upstreamDtsPaths: Record; + upstreamDtsFiles?: Map; + modifiedFiles?: string[]; +} + +export type CompileWorkerResponse = CompilationOutput; + +/** + * Compiles a library entry point in a worker thread. + * + * @param request The compilation request payload. + * @returns The serialized compilation output. + */ +export default async function compile( + request: CompileWorkerRequest, +): Promise { + await initializeHash(); + + const { entryPoint, options, upstreamDtsPaths, upstreamDtsFiles, modifiedFiles } = request; + const stylesheetBundler = createComponentStylesheetBundlerForLibrary( + options, + /* incremental */ false, + options.target, + ); + + try { + const { compilation } = await compileEntryPoint( + entryPoint, + options, + stylesheetBundler, + upstreamDtsPaths, + undefined, + modifiedFiles ? new Set(modifiedFiles) : undefined, + upstreamDtsFiles, + ); + + const referencedFiles = new Set(); + for (const file of compilation.referencedFiles) { + referencedFiles.add(toPosixPath(file)); + } + + return { + esmFiles: compilation.esmFiles, + dtsFiles: compilation.dtsFiles, + referencedFiles, + dtsSourcemap: compilation.dtsSourcemap, + warnings: compilation.warnings, + hasDtsChanges: compilation.hasDtsChanges, + hasEsmChanges: compilation.hasEsmChanges, + }; + } finally { + await stylesheetBundler.dispose(); + } +} + +/** + * Compiles an entry point in a worker thread using the provided worker pool. + * + * @param workerPool The worker pool instance. + * @param entryPoint The normalized entry point to compile. + * @param options The normalized library options. + * @param target The esbuild target environments derived from browserslist. + * @param upstreamDtsPaths Map of upstream entry point declaration file paths. + * @param modifiedFiles Optional array of modified file paths for watch mode. + * @returns The compilation output. + */ +export async function compileEntryPointInWorker( + workerPool: WorkerPool, + entryPoint: NormalizedEntryPoint, + options: NormalizedLibraryOptions, + target: string[], + upstreamDtsPaths: Record, + upstreamDtsFiles?: Map, + modifiedFiles?: string[], +): Promise { + const workerOptions: CompileWorkerOptions = { + compilationMode: options.compilationMode, + declarationMap: options.declarationMap, + packageName: options.packageName, + cacheOptions: options.cacheOptions, + inlineStyleLanguage: options.inlineStyleLanguage, + preserveSymlinks: options.preserveSymlinks, + colors: options.colors, + workspaceRoot: options.workspaceRoot, + styleIncludePaths: options.styleIncludePaths, + sass: options.sass, + postcssConfiguration: options.postcssConfiguration, + tailwindConfiguration: options.tailwindConfiguration, + target, + }; + + const compilationResponse = (await workerPool.run({ + entryPoint, + options: workerOptions, + upstreamDtsPaths, + upstreamDtsFiles, + modifiedFiles, + })) as CompileWorkerResponse; + + return compilationResponse; +} diff --git a/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts b/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts new file mode 100644 index 000000000000..61596b8c1d9a --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts @@ -0,0 +1,342 @@ +/** + * @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 path from 'node:path'; +import { toPosixPath } from '../../../utils/path'; +import type { NormalizedEntryPoint } from '../options'; +import type { BundleResult } from './bundler'; +import type { CachedProgram } from './compilation'; +import type { ScannedFileInfo } from './entry-point-scanner'; +import { TYPES_OUTPUT_DIR } from './utils'; + +/** + * Represents a single entry point node within the compilation dependency graph. + */ +export interface EntryPointNode { + /** The normalized entry point configuration. */ + readonly entryPoint: NormalizedEntryPoint; + + /** Nodes that this entry point directly depends on. */ + readonly dependencies: Set; + + /** Nodes that directly depend on this entry point. */ + readonly dependents: Set; + + /** All source, template, and stylesheet files referenced by this entry point. */ + readonly referencedFiles: Set; + + /** Indicates whether this entry point needs to be recompiled. */ + isDirty: boolean; + + /** The hash of the emitted .d.ts content from the previous compilation. */ + lastDtsHash?: string; + + /** Cached compilation instance for incremental rebuilds in watch mode. */ + cachedProgram?: CachedProgram; + + /** The bundle result from the previous compilation run. */ + lastBundleResult?: BundleResult; +} + +const COMPILATION_EXTENSIONS: ReadonlySet = new Set([ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.mjs', + '.cjs', + '.html', + '.svg', + '.css', + '.scss', + '.sass', + '.less', +]); + +/** + * Directed Acyclic Graph (DAG) of library entry points. + */ +export class EntryPointGraph { + /** Map of entry point names to their corresponding graph nodes. */ + readonly nodes = new Map(); + + /** Map of entry point module specifiers to target .d.ts paths for compilerOptions.paths. */ + readonly upstreamDtsPaths: Record = {}; + + /** + * Adds a new entry point to the graph. + * + * @param entryPoint The normalized entry point configuration. + * @returns The created EntryPointNode. + */ + addNode(entryPoint: NormalizedEntryPoint): EntryPointNode { + const node: EntryPointNode = { + entryPoint, + dependencies: new Set(), + dependents: new Set(), + referencedFiles: new Set(), + isDirty: true, + }; + this.nodes.set(entryPoint.name, node); + + return node; + } + + /** + * Adds a directed dependency edge from one entry point to another. + * + * @param fromName The dependent entry point name. + * @param toName The dependency entry point name. + */ + addDependency(fromName: string, toName: string): void { + const fromNode = this.nodes.get(fromName); + const toNode = this.nodes.get(toName); + + if (!fromNode || !toNode) { + throw new Error(`Invalid dependency edge: ${fromName} -> ${toName}`); + } + + fromNode.dependencies.add(toNode); + toNode.dependents.add(fromNode); + } + + /** + * Topologically sorts entry points into concurrent execution batches using Kahn's Algorithm. + * Entry points within the same batch have zero interdependencies and can be compiled in parallel. + * + * @returns An array of batches, where each batch contains independent entry points. + */ + topologicalSortBatches(): EntryPointNode[][] { + const inDegree = new Map(); + let currentBatch: EntryPointNode[] = []; + + for (const node of this.nodes.values()) { + const degree = node.dependencies.size; + inDegree.set(node, degree); + if (degree === 0) { + currentBatch.push(node); + } + } + + const batches: EntryPointNode[][] = []; + let processedCount = 0; + + while (currentBatch.length > 0) { + batches.push(currentBatch); + processedCount += currentBatch.length; + + const nextBatch: EntryPointNode[] = []; + for (const current of currentBatch) { + for (const dependent of current.dependents) { + const remaining = (inDegree.get(dependent) ?? 0) - 1; + inDegree.set(dependent, remaining); + if (remaining === 0) { + nextBatch.push(dependent); + } + } + } + + currentBatch = nextBatch; + } + + if (processedCount !== this.nodes.size) { + const cyclePath = findCyclePath(this.nodes.values(), inDegree); + throw new Error(`Circular dependency detected between entry points: ${cyclePath}`); + } + + return batches; + } + + private cachedNodeMeta?: Array<{ + node: EntryPointNode; + entryFile: string; + tsConfig: string; + dirWithSep: string; + }>; + + private getNodeMeta() { + this.cachedNodeMeta ??= Array.from(this.nodes.values()) + .map((node) => { + const { entryFilePath, tsConfigPath } = node.entryPoint; + const nodeDir = toPosixPath(path.dirname(entryFilePath)); + + return { + node, + entryFile: toPosixPath(entryFilePath), + tsConfig: toPosixPath(tsConfigPath), + dirWithSep: nodeDir.endsWith('/') ? nodeDir : `${nodeDir}/`, + }; + }) + .sort((a, b) => b.dirWithSep.length - a.dirWithSep.length); + + return this.cachedNodeMeta; + } + + /** + * Identifies and marks dirty any graph nodes whose source files or referenced files have changed. + * + * @param changedFiles Array of changed file paths. + * @returns True if at least one entry point was affected. + */ + markAffectedNodes(changedFiles: ReadonlySet): boolean { + let hasChanges = false; + const nodeMeta = this.getNodeMeta(); + + for (const file of changedFiles) { + let matched = false; + + for (const { node, entryFile, tsConfig } of nodeMeta) { + if (file === entryFile || file === tsConfig || node.referencedFiles.has(file)) { + node.isDirty = true; + hasChanges = true; + matched = true; + } + } + + if (matched) { + continue; + } + + const ext = path.posix.extname(file); + if (!COMPILATION_EXTENSIONS.has(ext) || /\.(spec|test)\.[mc]?[jt]sx?$/i.test(file)) { + continue; + } + + for (const { node, dirWithSep } of nodeMeta) { + if (file.startsWith(dirWithSep)) { + node.isDirty = true; + hasChanges = true; + break; + } + } + } + + return hasChanges; + } +} + +/** + * Traces a cycle path through the given nodes for diagnostic reporting using 3-color DFS. + * + * @param nodes All entry point nodes. + * @param inDegree The in-degree map from Kahn's algorithm. + * @returns Formatted cycle path string (e.g. 'A -> B -> A'). + */ +function findCyclePath( + nodes: Iterable, + inDegree: Map, +): string { + const cyclicCandidates = new Set(); + for (const node of nodes) { + if ((inDegree.get(node) ?? 0) > 0) { + cyclicCandidates.add(node); + } + } + + const visiting = new Set(); + const visited = new Set(); + const pathStack: EntryPointNode[] = []; + + function dfs(current: EntryPointNode): EntryPointNode[] | undefined { + visiting.add(current); + pathStack.push(current); + + for (const dep of current.dependencies) { + if (!cyclicCandidates.has(dep)) { + continue; + } + + if (visiting.has(dep)) { + const cycleStartIndex = pathStack.indexOf(dep); + + return [...pathStack.slice(cycleStartIndex), dep]; + } + + if (!visited.has(dep)) { + const result = dfs(dep); + if (result) { + return result; + } + } + } + + pathStack.pop(); + visiting.delete(current); + visited.add(current); + + return undefined; + } + + for (const node of cyclicCandidates) { + if (!visited.has(node)) { + const cycle = dfs(node); + if (cycle) { + return cycle.map((n) => n.entryPoint.name).join(' -> '); + } + } + } + + return Array.from(cyclicCandidates) + .map((n) => n.entryPoint.name) + .join(' -> '); +} + +/** + * Builds the entry points DAG by analyzing imports across all entry points concurrently. + * + * @param entryPoints The normalized library entry points. + * @param packageName The root package name (e.g. `@my/lib`). + * @returns A promise resolving to the populated EntryPointGraph. + */ +export async function buildEntryPointGraph( + entryPoints: Iterable, + packageName: string, + outputPath: string, +): Promise { + const { scanEntryPointDependencies } = await import('./entry-point-scanner'); + const graph = new EntryPointGraph(); + + for (const entryPoint of entryPoints) { + graph.addNode(entryPoint); + + const { displayName, bundleName } = entryPoint; + graph.upstreamDtsPaths[displayName] = [ + toPosixPath(path.join(outputPath, TYPES_OUTPUT_DIR, `${bundleName}.d.ts`)), + ]; + } + + const fileCache = new Map>(); + const resolutionCache = new Map>(); + const directoryCache = new Map>>(); + + // Analyze source files of all entry points concurrently + await Promise.all( + Array.from(graph.nodes.values(), async ({ entryPoint }) => { + const dependencies = await scanEntryPointDependencies( + entryPoint, + packageName, + fileCache, + resolutionCache, + directoryCache, + ); + + for (const dep of dependencies) { + if (!graph.nodes.has(dep)) { + throw new Error( + `Entry point '${dep}' imported by '${entryPoint.name}' does not exist in 'entryPoints'.`, + ); + } + + graph.addDependency(entryPoint.name, dep); + } + }), + ); + + return graph; +} diff --git a/packages/angular/build/src/builders/library/pipeline/entry-point-scanner.ts b/packages/angular/build/src/builders/library/pipeline/entry-point-scanner.ts new file mode 100644 index 000000000000..8c0119a7ce03 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/entry-point-scanner.ts @@ -0,0 +1,256 @@ +/** + * @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 fs from 'node:fs/promises'; +import path from 'node:path'; +import ts from 'typescript'; +import type { NormalizedEntryPoint } from '../options'; + +const FILE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.d.ts', '.d.mts', '.d.cts'] as const; +const INDEX_FILES = ['index.ts', 'index.tsx', 'index.mts', 'index.cts', 'index.d.ts'] as const; + +export interface ScannedFileInfo { + readonly packageImports: readonly string[]; + readonly relativeDependencies: readonly string[]; +} + +/** + * Retrieves the directory entries for a given directory, cached in a Map. + */ +function getDirectoryEntries( + dir: string, + directoryCache: Map>>, +): Promise> { + let entriesPromise = directoryCache.get(dir); + if (!entriesPromise) { + entriesPromise = fs + .readdir(dir) + .then((entries) => new Set(entries)) + .catch(() => new Set()); + + directoryCache.set(dir, entriesPromise); + } + + return entriesPromise; +} + +/** + * Resolves a relative module import specifier to an existing TypeScript candidate file on disk. + * + * @param dir Directory of the containing file. + * @param fileName Relative module specifier. + * @param resolutionCache Cache of in-flight and resolved module candidate paths. + * @param directoryCache Cache of directory entries to avoid repeated filesystem accesses. + * @returns Absolute path to candidate file if found, otherwise undefined. + */ +async function resolveCandidate( + dir: string, + fileName: string, + resolutionCache: Map>, + directoryCache: Map>>, +): Promise { + if (/\.[mc]?tsx?$/.test(fileName)) { + return path.resolve(dir, fileName); + } + + const basePath = path.resolve(dir, fileName.replace(/\.[mc]?js$/, '')); + let resolvePromise = resolutionCache.get(basePath); + if (resolvePromise) { + return resolvePromise; + } + + resolvePromise = (async () => { + const parentDir = path.dirname(basePath); + const baseName = path.basename(basePath); + const parentEntries = await getDirectoryEntries(parentDir, directoryCache); + + for (const ext of FILE_EXTENSIONS) { + const candidateName = baseName + ext; + if (parentEntries.has(candidateName)) { + return path.join(parentDir, candidateName); + } + } + + if (parentEntries.has(baseName)) { + const subDirEntries = await getDirectoryEntries(basePath, directoryCache); + for (const indexFile of INDEX_FILES) { + if (subDirEntries.has(indexFile)) { + return path.join(basePath, indexFile); + } + } + } + + return undefined; + })(); + + resolutionCache.set(basePath, resolvePromise); + + return resolvePromise; +} + +/** + * Reads a TypeScript file, extracts its module imports via preProcessFile, + * and resolves its relative dependencies. + */ +async function scanFile( + filePath: string, + resolutionCache: Map>, + directoryCache: Map>>, +): Promise { + let content: string; + try { + content = await fs.readFile(filePath, 'utf8'); + } catch { + return undefined; + } + + if (!content.includes('import') && !content.includes('export') && !content.includes('///')) { + return { packageImports: [], relativeDependencies: [] }; + } + + const { importedFiles, typeReferenceDirectives, referencedFiles } = ts.preProcessFile( + content, + true, + false, + ); + + const dir = path.dirname(filePath); + const packageImports = new Set(); + const relativeImports = new Set(); + + for (const { fileName } of [...importedFiles, ...typeReferenceDirectives, ...referencedFiles]) { + if (fileName[0] === '.') { + relativeImports.add(fileName); + } else { + packageImports.add(fileName); + } + } + + const relativeCandidates = await Promise.all( + Array.from(relativeImports, (rel) => + resolveCandidate(dir, rel, resolutionCache, directoryCache), + ), + ); + + const relativeDependencies: string[] = []; + for (const candidate of relativeCandidates) { + if (candidate !== undefined) { + relativeDependencies.push(candidate); + } + } + + return { packageImports: Array.from(packageImports), relativeDependencies }; +} + +/** + * Retrieves scanned file info with promise-level caching to avoid reading + * or preprocessing the same file multiple times. + */ +function getScannedFileInfo( + filePath: string, + fileCache: Map>, + resolutionCache: Map>, + directoryCache: Map>>, +): Promise { + let scanPromise = fileCache.get(filePath); + if (!scanPromise) { + scanPromise = scanFile(filePath, resolutionCache, directoryCache); + fileCache.set(filePath, scanPromise); + } + + return scanPromise; +} + +/** + * Recursively traverses a TypeScript file and its relative dependencies, + * invoking a callback for every external or sibling package import found. + * + * @param filePath Absolute path to the file being scanned. + * @param visited Set of already visited file paths to prevent infinite recursion. + * @param fileCache Cache of preprocessed file imports and dependencies. + * @param resolutionCache Cache of module candidate resolutions. + * @param onImport Callback invoked for each encountered module import specifier. + * @param directoryCache Optional cache of directory entries. + */ +export async function scanImports( + filePath: string, + visited: Set, + fileCache: Map>, + resolutionCache: Map>, + onImport: (importPath: string) => void, + directoryCache = new Map>>(), +): Promise { + if (visited.has(filePath)) { + return; + } + + visited.add(filePath); + + const fileInfo = await getScannedFileInfo(filePath, fileCache, resolutionCache, directoryCache); + if (!fileInfo) { + return; + } + + for (const importPath of fileInfo.packageImports) { + onImport(importPath); + } + + await Promise.all( + fileInfo.relativeDependencies.map((depPath) => + scanImports(depPath, visited, fileCache, resolutionCache, onImport, directoryCache), + ), + ); +} + +/** + * Scans an entry point's source files and returns all referenced sibling entry point names. + * + * @param entryPoint The normalized entry point to scan. + * @param packageName The root package name (e.g. `@my/lib`). + * @param fileCache Cache of preprocessed file imports and dependencies. + * @param resolutionCache Cache of module candidate resolutions. + * @param directoryCache Cache of directory entries. + * @returns An array of sibling entry point names referenced by this entry point. + */ +export async function scanEntryPointDependencies( + entryPoint: NormalizedEntryPoint, + packageName: string, + fileCache: Map>, + resolutionCache: Map>, + directoryCache: Map>>, +): Promise { + const { name: epName, entryFilePath, isPrimary } = entryPoint; + const visitedFiles = new Set(); + const siblingDependencies: string[] = []; + + await scanImports( + entryFilePath, + visitedFiles, + fileCache, + resolutionCache, + (importPath) => { + if (importPath === packageName) { + if (isPrimary) { + throw new Error(`Entry point '.' has a circular dependency on itself.`); + } + + siblingDependencies.push('.'); + } else if (importPath.startsWith(`${packageName}/`)) { + const subpath = importPath.slice(packageName.length + 1); + if (subpath === epName) { + throw new Error(`Entry point '${epName}' has a circular dependency on itself.`); + } + + siblingDependencies.push(subpath); + } + }, + directoryCache, + ); + + return siblingDependencies; +} diff --git a/packages/angular/build/src/builders/library/pipeline/package-manifests.ts b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts new file mode 100644 index 000000000000..2659c85c6dfa --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts @@ -0,0 +1,167 @@ +/** + * @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 path from 'node:path'; +import type { NormalizedLibraryOptions, PackageJsonData } from '../options'; +import type { EntryPointGraph } from './entry-point-graph'; +import { + FESM_OUTPUT_DIR, + type MemoryOutputFile, + TYPES_OUTPUT_DIR, + createMemoryOutputFile, +} from './utils'; + +/** + * Generates the APF package.json and secondary entry point package.json manifests. + * + * @param options The normalized library options. + * @param graph The entry points dependency graph. + * @param isWatchMode Whether the builder is running in watch mode. + * @returns An array of memory output files containing generated package manifests and .npmignore. + */ +export async function generatePackageManifests( + options: NormalizedLibraryOptions, + graph: EntryPointGraph, + isWatchMode: boolean, +): Promise { + const { packageJson: rawPackageJson, keepLifecycleScripts, compilationMode } = options; + + const { + devDependencies: _devDependencies, + scripts, + name, + version, + exports: userExports, + workspaces: _workspaces, + ...restPackageJson + } = rawPackageJson; + + const exportsMap: Record = { + ...(typeof userExports === 'object' && userExports !== null ? userExports : {}), + './package.json': { default: './package.json' }, + }; + + const primaryNode = graph.nodes.get('.'); + if (!primaryNode) { + throw new Error(`Primary entry point '.' was not found in the graph.`); + } + + const primaryName = primaryNode.entryPoint.bundleName; + + // Configure primary entry point + const primaryFesm = `./${FESM_OUTPUT_DIR}/${primaryName}.mjs`; + const primaryDts = `./${TYPES_OUTPUT_DIR}/${primaryName}.d.ts`; + + exportsMap['.'] = createExportConditions(exportsMap['.'], primaryDts, primaryFesm); + + const distPackageJson: PackageJsonData = { + ...restPackageJson, + name, + type: 'module', + sideEffects: rawPackageJson.sideEffects ?? false, + main: primaryFesm, + module: primaryFesm, + typings: primaryDts, + types: primaryDts, + exports: exportsMap, + // Needed because of Webpack's 5 `cachemanagedpaths` + // https://github.com/angular/angular-cli/issues/20962 + version: isWatchMode ? `0.0.0-watch+${Date.now()}` : version, + }; + + // Retain scripts if keepLifecycleScripts is set + if (keepLifecycleScripts && scripts) { + distPackageJson.scripts = scripts; + } + + // Prevent accidental publishing of non-partial compilation packages (APF requirement) + if (compilationMode !== 'partial') { + distPackageJson.scripts = { + ...distPackageJson.scripts, + prepublishOnly: + 'node --eval "' + + "console.error('ERROR: Trying to publish a package that has been compiled in full compilation mode. " + + 'This is not allowed by the Angular Package Format. ' + + "Please rebuild with compilationMode set to \\'partial\\' before publishing.'); " + + 'process.exit(1)"', + }; + } + + // Configure secondary entry points + const nestedPackageJsonDirs: string[] = []; + const filesToEmit: MemoryOutputFile[] = []; + + for (const { entryPoint } of graph.nodes.values()) { + if (entryPoint.isPrimary) { + continue; + } + + const { subpath, name: epSubpathName, bundleName: epName } = entryPoint; + const epFesm = `./${FESM_OUTPUT_DIR}/${epName}.mjs`; + const epDts = `./${TYPES_OUTPUT_DIR}/${epName}.d.ts`; + + exportsMap[subpath] = createExportConditions(exportsMap[subpath], epDts, epFesm); + + // Emit secondary package.json for legacy resolution tools + nestedPackageJsonDirs.push(epSubpathName); + + const relFesm = path.posix.relative(epSubpathName, epFesm); + const relDts = path.posix.relative(epSubpathName, epDts); + const secondaryModule = relFesm[0] === '.' ? relFesm : `./${relFesm}`; + const secondaryTypings = relDts[0] === '.' ? relDts : `./${relDts}`; + + const secondaryPackageJson = { + module: secondaryModule, + typings: secondaryTypings, + types: secondaryTypings, + }; + + filesToEmit.push( + createMemoryOutputFile(path.posix.join(epSubpathName, 'package.json'), secondaryPackageJson), + ); + } + + // Write or append to .npmignore to prevent publishing nested secondary package.json files + if (nestedPackageJsonDirs.length > 0) { + const entryPointsJsonPaths = nestedPackageJsonDirs.map((d) => `/${d}/package.json`); + + filesToEmit.push( + createMemoryOutputFile( + '.npmignore', + `# Nested package.json's are only needed for development.\n${entryPointsJsonPaths.join('\n')}`, + ), + ); + } + + // create root package.json + filesToEmit.push(createMemoryOutputFile('package.json', distPackageJson)); + + return filesToEmit; +} + +/** + * Creates or updates export conditions for an entry point, preserving custom user-defined conditions. + */ +function createExportConditions( + existingConditions: unknown, + dtsPath: string, + fesmPath: string, +): Record { + const existing = + typeof existingConditions === 'object' && existingConditions !== null + ? (existingConditions as Record) + : {}; + + const { types: _types, default: _default, ...otherConditions } = existing; + + return { + types: dtsPath, + ...otherConditions, + default: fesmPath, + }; +} 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 new file mode 100644 index 000000000000..34bae50cb867 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts @@ -0,0 +1,339 @@ +/** + * @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 assert from 'node:assert'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { NormalizedLibraryOptions, PackageJsonData } from '../options'; +import { EntryPointGraph } from './entry-point-graph'; +import { generatePackageManifests } from './package-manifests'; +import { type MemoryOutputFile, getEntryPointBundleName, getFileText } from './utils'; + +describe('generatePackageManifests', () => { + let tempDir: string; + + function getRootPackageJson(files: MemoryOutputFile[]): PackageJsonData { + const file = files.find((f) => f.path === 'package.json'); + assert(file, 'package.json must be present in emitted files'); + + return JSON.parse(getFileText(file.contents)) as PackageJsonData; + } + + function createOptions( + overrides: Partial = {}, + ): NormalizedLibraryOptions { + const packageName = + (overrides.packageJson?.name as string | undefined) ?? overrides.packageName ?? 'my-lib'; + + return { + workspaceRoot: tempDir, + projectRoot: tempDir, + packageName, + packageJson: { + name: packageName, + }, + outputPath: '', + deleteOutputPath: true, + packageJsonPath: join(tempDir, 'package.json'), + tsConfigPath: join(tempDir, 'tsconfig.lib.json'), + entryPoints: new Map(), + inlineStyleLanguage: 'css', + styleIncludePaths: [], + assets: [], + compilationMode: 'partial', + declarationMap: false, + allowedNonPeerDependencies: [], + keepLifecycleScripts: false, + watch: false, + preserveSymlinks: false, + progress: false, + colors: false, + cacheOptions: { + enabled: false, + basePath: '', + path: '', + cacheId: '', + } as unknown as NormalizedLibraryOptions['cacheOptions'], + ...overrides, + }; + } + + function createGraph( + packageNameOrOptions?: NormalizedLibraryOptions | string | boolean, + includeSecondary = false, + ): EntryPointGraph { + let packageName = 'my-lib'; + let secondary = includeSecondary; + + if (typeof packageNameOrOptions === 'boolean') { + secondary = packageNameOrOptions; + } else if (typeof packageNameOrOptions === 'string') { + packageName = packageNameOrOptions; + } else if (packageNameOrOptions && typeof packageNameOrOptions === 'object') { + packageName = packageNameOrOptions.packageName; + } + + const primaryBundleName = getEntryPointBundleName(packageName, '', true); + const graph = new EntryPointGraph(); + graph.addNode({ + subpath: '.', + name: '.', + displayName: packageName, + bundleName: primaryBundleName, + entryFilePath: join(tempDir, 'src/public-api.ts'), + tsConfigPath: join(tempDir, 'tsconfig.lib.json'), + isPrimary: true, + }); + + if (secondary) { + const secondaryBundleName = getEntryPointBundleName(packageName, 'testing', false); + graph.addNode({ + subpath: './testing', + name: 'testing', + displayName: `${packageName}/testing`, + bundleName: secondaryBundleName, + entryFilePath: join(tempDir, 'testing/src/public-api.ts'), + tsConfigPath: join(tempDir, 'tsconfig.lib.json'), + isPrimary: false, + }); + } + + return graph; + } + + beforeEach(async () => { + const TMP_DIR = process.env['TEST_TMPDIR']; + assert(TMP_DIR, 'TEST_TMPDIR must be set'); + tempDir = await mkdtemp(join(TMP_DIR, 'pkg-json-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('should generate a valid APF package.json for an unscoped package', async () => { + const options = createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + description: 'A test library', + devDependencies: { + typescript: '^5.0.0', + }, + scripts: { + test: 'npm run test', + }, + }, + }); + const graph = createGraph(); + + const files = await generatePackageManifests(options, graph, false); + const result = getRootPackageJson(files); + + expect(result).toEqual({ + name: 'my-lib', + version: '1.0.0', + description: 'A test library', + type: 'module', + sideEffects: false, + main: './fesm2022/my-lib.mjs', + module: './fesm2022/my-lib.mjs', + typings: './types/my-lib.d.ts', + types: './types/my-lib.d.ts', + exports: { + './package.json': { default: './package.json' }, + '.': { + types: './types/my-lib.d.ts', + default: './fesm2022/my-lib.mjs', + }, + }, + }); + }); + + it('should sanitize scoped package names in fesm and types paths', async () => { + const options = createOptions({ + packageJson: { + name: '@my-scope/my-lib', + version: '2.1.0', + }, + }); + const graph = createGraph(options); + + const files = await generatePackageManifests(options, graph, false); + const result = getRootPackageJson(files); + + expect(result).toEqual( + jasmine.objectContaining({ + name: '@my-scope/my-lib', + module: './fesm2022/my-scope-my-lib.mjs', + typings: './types/my-scope-my-lib.d.ts', + types: './types/my-scope-my-lib.d.ts', + exports: jasmine.objectContaining({ + '.': { + types: './types/my-scope-my-lib.d.ts', + default: './fesm2022/my-scope-my-lib.mjs', + }, + }), + }), + ); + }); + + it('should retain scripts when keepLifecycleScripts is true', async () => { + const options = createOptions({ + keepLifecycleScripts: true, + packageJson: { + name: 'my-lib', + version: '1.0.0', + scripts: { + postinstall: 'echo done', + }, + }, + }); + const graph = createGraph(); + + const files = await generatePackageManifests(options, graph, false); + const result = getRootPackageJson(files); + expect(result.scripts).toEqual({ postinstall: 'echo done' }); + }); + + it('should configure secondary entry points and create secondary manifests', async () => { + const options = createOptions({ + packageJson: { + name: '@my-scope/my-lib', + version: '1.0.0', + }, + }); + const graph = createGraph(options, true); + + const files = await generatePackageManifests(options, graph, false); + const result = getRootPackageJson(files); + + expect(result.exports).toEqual( + jasmine.objectContaining({ + './testing': { + types: './types/my-scope-my-lib-testing.d.ts', + default: './fesm2022/my-scope-my-lib-testing.mjs', + }, + }), + ); + + const secondaryPkgFile = files.find((f) => f.path === 'testing/package.json'); + const secondaryPkg = JSON.parse(getFileText(secondaryPkgFile?.contents ?? '')); + expect(secondaryPkg).toEqual({ + module: '../fesm2022/my-scope-my-lib-testing.mjs', + typings: '../types/my-scope-my-lib-testing.d.ts', + types: '../types/my-scope-my-lib-testing.d.ts', + }); + + const npmignoreFile = files.find((f) => f.path === '.npmignore'); + expect(npmignoreFile?.contents).toContain('/testing/package.json'); + }); + + it('should inject watch version when isWatchMode is true', async () => { + const options = createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + }, + }); + const graph = createGraph(); + + const files = await generatePackageManifests(options, graph, true); + const result = getRootPackageJson(files); + expect(result.version).toMatch(/^0\.0\.0-watch\+\d+$/); + }); + + it('should throw an error if primary entry point is missing from graph', async () => { + const options = createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + }, + }); + const graph = new EntryPointGraph(); // No primary node + + await expectAsync(generatePackageManifests(options, graph, false)).toBeRejectedWithError( + /Primary entry point '\.' was not found in the graph\./, + ); + }); + + it('should inject prepublishOnly guard script when compilationMode is full', async () => { + const options = createOptions({ + compilationMode: 'full', + packageJson: { + name: 'my-lib', + version: '1.0.0', + }, + }); + const graph = createGraph(); + + const files = await generatePackageManifests(options, graph, false); + const result = getRootPackageJson(files); + expect(result.scripts?.['prepublishOnly']).toContain( + 'Trying to publish a package that has been compiled in full compilation mode', + ); + }); + + it('should preserve custom user exports in package.json and merge subpath conditions', async () => { + const options = createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + exports: { + './styles.css': './styles.css', + './scss/*': './scss/*', + '.': { + development: './src/index.ts', + }, + }, + }, + }); + const graph = createGraph(); + + const files = await generatePackageManifests(options, graph, false); + const result = getRootPackageJson(files); + + expect(result.exports).toEqual({ + './styles.css': './styles.css', + './scss/*': './scss/*', + './package.json': { default: './package.json' }, + '.': { + types: './types/my-lib.d.ts', + development: './src/index.ts', + default: './fesm2022/my-lib.mjs', + }, + }); + }); + + it('should default sideEffects to false if not specified, and preserve when set', async () => { + const files1 = await generatePackageManifests( + createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + }, + }), + createGraph(), + false, + ); + expect(getRootPackageJson(files1).sideEffects).toBeFalse(); + + const files2 = await generatePackageManifests( + createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + sideEffects: ['*.css'], + }, + }), + createGraph(), + false, + ); + expect(getRootPackageJson(files2).sideEffects).toEqual(['*.css']); + }); +}); diff --git a/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts b/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts new file mode 100644 index 000000000000..b5f8823b4100 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts @@ -0,0 +1,66 @@ +/** + * @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 { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; +import type { BundleStylesheetOptions } from '../../../tools/esbuild/stylesheets/bundle-options'; +import type { NormalizedLibraryOptions } from '../options'; + +export type LibraryStylesheetBundlerOptions = Pick< + NormalizedLibraryOptions, + | 'workspaceRoot' + | 'preserveSymlinks' + | 'styleIncludePaths' + | 'sass' + | 'cacheOptions' + | 'inlineStyleLanguage' + | 'postcssConfiguration' + | 'tailwindConfiguration' +>; + +/** + * Creates a stylesheet bundler instance configured for library compilation. + * + * @param options The normalized library builder options. + * @param incremental Whether incremental watch mode is enabled. + * @param target The esbuild target environments derived from browserslist. + * @returns A new ComponentStylesheetBundler instance. + */ +export function createComponentStylesheetBundlerForLibrary( + options: LibraryStylesheetBundlerOptions, + incremental: boolean, + target: string[], +): ComponentStylesheetBundler { + const { + workspaceRoot, + preserveSymlinks, + styleIncludePaths, + sass, + cacheOptions, + inlineStyleLanguage, + postcssConfiguration, + tailwindConfiguration, + } = options; + + const bundleOptions: BundleStylesheetOptions = { + workspaceRoot, + optimization: true, + inlineFonts: false, + dataurl: true, + target, + preserveSymlinks, + sourcemap: false, + outputNames: { bundles: '[name]', media: 'media/[name]' }, + includePaths: styleIncludePaths, + sass, + cacheOptions, + postcssConfiguration, + tailwindConfiguration, + }; + + return new ComponentStylesheetBundler(bundleOptions, inlineStyleLanguage, incremental); +} diff --git a/packages/angular/build/src/builders/library/pipeline/types.d.ts b/packages/angular/build/src/builders/library/pipeline/types.d.ts new file mode 100644 index 000000000000..100c539f098c --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/types.d.ts @@ -0,0 +1,54 @@ +/** + * @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 + */ + +declare module 'rolldown-plugin-dts' { + import type { Plugin } from 'rolldown'; + import type { IsolatedDeclarationsOptions } from 'rolldown/experimental'; + + interface Logger { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + } + interface GeneralOptions { + generator?: 'tsc' | 'oxc' | 'tsgo'; + entry?: string | string[]; + cwd?: string; + dtsInput?: boolean; + emitDtsOnly?: boolean; + tsconfig?: string | boolean; + tsconfigRaw?: unknown; + compilerOptions?: unknown; + sourcemap?: boolean; + resolver?: 'oxc' | 'tsc'; + cjsDefault?: boolean; + sideEffects?: boolean; + logger?: Logger; + } + + interface TscOptions { + build?: boolean; + incremental?: boolean; + parallel?: boolean; + eager?: boolean; + newContext?: boolean; + emitJs?: boolean; + } + + interface Options extends GeneralOptions, TscOptions { + oxc?: Omit; + tsgo?: TsgoOptions; + customLanguages?: unknown[]; + } + + interface TsgoOptions { + path?: string; + } + + export declare function dts(options?: Options): Plugin[]; +} diff --git a/packages/angular/build/src/builders/library/pipeline/utils.ts b/packages/angular/build/src/builders/library/pipeline/utils.ts new file mode 100644 index 000000000000..8513a9a6207d --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/utils.ts @@ -0,0 +1,142 @@ +/** + * @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 { TextDecoder } from 'node:util'; + +let textDecoder: TextDecoder | undefined; +const IS_DTS_FILE_REGEXP = /\.d\.[cm]?ts$/i; +const IS_DTS_MAP_FILE_REGEXP = /\.d\.[cm]?ts\.map$/i; + +/** + * The output directory name for ES module format output files. + */ +export const FESM_OUTPUT_DIR = 'fesm2022'; + +/** + * The output directory name for TypeScript declaration files output. + */ +export const TYPES_OUTPUT_DIR = 'types'; + +/** + * Computes the base bundle file name for an entry point. + * + * @param packageName The package name from package.json. + * @param entryPointName The entry point subpath name. + * @param isPrimary Whether this is the primary entry point. + * @returns The sanitized bundle base name. + */ +export function getEntryPointBundleName( + packageName: string, + entryPointName: string, + isPrimary: boolean, +): string { + const pkgName = packageName[0] === '@' ? packageName.slice(1) : packageName; + const epName = isPrimary ? pkgName : `${pkgName}-${entryPointName}`; + + return epName.replaceAll('/', '-'); +} + +/** + * Represents an in-memory file to be emitted to disk. + */ +export interface MemoryOutputFile { + type: 'memory'; + + /** The destination path where the file should be written. */ + path: string; + + /** The contents of the file as either a string or byte array. */ + contents: string | Uint8Array; +} + +/** + * Represents an existing file on disk to be copied to a destination path. + */ +export interface DiskOutputFile { + type: 'disk'; + + /** The path to the source file on disk. */ + source: string; + + /** The destination path where the file should be copied. */ + destination: string; +} + +/** + * Represents a file to be emitted to disk, either from memory or copied from disk. + */ +export type OutputFile = MemoryOutputFile | DiskOutputFile; + +/** + * Creates an output file descriptor for an existing file on disk. + * + * @param source The path to the source file on disk. + * @param destination The destination path where the file should be copied. + * @returns A {@link DiskOutputFile} descriptor. + */ +export function createDiskOutputFile(source: string, destination: string): DiskOutputFile { + return { + type: 'disk', + source, + destination, + }; +} + +/** + * Creates an output file descriptor for an in-memory file. + * + * @param path The destination path where the file should be written. + * @param contents The contents of the file as either a string, byte array, or JSON object. + * @returns A {@link MemoryOutputFile} descriptor. + */ +export function createMemoryOutputFile( + path: string, + contents: string | Uint8Array | Record, +): MemoryOutputFile { + return { + type: 'memory', + path, + contents: + typeof contents === 'string' || contents instanceof Uint8Array + ? contents + : JSON.stringify(contents, null, 2) + '\n', + }; +} + +/** + * Gets the text content of a file. + */ +export function getFileText(contents: string | Uint8Array): string { + if (typeof contents === 'string') { + return contents; + } + + textDecoder ??= new TextDecoder(); + + return textDecoder.decode(contents); +} + +/** + * Determines whether a file path represents a TypeScript declaration file (`.d.ts`, `.d.mts`, or `.d.cts`). + * + * @param path The file path to check. + * @returns True if the path ends with `.d.ts`, `.d.mts`, or `.d.cts`. + */ +export function isDeclarationFile(path: string): boolean { + return IS_DTS_FILE_REGEXP.test(path); +} + +/** + * Determines whether a file path represents a declaration source map file (`.d.ts.map`, `.d.mts.map`, or `.d.cts.map`). + * + * @param path The file path to check. + * @returns True if the path ends with `.d.ts.map`, `.d.mts.map`, or `.d.cts.map`. + */ +export function isDeclarationSourceMapFile(path: string): boolean { + return IS_DTS_MAP_FILE_REGEXP.test(path); +} diff --git a/packages/angular/build/src/builders/library/schema.json b/packages/angular/build/src/builders/library/schema.json new file mode 100644 index 000000000000..265f2eebab5e --- /dev/null +++ b/packages/angular/build/src/builders/library/schema.json @@ -0,0 +1,199 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Library builder target options", + "description": "Library builder target options for Build Architect. Builds an Angular library package conforming to the Angular Package Format (APF).", + "type": "object", + "properties": { + "entryPoints": { + "type": "object", + "description": "Map of package entry points. The '.' key represents the primary entry point; other keys define secondary subpath entry points.", + "required": ["."], + "additionalProperties": { + "oneOf": [ + { + "type": "string", + "description": "Path to the entry file (e.g. 'projects/my-lib/src/public-api.ts')." + }, + { + "$ref": "#/definitions/entryPoint" + } + ] + } + }, + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace root." + }, + "outputPath": { + "type": "string", + "description": "Specify the output directory for the built package, relative to the workspace root." + }, + "assets": { + "type": "array", + "description": "Define the assets to be copied to the output directory. These assets are copied as-is without any further processing or hashing.", + "default": [], + "items": { + "$ref": "#/definitions/assetPattern" + } + }, + "inlineStyleLanguage": { + "description": "The stylesheet language to use for the library's inline component styles.", + "type": "string", + "default": "css", + "enum": ["css", "less", "sass", "scss"] + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "includePaths": { + "description": "Paths to include. Paths will be resolved to workspace root.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "sass": { + "description": "Options to pass to the sass preprocessor.", + "type": "object", + "properties": { + "fatalDeprecations": { + "description": "A set of deprecations to treat as fatal. If a deprecation warning of any provided type is encountered during compilation, the compiler will error instead. If a Version is provided, then all deprecations that were active in that compiler version will be treated as fatal.", + "type": "array", + "items": { + "type": "string" + } + }, + "silenceDeprecations": { + "description": " A set of active deprecations to ignore. If a deprecation warning of any provided type is encountered during compilation, the compiler will ignore it instead.", + "type": "array", + "items": { + "type": "string" + } + }, + "futureDeprecations": { + "description": "A set of future deprecations to opt into early. Future deprecations passed here will be treated as active by the compiler, emitting warnings as necessary.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "declarationMap": { + "type": "boolean", + "description": "Generates a sourcemap for each corresponding '.d.ts' file.", + "default": false + }, + "compilationMode": { + "type": "string", + "description": "Angular compilation mode. Use 'partial' when publishing to npm (APF requirement). Use 'full' only for private, internal monorepo packages that are never published.", + "enum": ["partial", "full"], + "default": "partial" + }, + "allowedNonPeerDependencies": { + "description": "A list of package names allowed in the 'dependencies' section of package.json. Values can be regular expression patterns.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "keepLifecycleScripts": { + "description": "Enable this to keep the 'scripts' section in the published package.json.", + "type": "boolean", + "default": false + }, + "deleteOutputPath": { + "type": "boolean", + "description": "Delete the output path before building.", + "default": true + }, + "watch": { + "type": "boolean", + "description": "Run build when files change.", + "default": false + }, + "poll": { + "type": "number", + "description": "Enable and define the file watching poll time period in milliseconds." + }, + "preserveSymlinks": { + "type": "boolean", + "description": "Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set." + }, + "progress": { + "type": "boolean", + "description": "Log progress to the console while building.", + "default": true + }, + "clearScreen": { + "type": "boolean", + "default": false, + "description": "Automatically clear the terminal screen during rebuilds." + } + }, + "additionalProperties": false, + "required": ["tsConfig", "entryPoints"], + "definitions": { + "entryPoint": { + "type": "object", + "properties": { + "entryPoint": { + "type": "string", + "description": "Path to the entry file." + }, + "tsConfig": { + "type": "string", + "description": "Optional TypeScript configuration file specific to this entry point." + } + }, + "required": ["entryPoint"], + "additionalProperties": false + }, + "assetPattern": { + "oneOf": [ + { + "type": "object", + "properties": { + "followSymlinks": { + "type": "boolean", + "default": false, + "description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched." + }, + "glob": { + "type": "string", + "description": "The pattern to match." + }, + "input": { + "type": "string", + "description": "The input directory path in which to apply 'glob'. Defaults to the project root." + }, + "ignore": { + "description": "An array of globs to ignore.", + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "string", + "default": "", + "description": "Absolute path within the output." + } + }, + "additionalProperties": false, + "required": ["glob", "input"] + }, + { + "type": "string" + } + ] + } + } +} diff --git a/packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts new file mode 100644 index 000000000000..21bd85222fcf --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts @@ -0,0 +1,106 @@ +/** + * @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 fs from 'node:fs'; +import path from 'node:path'; +import { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Behavior: "APF Specification Compliance"', () => { + it('should conform to Angular Package Format specifications', async () => { + await harness.writeFiles({ + 'projects/lib/README.md': '# Sample APF Library\n', + 'projects/lib/LICENSE': 'MIT License\n', + 'projects/lib/src/theming.scss': '$primary: #1976d2;\n', + 'projects/lib/secondary/src/public-api.ts': 'export const SECONDARY_VALUE = 42;\n', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + entryPoints: { + '.': 'projects/lib/src/public-api.ts', + 'secondary': 'projects/lib/secondary/src/public-api.ts', + }, + assets: [ + 'projects/lib/README.md', + 'projects/lib/LICENSE', + { + glob: 'theming.scss', + input: 'projects/lib/src', + output: '.', + }, + ], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // FESM2022 bundles and source maps + harness.expectFile('dist/lib/fesm2022/lib.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib.mjs.map').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-secondary.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-secondary.mjs.map').toExist(); + + // DTS declarations + harness.expectFile('dist/lib/types/lib.d.ts').toExist(); + harness.expectFile('dist/lib/types/lib-secondary.d.ts').toExist(); + + // Static assets + harness.expectFile('dist/lib/README.md').toExist(); + harness.expectFile('dist/lib/LICENSE').toExist(); + harness.expectFile('dist/lib/theming.scss').toExist(); + + // Root manifest with APF exports map + harness.expectFile('dist/lib/package.json').toExist(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg).toEqual( + jasmine.objectContaining({ + name: 'lib', + type: 'module', + module: './fesm2022/lib.mjs', + typings: './types/lib.d.ts', + types: './types/lib.d.ts', + exports: { + './package.json': { default: './package.json' }, + '.': { + types: './types/lib.d.ts', + default: './fesm2022/lib.mjs', + }, + './secondary': { + types: './types/lib-secondary.d.ts', + default: './fesm2022/lib-secondary.mjs', + }, + }, + }), + ); + + // Secondary entry point manifest + harness.expectFile('dist/lib/secondary/package.json').toExist(); + const secondaryPkg = JSON.parse(harness.readFile('dist/lib/secondary/package.json')); + expect(secondaryPkg).toEqual({ + module: '../fesm2022/lib-secondary.mjs', + typings: '../types/lib-secondary.d.ts', + types: '../types/lib-secondary.d.ts', + }); + + // .npmignore + harness.expectFile('dist/lib/.npmignore').toExist(); + const npmignore = harness.readFile('dist/lib/.npmignore'); + expect(npmignore).toContain('/secondary/package.json'); + + // Validate total number of output files (safeguard against emitting unexpected files) + const distDir = harness.resolvePath('dist/lib'); + const distFiles = fs + .readdirSync(distDir, { recursive: true }) + .map((f) => String(f)) + .filter((f) => fs.statSync(path.join(distDir, f)).isFile()); + expect(distFiles).toHaveSize(12); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts new file mode 100644 index 000000000000..4e817babf944 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts @@ -0,0 +1,51 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Behavior: "Library Build"', () => { + it('should build a library with FESM2022 and DTS bundles', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.error).toBeUndefined(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/lib/fesm2022/lib.mjs').toExist(); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeTrue(); + const fesmContent = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesmContent).toContain('LibComponent'); + expect(fesmContent).toContain('ɵcmp'); + + harness.expectFile('dist/lib/types/lib.d.ts').toExist(); + const dtsContent = harness.readFile('dist/lib/types/lib.d.ts'); + expect(dtsContent).toContain('LibComponent'); + + harness.expectFile('dist/lib/package.json').toExist(); + const pkgJson = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkgJson).toEqual( + jasmine.objectContaining({ + name: 'lib', + type: 'module', + module: './fesm2022/lib.mjs', + typings: './types/lib.d.ts', + exports: jasmine.objectContaining({ + '.': { + types: './types/lib.d.ts', + default: './fesm2022/lib.mjs', + }, + }), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/behavior/core_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/core_spec.ts new file mode 100644 index 000000000000..7bbab17c8cae --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/core_spec.ts @@ -0,0 +1,101 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Behavior: "Core Angular Features, Dynamic Imports, and Modern TS"', () => { + it('should compile standalone components with signal inputs, outputs, and pipes', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/custom.pipe.ts': ` + import { Pipe, PipeTransform } from '@angular/core'; + + @Pipe({ + name: 'customUpper', + standalone: true, + }) + export class CustomPipe implements PipeTransform { + transform(value: string): string { + return value.toUpperCase(); + } + } + `, + 'projects/lib/src/lib/lib.component.ts': ` + import { Component, input, output, signal } from '@angular/core'; + import { CustomPipe } from './custom.pipe'; + + @Component({ + selector: 'lib-core-features', + imports: [CustomPipe], + template: '

{{ title() | customUpper }}

', + }) + export class LibComponent { + readonly title = input('default-title'); + readonly statusChange = output(); + readonly count = signal(0); + } + `, + 'projects/lib/src/public-api.ts': ` + export * from './lib/custom.pipe'; + export * from './lib/lib.component'; + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toContain('CustomPipe'); + expect(fesm).toContain('customUpper'); + expect(fesm).toContain('LibComponent'); + expect(fesm).toContain('title'); + + const dts = harness.readFile('dist/lib/types/lib.d.ts'); + expect(dts).toContain('CustomPipe'); + expect(dts).toContain('LibComponent'); + }); + + it('should support dynamic imports in library code', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/lazy-module.ts': ` + export const LAZY_MESSAGE = 'lazy-loaded message'; + export function computeLazyValue(a: number, b: number): number { + return a + b; + } + `, + 'projects/lib/src/lib/lib.service.ts': ` + import { Injectable } from '@angular/core'; + + @Injectable({ providedIn: 'root' }) + export class LibService { + async loadLazy(): Promise { + const { LAZY_MESSAGE } = await import('./lazy-module'); + return LAZY_MESSAGE; + } + } + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toContain('loadLazy'); + expect(fesm).toContain('LibService'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts new file mode 100644 index 000000000000..28f676983f1c --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts @@ -0,0 +1,146 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Behavior: "Secondary Entry Points and Intra-Dependencies"', () => { + it('should build secondary entry points with intra-dependencies in topological order', async () => { + await harness.writeFiles({ + 'projects/lib/shared/src/public-api.ts': ` + import { Injectable } from '@angular/core'; + + @Injectable({ providedIn: 'root' }) + export class SharedService { + getValue(): string { + return 'shared-value'; + } + } + `, + 'projects/lib/feature-a/src/public-api.ts': ` + import { Component, inject } from '@angular/core'; + import { SharedService } from 'lib/shared'; + + @Component({ + selector: 'feature-a', + template: '

Feature A: {{ shared.getValue() }}

', + }) + export class FeatureAComponent { + protected readonly shared = inject(SharedService); + } + `, + 'projects/lib/feature-b/src/public-api.ts': ` + import { Component, inject } from '@angular/core'; + import { SharedService } from 'lib/shared'; + import { FeatureAComponent } from 'lib/feature-a'; + + @Component({ + selector: 'feature-b', + imports: [FeatureAComponent], + template: '

Feature B: {{ shared.getValue() }}

', + }) + export class FeatureBComponent { + protected readonly shared = inject(SharedService); + } + `, + 'projects/lib/sub-module/src/public-api.ts': `export const SUB_MODULE_CONSTANT = 'sub-module';\n`, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + entryPoints: { + '.': 'projects/lib/src/public-api.ts', + 'shared': 'projects/lib/shared/src/public-api.ts', + 'feature-a': 'projects/lib/feature-a/src/public-api.ts', + 'feature-b': 'projects/lib/feature-b/src/public-api.ts', + 'sub-module': 'projects/lib/sub-module/src/public-api.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Check all FESM2022 bundles exist + harness.expectFile('dist/lib/fesm2022/lib.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-shared.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-feature-a.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-feature-b.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-sub-module.mjs').toExist(); + + // Check all DTS declarations exist + harness.expectFile('dist/lib/types/lib.d.ts').toExist(); + harness.expectFile('dist/lib/types/lib-shared.d.ts').toExist(); + harness.expectFile('dist/lib/types/lib-feature-a.d.ts').toExist(); + harness.expectFile('dist/lib/types/lib-feature-b.d.ts').toExist(); + harness.expectFile('dist/lib/types/lib-sub-module.d.ts').toExist(); + + // Check secondary package.json manifests + harness.expectFile('dist/lib/shared/package.json').toExist(); + harness.expectFile('dist/lib/feature-a/package.json').toExist(); + harness.expectFile('dist/lib/feature-b/package.json').toExist(); + harness.expectFile('dist/lib/sub-module/package.json').toExist(); + + // Verify root export maps + const rootPkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(rootPkg.exports).toEqual( + jasmine.objectContaining({ + './shared': { + types: './types/lib-shared.d.ts', + default: './fesm2022/lib-shared.mjs', + }, + './feature-a': { + types: './types/lib-feature-a.d.ts', + default: './fesm2022/lib-feature-a.mjs', + }, + './feature-b': { + types: './types/lib-feature-b.d.ts', + default: './fesm2022/lib-feature-b.mjs', + }, + './sub-module': { + types: './types/lib-sub-module.d.ts', + default: './fesm2022/lib-sub-module.mjs', + }, + }), + ); + + // Verify .npmignore contains all secondary dirs + const npmignore = harness.readFile('dist/lib/.npmignore'); + expect(npmignore).toContain('/shared/package.json'); + expect(npmignore).toContain('/feature-a/package.json'); + expect(npmignore).toContain('/feature-b/package.json'); + expect(npmignore).toContain('/sub-module/package.json'); + }); + + it('should throw an error when a circular dependency exists between secondary entry points', async () => { + await harness.writeFiles({ + 'projects/lib/ep-one/src/public-api.ts': ` + import { EpTwoService } from 'lib/ep-two'; + export const VAL_ONE = 'one'; + `, + 'projects/lib/ep-two/src/public-api.ts': ` + import { VAL_ONE } from 'lib/ep-one'; + export class EpTwoService {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + entryPoints: { + '.': 'projects/lib/src/public-api.ts', + 'ep-one': 'projects/lib/ep-one/src/public-api.ts', + 'ep-two': 'projects/lib/ep-two/src/public-api.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expect(result?.error).toContain('Circular dependency detected'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/behavior/styles_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/styles_spec.ts new file mode 100644 index 000000000000..de8aa7bf1724 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/styles_spec.ts @@ -0,0 +1,145 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { InlineStyleLanguage } from '../../schema'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Behavior: "Stylesheet Preprocessing and Languages"', () => { + it('should resolve SCSS @use and @import using stylePreprocessorOptions.includePaths', async () => { + await harness.writeFiles({ + 'projects/lib/styles/_variables.scss': '$theme-color: #4caf50;\n', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-styled', + template: '

Styled with includePaths

', + styles: [\` + @use 'variables'; + p { + color: variables.$theme-color; + } + \`], + }) + export class LibComponent {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Scss, + stylePreprocessorOptions: { + includePaths: ['projects/lib/styles'], + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toMatch(/color:\s*#4caf50/); + }); + + it('should compile component external stylesheet files', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/lib.component.scss': ` + $bg-color: #2196f3; + .external-styled { + background-color: $bg-color; + } + `, + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-external-styled', + template: '
External
', + styleUrl: './lib.component.scss', + }) + export class LibComponent {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toMatch(/background-color:\s*#2196f3/); + }); + + it('should compile component inline Less styles', async () => { + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-less-styled', + template: 'Less Styled', + styles: [\` + @base-color: #9c27b0; + span { + color: @base-color; + } + \`], + }) + export class LibComponent {} + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Less, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toMatch(/color:\s*#9c27b0/); + }); + + it('should inline CSS url assets as data URIs', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/test.svg': + '', + 'projects/lib/src/lib/lib.component.css': ` + .icon { + background-image: url('./test.svg'); + } + `, + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-icon', + template: '
', + styleUrl: './lib.component.css', + }) + export class LibComponent {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toContain('data:image/svg+xml'); + }); + }); +}); 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 new file mode 100644 index 000000000000..265b08c233a5 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -0,0 +1,392 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Behavior: "Watch Mode Rebuilding"', () => { + it('should rebuild library when a component file is modified', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('LibComponent'); + + // Trigger a change + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-rebuilt', + template: 'Rebuilt', + }) + export class LibComponent {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('lib-rebuilt'); + }, + ]); + }); + + it('should rebuild when external template or stylesheet file is modified', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/lib.component.html': '

Initial Template

', + 'projects/lib/src/lib/lib.component.css': 'h1 { color: blue; }', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-resources', + templateUrl: './lib.component.html', + styleUrl: './lib.component.css', + }) + export class LibComponent {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('Initial Template'); + expect(content).toMatch(/color:\s*(?:blue|#00f)/); + + // Trigger change to external template + await harness.writeFile( + 'projects/lib/src/lib/lib.component.html', + '

Updated Template

', + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('Updated Template'); + + // Trigger change to external stylesheet + await harness.writeFile('projects/lib/src/lib/lib.component.css', 'h1 { color: green; }'); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toMatch(/color:\s*green/); + }, + ]); + }); + + it('should rebuild intra-dependent secondary entry points when upstream changes', async () => { + await harness.writeFiles({ + 'projects/lib/shared/src/public-api.ts': ` + export const SHARED_VERSION = '1.0.0'; + `, + 'projects/lib/feature/src/public-api.ts': ` + import { SHARED_VERSION } from 'lib/shared'; + export const FEATURE_INFO = \`Feature using \${SHARED_VERSION}\`; + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + entryPoints: { + '.': 'projects/lib/src/public-api.ts', + 'shared': 'projects/lib/shared/src/public-api.ts', + 'feature': 'projects/lib/feature/src/public-api.ts', + }, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const featureFesm = harness.readFile('dist/lib/fesm2022/lib-feature.mjs'); + expect(featureFesm).toContain('FEATURE_INFO'); + + // Modify upstream shared entry point + await harness.writeFile( + 'projects/lib/shared/src/public-api.ts', + ` + export const SHARED_VERSION = '2.0.0'; + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const sharedFesm = harness.readFile('dist/lib/fesm2022/lib-shared.mjs'); + expect(sharedFesm).toContain('2.0.0'); + const featureFesm = harness.readFile('dist/lib/fesm2022/lib-feature.mjs'); + expect(featureFesm).toContain('FEATURE_INFO'); + }, + ]); + }); + + it('should re-copy assets when an asset file is modified in watch mode', async () => { + await harness.writeFile('projects/lib/assets/data.json', '{"version": 1}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*', + input: 'projects/lib/assets', + output: 'assets', + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/data.json')).toBe('{"version": 1}'); + + // Modify asset file + await harness.writeFile('projects/lib/assets/data.json', '{"version": 2}'); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/data.json')).toBe('{"version": 2}'); + }, + ]); + }); + + it('should set a watch version in package.json in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.version).toMatch(/^0\.0\.0-watch\+\d+$/); + }, + ]); + }); + + it('should not update package.json when only source files change in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + let initialVersion: string; + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + initialVersion = pkg.version; + expect(initialVersion).toMatch(/^0\.0\.0-watch\+\d+$/); + + // Wait a brief moment so Date.now() would differ if regenerated + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Modify source file + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-rebuilt', + template: 'Rebuilt', + }) + export class LibComponent {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('lib-rebuilt'); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.version).toBe(initialVersion); + }, + ]); + }); + + it('should update package.json when package.json is modified in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.description).toBeUndefined(); + + // Modify package.json + const originalPkg = JSON.parse(harness.readFile('projects/lib/package.json')); + originalPkg.description = 'Updated description'; + await harness.writeFile( + 'projects/lib/package.json', + JSON.stringify(originalPkg, null, 2), + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.description).toBe('Updated description'); + }, + ]); + }); + + it('should recover from compilation errors in watch mode', async () => { + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title = 'hello world';`, + ); + + 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('hello world'); + + // Introduce a compilation error + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title: number = 'invalid type';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeFalse(); + + // Fix the compilation error + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title = 'fixed world';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('fixed world'); + }, + ]); + }); + + it('should rebuild secondary entry point when its file changes', async () => { + await harness.writeFiles({ + 'projects/lib/secondary/src/public-api.ts': `export const MSG = 'initial secondary';`, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + entryPoints: { + '.': 'projects/lib/src/public-api.ts', + 'secondary': 'projects/lib/secondary/src/public-api.ts', + }, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'initial secondary', + ); + + // Modify secondary entry point source + await harness.writeFile( + 'projects/lib/secondary/src/public-api.ts', + `export const MSG = 'updated secondary';`, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'updated secondary', + ); + const messages = logs.map((l) => l.message); + expect(messages.some((m) => m.includes('Compiling lib/secondary...'))).toBeTrue(); + expect(messages.some((m) => m.includes('Compiling lib...'))).toBeFalse(); + }, + ]); + }); + it('should recover when initial build fails with a compilation error', async () => { + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title: number = 'invalid type';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeFalse(); + + // Fix the compilation error + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title = 'fixed world';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('fixed world'); + }, + ]); + }); + + it('should rebuild when a new file is created in projectRoot', async () => { + await harness.writeFile('projects/lib/src/public-api.ts', `export * from './extra';`); + await harness.writeFile('projects/lib/src/extra.ts', `export const INITIAL = true;`); + + 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('INITIAL'); + + // Create a brand new file + await harness.writeFile( + 'projects/lib/src/lib/new-feature.ts', + `export const NEW_VAL = 123;`, + ); + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export * from './extra';\nexport * from './lib/new-feature';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('NEW_VAL'); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/allowed-non-peer-dependencies_spec.ts b/packages/angular/build/src/builders/library/tests/options/allowed-non-peer-dependencies_spec.ts new file mode 100644 index 000000000000..caca7c594f26 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/allowed-non-peer-dependencies_spec.ts @@ -0,0 +1,68 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "allowedNonPeerDependencies"', () => { + it('should fail build when package.json has unallowed dependencies', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.dependencies = { + 'lodash-es': '^4.17.21', + }; + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expect(result?.error).toContain('allowedNonPeerDependencies'); + expect(result?.error).toContain('lodash-es'); + }); + + it('should succeed build when dependency matches allowedNonPeerDependencies pattern', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.dependencies = { + 'lodash-es': '^4.17.21', + }; + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedNonPeerDependencies: ['^lodash-.*'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should allow tslib by default in dependencies without configuration', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.dependencies = { + tslib: '^2.3.0', + }; + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/assets_spec.ts b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts new file mode 100644 index 000000000000..ded7743675ff --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts @@ -0,0 +1,54 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "assets"', () => { + it('should copy assets matching glob patterns with input, output, and ignore', async () => { + await harness.writeFiles({ + 'projects/lib/assets-dir/file-a.png': 'PNG_A', + 'projects/lib/assets-dir/file-b.png': 'PNG_B', + 'projects/lib/assets-dir/file-c.svg': 'SVG_C', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*.png', + input: 'projects/lib/assets-dir', + output: 'assets', + }, + ], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expect(harness.readFile('dist/lib/assets/file-a.png')).toBe('PNG_A'); + expect(harness.readFile('dist/lib/assets/file-b.png')).toBe('PNG_B'); + expect(harness.hasFile('dist/lib/assets/file-c.svg')).toBeFalse(); + }); + + it('should support string-based asset paths', async () => { + await harness.writeFile('projects/lib/docs/README.md', '# Library Docs'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['projects/lib/docs/README.md'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expect(harness.readFile('dist/lib/docs/README.md')).toBe('# Library Docs'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/compilation-mode_spec.ts b/packages/angular/build/src/builders/library/tests/options/compilation-mode_spec.ts new file mode 100644 index 000000000000..cd794206960d --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/compilation-mode_spec.ts @@ -0,0 +1,41 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { CompilationMode } from '../../schema'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "compilationMode"', () => { + it('should emit partial declarations when compilationMode is "partial"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + compilationMode: CompilationMode.Partial, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toContain('ɵɵngDeclareComponent'); + }); + + it('should emit full definitions when compilationMode is "full"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + compilationMode: CompilationMode.Full, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toContain('ɵɵdefineComponent'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/declaration-map_spec.ts b/packages/angular/build/src/builders/library/tests/options/declaration-map_spec.ts new file mode 100644 index 000000000000..6ff112ac3912 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/declaration-map_spec.ts @@ -0,0 +1,43 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "declarationMap"', () => { + it('should not emit declaration sourcemaps by default', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // FESM sourcemaps are always enabled + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs.map')).toBeTrue(); + // DTS sourcemaps are disabled by default + expect(harness.hasFile('dist/lib/types/lib.d.ts.map')).toBeFalse(); + }); + + it('should emit declaration sourcemaps when declarationMap is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + declarationMap: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // FESM sourcemaps are always enabled + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs.map')).toBeTrue(); + // DTS sourcemaps should be generated + expect(harness.hasFile('dist/lib/types/lib.d.ts.map')).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/delete-output-path_spec.ts b/packages/angular/build/src/builders/library/tests/options/delete-output-path_spec.ts new file mode 100644 index 000000000000..e8001de35f33 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/delete-output-path_spec.ts @@ -0,0 +1,43 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "deleteOutputPath"', () => { + beforeEach(async () => { + // Add pre-existing files in output directory + await harness.writeFile('dist/lib/extra.txt', 'EXTRA'); + }); + + it('should delete the output files when deleteOutputPath is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/extra.txt').toNotExist(); + harness.expectFile('dist/lib/fesm2022/lib.mjs').toExist(); + }); + + it('should not delete existing output files when deleteOutputPath is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/extra.txt').toExist(); + harness.expectFile('dist/lib/fesm2022/lib.mjs').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts new file mode 100644 index 000000000000..c7d298e7dc14 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts @@ -0,0 +1,76 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "entryPoints"', () => { + it('should succeed when entry point is a .ts file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + entryPoints: { + '.': 'projects/lib/src/public-api.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should succeed when entry point is a .mts file', async () => { + await harness.writeFiles({ + 'projects/lib/src/public-api.mts': 'export const VALUE = 42;\n', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + entryPoints: { + '.': 'projects/lib/src/public-api.mts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should fail when entry point is not a .ts or .mts file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + entryPoints: { + '.': 'projects/lib/src/public-api.cts', + }, + }); + + const { result, error } = await harness.executeOnce({ + outputLogsOnException: false, + outputLogsOnFailure: false, + }); + expect(result).toBeUndefined(); + expect(error).toBeDefined(); + expect((error as Error).message).toMatch(/must be a TypeScript file \('\.ts' or '\.mts'\)/); + }); + + it('should fail when entry point is a declaration file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + entryPoints: { + '.': 'projects/lib/src/public-api.d.ts', + }, + }); + + const { result, error } = await harness.executeOnce({ + outputLogsOnException: false, + outputLogsOnFailure: false, + }); + expect(result).toBeUndefined(); + expect(error).toBeDefined(); + expect((error as Error).message).toMatch(/must be a TypeScript file \('\.ts' or '\.mts'\)/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts b/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts new file mode 100644 index 000000000000..676b60c15475 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts @@ -0,0 +1,63 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "keepLifecycleScripts"', () => { + it('should remove scripts from package.json by default', async () => { + await harness.writeFile( + 'projects/lib/package.json', + JSON.stringify({ + name: 'my-lib', + version: '1.0.0', + scripts: { + postinstall: 'echo postinstall', + }, + }), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const distPackageJson = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(distPackageJson.scripts).toBeUndefined(); + }); + + it('should preserve scripts in package.json when keepLifecycleScripts is true', async () => { + await harness.writeFile( + 'projects/lib/package.json', + JSON.stringify({ + name: 'my-lib', + version: '1.0.0', + scripts: { + postinstall: 'echo postinstall', + }, + }), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + keepLifecycleScripts: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const distPackageJson = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(distPackageJson.scripts).toEqual({ + postinstall: 'echo postinstall', + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/output-path_spec.ts b/packages/angular/build/src/builders/library/tests/options/output-path_spec.ts new file mode 100644 index 000000000000..2969692fa6ac --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/output-path_spec.ts @@ -0,0 +1,36 @@ +/** + * @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 { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "outputPath"', () => { + it('should default outputPath to dist/{projectName} when omitted', async () => { + const { outputPath: _, ...optionsWithoutOutputPath } = BASE_OPTIONS; + harness.useTarget('build', optionsWithoutOutputPath); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/fesm2022/lib.mjs').toExist(); + harness.expectFile('dist/lib/package.json').toExist(); + }); + + it('should use custom outputPath when specified', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + outputPath: 'dist/custom-output', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/custom-output/fesm2022/lib.mjs').toExist(); + harness.expectFile('dist/custom-output/package.json').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/setup.ts b/packages/angular/build/src/builders/library/tests/setup.ts new file mode 100644 index 000000000000..39112b5e8cf0 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/setup.ts @@ -0,0 +1,78 @@ +/** + * @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 { BuilderHandlerFn } from '@angular-devkit/architect'; +import { TestProjectHost } from '@angular-devkit/architect/testing'; +import { json, normalize, join } from '@angular-devkit/core'; +import { readFileSync } from 'node:fs'; +import { JasmineBuilderHarness } from '../../../../../../../modules/testing/builder/src'; +import { Schema } from '../schema'; + +export * from '../../../../../../../modules/testing/builder/src'; + +export const LIBRARY_BUILDER_INFO = Object.freeze({ + name: '@angular/build:library', + schemaPath: __dirname + '/../schema.json', +}); + +export const BASE_OPTIONS = Object.freeze({ + entryPoints: { + '.': 'projects/lib/src/public-api.ts', + }, + tsConfig: 'projects/lib/tsconfig.lib.json', + outputPath: 'dist/lib', + poll: 100, +}); + +const libWorkspaceRoot = join( + normalize(__dirname), + '../../../../../../../modules/testing/builder/projects/hello-world-lib/', +); +export const libHost = new TestProjectHost(libWorkspaceRoot); + +const optionSchemaCache = new Map(); + +function getCachedSchema(options: { schemaPath: string }): json.schema.JsonSchema { + let optionSchema = optionSchemaCache.get(options.schemaPath); + if (optionSchema === undefined) { + optionSchema = JSON.parse(readFileSync(options.schemaPath, 'utf8')) as json.schema.JsonSchema; + optionSchemaCache.set(options.schemaPath, optionSchema); + } + return optionSchema; +} + +let counter = 0; + +export function describeLibraryBuilder( + builderHandler: BuilderHandlerFn, + options: { name?: string; schemaPath: string }, + specDefinitions: (harness: JasmineBuilderHarness) => void, +): void { + const optionSchema = getCachedSchema(options); + const harness = new JasmineBuilderHarness(builderHandler, libHost, { + builderName: options.name, + optionSchema, + }); + + describe((options.name || builderHandler.name) + ` (Suite: ${counter++})`, () => { + beforeEach(async () => { + harness.resetProjectMetadata(); + harness.useProject('lib', { + root: 'projects/lib', + sourceRoot: 'projects/lib/src', + }); + harness.useTarget('build', BASE_OPTIONS); + + await libHost.initialize().toPromise(); + }); + + afterEach(() => libHost.restore().toPromise()); + + specDefinitions(harness); + }); +} diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts index bb1da57a7108..b496bd4c5d63 100644 --- a/packages/angular/build/src/builders/unit-test/builder.ts +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -249,6 +249,13 @@ export async function* execute( await context.getTargetOptions(normalizedOptions.buildTarget), builderName, )) as unknown as ApplicationBuilderInternalOptions; + } else if (builderName === '@angular/build:library') { + const libraryOptions = (await context.validateOptions( + await context.getTargetOptions(normalizedOptions.buildTarget), + builderName, + )) as Record; + + buildTargetOptions = transformLibraryOptions(libraryOptions); } else if (builderName === '@angular/build:ng-packagr') { const ngPackagrOptions = await context.validateOptions( await context.getTargetOptions(normalizedOptions.buildTarget), @@ -263,7 +270,8 @@ export async function* execute( } else { context.logger.warn( `The 'buildTarget' is configured to use '${builderName}', which is not supported. ` + - `The 'unit-test' builder is designed to work with '@angular/build:application' or '@angular/build:ng-packagr'. ` + + `The 'unit-test' builder is designed to work with '@angular/build:application', ` + + `'@angular/build:library', or '@angular/build:ng-packagr'. ` + 'Unexpected behavior or build failures may occur.', ); @@ -390,3 +398,26 @@ async function transformNgPackagrOptions( inlineStyleLanguage, } as ApplicationBuilderInternalOptions; } + +/** + * Transforms library builder options into internal application builder options for testing. + * + * @param options The raw validated options from the library build target. + * @returns Application builder options suitable for running tests. + */ +function transformLibraryOptions( + options: Record, +): ApplicationBuilderInternalOptions { + const { stylePreprocessorOptions, assets, inlineStyleLanguage, preserveSymlinks, tsConfig } = + options; + + return { + stylePreprocessorOptions: + stylePreprocessorOptions as ApplicationBuilderInternalOptions['stylePreprocessorOptions'], + assets: Array.isArray(assets) && assets.length ? assets : undefined, + inlineStyleLanguage: + inlineStyleLanguage as ApplicationBuilderInternalOptions['inlineStyleLanguage'], + preserveSymlinks: typeof preserveSymlinks === 'boolean' ? preserveSymlinks : undefined, + tsConfig: typeof tsConfig === 'string' ? tsConfig : undefined, + } as ApplicationBuilderInternalOptions; +} diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts new file mode 100644 index 000000000000..d7374f7f4b94 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts @@ -0,0 +1,76 @@ +/** + * @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 { execute } from '../../index'; +import { BASE_OPTIONS, describeBuilder, UNIT_TEST_BUILDER_INFO } from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "@angular/build:library buildTarget"', () => { + it('should support library buildTarget with stylePreprocessorOptions and inlineStyleLanguage', async () => { + harness.withBuilderTarget( + 'build', + async () => ({ success: true }), + { + tsConfig: 'src/tsconfig.lib.json', + entryPoints: { + '.': 'src/public-api.ts', + }, + inlineStyleLanguage: 'scss', + stylePreprocessorOptions: { + includePaths: ['src/styles'], + }, + }, + { + builderName: '@angular/build:library', + }, + ); + + await harness.writeFiles({ + 'src/styles/_vars.scss': '$primary-color: #123456;', + 'src/public-api.ts': `export * from './lib/lib.component';`, + 'src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-comp', + standalone: true, + template: '

lib

', + styles: [\` + @use 'vars'; + p { color: vars.$primary-color; } + \`], + }) + export class LibComponent {} + `, + 'src/lib/lib.component.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { describe, it, expect } from 'vitest'; + import { LibComponent } from './lib.component'; + + describe('LibComponent', () => { + it('creates component with scss styles', () => { + TestBed.configureTestingModule({ + imports: [LibComponent], + }); + const fixture = TestBed.createComponent(LibComponent); + expect(fixture).toBeTruthy(); + }); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/lib/**/*.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts index 147cfc31c53e..f76b9c4e9e7b 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts @@ -212,5 +212,42 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { expect(result?.success).toBeTrue(); expectNoLog(logs, /Zone\.js polyfills are being automatically injected/); }); + + it('should load Zone and Zone testing support when testing a library using @angular/build:library and zone.js is installed', async () => { + harness.withBuilderTarget( + 'build', + async () => ({ success: true }), + { + tsConfig: 'src/tsconfig.lib.json', + entryPoints: { + '.': 'src/public-api.ts', + }, + }, + { + builderName: '@angular/build:library', + }, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app.component.spec.ts'], + }); + + await harness.writeFile( + 'src/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Library Zone Test', () => { + it('should have Zone defined', () => { + expect((globalThis as any).Zone).toBeDefined(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); }); }); diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts index 6bd836139c5f..ebc761bface8 100644 --- a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts @@ -23,6 +23,7 @@ export interface FileTransformResult { export interface AngularCompilationOptions { allowJs?: boolean; + declarationMap?: boolean; isolatedModules?: boolean; sourceMap?: boolean; inlineSourceMap?: boolean; diff --git a/packages/angular/build/src/tools/angular/compilation/index.ts b/packages/angular/build/src/tools/angular/compilation/index.ts index 268abec678ca..ea7755863fbd 100644 --- a/packages/angular/build/src/tools/angular/compilation/index.ts +++ b/packages/angular/build/src/tools/angular/compilation/index.ts @@ -16,3 +16,4 @@ export { } from './angular-compilation'; export type { CompilerOptionOverrides } from './compiler-options'; export { createAngularCompilation, type AngularCompilationMode } from './factory'; +export { LibraryCompilation, type LibraryCompilationOptions } from './library-compilation'; diff --git a/packages/angular/build/src/tools/angular/compilation/library-compilation.ts b/packages/angular/build/src/tools/angular/compilation/library-compilation.ts new file mode 100644 index 000000000000..0f06cd131dd9 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/library-compilation.ts @@ -0,0 +1,451 @@ +/** + * @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 * as ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import path from 'node:path'; +import ts from 'typescript'; +import { toPosixPath } from '../../../utils/path'; +import { profileAsync, profileSync } from '../../esbuild/profiling'; +import { + type AngularCompilerHost, + type AngularHostOptions, + createAngularCompilerHost, + ensureSourceFileVersions, +} from '../angular-host'; +import { + type AngularCompilationResult, + DiagnosticModes, + type EmitFileResult, +} from './angular-compilation'; +import type { CompilerOptionOverrides } from './compiler-options'; +import { TypeScriptCompilation } from './typescript-compilation'; + +/** + * Options for configuring a library compilation. + */ +export interface LibraryCompilationOptions { + entryFilePath: string; + compilationMode?: 'partial' | 'full'; + declarationMap?: boolean; + + /** Map of entry point module specifiers to target .d.ts paths for compilerOptions.paths. */ + upstreamDtsPaths?: Record; + + /** Map of .d.ts file paths to in-memory contents for compiler host resolution. */ + upstreamDtsFiles?: Map; + basePath?: string; + rootDir?: string; + tsBuildInfoFile?: string; + sourceFileCache?: Map; +} + +class LibraryCompilationState { + constructor( + public readonly angularProgram: ng.NgtscProgram, + public readonly compilerHost: AngularCompilerHost, + public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + public readonly configurationDiagnostics: readonly ts.Diagnostic[], + public readonly affectedFiles: ReadonlySet, + public readonly optimizeFor: ng.OptimizeFor, + public readonly diagnosticCache = new WeakMap(), + ) {} + + get angularCompiler() { + return this.angularProgram.compiler; + } +} + +/** + * An Angular compilation implementation specifically tailored for library building + * according to the Angular Package Format (APF). Supports partial/full compilation modes, + * in-memory declaration emitting, upstream entry point path mapping, and incremental builds. + */ +export class LibraryCompilation extends TypeScriptCompilation { + #state?: LibraryCompilationState; + #cachedConfig?: { + compilerOptions: ng.CompilerOptions; + parsedRootNames: string[]; + configurationDiagnostics: readonly ts.Diagnostic[]; + }; + + constructor(private readonly libraryOptions: LibraryCompilationOptions) { + super(libraryOptions.sourceFileCache); + } + + updateLibraryOptions(options: Partial): void { + Object.assign(this.libraryOptions, options); + } + + #loadConfiguration( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides: CompilerOptionOverrides | undefined, + readConfiguration: (project: string, options?: ng.CompilerOptions) => ng.ParsedConfiguration, + ) { + const shouldReloadConfig = + !this.#cachedConfig || hostOptions.modifiedFiles?.has(toPosixPath(tsconfig)); + + if (shouldReloadConfig) { + const { + compilationMode = 'partial', + declarationMap = false, + basePath, + rootDir, + tsBuildInfoFile, + } = this.libraryOptions; + + const { + options: rawCompilerOptions, + rootNames: parsedRootNames, + errors: configurationDiagnostics, + } = profileSync('NG_READ_CONFIG', () => + readConfiguration(tsconfig, { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + moduleResolution: ts.ModuleResolutionKind.Bundler, + importHelpers: true, + composite: false, + sourceMap: true, + inlineSources: true, + inlineSourceMap: false, + outDir: '', + declaration: true, + declarationMap, + allowEmptyCodegenFiles: false, + annotationsAs: 'decorators', + enableResourceInlining: true, + noEmitOnError: false, + suppressOutputPathCheck: true, + compilationMode, + basePath, + rootDir, + tsBuildInfoFile, + preserveSymlinks: compilerOptionOverrides?.preserveSymlinks, + // Disable removing of comments as TS is quite aggressive with these and can + // remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */. + removeComments: false, + }), + ); + + this.#cachedConfig = { + compilerOptions: rawCompilerOptions, + parsedRootNames, + configurationDiagnostics, + }; + } + + assert(this.#cachedConfig); + + return this.#cachedConfig; + } + + async initialize( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides?: CompilerOptionOverrides, + ): Promise { + const { NgtscProgram, OptimizeFor, readConfiguration } = + await TypeScriptCompilation.loadCompilerCli(); + + const { upstreamDtsPaths, upstreamDtsFiles, entryFilePath, tsBuildInfoFile } = + this.libraryOptions; + + const { + compilerOptions: rawCompilerOptions, + parsedRootNames, + configurationDiagnostics, + } = this.#loadConfiguration(tsconfig, hostOptions, compilerOptionOverrides, readConfiguration); + + const compilerOptions = { ...rawCompilerOptions }; + + if (upstreamDtsPaths) { + compilerOptions.paths = { + ...compilerOptions.paths, + ...upstreamDtsPaths, + }; + } + + if (tsBuildInfoFile) { + compilerOptions.incremental = true; + compilerOptions.tsBuildInfoFile = tsBuildInfoFile; + } else if (compilerOptionOverrides?.cachePath && compilerOptions.incremental !== false) { + const safeEntryName = toPosixPath(entryFilePath) + .replace(/[:\\/]/g, '_') + .replace(/\.[^.]+$/, ''); + compilerOptions.incremental = true; + compilerOptions.tsBuildInfoFile = path.join( + compilerOptionOverrides.cachePath, + 'tsbuildinfo', + `${safeEntryName}.tsbuildinfo`, + ); + } else { + compilerOptions.incremental = false; + } + + const packageJsonCache = this.#state?.compilerHost + .getModuleResolutionCache?.() + ?.getPackageJsonInfoCache(); + + if (hostOptions.modifiedFiles) { + this.invalidateFiles(hostOptions.modifiedFiles); + } + + const host = createAngularCompilerHost( + ts, + compilerOptions, + hostOptions, + packageJsonCache, + this.sourceFiles, + ); + + if (upstreamDtsFiles && upstreamDtsFiles.size > 0) { + const originalFileExists = host.fileExists.bind(host); + host.fileExists = (fileName: string) => { + if (upstreamDtsFiles.has(toPosixPath(fileName))) { + return true; + } + + return originalFileExists(fileName); + }; + + const originalReadFile = host.readFile.bind(host); + host.readFile = (fileName: string) => { + const content = upstreamDtsFiles.get(toPosixPath(fileName)); + if (content !== undefined) { + return content; + } + + return originalReadFile(fileName); + }; + + if (host.realpath) { + const originalRealpath = host.realpath.bind(host); + host.realpath = (fileName: string) => { + if (upstreamDtsFiles.has(toPosixPath(fileName))) { + return fileName; + } + + return originalRealpath(fileName); + }; + } + } + + const rootNames = [ + entryFilePath, + ...parsedRootNames.filter((file) => /\.d\.[cm]?ts$/i.test(file)), + ]; + + const angularProgram = profileSync( + 'NG_CREATE_PROGRAM', + () => new NgtscProgram(rootNames, compilerOptions, host, this.#state?.angularProgram), + ); + const angularCompiler = angularProgram.compiler; + const angularTypeScriptProgram = angularProgram.getTsProgram(); + ensureSourceFileVersions(angularTypeScriptProgram); + + let oldProgram = this.#state?.typeScriptProgram; + if (!oldProgram && compilerOptions.tsBuildInfoFile) { + oldProgram = ts.readBuilderProgram(compilerOptions, host); + } + + const typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + angularTypeScriptProgram, + host, + oldProgram, + configurationDiagnostics.length ? configurationDiagnostics : undefined, + ); + + await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync()); + + const affectedFiles = new Set(); + // eslint-disable-next-line no-constant-condition + while (true) { + const result = typeScriptProgram.getSemanticDiagnosticsOfNextAffectedFile( + undefined, + (sourceFile) => { + if ( + angularCompiler.ignoreForDiagnostics.has(sourceFile) && + sourceFile.fileName.endsWith('.ngtypecheck.ts') + ) { + const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts'; + const originalSourceFile = typeScriptProgram.getSourceFile(originalFilename); + if (originalSourceFile) { + affectedFiles.add(originalSourceFile); + } + + return true; + } + + return false; + }, + ); + if (!result) { + break; + } + if (result.affected && 'fileName' in result.affected) { + affectedFiles.add(result.affected); + } + } + + const diagnosticCache = + this.#state?.diagnosticCache ?? new WeakMap(); + + const referencedFiles: string[] = []; + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForEmit.has(sourceFile) || sourceFile.isDeclarationFile) { + continue; + } + + referencedFiles.push(sourceFile.fileName); + const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile); + if (resourceDependencies.length > 0) { + referencedFiles.push(...resourceDependencies); + if (this.#state && hostOptions.modifiedFiles?.size) { + for (const resourceDependency of resourceDependencies) { + if (hostOptions.modifiedFiles.has(resourceDependency)) { + diagnosticCache.delete(sourceFile); + affectedFiles.add(sourceFile); + } + } + } + } + } + + const optimizeFor = + affectedFiles.size === 1 ? OptimizeFor.SingleFile : OptimizeFor.WholeProgram; + + this.#state = new LibraryCompilationState( + angularProgram, + host, + typeScriptProgram, + configurationDiagnostics, + affectedFiles, + optimizeFor, + diagnosticCache, + ); + + return { + compilerOptions, + referencedFiles, + }; + } + + protected override *collectDiagnostics(modes: DiagnosticModes): Iterable { + assert(this.#state, 'Library compilation must be initialized prior to collecting diagnostics.'); + const { + angularProgram, + typeScriptProgram, + configurationDiagnostics, + affectedFiles, + optimizeFor, + diagnosticCache, + } = this.#state; + const angularCompiler = angularProgram.compiler; + + const syntactic = modes & DiagnosticModes.Syntactic; + const semantic = modes & DiagnosticModes.Semantic; + + if (modes & DiagnosticModes.Option) { + yield* configurationDiagnostics; + yield* angularCompiler.getOptionDiagnostics(); + yield* typeScriptProgram.getOptionsDiagnostics(); + yield* typeScriptProgram.getConfigFileParsingDiagnostics(); + } + + if (syntactic) { + yield* typeScriptProgram.getGlobalDiagnostics(); + } + + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) { + continue; + } + + if (syntactic) { + yield* typeScriptProgram.getSyntacticDiagnostics(sourceFile); + } + + if (!semantic) { + continue; + } + + yield* typeScriptProgram.getSemanticDiagnostics(sourceFile); + + if (sourceFile.isDeclarationFile) { + continue; + } + + if (affectedFiles.has(sourceFile)) { + const diagnostics = angularCompiler.getDiagnosticsForFile(sourceFile, optimizeFor); + diagnosticCache.set(sourceFile, diagnostics); + yield* diagnostics; + } else { + const cachedDiagnostics = diagnosticCache.get(sourceFile); + if (cachedDiagnostics) { + yield* cachedDiagnostics; + } + } + } + } + + override emitAffectedFiles(): Iterable { + assert(this.#state, 'Library compilation must be initialized prior to emitting files.'); + const { angularProgram, compilerHost, typeScriptProgram } = this.#state; + const angularCompiler = angularProgram.compiler; + const compilerOptions = typeScriptProgram.getCompilerOptions(); + const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; + + const emittedFiles: EmitFileResult[] = []; + const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { + if ( + !sourceFiles?.length && + (filename.endsWith('.tsbuildinfo') || filename.endsWith(buildInfoFilename)) + ) { + compilerHost.writeFile(filename, contents, false); + + return; + } + + emittedFiles.push({ filename, contents }); + }; + + const transformers = angularCompiler.prepareEmit().transformers; + + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; + } + + if (sourceFile.isDeclarationFile) { + continue; + } + + if ( + angularCompiler.incrementalCompilation?.safeToSkipEmit(sourceFile) && + !this.#state.affectedFiles.has(sourceFile) + ) { + continue; + } + + typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, undefined, transformers); + angularCompiler.incrementalCompilation?.recordSuccessfulEmit(sourceFile); + } + + if (compilerOptions.tsBuildInfoFile) { + const programWithGetState = typeScriptProgram.getProgram() as ts.Program & { + emitBuildInfo?(writeFileCallback?: ts.WriteFileCallback): void; + }; + if (typeof programWithGetState.emitBuildInfo === 'function') { + programWithGetState.emitBuildInfo(writeFileCallback); + } + } + + return emittedFiles; + } +} diff --git a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts index 48975b672154..c904e6f53f91 100644 --- a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts @@ -88,7 +88,9 @@ export abstract class TypeScriptCompilation extends AngularCompilation { }; } - protected readonly sourceFiles = new Map(); + constructor(protected readonly sourceFiles: Map = new Map()) { + super(); + } protected invalidateFiles(files: Iterable): void { for (const file of files) { diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index f73424b5b554..00bd43311e03 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -403,6 +403,7 @@ "not": { "enum": [ "@angular/build:application", + "@angular/build:library", "@angular/build:dev-server", "@angular/build:extract-i18n", "@angular/build:karma", @@ -484,6 +485,28 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:library" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/library/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/library/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 831e06bbd723..1e2de7d8b677 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -534,6 +534,9 @@ importers: rolldown: specifier: 1.2.11 version: 1.2.11 + rolldown-plugin-dts: + specifier: 0.28.5 + version: 0.28.5(rolldown@1.2.11)(typescript@6.0.3) sass: specifier: 1.105.0 version: 1.105.0 @@ -1029,6 +1032,7 @@ packages: '@angular/animations@22.2.0-rc.0': resolution: {integrity: sha512-MnkNstor6AH92M9ps7RZfcCyUo24SqBZU71soDgYC3aXFJhSYOtJFPKE1/KgrpVt416lTzbhHe/68kAZ3cH9MQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} + deprecated: '@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.' peerDependencies: '@angular/core': 22.2.0-rc.0 @@ -4114,136 +4118,271 @@ packages: cpu: [arm64] os: [android] + '@yuku-codegen/binding-android-arm64@0.9.5': + resolution: {integrity: sha512-jrOY5WM+AaAqkv51fHP1x28ifto4WgcZVzodLUyMU1jMWn5Sq+VciRdk/n8E0Ey5w4p4cWWE+m5GfXWOYh7Kzw==} + cpu: [arm64] + os: [android] + '@yuku-codegen/binding-darwin-arm64@0.10.2': resolution: {integrity: sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A==} cpu: [arm64] os: [darwin] + '@yuku-codegen/binding-darwin-arm64@0.9.5': + resolution: {integrity: sha512-4O4lkCQIzPZGjiNB1GORSI6hBECbiiSBS/APZXPvNKYH+nhY4uuqv03LNXA+SET3hoBjvr95P5rIhY8KQaQUBA==} + cpu: [arm64] + os: [darwin] + '@yuku-codegen/binding-darwin-x64@0.10.2': resolution: {integrity: sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA==} cpu: [x64] os: [darwin] + '@yuku-codegen/binding-darwin-x64@0.9.5': + resolution: {integrity: sha512-9EvoUO0SEhD6/d8VHdWuPerepPMSR1y84+UEgz8Un1Ope14Oe7xMvePpEQLTLovoIFZ8Zg3iZ8z8E11pZMqC3g==} + cpu: [x64] + os: [darwin] + '@yuku-codegen/binding-freebsd-x64@0.10.2': resolution: {integrity: sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w==} cpu: [x64] os: [freebsd] + '@yuku-codegen/binding-freebsd-x64@0.9.5': + resolution: {integrity: sha512-HqT78WwgHTmp8lwujoUa9CrIortX4DdpuiVC18ZPSGvuuJf4ylpIEI6QrQEM78Zwz3muhAWAOXZ5irdiYY+AyA==} + cpu: [x64] + os: [freebsd] + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': resolution: {integrity: sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A==} cpu: [arm] os: [linux] libc: [glibc] + '@yuku-codegen/binding-linux-arm-gnu@0.9.5': + resolution: {integrity: sha512-QJXwIW6Ms3QIawbcBIedmrmXnfdpuAOjZJ/eAABq5XTzWvSxZ7lutu9W5yIHahlaTaFnBo7ikrVMD1szLTpPUw==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@yuku-codegen/binding-linux-arm-musl@0.10.2': resolution: {integrity: sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q==} cpu: [arm] os: [linux] libc: [musl] + '@yuku-codegen/binding-linux-arm-musl@0.9.5': + resolution: {integrity: sha512-eHbFy3IHGb+IYarrYwAo0yWSEQV3eAmEn6VrsKA6I1Wy1YPJxWqSJlV69JhqsHtJuJlljUIF3ex0y46yaKIu9w==} + cpu: [arm] + os: [linux] + libc: [musl] + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': resolution: {integrity: sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw==} cpu: [arm64] os: [linux] libc: [glibc] + '@yuku-codegen/binding-linux-arm64-gnu@0.9.5': + resolution: {integrity: sha512-ByoJMbySTaDhjAXSu8q6Lh7HKg3YoesXpcT72aYk0Aiw4PCznmY4ybpLTq0RCvp0RIPhFm/6yECFiGBwyCC1nw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': resolution: {integrity: sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA==} cpu: [arm64] os: [linux] libc: [musl] + '@yuku-codegen/binding-linux-arm64-musl@0.9.5': + resolution: {integrity: sha512-gD9vfXIoBw1toSxhO9rgmSu/FEfy3PMznJAxVjIuH8DrWEiDKXmJO0pJKfj1Ltbe/TmtWVZR+TjISqeSIGQzMg==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': resolution: {integrity: sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg==} cpu: [x64] os: [linux] libc: [glibc] + '@yuku-codegen/binding-linux-x64-gnu@0.9.5': + resolution: {integrity: sha512-BARvdnvqMGjOr5Iel2JH+9H5vAIE0R6H0Z2fsT02xrvmI/1ZXNB8lkuX+ZGevPhZb3zJ9WeC/R8JZstrsUOK5g==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@yuku-codegen/binding-linux-x64-musl@0.10.2': resolution: {integrity: sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q==} cpu: [x64] os: [linux] libc: [musl] + '@yuku-codegen/binding-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-x8flcevS1fbb7ESrmpOY/pON4eInSaE/7Ktjqx2udOE2W33BNSZmJuwpyUgo54AoHNqrcaM7szqydyJn5fNvew==} + cpu: [x64] + os: [linux] + libc: [musl] + '@yuku-codegen/binding-win32-arm64@0.10.2': resolution: {integrity: sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw==} cpu: [arm64] os: [win32] + '@yuku-codegen/binding-win32-arm64@0.9.5': + resolution: {integrity: sha512-KOL/rBatWqH4ZpCNoF8ZtSNdOJbAxBJLmk/VeRciepGROPTbPum1A9t67GpFgOU7qrkUWlPJdJ+KHKbvbmOt+w==} + cpu: [arm64] + os: [win32] + '@yuku-codegen/binding-win32-x64@0.10.2': resolution: {integrity: sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA==} cpu: [x64] os: [win32] + '@yuku-codegen/binding-win32-x64@0.9.5': + resolution: {integrity: sha512-FxENahEjWSan59Syh/us/Kf4wNq8qrJLFJ3R2N4Oiwtb6yNdz/rWLNTIoNSssnsp7IoWJdUP6o/Z8ppg7lXMcg==} + cpu: [x64] + os: [win32] + '@yuku-parser/binding-android-arm64@0.10.2': resolution: {integrity: sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw==} cpu: [arm64] os: [android] + '@yuku-parser/binding-android-arm64@0.9.5': + resolution: {integrity: sha512-A2JCFCSHfnficqYEw4Iujpx7XrkMM3UfFcgJFmYpZSo9zM4nyhmdIIE0FogSduuU60lhM0/UcfuUBXIVNBMlGQ==} + cpu: [arm64] + os: [android] + '@yuku-parser/binding-darwin-arm64@0.10.2': resolution: {integrity: sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA==} cpu: [arm64] os: [darwin] + '@yuku-parser/binding-darwin-arm64@0.9.5': + resolution: {integrity: sha512-3PiyU+Eare4YuKaQ22N98/yAiROPY5o/NQJHraICzDvk4pgS+m+bgLKOvkGBRn33OnV95Vdv1Mn38b+MQHpULQ==} + cpu: [arm64] + os: [darwin] + '@yuku-parser/binding-darwin-x64@0.10.2': resolution: {integrity: sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ==} cpu: [x64] os: [darwin] + '@yuku-parser/binding-darwin-x64@0.9.5': + resolution: {integrity: sha512-blFMAFI7AInI83XaiOF8cIeiRM46Nz9EfpZtZPRLbKxSA9aAr5v8aHYpmfN6NoyXxAv/10pwS4gsAuc1W6fy5Q==} + cpu: [x64] + os: [darwin] + '@yuku-parser/binding-freebsd-x64@0.10.2': resolution: {integrity: sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ==} cpu: [x64] os: [freebsd] + '@yuku-parser/binding-freebsd-x64@0.9.5': + resolution: {integrity: sha512-TsNuL4qsZdO0tHp5GW43Y5fHeLPpPHA675wdcPNTcdq2ZO5AvsQWFFoiDg8CH4oBktfsQ9tdvKhjLnLYwKvp4g==} + cpu: [x64] + os: [freebsd] + '@yuku-parser/binding-linux-arm-gnu@0.10.2': resolution: {integrity: sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA==} cpu: [arm] os: [linux] libc: [glibc] + '@yuku-parser/binding-linux-arm-gnu@0.9.5': + resolution: {integrity: sha512-b0afYK5gHeV8RdmOcqAlgM8ONsye4cax4DMnIBaoJyPMd1UTGvTbpkqQcPVdhmHlcfcGVGMV1laeVovlr/dscw==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-arm-musl@0.10.2': resolution: {integrity: sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg==} cpu: [arm] os: [linux] libc: [musl] + '@yuku-parser/binding-linux-arm-musl@0.9.5': + resolution: {integrity: sha512-fD3lKzl+r6j6n8DwiMY53qnh5DLqD8KJjCp+NbVud54Nnh3Q9Wprqss3YzyMHP3NSJk663Wlg1E1X5qOuFVFig==} + cpu: [arm] + os: [linux] + libc: [musl] + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': resolution: {integrity: sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ==} cpu: [arm64] os: [linux] libc: [glibc] + '@yuku-parser/binding-linux-arm64-gnu@0.9.5': + resolution: {integrity: sha512-aRU/aCphV1MWCl/lvI6NX8LgucHQ68Fx+vz7NBb/5MEr2EuzCxiPOQqeFcMdWh+2AED/p0AvAREch0c+cSnlhA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-arm64-musl@0.10.2': resolution: {integrity: sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A==} cpu: [arm64] os: [linux] libc: [musl] + '@yuku-parser/binding-linux-arm64-musl@0.9.5': + resolution: {integrity: sha512-5+Guro0l8H473YXlEjVNBRLN/IbPbJdnQh1zo0OLt49xxnON+XMJRRaEyTOTfkn9QSRg0+BhEKGR4W9Gu2uZJQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@yuku-parser/binding-linux-x64-gnu@0.10.2': resolution: {integrity: sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg==} cpu: [x64] os: [linux] libc: [glibc] + '@yuku-parser/binding-linux-x64-gnu@0.9.5': + resolution: {integrity: sha512-pwwSyV9q+GlvzSXlsMZBMlgk22L5bud714/vqZCdeUTivzeUVltQzUaf3IQXOGXdpc59JYTeuMCtbGquaAUoCA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-x64-musl@0.10.2': resolution: {integrity: sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w==} cpu: [x64] os: [linux] libc: [musl] + '@yuku-parser/binding-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-z18j6JN3lBHH8vzN7gG1M8fI0nlgItSfnC9PfbmUbt97iHViKfw2iA2G9fGwRMe5LyPRZBrejIlapbygPbpdaw==} + cpu: [x64] + os: [linux] + libc: [musl] + '@yuku-parser/binding-win32-arm64@0.10.2': resolution: {integrity: sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw==} cpu: [arm64] os: [win32] + '@yuku-parser/binding-win32-arm64@0.9.5': + resolution: {integrity: sha512-s6Gwttb1dQvtPX6Bgkw+UPC9IO3UBDXc6Zezog8MMgvu3UfJBBB9TQqKBX/2quu0Eyh+lLSAyNlIyyecaagUPg==} + cpu: [arm64] + os: [win32] + '@yuku-parser/binding-win32-x64@0.10.2': resolution: {integrity: sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA==} cpu: [x64] os: [win32] + '@yuku-parser/binding-win32-x64@0.9.5': + resolution: {integrity: sha512-FrERt9YWatY3bJfSUdi4YWY+6iQcyo3MzCC821BxlWM5TFZEHijnpz/bknR79VHlXY/9CvXz8ZlaAGPsTtN3nw==} + cpu: [x64] + os: [win32] + '@yuku-toolchain/types@0.10.2': resolution: {integrity: sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q==} + '@yuku-toolchain/types@0.9.5': + resolution: {integrity: sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w==} + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -7480,6 +7619,25 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true + rolldown-plugin-dts@0.28.5: + resolution: {integrity: sha512-yYd3C9CeJwqjOc9X23m0Tyxcqic491uLZlfg51szT287S8zCCqLR2uoySoElgqy2CLn7PdXcEo1dlkBs4n1WHg==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.2.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + rolldown-plugin-dts@0.28.6: resolution: {integrity: sha512-qKrFtBfRfR2hP233m7Ic9zf3wv6MSYdZghWKMdEO6dRYZGlNfINJAa1P5ZVvyHh3mrcd7IJQvIY8L9vnm+v5rQ==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} @@ -8859,12 +9017,21 @@ packages: yuku-ast@0.10.2: resolution: {integrity: sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ==} + yuku-ast@0.9.5: + resolution: {integrity: sha512-Q8qW8WwQnN5Cm0ZZivdRIfv0sRLTjUq0YumXJkw8CYN1aCdICH9rk4C47/4n6kA5EqDtlj+F608twoTSV2MwdQ==} + yuku-codegen@0.10.2: resolution: {integrity: sha512-hentl2dtrF6cPjiAirtkfxfFZBA6Hdm61UqRJ7cA0qrlDNMk9PZGOjw5w1mx3E0hu/6pHcJF4dJW+D0SXHPZOA==} + yuku-codegen@0.9.5: + resolution: {integrity: sha512-zGUVyDpSK4b6c9B0yyxi2P0wujn1XZTbG9dCb7d6gyAoP63EMgyLzUwPUvwxPG5jgYqhlI7w+S3oCyapqGr4PA==} + yuku-parser@0.10.2: resolution: {integrity: sha512-CgaU0/PPjCAIEZ3WQroosOxTY3eeKldAN3h+vk8pMNz4+jl1CZzBR4pW+K/rRPF112VooCL5FdjJoiMNjDrL2A==} + yuku-parser@0.9.5: + resolution: {integrity: sha512-IBnAdNVMswWbJcM7woPluOudectJzFjJ1N8jVRxP9Uq4CIv5hZdBIcdmtRbFDYF/Ph2j2gVup4YJ4N9mh0jYFA==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -12240,77 +12407,151 @@ snapshots: '@yuku-codegen/binding-android-arm64@0.10.2': optional: true + '@yuku-codegen/binding-android-arm64@0.9.5': + optional: true + '@yuku-codegen/binding-darwin-arm64@0.10.2': optional: true + '@yuku-codegen/binding-darwin-arm64@0.9.5': + optional: true + '@yuku-codegen/binding-darwin-x64@0.10.2': optional: true + '@yuku-codegen/binding-darwin-x64@0.9.5': + optional: true + '@yuku-codegen/binding-freebsd-x64@0.10.2': optional: true + '@yuku-codegen/binding-freebsd-x64@0.9.5': + optional: true + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm-gnu@0.9.5': + optional: true + '@yuku-codegen/binding-linux-arm-musl@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm-musl@0.9.5': + optional: true + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm64-gnu@0.9.5': + optional: true + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm64-musl@0.9.5': + optional: true + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': optional: true + '@yuku-codegen/binding-linux-x64-gnu@0.9.5': + optional: true + '@yuku-codegen/binding-linux-x64-musl@0.10.2': optional: true + '@yuku-codegen/binding-linux-x64-musl@0.9.5': + optional: true + '@yuku-codegen/binding-win32-arm64@0.10.2': optional: true + '@yuku-codegen/binding-win32-arm64@0.9.5': + optional: true + '@yuku-codegen/binding-win32-x64@0.10.2': optional: true + '@yuku-codegen/binding-win32-x64@0.9.5': + optional: true + '@yuku-parser/binding-android-arm64@0.10.2': optional: true + '@yuku-parser/binding-android-arm64@0.9.5': + optional: true + '@yuku-parser/binding-darwin-arm64@0.10.2': optional: true + '@yuku-parser/binding-darwin-arm64@0.9.5': + optional: true + '@yuku-parser/binding-darwin-x64@0.10.2': optional: true + '@yuku-parser/binding-darwin-x64@0.9.5': + optional: true + '@yuku-parser/binding-freebsd-x64@0.10.2': optional: true + '@yuku-parser/binding-freebsd-x64@0.9.5': + optional: true + '@yuku-parser/binding-linux-arm-gnu@0.10.2': optional: true + '@yuku-parser/binding-linux-arm-gnu@0.9.5': + optional: true + '@yuku-parser/binding-linux-arm-musl@0.10.2': optional: true + '@yuku-parser/binding-linux-arm-musl@0.9.5': + optional: true + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': optional: true + '@yuku-parser/binding-linux-arm64-gnu@0.9.5': + optional: true + '@yuku-parser/binding-linux-arm64-musl@0.10.2': optional: true + '@yuku-parser/binding-linux-arm64-musl@0.9.5': + optional: true + '@yuku-parser/binding-linux-x64-gnu@0.10.2': optional: true + '@yuku-parser/binding-linux-x64-gnu@0.9.5': + optional: true + '@yuku-parser/binding-linux-x64-musl@0.10.2': optional: true + '@yuku-parser/binding-linux-x64-musl@0.9.5': + optional: true + '@yuku-parser/binding-win32-arm64@0.10.2': optional: true + '@yuku-parser/binding-win32-arm64@0.9.5': + optional: true + '@yuku-parser/binding-win32-x64@0.10.2': optional: true + '@yuku-parser/binding-win32-x64@0.9.5': + optional: true + '@yuku-toolchain/types@0.10.2': {} + '@yuku-toolchain/types@0.9.5': {} + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -15970,6 +16211,20 @@ snapshots: dependencies: glob: 10.5.0 + rolldown-plugin-dts@0.28.5(rolldown@1.2.11)(typescript@6.0.3): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.6 + obug: 2.2.1 + rolldown: 1.2.11 + yuku-ast: 0.9.5 + yuku-codegen: 0.9.5 + yuku-parser: 0.9.5 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + rolldown-plugin-dts@0.28.6(rolldown@1.2.8)(typescript@6.0.3): dependencies: dts-resolver: 3.0.0 @@ -17567,6 +17822,10 @@ snapshots: dependencies: '@yuku-toolchain/types': 0.10.2 + yuku-ast@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + yuku-codegen@0.10.2: dependencies: '@yuku-toolchain/types': 0.10.2 @@ -17584,6 +17843,23 @@ snapshots: '@yuku-codegen/binding-win32-arm64': 0.10.2 '@yuku-codegen/binding-win32-x64': 0.10.2 + yuku-codegen@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.9.5 + '@yuku-codegen/binding-darwin-arm64': 0.9.5 + '@yuku-codegen/binding-darwin-x64': 0.9.5 + '@yuku-codegen/binding-freebsd-x64': 0.9.5 + '@yuku-codegen/binding-linux-arm-gnu': 0.9.5 + '@yuku-codegen/binding-linux-arm-musl': 0.9.5 + '@yuku-codegen/binding-linux-arm64-gnu': 0.9.5 + '@yuku-codegen/binding-linux-arm64-musl': 0.9.5 + '@yuku-codegen/binding-linux-x64-gnu': 0.9.5 + '@yuku-codegen/binding-linux-x64-musl': 0.9.5 + '@yuku-codegen/binding-win32-arm64': 0.9.5 + '@yuku-codegen/binding-win32-x64': 0.9.5 + yuku-parser@0.10.2: dependencies: '@yuku-toolchain/types': 0.10.2 @@ -17602,6 +17878,24 @@ snapshots: '@yuku-parser/binding-win32-arm64': 0.10.2 '@yuku-parser/binding-win32-x64': 0.10.2 + yuku-parser@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + yuku-ast: 0.9.5 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.9.5 + '@yuku-parser/binding-darwin-arm64': 0.9.5 + '@yuku-parser/binding-darwin-x64': 0.9.5 + '@yuku-parser/binding-freebsd-x64': 0.9.5 + '@yuku-parser/binding-linux-arm-gnu': 0.9.5 + '@yuku-parser/binding-linux-arm-musl': 0.9.5 + '@yuku-parser/binding-linux-arm64-gnu': 0.9.5 + '@yuku-parser/binding-linux-arm64-musl': 0.9.5 + '@yuku-parser/binding-linux-x64-gnu': 0.9.5 + '@yuku-parser/binding-linux-x64-musl': 0.9.5 + '@yuku-parser/binding-win32-arm64': 0.9.5 + '@yuku-parser/binding-win32-x64': 0.9.5 + zod@3.25.76: {} zod@4.6.5: {} From 2ac31b2ae324824906bb5b465e47d05e954386a2 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:31:20 +0000 Subject: [PATCH 2/8] fixup! feat(@angular/build): add library builder --- packages/angular/build/src/builders/library/builder.ts | 6 +++--- .../build/src/builders/library/pipeline/compilation.ts | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts index eb39c06e3d72..d52b2bf6d4e2 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -12,22 +12,22 @@ import assert from 'node:assert'; import fs from 'node:fs/promises'; import { createRequire } from 'node:module'; import type ts from 'typescript'; -import { logCumulativeDurations } from '../../tools/esbuild/profiling'; import { resetSassWorkerPoolCaches, shutdownSassWorkerPool, } from '../../tools/esbuild/stylesheets/sass-language'; import { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target'; import { withNoProgress, withSpinner } from '../../tools/esbuild/utils'; -import type { BuildWatcher } from '../../tools/esbuild/watcher'; import { deleteOutputDir } from '../../utils/delete-output-dir'; import { maxWorkers } from '../../utils/environment-options'; import { assertIsError } from '../../utils/error'; import { initializeHash } from '../../utils/hash'; import { toPosixPath } from '../../utils/path'; +import { logCumulativeDurations } from '../../utils/profiling'; import { purgeStaleBuildCache } from '../../utils/purge-cache'; import { getSupportedBrowsers } from '../../utils/supported-browsers'; import { assertCompatibleAngularVersion } from '../../utils/version'; +import type { BuildWatcher } from '../../utils/watcher'; import { WorkerPool } from '../../utils/worker-pool'; import { type NormalizedLibraryOptions, @@ -156,7 +156,7 @@ export async function* executeLibraryBuilder( logger.info('Watch mode enabled. Watching for file changes...'); } - const { setupWatcher } = await import('../../tools/esbuild/watcher'); + const { setupWatcher } = await import('../../utils/watcher'); watcher = await setupWatcher({ workspaceRoot, projectRoot, diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts index f70b59442081..ea6d8ddc4b2a 100644 --- a/packages/angular/build/src/builders/library/pipeline/compilation.ts +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -145,7 +145,7 @@ export async function compileEntryPoint( compilationInstance.updateLibraryOptions({ upstreamDtsPaths, upstreamDtsFiles }); } - let stylesheetReferencedFiles: string[] = []; + const stylesheetReferencedFiles: string[] = []; const stylesheetWarnings: PartialMessage[] = []; const hostOptions: AngularHostOptions = { modifiedFiles, @@ -166,7 +166,7 @@ export async function compileEntryPoint( } if (bundleReferencedFiles?.size) { - stylesheetReferencedFiles = [...bundleReferencedFiles]; + stylesheetReferencedFiles.push(...bundleReferencedFiles); } if (bundleErrors?.length) { @@ -208,7 +208,9 @@ export async function compileEntryPoint( kind: 'warning', color: colors, }); - formattedWarnings = [...(formattedWarnings ?? []), ...formattedStyleWarnings]; + + formattedWarnings ??= []; + formattedWarnings.push(...formattedStyleWarnings); } const emittedFiles = compilationInstance.emitAffectedFiles(); From 3b59f14e194d661c746d80fdd9c7eabd38ce3b81 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:03:02 +0000 Subject: [PATCH 3/8] fixup! feat(@angular/build): add library builder --- .../build/src/tools/angular/compilation/library-compilation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/angular/build/src/tools/angular/compilation/library-compilation.ts b/packages/angular/build/src/tools/angular/compilation/library-compilation.ts index 0f06cd131dd9..76d55dde9857 100644 --- a/packages/angular/build/src/tools/angular/compilation/library-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/library-compilation.ts @@ -11,7 +11,7 @@ import assert from 'node:assert'; import path from 'node:path'; import ts from 'typescript'; import { toPosixPath } from '../../../utils/path'; -import { profileAsync, profileSync } from '../../esbuild/profiling'; +import { profileAsync, profileSync } from '../../../utils/profiling'; import { type AngularCompilerHost, type AngularHostOptions, From 4163a524634ecc8c2176d09ca3f27901b1b5f581 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:28:16 +0000 Subject: [PATCH 4/8] fixup! feat(@angular/build): add library builder --- packages/angular/build/src/builders/library/schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/angular/build/src/builders/library/schema.json b/packages/angular/build/src/builders/library/schema.json index 265f2eebab5e..8c9ee15b2873 100644 --- a/packages/angular/build/src/builders/library/schema.json +++ b/packages/angular/build/src/builders/library/schema.json @@ -92,7 +92,7 @@ }, "compilationMode": { "type": "string", - "description": "Angular compilation mode. Use 'partial' when publishing to npm (APF requirement). Use 'full' only for private, internal monorepo packages that are never published.", + "description": "Angular compilation mode. Use 'partial' when publishing to npm (APF requirement). Use 'full' only during development or for private, internal monorepo packages that are never published.", "enum": ["partial", "full"], "default": "partial" }, From 7bb74afbc73041ee3061b5f8b1594de9b7b062fb Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:33:59 +0000 Subject: [PATCH 5/8] fixup! feat(@angular/build): add library builder --- .../build/src/builders/library/builder.ts | 1 - .../build/src/builders/library/options.ts | 109 ++++++++++-------- .../builders/library/pipeline/compilation.ts | 4 +- .../library/pipeline/compiler-worker.ts | 1 + .../library/pipeline/entry-point-graph.ts | 10 +- .../pipeline/package-manifests_spec.ts | 2 - .../build/src/builders/library/schema.json | 33 +----- .../library/tests/behavior/apf_spec.ts | 14 ++- .../library/tests/behavior/secondary_spec.ts | 36 +++--- .../library/tests/behavior/watch_spec.ts | 30 +++-- .../tests/options/entry-points_spec.ts | 52 +++++---- .../options/keep-lifecycle-scripts_spec.ts | 6 + .../build/src/builders/library/tests/setup.ts | 11 +- .../tests/behavior/library-target_spec.ts | 3 - .../tests/behavior/vitest-zone-init_spec.ts | 3 - 15 files changed, 169 insertions(+), 146 deletions(-) diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts index d52b2bf6d4e2..93d87dd4627f 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -148,7 +148,6 @@ export async function* executeLibraryBuilder( for (const { entryPoint } of graph.nodes.values()) { allWatchedFiles.add(entryPoint.entryFilePath); - allWatchedFiles.add(entryPoint.tsConfigPath); } if (isWatchMode) { diff --git a/packages/angular/build/src/builders/library/options.ts b/packages/angular/build/src/builders/library/options.ts index 632fc8e67190..80cacdf30404 100644 --- a/packages/angular/build/src/builders/library/options.ts +++ b/packages/angular/build/src/builders/library/options.ts @@ -41,9 +41,6 @@ export interface NormalizedEntryPoint { /** Absolute path to entry file. */ entryFilePath: string; - /** Absolute path to tsConfig file for this entry point. */ - tsConfigPath: string; - /** Is this the primary entry point ('.')? */ isPrimary: boolean; } @@ -57,7 +54,7 @@ export interface PackageJsonData { typings?: string; types?: string; sideEffects?: boolean | string[]; - exports?: Record; + exports?: string | Record; scripts?: Record; workspaces?: unknown; dependencies?: Record; @@ -119,7 +116,6 @@ export async function normalizeLibraryOptions( const { tsConfig, - entryPoints: rawEntryPoints, assets: rawAssets, stylePreprocessorOptions, inlineStyleLanguage = 'css', @@ -155,10 +151,9 @@ export async function normalizeLibraryOptions( } const entryPoints = normalizeEntryPoints( - rawEntryPoints, - workspaceRoot, - resolvedTsConfigPath, - projectName, + packageJson.exports, + projectRoot, + packageJsonPath, packageName, ); @@ -241,18 +236,16 @@ export async function normalizeLibraryOptions( /** * Normalizes a single entry point specification. * - * @param key The entry point key from configuration (e.g. '.' or './testing'). - * @param value The entry point file path string or object with entryPoint and tsConfig. - * @param workspaceRoot The workspace root directory. - * @param defaultTsConfigPath The default tsConfig path for the project. + * @param key The entry point key from package.json exports (e.g. '.' or './testing'). + * @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, - value: LibraryBuilderOptions['entryPoints'][string], - workspaceRoot: string, - defaultTsConfigPath: string, + targetPath: string, + projectRoot: string, packageName: string, ): NormalizedEntryPoint { const posixKey = toPosixPath(key).replace(/\/+$/, ''); @@ -265,7 +258,7 @@ function normalizeEntryPoint( 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' or 'testing').`, + `Invalid entry point key '${key}'. Entry point keys must be relative subpaths without '..' (e.g. './testing').`, ); } @@ -273,10 +266,7 @@ function normalizeEntryPoint( const displayName = isPrimary ? packageName : `${packageName}/${name}`; const bundleName = getEntryPointBundleName(packageName, name, isPrimary); - const entryFilePath = path.resolve( - workspaceRoot, - typeof value === 'string' ? value : value.entryPoint, - ); + const entryFilePath = path.resolve(projectRoot, targetPath); if (!/\.(?:ts|mts)$/.test(entryFilePath) || /\.d\.(?:ts|mts)$/.test(entryFilePath)) { throw new Error( @@ -284,50 +274,79 @@ function normalizeEntryPoint( ); } - const tsConfigPath = - typeof value !== 'string' && value.tsConfig - ? path.resolve(workspaceRoot, value.tsConfig) - : defaultTsConfigPath; - return { subpath, name, displayName, bundleName, entryFilePath, - tsConfigPath, isPrimary, }; } /** - * Normalizes all entry points for the library project. + * Normalizes all entry points from the library's `package.json` `exports` field. * - * @param rawEntryPoints The raw entryPoints dictionary from schema options. - * @param workspaceRoot The workspace root directory. - * @param defaultTsConfigPath The default tsConfig path for the project. - * @param projectName The project name used in error reporting. + * @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. */ function normalizeEntryPoints( - rawEntryPoints: LibraryBuilderOptions['entryPoints'], - workspaceRoot: string, - defaultTsConfigPath: string, - projectName: string, + rawExports: PackageJsonData['exports'], + 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 ('.').`, + ); + } + + const exportsRecord = typeof rawExports === 'string' ? { '.': rawExports } : rawExports; + const entryPoints = new Map(); let hasPrimary = false; - for (const [key, value] of Object.entries(rawEntryPoints)) { - const entryPoint = normalizeEntryPoint( - key, - value, - workspaceRoot, - defaultTsConfigPath, - packageName, - ); + 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) { + const isTsSource = /\.(?:ts|mts)$/.test(target) && !/\.d\.(?:ts|mts)$/.test(target); + const isInvalidCodeEntry = /\.(?:d\.[cm]?ts|cts|tsx|jsx)$/.test(target); + if (!isTsSource && !isInvalidCodeEntry) { + // Static asset, stylesheet, or package.json export; preserve in package.json without compiling. + continue; + } + } + + const entryPoint = normalizeEntryPoint(key, 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.`, @@ -341,7 +360,7 @@ function normalizeEntryPoints( if (!hasPrimary) { throw new Error( - `The 'entryPoints' option in project '${projectName}' must contain a primary entry point with key '.'.`, + `The 'exports' field in '${packageJsonPath}' must contain a primary entry point with key '.'.`, ); } diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts index ea6d8ddc4b2a..ef48608610ae 100644 --- a/packages/angular/build/src/builders/library/pipeline/compilation.ts +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -82,6 +82,7 @@ export interface StylesheetBundlerAdapter { export type CompileEntryPointOptions = Pick< NormalizedLibraryOptions, + | 'tsConfigPath' | 'compilationMode' | 'declarationMap' | 'packageName' @@ -113,8 +114,9 @@ export async function compileEntryPoint( upstreamDtsFiles?: Map, sourceFileCache?: Map, ): Promise { - const { entryFilePath, tsConfigPath, bundleName } = entryPoint; + const { entryFilePath, bundleName } = entryPoint; const { + tsConfigPath, compilationMode, declarationMap, cacheOptions, diff --git a/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts b/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts index 1e08b657ceda..21c0aabb295b 100644 --- a/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts +++ b/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts @@ -105,6 +105,7 @@ export async function compileEntryPointInWorker( modifiedFiles?: string[], ): Promise { const workerOptions: CompileWorkerOptions = { + tsConfigPath: options.tsConfigPath, compilationMode: options.compilationMode, declarationMap: options.declarationMap, packageName: options.packageName, diff --git a/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts b/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts index 61596b8c1d9a..369babaa5200 100644 --- a/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts +++ b/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts @@ -156,20 +156,18 @@ export class EntryPointGraph { private cachedNodeMeta?: Array<{ node: EntryPointNode; entryFile: string; - tsConfig: string; dirWithSep: string; }>; private getNodeMeta() { this.cachedNodeMeta ??= Array.from(this.nodes.values()) .map((node) => { - const { entryFilePath, tsConfigPath } = node.entryPoint; + const { entryFilePath } = node.entryPoint; const nodeDir = toPosixPath(path.dirname(entryFilePath)); return { node, entryFile: toPosixPath(entryFilePath), - tsConfig: toPosixPath(tsConfigPath), dirWithSep: nodeDir.endsWith('/') ? nodeDir : `${nodeDir}/`, }; }) @@ -191,8 +189,8 @@ export class EntryPointGraph { for (const file of changedFiles) { let matched = false; - for (const { node, entryFile, tsConfig } of nodeMeta) { - if (file === entryFile || file === tsConfig || node.referencedFiles.has(file)) { + for (const { node, entryFile } of nodeMeta) { + if (file === entryFile || node.referencedFiles.has(file)) { node.isDirty = true; hasChanges = true; matched = true; @@ -329,7 +327,7 @@ export async function buildEntryPointGraph( for (const dep of dependencies) { if (!graph.nodes.has(dep)) { throw new Error( - `Entry point '${dep}' imported by '${entryPoint.name}' does not exist in 'entryPoints'.`, + `Entry point '${dep}' imported by '${entryPoint.name}' does not exist in 'package.json' exports.`, ); } 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 34bae50cb867..62f11523cd80 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 @@ -86,7 +86,6 @@ describe('generatePackageManifests', () => { displayName: packageName, bundleName: primaryBundleName, entryFilePath: join(tempDir, 'src/public-api.ts'), - tsConfigPath: join(tempDir, 'tsconfig.lib.json'), isPrimary: true, }); @@ -98,7 +97,6 @@ describe('generatePackageManifests', () => { displayName: `${packageName}/testing`, bundleName: secondaryBundleName, entryFilePath: join(tempDir, 'testing/src/public-api.ts'), - tsConfigPath: join(tempDir, 'tsconfig.lib.json'), isPrimary: false, }); } diff --git a/packages/angular/build/src/builders/library/schema.json b/packages/angular/build/src/builders/library/schema.json index 8c9ee15b2873..3aefc5e4cd65 100644 --- a/packages/angular/build/src/builders/library/schema.json +++ b/packages/angular/build/src/builders/library/schema.json @@ -4,22 +4,6 @@ "description": "Library builder target options for Build Architect. Builds an Angular library package conforming to the Angular Package Format (APF).", "type": "object", "properties": { - "entryPoints": { - "type": "object", - "description": "Map of package entry points. The '.' key represents the primary entry point; other keys define secondary subpath entry points.", - "required": ["."], - "additionalProperties": { - "oneOf": [ - { - "type": "string", - "description": "Path to the entry file (e.g. 'projects/my-lib/src/public-api.ts')." - }, - { - "$ref": "#/definitions/entryPoint" - } - ] - } - }, "tsConfig": { "type": "string", "description": "The full path for the TypeScript configuration file, relative to the current workspace root." @@ -139,23 +123,8 @@ } }, "additionalProperties": false, - "required": ["tsConfig", "entryPoints"], + "required": ["tsConfig"], "definitions": { - "entryPoint": { - "type": "object", - "properties": { - "entryPoint": { - "type": "string", - "description": "Path to the entry file." - }, - "tsConfig": { - "type": "string", - "description": "Optional TypeScript configuration file specific to this entry point." - } - }, - "required": ["entryPoint"], - "additionalProperties": false - }, "assetPattern": { "oneOf": [ { diff --git a/packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts index 21bd85222fcf..2cdf5108d8fb 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts @@ -21,12 +21,18 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => 'projects/lib/secondary/src/public-api.ts': 'export const SECONDARY_VALUE = 42;\n', }); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + harness.useTarget('build', { ...BASE_OPTIONS, - entryPoints: { - '.': 'projects/lib/src/public-api.ts', - 'secondary': 'projects/lib/secondary/src/public-api.ts', - }, assets: [ 'projects/lib/README.md', 'projects/lib/LICENSE', diff --git a/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts index 28f676983f1c..26c0b6ac8969 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts @@ -52,15 +52,17 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => 'projects/lib/sub-module/src/public-api.ts': `export const SUB_MODULE_CONSTANT = 'sub-module';\n`, }); - harness.useTarget('build', { - ...BASE_OPTIONS, - entryPoints: { - '.': 'projects/lib/src/public-api.ts', - 'shared': 'projects/lib/shared/src/public-api.ts', - 'feature-a': 'projects/lib/feature-a/src/public-api.ts', - 'feature-b': 'projects/lib/feature-b/src/public-api.ts', - 'sub-module': 'projects/lib/sub-module/src/public-api.ts', - }, + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + './feature-a': './feature-a/src/public-api.ts', + './feature-b': './feature-b/src/public-api.ts', + './sub-module': './sub-module/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); }); const { result } = await harness.executeOnce(); @@ -129,13 +131,15 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => `, }); - harness.useTarget('build', { - ...BASE_OPTIONS, - entryPoints: { - '.': 'projects/lib/src/public-api.ts', - 'ep-one': 'projects/lib/ep-one/src/public-api.ts', - 'ep-two': 'projects/lib/ep-two/src/public-api.ts', - }, + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './ep-one': './ep-one/src/public-api.ts', + './ep-two': './ep-two/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); }); const { result } = await harness.executeOnce(); 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 265b08c233a5..d78ae7e2b731 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 @@ -106,13 +106,19 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => `, }); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + './feature': './feature/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + harness.useTarget('build', { ...BASE_OPTIONS, - entryPoints: { - '.': 'projects/lib/src/public-api.ts', - 'shared': 'projects/lib/shared/src/public-api.ts', - 'feature': 'projects/lib/feature/src/public-api.ts', - }, watch: true, }); @@ -297,12 +303,18 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => 'projects/lib/secondary/src/public-api.ts': `export const MSG = 'initial secondary';`, }); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + harness.useTarget('build', { ...BASE_OPTIONS, - entryPoints: { - '.': 'projects/lib/src/public-api.ts', - 'secondary': 'projects/lib/secondary/src/public-api.ts', - }, watch: true, }); diff --git a/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts index c7d298e7dc14..f50f49b4a696 100644 --- a/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts +++ b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts @@ -10,13 +10,18 @@ import { executeLibraryBuilder } from '../../builder'; import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { - describe('Option: "entryPoints"', () => { + describe('Package.json "exports" entry points', () => { it('should succeed when entry point is a .ts file', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - entryPoints: { - '.': 'projects/lib/src/public-api.ts', - }, + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should succeed when exports is a string shorthand', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = './src/public-api.ts'; + + return JSON.stringify(pkg, null, 2); }); const { result } = await harness.executeOnce(); @@ -27,12 +32,13 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => await harness.writeFiles({ 'projects/lib/src/public-api.mts': 'export const VALUE = 42;\n', }); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.mts', + }; - harness.useTarget('build', { - ...BASE_OPTIONS, - entryPoints: { - '.': 'projects/lib/src/public-api.mts', - }, + return JSON.stringify(pkg, null, 2); }); const { result } = await harness.executeOnce(); @@ -40,11 +46,13 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }); it('should fail when entry point is not a .ts or .mts file', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - entryPoints: { - '.': 'projects/lib/src/public-api.cts', - }, + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.cts', + }; + + return JSON.stringify(pkg, null, 2); }); const { result, error } = await harness.executeOnce({ @@ -57,11 +65,13 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }); it('should fail when entry point is a declaration file', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - entryPoints: { - '.': 'projects/lib/src/public-api.d.ts', - }, + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.d.ts', + }; + + return JSON.stringify(pkg, null, 2); }); const { result, error } = await harness.executeOnce({ diff --git a/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts b/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts index 676b60c15475..4dc3de98cc87 100644 --- a/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts +++ b/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts @@ -17,6 +17,9 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => JSON.stringify({ name: 'my-lib', version: '1.0.0', + exports: { + '.': './src/public-api.ts', + }, scripts: { postinstall: 'echo postinstall', }, @@ -40,6 +43,9 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => JSON.stringify({ name: 'my-lib', version: '1.0.0', + exports: { + '.': './src/public-api.ts', + }, scripts: { postinstall: 'echo postinstall', }, diff --git a/packages/angular/build/src/builders/library/tests/setup.ts b/packages/angular/build/src/builders/library/tests/setup.ts index 39112b5e8cf0..19c529d2a632 100644 --- a/packages/angular/build/src/builders/library/tests/setup.ts +++ b/packages/angular/build/src/builders/library/tests/setup.ts @@ -21,9 +21,6 @@ export const LIBRARY_BUILDER_INFO = Object.freeze({ }); export const BASE_OPTIONS = Object.freeze({ - entryPoints: { - '.': 'projects/lib/src/public-api.ts', - }, tsConfig: 'projects/lib/tsconfig.lib.json', outputPath: 'dist/lib', poll: 100, @@ -69,6 +66,14 @@ export function describeLibraryBuilder( harness.useTarget('build', BASE_OPTIONS); await libHost.initialize().toPromise(); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); }); afterEach(() => libHost.restore().toPromise()); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts index d7374f7f4b94..d6d604d9f311 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts @@ -17,9 +17,6 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { async () => ({ success: true }), { tsConfig: 'src/tsconfig.lib.json', - entryPoints: { - '.': 'src/public-api.ts', - }, inlineStyleLanguage: 'scss', stylePreprocessorOptions: { includePaths: ['src/styles'], diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts index f76b9c4e9e7b..e19ef884aef5 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts @@ -219,9 +219,6 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { async () => ({ success: true }), { tsConfig: 'src/tsconfig.lib.json', - entryPoints: { - '.': 'src/public-api.ts', - }, }, { builderName: '@angular/build:library', From f62445c214dcd213aec71ea0c244072259ef42ad Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:52:49 +0000 Subject: [PATCH 6/8] fixup! feat(@angular/build): add library builder --- .../build/src/builders/library/builder.ts | 294 +++----- .../build/src/builders/library/options.ts | 47 +- .../src/builders/library/pipeline/assets.ts | 63 +- .../builders/library/pipeline/build-action.ts | 460 ++++++------ .../src/builders/library/pipeline/bundler.ts | 683 ++++++++---------- .../builders/library/pipeline/compilation.ts | 407 +++++------ .../library/pipeline/compiler-worker.ts | 133 ---- .../library/pipeline/entry-point-graph.ts | 340 --------- .../library/pipeline/entry-point-scanner.ts | 256 ------- .../library/pipeline/package-manifests.ts | 27 +- .../pipeline/package-manifests_spec.ts | 160 ++-- .../src/builders/library/pipeline/utils.ts | 34 +- .../build/src/builders/library/schema.json | 2 +- .../library/tests/behavior/secondary_spec.ts | 32 +- .../tests/options/entry-points_spec.ts | 2 +- .../compilation/angular-compilation.ts | 1 + .../angular/compilation/aot-compilation.ts | 38 +- .../angular/compilation/compiler-options.ts | 35 +- .../src/tools/angular/compilation/index.ts | 1 - .../angular/compilation/jit-compilation.ts | 3 +- .../compilation/library-compilation.ts | 451 ------------ .../compilation/parallel-compilation.ts | 2 + .../angular/compilation/parallel-worker.ts | 2 + .../compilation/typescript-compilation.ts | 6 +- 24 files changed, 1051 insertions(+), 2428 deletions(-) delete mode 100644 packages/angular/build/src/builders/library/pipeline/compiler-worker.ts delete mode 100644 packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts delete mode 100644 packages/angular/build/src/builders/library/pipeline/entry-point-scanner.ts delete mode 100644 packages/angular/build/src/tools/angular/compilation/library-compilation.ts diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts index 93d87dd4627f..6eb450879ed7 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -8,10 +8,7 @@ import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; import type { logging } from '@angular-devkit/core'; -import assert from 'node:assert'; import fs from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import type ts from 'typescript'; import { resetSassWorkerPoolCaches, shutdownSassWorkerPool, @@ -19,7 +16,6 @@ import { import { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target'; import { withNoProgress, withSpinner } from '../../tools/esbuild/utils'; import { deleteOutputDir } from '../../utils/delete-output-dir'; -import { maxWorkers } from '../../utils/environment-options'; import { assertIsError } from '../../utils/error'; import { initializeHash } from '../../utils/hash'; import { toPosixPath } from '../../utils/path'; @@ -28,13 +24,12 @@ import { purgeStaleBuildCache } from '../../utils/purge-cache'; import { getSupportedBrowsers } from '../../utils/supported-browsers'; import { assertCompatibleAngularVersion } from '../../utils/version'; import type { BuildWatcher } from '../../utils/watcher'; -import { WorkerPool } from '../../utils/worker-pool'; import { type NormalizedLibraryOptions, type PackageJsonData, normalizeLibraryOptions, } from './options'; -import type { EntryPointGraph, EntryPointNode } from './pipeline/entry-point-graph'; +import type { SingleBuildState } from './pipeline/build-action'; import type { createComponentStylesheetBundlerForLibrary } from './pipeline/stylesheet-bundler'; import type { Schema as LibraryBuilderOptions } from './schema'; @@ -95,33 +90,16 @@ export async function* executeLibraryBuilder( // Dynamically lazy-loaded to prevent importing dependencies at the top level. const [ - { buildAction }, - { buildEntryPointGraph }, + { buildAction, createSingleBuildState, hasModifiedWatchedFile }, { createComponentStylesheetBundlerForLibrary }, ] = await Promise.all([ import('./pipeline/build-action'), - import('./pipeline/entry-point-graph'), import('./pipeline/stylesheet-bundler'), ]); - let graph: EntryPointGraph; - let batches: EntryPointNode[][]; - - try { - const { packageName, entryPoints } = normalizedOptions; - graph = await buildEntryPointGraph(entryPoints.values(), packageName, outputPath); - batches = graph.topologicalSortBatches(); - } catch (error) { - assertIsError(error); - yield { success: false, error: error.message }; - - return; - } - let stylesheetBundler: ReturnType | undefined; - let compilerWorkerPool: WorkerPool | undefined; let watcher: BuildWatcher | undefined; - const sourceFileCache = new Map(); + const buildState = createSingleBuildState(); try { const browsers = getSupportedBrowsers(projectRoot, logger); @@ -132,22 +110,14 @@ export async function* executeLibraryBuilder( target, ); - if (!isWatchMode) { - // TODO: Convert to import.meta usage during ESM transition - const localRequire = createRequire(__filename); - - compilerWorkerPool = new WorkerPool({ - maxThreads: maxWorkers, - idleTimeout: 4_000, - filename: localRequire.resolve('./pipeline/compiler-worker'), - }); - } - // Track all referenced files for watch mode - const allWatchedFiles = new Set([tsConfigPath, packageJsonPath]); + const allWatchedFiles = new Set([ + toPosixPath(tsConfigPath), + toPosixPath(packageJsonPath), + ]); - for (const { entryPoint } of graph.nodes.values()) { - allWatchedFiles.add(entryPoint.entryFilePath); + for (const entryPoint of normalizedOptions.entryPoints.values()) { + allWatchedFiles.add(toPosixPath(entryPoint.entryFilePath)); } if (isWatchMode) { @@ -171,44 +141,21 @@ export async function* executeLibraryBuilder( } // Execute initial build - const startTime = process.hrtime.bigint(); - try { - await withProgress('Building...', () => { - assert(stylesheetBundler); - - return buildAction({ - options: normalizedOptions, - graph, - batches, - stylesheetBundler, - allWatchedFiles, - isWatchMode, - context, - compilerWorkerPool, - target, - signal, - sourceFileCache, - }); - }); - - logBuildResult(logger, startTime, true); - logCumulativeDurations(); - - watcher?.add(Array.from(allWatchedFiles)); - - yield { success: true }; - } catch (error) { - assertIsError(error); - logBuildResult(logger, startTime, false); - - watcher?.add(Array.from(allWatchedFiles)); - - yield { success: false, error: error.message }; - - if (!isWatchMode) { - return; - } - } + const initialResult = await executeBuild( + 'Building...', + { + options: normalizedOptions, + stylesheetBundler, + allWatchedFiles, + isWatchMode, + context, + buildState, + }, + withProgress, + watcher, + buildAction, + ); + yield initialResult; if (!isWatchMode || !watcher) { return; @@ -217,15 +164,14 @@ export async function* executeLibraryBuilder( yield* runWatchLoop( watcher, normalizedOptions, - graph, - batches, stylesheetBundler, allWatchedFiles, context, withProgress, - target, + buildState, + buildAction, + hasModifiedWatchedFile, signal, - sourceFileCache, ); } finally { logCumulativeDurations(); @@ -234,48 +180,58 @@ export async function* executeLibraryBuilder( await Promise.allSettled([ watcher?.close(), stylesheetBundler?.dispose(), - compilerWorkerPool?.destroy(), + buildState.singleProgramCache?.compilationInstance.close?.(), ]); } } +async function executeBuild( + message: string, + actionContext: import('./pipeline/build-action').BuildActionContext, + withProgress: typeof withSpinner, + watcher: BuildWatcher | undefined, + buildAction: typeof import('./pipeline/build-action').buildAction, +): Promise { + const startTime = process.hrtime.bigint(); + const { context, allWatchedFiles, isWatchMode } = actionContext; + + try { + await withProgress(message, () => buildAction(actionContext)); + logBuildResult(context.logger, startTime, true); + if (isWatchMode) { + logCumulativeDurations(); + } + + return { success: true }; + } catch (error) { + assertIsError(error); + logBuildResult(context.logger, startTime, false); + + return { success: false, error: error.message }; + } finally { + watcher?.add(Array.from(allWatchedFiles)); + } +} + /** * Runs the watch loop, rebuilding the library as watched files are modified. - * - * @param watcher The build watcher instance. - * @param options The normalized library options. - * @param graph The entry points dependency graph. - * @param batches The topologically sorted entry point batches. - * @param stylesheetBundler The component stylesheet bundler instance. - * @param allWatchedFiles Set of all watched file paths. - * @param context The architect builder context. - * @param withProgress Function to wrap build actions with progress reporting. - * @param target The esbuild target environments derived from browserslist. - * @param signal Optional abort signal to cancel the watch loop. - * @param sourceFileCache Optional shared cache of TypeScript source files across entry points. - * @returns An async generator yielding builder outputs. */ async function* runWatchLoop( watcher: BuildWatcher, options: NormalizedLibraryOptions, - graph: EntryPointGraph, - batches: EntryPointNode[][], stylesheetBundler: ReturnType, allWatchedFiles: Set, context: BuilderContext, withProgress: typeof withSpinner, - target: string[], + buildState: SingleBuildState, + buildAction: typeof import('./pipeline/build-action').buildAction, + hasModifiedWatchedFile: typeof import('./pipeline/build-action').hasModifiedWatchedFile, signal?: AbortSignal, - sourceFileCache?: Map, ): AsyncIterableIterator { - // Dynamically lazy-loaded to prevent importing dependencies at the top level. - const [{ buildAction }, { checkAssetChanges }] = await Promise.all([ - import('./pipeline/build-action'), - import('./pipeline/assets'), - ]); + const { checkAssetChanges } = await import('./pipeline/assets'); - const { logger } = context; const { workspaceRoot, packageJsonPath, assets, clearScreen } = options; + const posixPackageJsonPath = toPosixPath(packageJsonPath); for await (const changes of watcher) { if (signal?.aborted) { @@ -287,17 +243,34 @@ async function* runWatchLoop( console.clear(); } - const changedFiles = new Set(changes.all.map(toPosixPath)); + const changedFiles = new Set(); + let hasStyleChanges = false; + let hasSassChanges = false; + for (const file of changes.all) { + const posixFile = toPosixPath(file); + changedFiles.add(posixFile); + if (/\.(?:scss|sass)$/i.test(posixFile)) { + hasStyleChanges = true; + hasSassChanges = true; + } else if (/\.(?:less|css)$/i.test(posixFile)) { + hasStyleChanges = true; + } + } - if (sourceFileCache) { - for (const file of changedFiles) { - sourceFileCache.delete(file); + if (hasStyleChanges) { + if (hasSassChanges) { + resetSassWorkerPoolCaches(); + } + const invalidatedStyles = stylesheetBundler.invalidate(changedFiles); + if (invalidatedStyles) { + for (const styleFile of invalidatedStyles) { + changedFiles.add(toPosixPath(styleFile)); + } } } // Check if package.json was modified let hasPackageJsonChanges = false; - const posixPackageJsonPath = toPosixPath(packageJsonPath); if (changedFiles.has(posixPackageJsonPath)) { try { const packageJson = await loadPackageJson(packageJsonPath); @@ -305,6 +278,8 @@ async function* runWatchLoop( hasPackageJsonChanges = true; } catch (error) { assertIsError(error); + await buildState.singleProgramCache?.compilationInstance.update?.(changedFiles); + buildState.hasEmittedManifests = false; yield { success: false, error: `Failed to reload 'package.json': ${error.message}`, @@ -313,98 +288,57 @@ async function* runWatchLoop( } } - const hasNodeChanges = graph.markAffectedNodes(changedFiles); + const hasSourceChanges = + !buildState.singleProgramCache || + Boolean(buildState.hasCompilationError) || + hasModifiedWatchedFile(changedFiles, allWatchedFiles, posixPackageJsonPath); if ( - !hasNodeChanges && + !hasSourceChanges && !hasPackageJsonChanges && !checkAssetChanges(assets, workspaceRoot, changedFiles) ) { continue; } - const hasSassChanges = changes.all.some((f) => /\.(scss|sass|css)$/i.test(f)); - if (hasSassChanges) { - resetSassWorkerPoolCaches(); - } - - stylesheetBundler.invalidate(changedFiles); - - const startTime = process.hrtime.bigint(); - - try { - await withProgress('Changes detected. Rebuilding...', () => - buildAction({ - options, - graph, - batches, - stylesheetBundler, - allWatchedFiles, - isWatchMode: true, - context, - modifiedFiles: changedFiles, - target, - signal, - sourceFileCache, - }), - ); - - logBuildResult(logger, startTime, true); - watcher.add(Array.from(allWatchedFiles)); - - yield { success: true }; - } catch (error) { - assertIsError(error); - logBuildResult(logger, startTime, false); - - watcher.add(Array.from(allWatchedFiles)); - - yield { success: false, error: error.message }; - } + yield await executeBuild( + 'Changes detected. Rebuilding...', + { + options, + stylesheetBundler, + allWatchedFiles, + isWatchMode: true, + context, + buildState, + modifiedFiles: changedFiles, + }, + withProgress, + watcher, + buildAction, + ); } } /** - * Loads and validates the package.json file for the library project. - * - * @param packageJsonPath Path to the package.json file. - * @returns The parsed PackageJsonData. + * Loads and parses a JSON file from disk. */ async function loadPackageJson(packageJsonPath: string): Promise { - let packageJson: PackageJsonData; - try { - const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8'); - packageJson = JSON.parse(packageJsonContent) as PackageJsonData; - } catch (error) { - assertIsError(error); - throw new Error(`Failed to read 'package.json' at '${packageJsonPath}': ${error.message}`, { - cause: error, - }); - } + const content = await fs.readFile(packageJsonPath, 'utf-8'); - const { name: packageName } = packageJson; - if (!packageName) { - throw new Error(`The package.json at '${packageJsonPath}' must contain a 'name'.`); - } - - return packageJson; + return JSON.parse(content) as PackageJsonData; } /** - * Logs the completion or failure message for a library build iteration. - * - * @param logger The builder context logger. - * @param startTime The high-resolution start time of the build iteration. - * @param success Whether the build iteration succeeded. + * Logs the build completion time and status. */ function logBuildResult(logger: logging.LoggerApi, startTime: bigint, success: boolean): void { - const buildDuration = Number(process.hrtime.bigint() - startTime) / 10 ** 9; - const status = success ? 'complete' : 'failed'; - const message = `\nLibrary bundle generation ${status}. [${buildDuration.toFixed(3)} seconds] - ${new Date().toISOString()}\n`; + const durationMs = Number(process.hrtime.bigint() - startTime) / 1_000_000; + const durationSec = (durationMs / 1000).toFixed(2); if (success) { - logger.info(message); + logger.info(`Build at: ${new Date().toISOString()} - Time: ${durationMs.toFixed(0)}ms`); + logger.info(`Built Angular library in ${durationSec}s.`); } else { - logger.error(message); + logger.error(`Build failed after ${durationSec}s.`); } } diff --git a/packages/angular/build/src/builders/library/options.ts b/packages/angular/build/src/builders/library/options.ts index 80cacdf30404..246d1bc05c16 100644 --- a/packages/angular/build/src/builders/library/options.ts +++ b/packages/angular/build/src/builders/library/options.ts @@ -58,6 +58,7 @@ export interface PackageJsonData { scripts?: Record; workspaces?: unknown; dependencies?: Record; + optionalDependencies?: Record; peerDependencies?: Record; peerDependenciesMeta?: Record; [key: string]: unknown; @@ -157,7 +158,7 @@ export async function normalizeLibraryOptions( packageName, ); - const allowedNonPeerDependencies: RegExp[] = []; + const allowedNonPeerDependencies: RegExp[] = [/^tslib$/]; for (const pattern of rawAllowedNonPeerDependencies) { try { allowedNonPeerDependencies.push(new RegExp(pattern)); @@ -186,10 +187,12 @@ export async function normalizeLibraryOptions( }); } - const combinedAssets = [...defaultAssets, ...(rawAssets ?? [])]; - const assets = combinedAssets.length - ? normalizeAssetPatterns(combinedAssets, workspaceRoot, projectRoot, projectSourceRoot) - : []; + const assets = normalizeAssetPatterns( + [...defaultAssets, ...(rawAssets ?? [])], + workspaceRoot, + projectRoot, + projectSourceRoot, + ); const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot); @@ -237,6 +240,8 @@ export async function normalizeLibraryOptions( * 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`). @@ -244,12 +249,12 @@ export async function normalizeLibraryOptions( */ function normalizeEntryPoint( key: string, + posixKey: string, + isPrimary: boolean, targetPath: string, projectRoot: string, packageName: string, ): NormalizedEntryPoint { - const posixKey = toPosixPath(key).replace(/\/+$/, ''); - const isPrimary = posixKey === '.' || posixKey === ''; const name = isPrimary ? '.' : posixKey[0] === '.' && posixKey[1] === '/' @@ -264,11 +269,11 @@ function normalizeEntryPoint( const subpath = isPrimary ? '.' : `./${name}`; const displayName = isPrimary ? packageName : `${packageName}/${name}`; - const bundleName = getEntryPointBundleName(packageName, name, isPrimary); + const bundleName = getEntryPointBundleName(packageName, name); const entryFilePath = path.resolve(projectRoot, targetPath); - if (!/\.(?:ts|mts)$/.test(entryFilePath) || /\.d\.(?:ts|mts)$/.test(entryFilePath)) { + if (!/(? { - const absInput = path.resolve(workspaceRoot, asset.input); - const posixInput = toPosixPath(absInput).replace(/\/+$/, ''); - const isMatch = picomatch(asset.glob, { - dot: true, - ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])], - }); - - return { posixInputPrefix: `${posixInput}/`, isMatch }; - }); + const matchers = createAssetMatchers(assets, workspaceRoot); for (const file of changedFiles) { const resolvedFile = path.isAbsolute(file) ? file : path.resolve(workspaceRoot, file); @@ -98,3 +112,16 @@ export function checkAssetChanges( return false; } + +function createAssetMatchers(assets: NormalizedLibraryOptions['assets'], workspaceRoot: string) { + return assets.map((asset) => { + const absInput = path.resolve(workspaceRoot, asset.input); + const posixInput = toPosixPath(absInput).replace(/\/+$/, ''); + const isMatch = picomatch(asset.glob, { + dot: true, + ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])], + }); + + return { asset, posixInputPrefix: `${posixInput}/`, isMatch }; + }); +} 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 8549e1b8d67d..8c96292d60d3 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -7,319 +7,275 @@ */ import type { BuilderContext } from '@angular-devkit/architect'; -import fs from 'node:fs/promises'; +import { constants, copyFile, mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import type ts from 'typescript'; import { emitFilesToDisk } from '../../../tools/esbuild/utils'; -import { runConcurrent } from '../../../utils/concurrency'; -import { maxWorkers } from '../../../utils/environment-options'; import { toPosixPath } from '../../../utils/path'; -import type { WorkerPool } from '../../../utils/worker-pool'; -import type { NormalizedLibraryOptions, PackageJsonData } from '../options'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; import { collectAssetsToEmit } from './assets'; -import { type BundleResult, bundleEntryPoint } from './bundler'; -import { type CompilationOutput, compileEntryPoint } from './compilation'; -import { compileEntryPointInWorker } from './compiler-worker'; -import { type EntryPointGraph, type EntryPointNode } from './entry-point-graph'; +import { + type BundleEntryPointInput, + type BundleResult, + type EntryPointLookup, + bundleEntryPoints, + createEntryDirectoryLookup, +} from './bundler'; +import { type SingleProgramCache, compileLibrary } from './compilation'; import { generatePackageManifests } from './package-manifests'; import type { createComponentStylesheetBundlerForLibrary } from './stylesheet-bundler'; -import { type OutputFile, getFileText, isDeclarationFile } from './utils'; +import type { OutputFile } from './utils'; /** - * Context object containing all dependencies and state required to execute a build action. + * 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; + entryDirectoryLookup?: EntryPointLookup; + directoryExists: Set; +} + +/** + * Creates a fresh {@link SingleBuildState} instance. + */ +export function createSingleBuildState(): SingleBuildState { + return { + previousBundleResults: new Map(), + pendingChangedEsmFiles: new Set(), + pendingChangedDtsFiles: new Set(), + directoryExists: new Set(), + }; +} + +/** + * Context required to execute a single library build iteration. */ export interface BuildActionContext { options: NormalizedLibraryOptions; - graph: EntryPointGraph; - batches: EntryPointNode[][]; + context: BuilderContext; stylesheetBundler: ReturnType; - allWatchedFiles: Set; isWatchMode: boolean; - context: BuilderContext; - compilerWorkerPool?: WorkerPool; + allWatchedFiles: Set; + buildState: SingleBuildState; modifiedFiles?: Set; - signal?: AbortSignal; - target: string[]; - sourceFileCache?: Map; } /** - * Core build pipeline that executes compilation, bundling, package.json generation, and asset copying. + * Executes a single iteration of the library build pipeline, including + * single-program Angular compilation, parallel typechecking, 2-instance Rolldown bundling, + * manifest generation, and asset copying. * - * @param actionContext The build action context containing options, graph, and dependencies. + * @param actionContext The build action state and configuration. */ export async function buildAction(actionContext: BuildActionContext): Promise { const { options, - graph, - batches, + context, stylesheetBundler, - allWatchedFiles, isWatchMode, - context, - compilerWorkerPool, + allWatchedFiles, + buildState, modifiedFiles, - signal, - target, - sourceFileCache, } = actionContext; - signal?.throwIfAborted?.(); + const posixPackageJsonPath = toPosixPath(options.packageJsonPath); + const { pendingChangedEsmFiles, pendingChangedDtsFiles, directoryExists } = buildState; + + if (!modifiedFiles || modifiedFiles.has(posixPackageJsonPath)) { + buildState.hasEmittedManifests = false; + } + + const shouldCompileEntryPoints = + !modifiedFiles || + !buildState.singleProgramCache || + Boolean(buildState.hasCompilationError) || + pendingChangedEsmFiles.size > 0 || + pendingChangedDtsFiles.size > 0 || + hasModifiedWatchedFile(modifiedFiles, allWatchedFiles, posixPackageJsonPath); + const shouldGenerateManifests = !buildState.hasEmittedManifests; + + if (shouldGenerateManifests) { + verifyAllowedDependencies(options); + } - const { - outputPath, - assets, - workspaceRoot, - packageJson: rawPackageJson, - allowedNonPeerDependencies, - packageJsonPath, - } = options; - - // Validate allowed non-peer dependencies - validateDependencies(rawPackageJson, allowedNonPeerDependencies); - - // Collect cached declaration files across all entry points in the graph. - // This provides in-memory declaration file access for incremental builds. - const upstreamDtsFiles = collectCachedDtsFiles(graph, outputPath); const filesToEmit: OutputFile[] = []; - const successfulBundles: Array<{ node: EntryPointNode; bundleResult: BundleResult }> = []; - // Process batches in topological order. Within each batch, entry points are compiled concurrently up to maxWorkers. - for (const batch of batches) { - signal?.throwIfAborted?.(); + if (shouldCompileEntryPoints) { + buildState.hasCompilationError = true; + + const { + esmFiles, + dtsFiles, + changedEsmFiles, + changedDtsFiles, + referencedFiles, + cache, + diagnosePromise, + } = await compileLibrary( + options.entryPoints.values(), + options, + stylesheetBundler, + buildState.singleProgramCache, + modifiedFiles, + ); + buildState.singleProgramCache = cache; - await runConcurrent(batch, maxWorkers, async (node) => { - signal?.throwIfAborted?.(); + for (const file of referencedFiles) { + allWatchedFiles.add(file); + } - const { entryPoint, isDirty } = node; - if (!isDirty) { - return; - } + for (const file of changedEsmFiles) { + pendingChangedEsmFiles.add(file); + } + for (const file of changedDtsFiles) { + pendingChangedDtsFiles.add(file); + } + + const findEntryPoint = (buildState.entryDirectoryLookup ??= createEntryDirectoryLookup( + options.entryPoints.values(), + )); + const itemsToBundle: BundleEntryPointInput[] = []; - const epStartTime = process.hrtime.bigint(); - const { displayName, entryFilePath } = entryPoint; - - context.logger.info(`Compiling ${displayName}...`); - - try { - let compilation: CompilationOutput; - // In watch mode, compilation runs on the main thread to reuse the in-memory incremental - // program cache (`node.cachedProgram`). TypeScript Program and compiler instances contain - // ASTs, closures, and circular references that cannot be serialized or transferred across - // worker threads via structured clone (`postMessage`). - if (compilerWorkerPool && !isWatchMode) { - compilation = await compileEntryPointInWorker( - compilerWorkerPool, - entryPoint, - options, - target, - graph.upstreamDtsPaths, - upstreamDtsFiles, - modifiedFiles ? Array.from(modifiedFiles) : undefined, - ); - } else { - const result = await compileEntryPoint( - entryPoint, - options, - stylesheetBundler, - graph.upstreamDtsPaths, - node.cachedProgram, - modifiedFiles, - upstreamDtsFiles, - sourceFileCache, - ); - compilation = result.compilation; - node.cachedProgram = result.cachedProgram; - } - - if (compilation.warnings?.length) { - for (const warning of compilation.warnings) { - context.logger.warn(warning); - } - } - - // Track referenced source files for watch mode - node.referencedFiles.clear(); - for (const ref of compilation.referencedFiles) { - node.referencedFiles.add(toPosixPath(ref)); - } - - // Bundle compiled JavaScript and declaration files with Rolldown - const bundleResult = await bundleEntryPoint( + for (const entryPoint of options.entryPoints.values()) { + const previousBundleResult = buildState.previousBundleResults.get(entryPoint.name); + const hasEsmChanges = + !previousBundleResult || + hasEntryPointChanges( + entryPoint, + previousBundleResult.esmModuleIds, + findEntryPoint, + pendingChangedEsmFiles, + ); + const hasDtsChanges = + !previousBundleResult || + hasEntryPointChanges( entryPoint, - compilation, - options, - node.lastBundleResult, + previousBundleResult.dtsModuleIds, + findEntryPoint, + pendingChangedDtsFiles, ); - filesToEmit.push(...bundleResult.filesToEmit); - - for (const file of bundleResult.files) { - if (isDeclarationFile(file.path)) { - const posixPath = toPosixPath(path.join(outputPath, file.path)); - const text = getFileText(file.contents); - upstreamDtsFiles.set(posixPath, text); - if (sourceFileCache && sourceFileCache.get(posixPath)?.text !== text) { - sourceFileCache.delete(posixPath); - } - } - } - - // Invalidate downstream dependents if the public type declarations changed - if (node.lastDtsHash !== bundleResult.dtsHash) { - for (const dependent of node.dependents) { - dependent.isDirty = true; - } - } - node.lastDtsHash = bundleResult.dtsHash; - successfulBundles.push({ node, bundleResult }); - - const epDuration = Number(process.hrtime.bigint() - epStartTime) / 10 ** 9; - context.logger.info(`Compiled ${displayName} [${epDuration.toFixed(3)} seconds]`); - } finally { - // Ensure referenced files are watched even if compilation or bundling fails - for (const ref of node.referencedFiles) { - allWatchedFiles.add(ref); - } - allWatchedFiles.add(entryFilePath); + if (hasEsmChanges || hasDtsChanges) { + context.logger.info(`Compiling ${entryPoint.displayName}...`); + itemsToBundle.push({ + entryPoint, + hasEsmChanges, + hasDtsChanges, + previousBundleResult, + }); } - }); - } - - signal?.throwIfAborted?.(); + } - // Copy assets if configured (collectAssetsToEmit handles incremental filtering in watch mode) - if (assets.length > 0) { - const resolvedAssets = await collectAssetsToEmit( - assets, - workspaceRoot, - allWatchedFiles, - modifiedFiles, - ); + let bundleOutput: Awaited>; + let warnings: string[]; + try { + [bundleOutput, warnings] = await Promise.all([ + bundleEntryPoints(itemsToBundle, esmFiles, dtsFiles, options, findEntryPoint), + diagnosePromise, + ]); + } catch (error) { + // Prioritize TypeScript/Angular diagnostic errors over secondary bundler failures. + await diagnosePromise; + throw error; + } - filesToEmit.push(...resolvedAssets); - } + buildState.hasCompilationError = false; + pendingChangedEsmFiles.clear(); + pendingChangedDtsFiles.clear(); - // Generate package.json and .npmignore files only on initial build or when package.json was modified. - if (!modifiedFiles || modifiedFiles.has(toPosixPath(packageJsonPath))) { - const manifestFiles = await generatePackageManifests(options, graph, isWatchMode); - filesToEmit.push(...manifestFiles); - } + for (const warning of warnings) { + context.logger.warn(warning); + } - // Emit all files (FESM, DTS, sourcemaps, assets, package.json manifests, .npmignore) with a single emitFilesToDisk call - if (filesToEmit.length > 0) { - signal?.throwIfAborted?.(); - await emitOutputsToDisk(outputPath, filesToEmit); + filesToEmit.push(...bundleOutput.filesToEmit); + for (const [name, bundleResult] of bundleOutput.bundleResults) { + buildState.previousBundleResults.set(name, bundleResult); + } } - for (const { node, bundleResult } of successfulBundles) { - node.lastBundleResult = bundleResult; - node.isDirty = false; + if (shouldGenerateManifests) { + filesToEmit.push(...generatePackageManifests(options, isWatchMode)); } -} -async function emitOutputsToDisk( - outputPath: string, - filesToEmit: readonly OutputFile[], -): Promise { - const createdDirectories = new Set(); - const directoryCreationPromises = new Map>(); - - await emitFilesToDisk(filesToEmit, async (file) => { - const isInMemoryFile = file.type === 'memory'; - const dest = path.join(outputPath, isInMemoryFile ? file.path : file.destination); - const destDir = path.dirname(dest); - - if (!createdDirectories.has(destDir)) { - let createPromise = directoryCreationPromises.get(destDir); - if (!createPromise) { - createPromise = fs - .mkdir(destDir, { recursive: true }) - .then(() => { - let current = destDir; - while (current) { - createdDirectories.add(current); - const parent = path.dirname(current); - if (parent === current || createdDirectories.has(parent)) { - break; - } - current = parent; - } - }) - .finally(() => { - directoryCreationPromises.delete(destDir); - }); - - directoryCreationPromises.set(destDir, createPromise); - } - - await createPromise; + filesToEmit.push( + ...(await collectAssetsToEmit( + options.assets, + options.workspaceRoot, + allWatchedFiles, + buildState.hasEmittedAssets ? modifiedFiles : undefined, + )), + ); + + await emitFilesToDisk(filesToEmit, async (file) => { + const fullFilePath = path.join(options.outputPath, file.path); + const fileBasePath = path.dirname(fullFilePath); + if (fileBasePath && !directoryExists.has(fileBasePath)) { + await mkdir(fileBasePath, { recursive: true }); + directoryExists.add(fileBasePath); } - if (isInMemoryFile) { - await fs.writeFile(dest, file.contents); + if (file.type === 'memory') { + await writeFile(fullFilePath, file.contents); } else { - await fs.copyFile(file.source, dest, fs.constants.COPYFILE_FICLONE); + await copyFile(file.source, fullFilePath, constants.COPYFILE_FICLONE); } }); -} - -/** - * Collects bundled declaration files from previous build runs across the graph - * to seed the in-memory declaration file cache for downstream dependency resolution. - * - * @param graph The entry point dependency graph. - * @returns A map of POSIX declaration file paths to their text contents. - */ -function collectCachedDtsFiles(graph: EntryPointGraph, outputPath: string): Map { - const upstreamDtsFiles = new Map(); - for (const node of graph.nodes.values()) { - if (!node.lastBundleResult) { - continue; - } + buildState.hasEmittedManifests = true; + buildState.hasEmittedAssets = true; +} - for (const file of node.lastBundleResult.files) { - if (isDeclarationFile(file.path)) { - upstreamDtsFiles.set( - toPosixPath(path.join(outputPath, file.path)), - getFileText(file.contents), - ); - } +export function hasModifiedWatchedFile( + modifiedFiles: ReadonlySet, + allWatchedFiles: ReadonlySet, + posixPackageJsonPath: string, +): boolean { + for (const file of modifiedFiles) { + if (file !== posixPackageJsonPath && allWatchedFiles.has(file)) { + return true; } } - return upstreamDtsFiles; + return false; } -/** - * Validate that the package.json dependencies only contain allowed dependencies. - * @param pkg The package.json data. - * @param allowedPatterns Array of regex patterns for allowed dependencies. - */ -function validateDependencies(pkg: PackageJsonData, allowedPatterns: RegExp[]): void { - const { dependencies } = pkg; - if (!dependencies) { - return; +function hasEntryPointChanges( + entryPoint: NormalizedEntryPoint, + moduleIds: ReadonlySet, + findEntryPoint: EntryPointLookup, + changedFiles: ReadonlySet, +): boolean { + if (changedFiles.size === 0) { + return false; } - const invalidDeps: string[] = []; - - for (const dep of Object.keys(dependencies)) { - if (dep === 'tslib') { - continue; - } - - const isAllowed = allowedPatterns.some((pattern) => pattern.test(dep)); - if (!isAllowed) { - invalidDeps.push(dep); + for (const file of changedFiles) { + if (moduleIds.has(file) || findEntryPoint(file) === entryPoint) { + return true; } } - if (invalidDeps.length > 0) { - throw new Error( - `Package.json contains dependencies not listed in 'allowedNonPeerDependencies': ${invalidDeps.join(', ')}. ` + - `Third-party dependencies must usually be 'peerDependencies' in Angular libraries.`, - ); + return false; +} + +function verifyAllowedDependencies(options: NormalizedLibraryOptions): void { + const { packageJson, allowedNonPeerDependencies } = options; + const dependencies = { + ...(packageJson.dependencies ?? {}), + ...(packageJson.optionalDependencies ?? {}), + }; + + for (const dep of Object.keys(dependencies)) { + if (!allowedNonPeerDependencies.some((regex) => regex.test(dep))) { + throw new Error( + `Dependency '${dep}' must be explicitly allowed using the 'allowedNonPeerDependencies' option, ` + + `or moved to 'peerDependencies' in 'package.json'.`, + ); + } } } diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts index 2ed1833156f6..9a7ba51e15a3 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -6,478 +6,405 @@ * found in the LICENSE file at https://angular.dev/license */ +import assert from 'node:assert'; import path from 'node:path'; import { + type OutputChunk, type OutputOptions, type Plugin, - type RolldownOptions, + type RolldownOutput, type RolldownPluginOption, rolldown, } from 'rolldown'; import { dts } from 'rolldown-plugin-dts'; -import { calculateHash } from '../../../utils/hash'; import { toPosixPath } from '../../../utils/path'; import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; -import type { CompilationOutput } from './compilation'; import { FESM_OUTPUT_DIR, type MemoryOutputFile, TYPES_OUTPUT_DIR, createMemoryOutputFile, - getFileText, - isDeclarationFile, } from './utils'; /** - * Result of bundling an entry point. + * Cached module ID sets for a bundled entry point. */ export interface BundleResult { - /** Hash of the declaration file content used for downstream invalidation. */ - dtsHash: string; + /** Exact set of virtual ESM module IDs bundled into this entry point. */ + esmModuleIds: ReadonlySet; - /** All current output files for this entry point (chunks, sourcemaps, etc.). */ - files: MemoryOutputFile[]; + /** Exact set of virtual DTS module IDs bundled into this entry point. */ + dtsModuleIds: ReadonlySet; +} - /** Newly generated files that need to be written to disk in this build iteration. */ +export interface BundleEntryPointsOutput { filesToEmit: MemoryOutputFile[]; + bundleResults: Map; +} + +export interface BundleEntryPointInput { + entryPoint: NormalizedEntryPoint; + hasEsmChanges: boolean; + hasDtsChanges: boolean; + previousBundleResult?: BundleResult; } -const ESM_EXTENSIONS = ['.js', '.mjs', '/index.js'] as const; -const DTS_EXTENSIONS = ['.d.ts', '.d.mts', '/index.d.ts'] as const; +const ESM_EXTENSIONS = ['.js', '.mjs', '/index.js', '/index.mjs'] as const; +const DTS_EXTENSIONS = ['.d.ts', '.d.mts', '/index.d.ts', '/index.d.mts'] as const; + +export type EntryPointLookup = (filePath: string) => NormalizedEntryPoint | undefined; + +interface MultiBundleOutput { + filesToEmit: MemoryOutputFile[]; + moduleIdsByBundle: Map>; +} /** - * Bundles the compiled in-memory JavaScript and declaration files for an entry point using Rolldown. - * - * @param entryPoint The normalized entry point being bundled. - * @param compilation The in-memory compilation output containing emitted JavaScript and declaration files. - * @param options The normalized library builder options. - * @param previousBundleResult Optional bundle result from a previous compilation run. - * @returns The bundle result containing file paths and DTS content hash. + * Bundles the compiled in-memory JavaScript and declaration files for all dirty entry points + * using at most 2 Rolldown instances total (1 for all .mjs bundles, 1 for all .d.ts bundles). */ -export async function bundleEntryPoint( - entryPoint: NormalizedEntryPoint, - compilation: CompilationOutput, +export async function bundleEntryPoints( + items: readonly BundleEntryPointInput[], + esmFiles: ReadonlyMap, + dtsFiles: ReadonlyMap, options: NormalizedLibraryOptions, - previousBundleResult?: BundleResult, -): Promise { - const { entryFilePath, bundleName } = entryPoint; - const { preserveSymlinks } = options; - const { esmFiles, dtsFiles, dtsSourcemap, hasDtsChanges, hasEsmChanges } = compilation; - - const entryBase = entryFilePath.replace(/\.m?ts$/, ''); - const jsEntry = entryFilePath.endsWith('.mts') ? `${entryBase}.mjs` : `${entryBase}.js`; - const dtsEntry = entryFilePath.endsWith('.mts') ? `${entryBase}.d.mts` : `${entryBase}.d.ts`; - - const isExternal = createExternalDependencyPredicate(entryPoint, options); - - const [esmResult, dtsResult] = await Promise.all([ - bundleEsm( - jsEntry, - bundleName, - esmFiles, - isExternal, - preserveSymlinks, - hasEsmChanges, - previousBundleResult, - ), - bundleDts( - dtsEntry, - bundleName, - dtsFiles, - dtsSourcemap, - isExternal, - preserveSymlinks, - hasDtsChanges, - previousBundleResult, - ), + findEntryPoint: EntryPointLookup = createEntryDirectoryLookup(options.entryPoints.values()), +): Promise { + const bundleResults = new Map(); + if (items.length === 0) { + return { filesToEmit: [], bundleResults }; + } + + const esmEntryPoints: NormalizedEntryPoint[] = []; + const dtsEntryPoints: NormalizedEntryPoint[] = []; + + for (const item of items) { + if (item.hasEsmChanges || !item.previousBundleResult) { + esmEntryPoints.push(item.entryPoint); + } + if (item.hasDtsChanges || !item.previousBundleResult) { + dtsEntryPoints.push(item.entryPoint); + } + } + + const [esmOutput, dtsOutput] = await Promise.all([ + bundleAllEsm(esmEntryPoints, esmFiles, options, findEntryPoint), + bundleAllDts(dtsEntryPoints, dtsFiles, options, findEntryPoint), ]); + for (const { entryPoint, previousBundleResult } of items) { + const { bundleName, name } = entryPoint; + bundleResults.set(name, { + esmModuleIds: + esmOutput.moduleIdsByBundle.get(bundleName) ?? + previousBundleResult?.esmModuleIds ?? + new Set(), + dtsModuleIds: + dtsOutput.moduleIdsByBundle.get(bundleName) ?? + previousBundleResult?.dtsModuleIds ?? + new Set(), + }); + } + return { - dtsHash: dtsResult.dtsHash, - files: [...esmResult.files, ...dtsResult.files], - filesToEmit: [...esmResult.filesToEmit, ...dtsResult.filesToEmit], + filesToEmit: [...esmOutput.filesToEmit, ...dtsOutput.filesToEmit], + bundleResults, }; } -/** - * Creates an external dependency predicate that prevents relative imports across entry point boundaries. - * - * @param entryPoint The normalized entry point being bundled. - * @param options The normalized library options. - * @returns A predicate function for Rolldown. - */ -function createExternalDependencyPredicate( - entryPoint: NormalizedEntryPoint, - options: NormalizedLibraryOptions, -): (moduleId: string, importer?: string) => boolean { - const { name: epName } = entryPoint; - const { entryPoints } = options; +export function createEntryDirectoryLookup( + entryPoints: Iterable, +): EntryPointLookup { + const dirs = Array.from(entryPoints, (ep) => { + const dir = toPosixPath(path.dirname(ep.entryFilePath)); - const entryPointBases = new Map(); - const entryPointsByDirLength = Array.from(entryPoints.values()) - .map((ep) => { - const epDir = toPosixPath(path.dirname(ep.entryFilePath)); - const epEntryBase = toPosixPath(ep.entryFilePath).replace(/\.(?:d\.)?[cm]?[jt]s$/, ''); - entryPointBases.set(epEntryBase, ep); + return { + ep, + dir, + dirSlash: dir.endsWith('/') ? dir : `${dir}/`, + }; + }).sort((a, b) => b.dir.length - a.dir.length); - return { - ep, - epDir, - epDirSlash: epDir.endsWith('/') ? epDir : `${epDir}/`, - epDirLength: epDir.length, - }; - }) - .sort((a, b) => { - if (b.epDirLength !== a.epDirLength) { - return b.epDirLength - a.epDirLength; - } + const cache = new Map(); - if (a.ep.name === epName) { - return -1; - } + return (filePath: string): NormalizedEntryPoint | undefined => { + const posix = toPosixPath(filePath); + const cached = cache.get(posix); + if (cached !== undefined || cache.has(posix)) { + return cached; + } + const found = dirs.find(({ dir, dirSlash }) => posix === dir || posix.startsWith(dirSlash))?.ep; + cache.set(posix, found); - if (b.ep.name === epName) { - return 1; - } + return found; + }; +} - return 0; - }); +function resolveEntryInputMap( + entryPoints: readonly NormalizedEntryPoint[], + dtsMode: boolean, +): Record { + const input: Record = {}; + for (const { bundleName, entryFilePath } of entryPoints) { + const posixPath = toPosixPath(entryFilePath); + input[bundleName] = dtsMode + ? posixPath.replace(/\.([cm]?ts)$/, '.d.$1') + : posixPath.replace(/\.([cm]?)ts$/, '.$1js'); + } - const predicateCache = new Map(); + return input; +} - return (moduleId: string, importer?: string): boolean => { - if (moduleId[0] === '.' || path.isAbsolute(moduleId)) { - if (importer) { - const cacheKey = `${importer}\0${moduleId}`; - const cached = predicateCache.get(cacheKey); - if (cached !== undefined) { - return cached; +function createMemoryFileLoaderPlugin( + files: ReadonlyMap, + extensions: readonly string[], + includeMap: boolean, + findEntryPoint: EntryPointLookup, +): Plugin { + return { + name: 'memory-file-loader', + resolveId: { + order: 'pre', + handler(id, importer) { + if (id[0] === '\0') { + return undefined; } - const resolved = toPosixPath(path.resolve(path.dirname(importer), moduleId)); - const resolvedBase = resolved.replace(/\.(?:d\.)?[cm]?[jt]s$/, ''); + if (!importer) { + return files.has(id) ? { id, external: false } : undefined; + } + + if (id[0] !== '.' && !path.isAbsolute(id)) { + return { id, external: true }; + } - let owner = entryPointBases.get(resolvedBase); - if (!owner) { - for (const { ep, epDir, epDirSlash } of entryPointsByDirLength) { - if (resolved === epDir || resolved.startsWith(epDirSlash)) { - owner = ep; + const posixId = toPosixPath(id); + const importerPosix = toPosixPath(importer); + const resolved = + posixId[0] === '.' + ? path.posix.join(path.posix.dirname(importerPosix), posixId) + : posixId; + + let resolvedCandidate: string | undefined; + if (files.has(resolved)) { + resolvedCandidate = resolved; + } else { + const base = resolved.replace(/\.[cm]?js$/, ''); + for (const ext of extensions) { + const candidate = base + ext; + if (files.has(candidate)) { + resolvedCandidate = candidate; break; } } } - if (owner && owner.name !== epName) { + const importerEp = findEntryPoint(importerPosix); + const targetEp = findEntryPoint(resolvedCandidate ?? resolved); + if (importerEp && targetEp && importerEp.name !== targetEp.name) { throw new Error( - `Entry point '${epName}' cannot import '${moduleId}' from sibling entry point directly. ` + + `Entry point '${importerEp.name}' cannot import '${id}' from sibling entry point directly. ` + `Import using the entry point package name instead.`, ); } - predicateCache.set(cacheKey, false); + if (resolvedCandidate) { + return { id: resolvedCandidate, external: false }; + } + + return { id, external: true }; + }, + }, + load(id) { + const code = files.get(id); + if (code === undefined) { + return null; } - return false; + return { + code, + map: includeMap ? files.get(`${id}.map`) : undefined, + }; + }, + }; +} + +function resolveChunkBundleName( + findEntryPoint: EntryPointLookup, + moduleIds: readonly string[], +): string | undefined { + for (const modId of moduleIds) { + const ep = findEntryPoint(modId); + if (ep) { + return ep.bundleName; } + } - return true; - }; + return undefined; } -/** - * Creates the Rolldown options shared across ESM and DTS bundling. - * - * @param input Entry file path in memory. - * @param plugins Array of Rolldown plugins. - * @param isExternal Predicate determining if a module specifier is external. - * @param preserveSymlinks Whether to preserve symlinks when resolving dependencies. - * @returns Rolldown options configuration. - */ -function createRolldownOptions( - input: string, +function processRolldownOutput(output: RolldownOutput['output'], dir: string): MultiBundleOutput { + const filesToEmit: MemoryOutputFile[] = []; + const moduleIdsByBundle = new Map>(); + const chunksByFileName = new Map(); + const entryChunks: OutputChunk[] = []; + + for (const item of output) { + filesToEmit.push( + createMemoryOutputFile( + path.posix.join(dir, item.fileName), + item.type === 'chunk' ? item.code : item.source, + ), + ); + + if (item.type === 'chunk') { + chunksByFileName.set(item.fileName, item); + if (item.isEntry) { + entryChunks.push(item); + } + } + } + + for (const entryChunk of entryChunks) { + const modSet = new Set(); + moduleIdsByBundle.set(entryChunk.name, modSet); + + const visited = new Set(); + const queue: OutputChunk[] = [entryChunk]; + + while (queue.length) { + const chunk = queue.pop(); + if (!chunk) { + break; + } + + if (visited.has(chunk)) { + continue; + } + + visited.add(chunk); + + for (const modId of chunk.moduleIds) { + if (modId[0] !== '\0') { + modSet.add(toPosixPath(modId)); + } + } + + for (const depFile of [...chunk.imports, ...chunk.dynamicImports]) { + const depChunk = chunksByFileName.get(depFile); + if (depChunk && !visited.has(depChunk)) { + queue.push(depChunk); + } + } + } + } + + return { filesToEmit, moduleIdsByBundle }; +} + +async function executeMultiBundle( + input: Record, plugins: RolldownPluginOption[], - isExternal: (moduleId: string, parentId?: string) => boolean, preserveSymlinks: boolean, -): RolldownOptions { - return { + extension: 'mjs' | 'd.ts', + sourcemap: boolean, + findEntryPoint: EntryPointLookup, +): Promise { + const isDts = extension === 'd.ts'; + const dir = isDts ? TYPES_OUTPUT_DIR : FESM_OUTPUT_DIR; + const comments: OutputOptions['comments'] = isDts ? false : { legal: true, annotation: true }; + const bundle = await rolldown({ context: 'this', input, - external: isExternal, plugins, - treeshake: false, // APF preserves top-level exports without treeshaking + treeshake: false, resolve: { symlinks: preserveSymlinks }, checks: { circularDependency: false }, experimental: { attachDebugInfo: 'none', }, - }; -} - -interface BundleOutputOptions { - dir: string; - bundleName: string; - extension: 'mjs' | 'd.ts'; - sourcemap: boolean; - comments: OutputOptions['comments']; -} - -/** - * Executes a Rolldown build and generates the output bundle in memory. - * - * @param inputOptions Rolldown input options. - * @param outputOptions Output configuration for generating the bundle. - * @returns An object containing the primary output file path, emitted code, and all generated files. - */ -async function executeBundle( - inputOptions: RolldownOptions, - outputOptions: BundleOutputOptions, -): Promise { - const bundle = await rolldown(inputOptions); + }); try { - const { dir, bundleName, extension, sourcemap, comments } = outputOptions; const { output } = await bundle.generate({ format: 'es', dir, - entryFileNames: `${bundleName}.${extension}`, - chunkFileNames: `${bundleName}-[name]-[hash].${extension}`, + entryFileNames: `[name].${extension}`, + chunkFileNames: (chunk) => { + const bundleName = resolveChunkBundleName(findEntryPoint, chunk.moduleIds); + const prefix = bundleName ? `${bundleName}-` : ''; + + return `${prefix}[name]-[hash].${extension}`; + }, sourcemap, hoistTransitiveImports: false, comments, }); - return output.map((item) => - createMemoryOutputFile( - path.join(dir, item.fileName), - 'code' in item ? item.code : item.source, - ), - ); + return processRolldownOutput(output, dir); } finally { await bundle.close(); } } -/** - * Bundles the compiled in-memory JavaScript into a flattened FESM module. - * - * @param jsEntry Absolute path to the JavaScript entry file in memory. - * @param bundleName Base name of the output bundle. - * @param esmFiles Map of in-memory JavaScript files and sourcemaps. - * @param isExternal Predicate determining if a module specifier is external. - * @param preserveSymlinks Whether to preserve symlinks when resolving dependencies. - * @param hasChanges Whether the compiled JavaScript files changed in this compilation. - * @param previousBundleResult Optional bundle result from a previous compilation run. - * @returns All generated or cached FESM files, and files that need to be emitted to disk. - */ -async function bundleEsm( - jsEntry: string, - bundleName: string, - esmFiles: Map, - isExternal: (moduleId: string, parentId?: string) => boolean, - preserveSymlinks: boolean, - hasChanges: boolean, - previousBundleResult?: BundleResult, -): Promise<{ files: MemoryOutputFile[]; filesToEmit: MemoryOutputFile[] }> { - if (!hasChanges && previousBundleResult) { - // If compiled JavaScript hasn't changed, skip Rolldown bundling and disk writes. - // Preserving previous ESM files maintains a complete file list in BundleResult.files. - return { - files: previousBundleResult.files.filter((f) => f.path.startsWith(FESM_OUTPUT_DIR)), - filesToEmit: [], - }; +async function bundleAllEsm( + entryPoints: readonly NormalizedEntryPoint[], + esmFiles: ReadonlyMap, + options: NormalizedLibraryOptions, + findEntryPoint: EntryPointLookup, +): Promise { + if (entryPoints.length === 0) { + return { filesToEmit: [], moduleIdsByBundle: new Map() }; } - const files = await executeBundle( - createRolldownOptions( - jsEntry, - [createMemoryFileLoaderPlugin(esmFiles, false, true)], - isExternal, - preserveSymlinks, - ), - { - dir: FESM_OUTPUT_DIR, - bundleName, - extension: 'mjs', - sourcemap: true, - comments: { - legal: true, - annotation: true, - }, - }, + return executeMultiBundle( + resolveEntryInputMap(entryPoints, false), + [createMemoryFileLoaderPlugin(esmFiles, ESM_EXTENSIONS, true, findEntryPoint)], + options.preserveSymlinks, + 'mjs', + true, + findEntryPoint, ); - - return { files, filesToEmit: files }; } -/** - * Bundles compiled in-memory declaration files (.d.ts) into a single declaration file. - * - * @param dtsEntry Absolute path to the declaration entry file in memory. - * @param bundleName Base name of the output bundle. - * @param dtsFiles Map of in-memory declaration files and sourcemaps. - * @param dtsSourcemap Whether declaration sourcemaps are enabled. - * @param isExternal Predicate determining if a module specifier is external. - * @param preserveSymlinks Whether to preserve symlinks when resolving dependencies. - * @param hasChanges Whether the compiled declaration files changed in this compilation. - * @param previousBundleResult Optional bundle result from a previous compilation run. - * @returns An object containing the content hash, all generated or cached files, and files to emit. - */ -async function bundleDts( - dtsEntry: string, - bundleName: string, - dtsFiles: Map, - dtsSourcemap: boolean, - isExternal: (moduleId: string, parentId?: string) => boolean, - preserveSymlinks: boolean, - hasChanges: boolean, - previousBundleResult?: BundleResult, -): Promise<{ dtsHash: string; files: MemoryOutputFile[]; filesToEmit: MemoryOutputFile[] }> { - if (!hasChanges && previousBundleResult) { - // If declaration files (.d.ts) haven't changed, skip Rolldown DTS bundling and disk writes. - // Retaining the previous `dtsHash` signals to the build pipeline that downstream dependents - // do not need to be marked dirty or recompiled. - // Crucially, `previousDtsFiles` are preserved in `files` so downstream entry points can continue - // to resolve this entry point's type declarations in memory via `collectUpstreamDts`. - return { - dtsHash: previousBundleResult.dtsHash, - files: previousBundleResult.files.filter((f) => f.path.startsWith(TYPES_OUTPUT_DIR)), - filesToEmit: [], - }; +async function bundleAllDts( + entryPoints: readonly NormalizedEntryPoint[], + dtsFiles: ReadonlyMap, + options: NormalizedLibraryOptions, + findEntryPoint: EntryPointLookup, +): Promise { + if (entryPoints.length === 0) { + return { filesToEmit: [], moduleIdsByBundle: new Map() }; } - const files = await executeBundle( - createRolldownOptions( - dtsEntry, - [ - createMemoryFileLoaderPlugin(dtsFiles, true, dtsSourcemap), - dts({ - dtsInput: true, - tsconfig: false, - generator: 'oxc', - sourcemap: dtsSourcemap, - }), - ], - isExternal, - preserveSymlinks, - ), - { - dir: TYPES_OUTPUT_DIR, - bundleName, - extension: 'd.ts', - sourcemap: dtsSourcemap, - comments: { - legal: true, - jsdoc: true, - }, - }, + const dtsSourcemap = options.declarationMap; + // Filter out `rolldown-plugin-dts:resolver` because all `.d.ts` files are already emitted + // in-memory by the Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`. + // The default `rolldown-plugin-dts:resolver` plugin performs filesystem resolution (`oxc-resolver`) + // and calls `this.load()` on on-disk `.ts` source files, which is unnecessary and causes a + // significant performance regression across multi-entry builds. + const rawDtsPlugins = dts({ + dtsInput: true, + tsconfig: false, + sourcemap: dtsSourcemap, + }); + const dtsPlugins = rawDtsPlugins.filter( + (plugin) => plugin.name !== 'rolldown-plugin-dts:resolver', ); - - // Compute hash from all declaration chunks (excluding sourcemaps) sorted by path for determinism - const dtsFilesOnly = files - .filter((f) => isDeclarationFile(f.path)) - .sort((a, b) => a.path.localeCompare(b.path)); - const dtsHash = - dtsFilesOnly.length > 0 - ? calculateHash(dtsFilesOnly.map(({ contents }) => getFileText(contents)).join('\0')) - : ''; - - return { - dtsHash, - files, - filesToEmit: files, - }; -} - -/** - * Resolves a file specifier against in-memory virtual files. - * - * @param id The import specifier or file path. - * @param importer The path of the importing file, if any. - * @param files Map of virtual files. - * @param extensions Array of candidate extensions to search. - * @returns The resolved virtual file path, or undefined if not found. - */ -function resolveFile( - id: string, - importer: string | undefined, - files: Map, - extensions: readonly string[], -): string | undefined { - if (importer && id[0] !== '.' && id[0] !== '/' && !path.isAbsolute(id)) { - return undefined; - } - - const resolved = toPosixPath( - importer ? path.resolve(path.dirname(importer), id) : path.resolve(id), + assert( + dtsPlugins.length < rawDtsPlugins.length, + 'Expected "rolldown-plugin-dts:resolver" plugin to be present in rolldown-plugin-dts.', ); - if (files.has(resolved)) { - return resolved; - } - const base = resolved.replace(/\.m?js$/, ''); - for (const extension of extensions) { - const candidate = base + extension; - if (files.has(candidate)) { - return candidate; - } - } - - return undefined; -} - -/** - * Creates a Rolldown plugin to load virtual files from in-memory maps. - * - * @param files Map of virtual files and their hashes. - * @param dtsMode Whether the plugin is operating in declaration file mode. - * @param includeMap Whether to include sourcemaps when loading virtual files. - * @returns A Rolldown plugin. - */ -function createMemoryFileLoaderPlugin( - files: Map, - dtsMode: boolean, - includeMap = true, -): Plugin { - const extensions = dtsMode ? DTS_EXTENSIONS : ESM_EXTENSIONS; - const resolutionCache = new Map(); - - return { - name: 'memory-file-loader', - resolveId: (id, importer) => { - const cacheKey = importer ? `${importer}\0${id}` : id; - if (resolutionCache.has(cacheKey)) { - return resolutionCache.get(cacheKey); - } - - const resolved = resolveFile(id, importer, files, extensions); - resolutionCache.set(cacheKey, resolved); - - return resolved; - }, - load: (id) => { - const normalizedId = toPosixPath(id); - let file = files.get(normalizedId); - let fileKey = normalizedId; - - if (file === undefined) { - const dtsMatch = /\.d\.m?ts$/.exec(normalizedId); - const ext = dtsMatch ? dtsMatch[0] : path.extname(normalizedId); - const base = ext.length > 0 ? normalizedId.slice(0, -ext.length) : normalizedId; - const fallback = dtsMode ? `${base}.d.ts` : `${base}.js`; - file = files.get(fallback); - if (file !== undefined) { - fileKey = fallback; - } - } - - if (file === undefined) { - return null; - } - - return { - code: file, - map: includeMap ? files.get(`${fileKey}.map`) : undefined, - }; - }, - }; + return executeMultiBundle( + resolveEntryInputMap(entryPoints, true), + [ + createMemoryFileLoaderPlugin(dtsFiles, DTS_EXTENSIONS, dtsSourcemap, findEntryPoint), + ...dtsPlugins, + ], + options.preserveSymlinks, + 'd.ts', + dtsSourcemap, + findEntryPoint, + ); } diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts index ef48608610ae..0df655cb6f2a 100644 --- a/packages/angular/build/src/builders/library/pipeline/compilation.ts +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -8,191 +8,230 @@ import { type PartialMessage, formatMessages } from 'esbuild'; import { existsSync } from 'node:fs'; -import path from 'node:path'; -import type ts from 'typescript'; -import type { AngularHostOptions } from '../../../tools/angular/angular-host'; -import { LibraryCompilation } from '../../../tools/angular/compilation'; -import { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; +import { + type AngularCompilation, + createAngularCompilation, +} from '../../../tools/angular/compilation'; +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 { isDeclarationFile, isDeclarationSourceMapFile } from './utils'; -const EMITTED_EXTENSIONS = [ - '.js', - '.js.map', - '.mjs', - '.mjs.map', - '.d.ts', - '.d.ts.map', - '.d.mts', - '.d.mts.map', -] as const; +const EMITTED_EXTENSIONS = ['.js', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts']; /** - * Cached compilation instance for incremental rebuilds in watch mode. + * Cached state for the single unified library compilation. */ -export interface CachedProgram { - compilationInstance: LibraryCompilation; - esmFiles: Map; - dtsFiles: Map; +export interface SingleProgramCache { + readonly compilationInstance: AngularCompilation; + readonly esmFiles: Map; + readonly dtsFiles: Map; + readonly failedFiles?: ReadonlySet; } /** - * In-memory compilation output containing emitted JavaScript and declaration files. + * Output of the unified library compilation step. */ -export interface CompilationOutput { - /** Map of emitted JavaScript files and sourcemaps keyed by absolute path. */ - esmFiles: Map; - - /** Map of emitted declaration files and sourcemaps keyed by absolute path. */ - dtsFiles: Map; - - /** Set of all referenced source, template, and stylesheet file paths. */ - referencedFiles: Set; - - /** Whether declaration sourcemaps are enabled. */ - dtsSourcemap: boolean; - - /** Formatted compiler warning diagnostics, if any. */ - warnings?: string[]; - - /** Whether any declaration files were added, modified, or removed in this compilation run. */ - hasDtsChanges: boolean; - - /** Whether any JavaScript or ESM files were added, modified, or removed in this compilation run. */ - hasEsmChanges: boolean; +export interface LibraryCompilationOutput { + readonly esmFiles: ReadonlyMap; + readonly dtsFiles: ReadonlyMap; + readonly changedEsmFiles: ReadonlySet; + readonly changedDtsFiles: ReadonlySet; + readonly referencedFiles: ReadonlySet; + readonly cache: SingleProgramCache; + readonly diagnosePromise: Promise; } /** - * Result of compiling an entry point, including compilation output and updated program cache. + * Compiles all library entry points in a single TypeScript and Angular compilation pass. */ -export interface CompilationResult { - compilation: CompilationOutput; - cachedProgram?: CachedProgram; -} +export async function compileLibrary( + entryPoints: Iterable, + options: NormalizedLibraryOptions, + stylesheetBundler: ComponentStylesheetBundler, + cached?: SingleProgramCache, + modifiedFiles?: Set, +): Promise { + const { tsConfigPath, compilationMode, preserveSymlinks, colors, declarationMap } = options; + + const entryPathsMap: Record = {}; + const rootFiles: string[] = []; + for (const ep of entryPoints) { + entryPathsMap[ep.displayName] = [ep.entryFilePath]; + rootFiles.push(ep.entryFilePath); + } -/** - * Interface representing the stylesheet bundler operations needed during compilation. - */ -export interface StylesheetBundlerAdapter { - bundleFile: ComponentStylesheetBundler['bundleFile']; - bundleInline: ComponentStylesheetBundler['bundleInline']; -} + const compilationInstance = + cached?.compilationInstance ?? (await createAngularCompilation('aot', false)); + + try { + let effectiveModifiedFiles = modifiedFiles; + if (cached?.failedFiles?.size) { + stylesheetBundler.invalidate(cached.failedFiles); + effectiveModifiedFiles = new Set(modifiedFiles); + for (const file of cached.failedFiles) { + effectiveModifiedFiles.add(file); + } + } -export type CompileEntryPointOptions = Pick< - NormalizedLibraryOptions, - | 'tsConfigPath' - | 'compilationMode' - | 'declarationMap' - | 'packageName' - | 'cacheOptions' - | 'inlineStyleLanguage' - | 'preserveSymlinks' - | 'colors' ->; + if (effectiveModifiedFiles && effectiveModifiedFiles.size > 0) { + await compilationInstance.update?.(effectiveModifiedFiles); + } -/** - * Compiles an entry point with the Angular Compiler (Ngtsc) and TypeScript using LibraryCompilation. - * Emits JavaScript and .d.ts files into in-memory maps. - * - * @param entryPoint The normalized entry point to compile. - * @param options The compilation options for this entry point. - * @param stylesheetBundler The component stylesheet bundler instance or adapter. - * @param upstreamDtsPaths Map of upstream entry point names to their emitted .d.ts file paths. - * @param cachedProgram Cached program from a previous compilation run, if available. - * @param modifiedFiles Set of modified file paths for incremental rebuilding in watch mode. - * @returns The compilation result containing in-memory files, referenced file paths, and updated program cache. - */ -export async function compileEntryPoint( - entryPoint: NormalizedEntryPoint, - options: CompileEntryPointOptions, - stylesheetBundler: StylesheetBundlerAdapter, - upstreamDtsPaths: Record, - cachedProgram?: CachedProgram, - modifiedFiles?: Set, - upstreamDtsFiles?: Map, - sourceFileCache?: Map, -): Promise { - const { entryFilePath, bundleName } = entryPoint; - const { - tsConfigPath, - compilationMode, - declarationMap, - cacheOptions, - inlineStyleLanguage, - preserveSymlinks, - colors, - } = options; - const basePath = path.dirname(entryFilePath); + const allReferencedFiles = new Set(); + const stylesheetWarnings: PartialMessage[] = []; + const stylesheetErrors: PartialMessage[] = []; + const failedFiles = new Set(); + + const hostOptions = { + modifiedFiles: effectiveModifiedFiles, + async transformStylesheet( + data: string, + containingFile: string, + stylesheetFile?: string, + ): Promise { + const result = stylesheetFile + ? await stylesheetBundler.bundleFile(stylesheetFile) + : await stylesheetBundler.bundleInline(data, containingFile); + + result.referencedFiles?.forEach((f) => allReferencedFiles.add(toPosixPath(f))); + if (result.warnings.length > 0) { + stylesheetWarnings.push(...result.warnings); + } - const tsBuildInfoFile = cacheOptions.enabled - ? path.join(cacheOptions.path, 'tsbuildinfo', `${bundleName}.tsbuildinfo`) - : undefined; + if (result.errors?.length) { + stylesheetErrors.push(...result.errors); + failedFiles.add(toPosixPath(containingFile)); + if (stylesheetFile) { + failedFiles.add(toPosixPath(stylesheetFile)); + } - const compilationInstance = - cachedProgram?.compilationInstance ?? - new LibraryCompilation({ - entryFilePath, - compilationMode, - declarationMap, - upstreamDtsPaths, - upstreamDtsFiles, - basePath, - tsBuildInfoFile, - sourceFileCache, - }); + return ''; + } - if (cachedProgram) { - compilationInstance.updateLibraryOptions({ upstreamDtsPaths, upstreamDtsFiles }); - } + return result.contents; + }, + processWebWorker: () => '', + }; + + const { referencedFiles } = await compilationInstance.initialize( + tsConfigPath, + hostOptions, + { + sourcemap: true, + preserveSymlinks, + rootFiles, + declarationMap, + compilationMode, + paths: entryPathsMap, + }, + 'library', + ); + + const emittedFiles = + stylesheetErrors.length === 0 ? await compilationInstance.emitAffectedFiles() : []; + const diagnosePromise = runDiagnosticsAndFormat( + compilationInstance, + stylesheetErrors, + stylesheetWarnings, + colors, + ); + + // Prevent unhandled promise rejection if an error occurs before diagnosePromise is awaited. + diagnosePromise.catch(() => {}); + + const esmFiles = cached?.esmFiles ?? new Map(); + const dtsFiles = cached?.dtsFiles ?? new Map(); + const changedEsmFiles = new Set(); + const changedDtsFiles = new Set(); + + for (const ref of referencedFiles) { + allReferencedFiles.add(toPosixPath(ref)); + } - const stylesheetReferencedFiles: string[] = []; - const stylesheetWarnings: PartialMessage[] = []; - const hostOptions: AngularHostOptions = { - modifiedFiles, - transformStylesheet: async (data: string, containingFile: string, stylesheetFile?: string) => { - const result = stylesheetFile - ? await stylesheetBundler.bundleFile(stylesheetFile) - : await stylesheetBundler.bundleInline(data, containingFile, inlineStyleLanguage); + if (effectiveModifiedFiles) { + for (const modifiedFile of effectiveModifiedFiles) { + const posixModified = toPosixPath(modifiedFile); + if (allReferencedFiles.has(posixModified)) { + continue; + } - const { - contents, - referencedFiles: bundleReferencedFiles, - errors: bundleErrors, - warnings: bundleWarnings, - } = result; + const basePathWithoutExt = posixModified.replace(/(?:\.d\.[cm]?ts|\.[cm]?[jt]sx?)$/i, ''); + if (basePathWithoutExt === posixModified || existsSync(posixModified)) { + continue; + } - if (bundleWarnings?.length) { - stylesheetWarnings.push(...bundleWarnings); + for (const ext of EMITTED_EXTENSIONS) { + const outputPath = `${basePathWithoutExt}${ext}`; + const mapPath = `${outputPath}.map`; + if (esmFiles.delete(outputPath)) { + changedEsmFiles.add(outputPath); + } + if (dtsFiles.delete(outputPath)) { + changedDtsFiles.add(outputPath); + } + esmFiles.delete(mapPath); + dtsFiles.delete(mapPath); + } } + } - if (bundleReferencedFiles?.size) { - stylesheetReferencedFiles.push(...bundleReferencedFiles); + for (const { filename, contents } of emittedFiles) { + const normalized = toPosixPath(filename); + if (normalized.endsWith('.map')) { + const isDtsMap = isDeclarationSourceMapFile(normalized); + const targetMap = isDtsMap ? dtsFiles : esmFiles; + if (targetMap.get(normalized) !== contents) { + targetMap.set(normalized, contents); + (isDtsMap ? changedDtsFiles : changedEsmFiles).add(normalized.slice(0, -4)); + } + } else if (isDeclarationFile(normalized)) { + if (dtsFiles.get(normalized) !== contents) { + changedDtsFiles.add(normalized); + dtsFiles.set(normalized, contents); + } + } else if (esmFiles.get(normalized) !== contents) { + changedEsmFiles.add(normalized); + esmFiles.set(normalized, contents); } + } - if (bundleErrors?.length) { - const errorMessages = bundleErrors.map((e) => e.text).join('\n'); - throw new Error( - `Failed to bundle stylesheet in '${stylesheetFile ?? containingFile}':\n${errorMessages}`, - ); - } + return { + esmFiles, + dtsFiles, + changedEsmFiles, + changedDtsFiles, + referencedFiles: allReferencedFiles, + cache: { + compilationInstance, + esmFiles, + dtsFiles, + failedFiles: failedFiles.size > 0 ? failedFiles : undefined, + }, + diagnosePromise, + }; + } catch (error) { + if (!cached) { + await compilationInstance.close?.(); + } + throw error; + } +} - return contents; - }, - processWebWorker: () => '', - }; +async function runDiagnosticsAndFormat( + compilationInstance: AngularCompilation, + stylesheetErrors: PartialMessage[], + stylesheetWarnings: PartialMessage[], + colors: boolean, +): Promise { + if (stylesheetErrors.length > 0) { + const formatted = await formatMessages(stylesheetErrors, { kind: 'error', color: colors }); + throw new Error(`Failed to bundle stylesheet:\n${formatted.join('\n')}`); + } - const { compilerOptions, referencedFiles } = await compilationInstance.initialize( - tsConfigPath, - hostOptions, - { - preserveSymlinks, - cachePath: cacheOptions.enabled ? cacheOptions.path : undefined, - }, - ); + const warningsOut: string[] = []; - let formattedWarnings: string[] | undefined; if (useTypeChecking) { const { errors, warnings } = await compilationInstance.diagnoseFiles(); if (errors?.length) { @@ -201,7 +240,8 @@ export async function compileEntryPoint( } if (warnings?.length) { - formattedWarnings = await formatMessages(warnings, { kind: 'warning', color: colors }); + const formatted = await formatMessages(warnings, { kind: 'warning', color: colors }); + warningsOut.push(...formatted); } } @@ -210,63 +250,8 @@ export async function compileEntryPoint( kind: 'warning', color: colors, }); - - formattedWarnings ??= []; - formattedWarnings.push(...formattedStyleWarnings); + warningsOut.push(...formattedStyleWarnings); } - const emittedFiles = compilationInstance.emitAffectedFiles(); - const esmFiles = new Map(cachedProgram?.esmFiles); - const dtsFiles = new Map(cachedProgram?.dtsFiles); - let hasDtsChanges = !cachedProgram; - let hasEsmChanges = !cachedProgram; - - if (modifiedFiles) { - for (const modifiedFile of modifiedFiles) { - const posixModified = toPosixPath(modifiedFile); - if (existsSync(posixModified)) { - continue; - } - - const basePathWithoutExt = posixModified.replace(/\.[cm]?[jt]sx?$/, ''); - for (const ext of EMITTED_EXTENSIONS) { - const outputPath = `${basePathWithoutExt}${ext}`; - - if (esmFiles.delete(outputPath)) { - hasEsmChanges = true; - } - - if (dtsFiles.delete(outputPath)) { - hasDtsChanges = true; - } - } - } - } - - for (const { filename, contents } of emittedFiles) { - const normalized = toPosixPath(filename); - const isDts = isDeclarationFile(normalized); - const isDtsMap = !isDts && isDeclarationSourceMapFile(normalized); - - if (isDts || isDtsMap) { - hasDtsChanges ||= dtsFiles.get(normalized) !== contents; - dtsFiles.set(normalized, contents); - } else { - hasEsmChanges ||= esmFiles.get(normalized) !== contents; - esmFiles.set(normalized, contents); - } - } - - return { - compilation: { - esmFiles, - dtsFiles, - referencedFiles: new Set([...referencedFiles, ...stylesheetReferencedFiles]), - dtsSourcemap: !!compilerOptions.declarationMap, - warnings: formattedWarnings, - hasDtsChanges, - hasEsmChanges, - }, - cachedProgram: { compilationInstance, esmFiles, dtsFiles }, - }; + return warningsOut; } diff --git a/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts b/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts deleted file mode 100644 index 21c0aabb295b..000000000000 --- a/packages/angular/build/src/builders/library/pipeline/compiler-worker.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @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 { initializeHash } from '../../../utils/hash'; -import { toPosixPath } from '../../../utils/path'; -import type { WorkerPool } from '../../../utils/worker-pool'; -import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; -import { - type CompilationOutput, - type CompileEntryPointOptions, - compileEntryPoint, -} from './compilation'; -import { createComponentStylesheetBundlerForLibrary } from './stylesheet-bundler'; - -export type CompileWorkerOptions = CompileEntryPointOptions & { - workspaceRoot: string; - styleIncludePaths: string[]; - sass?: NormalizedLibraryOptions['sass']; - postcssConfiguration?: NormalizedLibraryOptions['postcssConfiguration']; - tailwindConfiguration?: NormalizedLibraryOptions['tailwindConfiguration']; - target: string[]; -}; - -export interface CompileWorkerRequest { - entryPoint: NormalizedEntryPoint; - options: CompileWorkerOptions; - upstreamDtsPaths: Record; - upstreamDtsFiles?: Map; - modifiedFiles?: string[]; -} - -export type CompileWorkerResponse = CompilationOutput; - -/** - * Compiles a library entry point in a worker thread. - * - * @param request The compilation request payload. - * @returns The serialized compilation output. - */ -export default async function compile( - request: CompileWorkerRequest, -): Promise { - await initializeHash(); - - const { entryPoint, options, upstreamDtsPaths, upstreamDtsFiles, modifiedFiles } = request; - const stylesheetBundler = createComponentStylesheetBundlerForLibrary( - options, - /* incremental */ false, - options.target, - ); - - try { - const { compilation } = await compileEntryPoint( - entryPoint, - options, - stylesheetBundler, - upstreamDtsPaths, - undefined, - modifiedFiles ? new Set(modifiedFiles) : undefined, - upstreamDtsFiles, - ); - - const referencedFiles = new Set(); - for (const file of compilation.referencedFiles) { - referencedFiles.add(toPosixPath(file)); - } - - return { - esmFiles: compilation.esmFiles, - dtsFiles: compilation.dtsFiles, - referencedFiles, - dtsSourcemap: compilation.dtsSourcemap, - warnings: compilation.warnings, - hasDtsChanges: compilation.hasDtsChanges, - hasEsmChanges: compilation.hasEsmChanges, - }; - } finally { - await stylesheetBundler.dispose(); - } -} - -/** - * Compiles an entry point in a worker thread using the provided worker pool. - * - * @param workerPool The worker pool instance. - * @param entryPoint The normalized entry point to compile. - * @param options The normalized library options. - * @param target The esbuild target environments derived from browserslist. - * @param upstreamDtsPaths Map of upstream entry point declaration file paths. - * @param modifiedFiles Optional array of modified file paths for watch mode. - * @returns The compilation output. - */ -export async function compileEntryPointInWorker( - workerPool: WorkerPool, - entryPoint: NormalizedEntryPoint, - options: NormalizedLibraryOptions, - target: string[], - upstreamDtsPaths: Record, - upstreamDtsFiles?: Map, - modifiedFiles?: string[], -): Promise { - const workerOptions: CompileWorkerOptions = { - tsConfigPath: options.tsConfigPath, - compilationMode: options.compilationMode, - declarationMap: options.declarationMap, - packageName: options.packageName, - cacheOptions: options.cacheOptions, - inlineStyleLanguage: options.inlineStyleLanguage, - preserveSymlinks: options.preserveSymlinks, - colors: options.colors, - workspaceRoot: options.workspaceRoot, - styleIncludePaths: options.styleIncludePaths, - sass: options.sass, - postcssConfiguration: options.postcssConfiguration, - tailwindConfiguration: options.tailwindConfiguration, - target, - }; - - const compilationResponse = (await workerPool.run({ - entryPoint, - options: workerOptions, - upstreamDtsPaths, - upstreamDtsFiles, - modifiedFiles, - })) as CompileWorkerResponse; - - return compilationResponse; -} diff --git a/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts b/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts deleted file mode 100644 index 369babaa5200..000000000000 --- a/packages/angular/build/src/builders/library/pipeline/entry-point-graph.ts +++ /dev/null @@ -1,340 +0,0 @@ -/** - * @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 path from 'node:path'; -import { toPosixPath } from '../../../utils/path'; -import type { NormalizedEntryPoint } from '../options'; -import type { BundleResult } from './bundler'; -import type { CachedProgram } from './compilation'; -import type { ScannedFileInfo } from './entry-point-scanner'; -import { TYPES_OUTPUT_DIR } from './utils'; - -/** - * Represents a single entry point node within the compilation dependency graph. - */ -export interface EntryPointNode { - /** The normalized entry point configuration. */ - readonly entryPoint: NormalizedEntryPoint; - - /** Nodes that this entry point directly depends on. */ - readonly dependencies: Set; - - /** Nodes that directly depend on this entry point. */ - readonly dependents: Set; - - /** All source, template, and stylesheet files referenced by this entry point. */ - readonly referencedFiles: Set; - - /** Indicates whether this entry point needs to be recompiled. */ - isDirty: boolean; - - /** The hash of the emitted .d.ts content from the previous compilation. */ - lastDtsHash?: string; - - /** Cached compilation instance for incremental rebuilds in watch mode. */ - cachedProgram?: CachedProgram; - - /** The bundle result from the previous compilation run. */ - lastBundleResult?: BundleResult; -} - -const COMPILATION_EXTENSIONS: ReadonlySet = new Set([ - '.ts', - '.tsx', - '.mts', - '.cts', - '.js', - '.mjs', - '.cjs', - '.html', - '.svg', - '.css', - '.scss', - '.sass', - '.less', -]); - -/** - * Directed Acyclic Graph (DAG) of library entry points. - */ -export class EntryPointGraph { - /** Map of entry point names to their corresponding graph nodes. */ - readonly nodes = new Map(); - - /** Map of entry point module specifiers to target .d.ts paths for compilerOptions.paths. */ - readonly upstreamDtsPaths: Record = {}; - - /** - * Adds a new entry point to the graph. - * - * @param entryPoint The normalized entry point configuration. - * @returns The created EntryPointNode. - */ - addNode(entryPoint: NormalizedEntryPoint): EntryPointNode { - const node: EntryPointNode = { - entryPoint, - dependencies: new Set(), - dependents: new Set(), - referencedFiles: new Set(), - isDirty: true, - }; - this.nodes.set(entryPoint.name, node); - - return node; - } - - /** - * Adds a directed dependency edge from one entry point to another. - * - * @param fromName The dependent entry point name. - * @param toName The dependency entry point name. - */ - addDependency(fromName: string, toName: string): void { - const fromNode = this.nodes.get(fromName); - const toNode = this.nodes.get(toName); - - if (!fromNode || !toNode) { - throw new Error(`Invalid dependency edge: ${fromName} -> ${toName}`); - } - - fromNode.dependencies.add(toNode); - toNode.dependents.add(fromNode); - } - - /** - * Topologically sorts entry points into concurrent execution batches using Kahn's Algorithm. - * Entry points within the same batch have zero interdependencies and can be compiled in parallel. - * - * @returns An array of batches, where each batch contains independent entry points. - */ - topologicalSortBatches(): EntryPointNode[][] { - const inDegree = new Map(); - let currentBatch: EntryPointNode[] = []; - - for (const node of this.nodes.values()) { - const degree = node.dependencies.size; - inDegree.set(node, degree); - if (degree === 0) { - currentBatch.push(node); - } - } - - const batches: EntryPointNode[][] = []; - let processedCount = 0; - - while (currentBatch.length > 0) { - batches.push(currentBatch); - processedCount += currentBatch.length; - - const nextBatch: EntryPointNode[] = []; - for (const current of currentBatch) { - for (const dependent of current.dependents) { - const remaining = (inDegree.get(dependent) ?? 0) - 1; - inDegree.set(dependent, remaining); - if (remaining === 0) { - nextBatch.push(dependent); - } - } - } - - currentBatch = nextBatch; - } - - if (processedCount !== this.nodes.size) { - const cyclePath = findCyclePath(this.nodes.values(), inDegree); - throw new Error(`Circular dependency detected between entry points: ${cyclePath}`); - } - - return batches; - } - - private cachedNodeMeta?: Array<{ - node: EntryPointNode; - entryFile: string; - dirWithSep: string; - }>; - - private getNodeMeta() { - this.cachedNodeMeta ??= Array.from(this.nodes.values()) - .map((node) => { - const { entryFilePath } = node.entryPoint; - const nodeDir = toPosixPath(path.dirname(entryFilePath)); - - return { - node, - entryFile: toPosixPath(entryFilePath), - dirWithSep: nodeDir.endsWith('/') ? nodeDir : `${nodeDir}/`, - }; - }) - .sort((a, b) => b.dirWithSep.length - a.dirWithSep.length); - - return this.cachedNodeMeta; - } - - /** - * Identifies and marks dirty any graph nodes whose source files or referenced files have changed. - * - * @param changedFiles Array of changed file paths. - * @returns True if at least one entry point was affected. - */ - markAffectedNodes(changedFiles: ReadonlySet): boolean { - let hasChanges = false; - const nodeMeta = this.getNodeMeta(); - - for (const file of changedFiles) { - let matched = false; - - for (const { node, entryFile } of nodeMeta) { - if (file === entryFile || node.referencedFiles.has(file)) { - node.isDirty = true; - hasChanges = true; - matched = true; - } - } - - if (matched) { - continue; - } - - const ext = path.posix.extname(file); - if (!COMPILATION_EXTENSIONS.has(ext) || /\.(spec|test)\.[mc]?[jt]sx?$/i.test(file)) { - continue; - } - - for (const { node, dirWithSep } of nodeMeta) { - if (file.startsWith(dirWithSep)) { - node.isDirty = true; - hasChanges = true; - break; - } - } - } - - return hasChanges; - } -} - -/** - * Traces a cycle path through the given nodes for diagnostic reporting using 3-color DFS. - * - * @param nodes All entry point nodes. - * @param inDegree The in-degree map from Kahn's algorithm. - * @returns Formatted cycle path string (e.g. 'A -> B -> A'). - */ -function findCyclePath( - nodes: Iterable, - inDegree: Map, -): string { - const cyclicCandidates = new Set(); - for (const node of nodes) { - if ((inDegree.get(node) ?? 0) > 0) { - cyclicCandidates.add(node); - } - } - - const visiting = new Set(); - const visited = new Set(); - const pathStack: EntryPointNode[] = []; - - function dfs(current: EntryPointNode): EntryPointNode[] | undefined { - visiting.add(current); - pathStack.push(current); - - for (const dep of current.dependencies) { - if (!cyclicCandidates.has(dep)) { - continue; - } - - if (visiting.has(dep)) { - const cycleStartIndex = pathStack.indexOf(dep); - - return [...pathStack.slice(cycleStartIndex), dep]; - } - - if (!visited.has(dep)) { - const result = dfs(dep); - if (result) { - return result; - } - } - } - - pathStack.pop(); - visiting.delete(current); - visited.add(current); - - return undefined; - } - - for (const node of cyclicCandidates) { - if (!visited.has(node)) { - const cycle = dfs(node); - if (cycle) { - return cycle.map((n) => n.entryPoint.name).join(' -> '); - } - } - } - - return Array.from(cyclicCandidates) - .map((n) => n.entryPoint.name) - .join(' -> '); -} - -/** - * Builds the entry points DAG by analyzing imports across all entry points concurrently. - * - * @param entryPoints The normalized library entry points. - * @param packageName The root package name (e.g. `@my/lib`). - * @returns A promise resolving to the populated EntryPointGraph. - */ -export async function buildEntryPointGraph( - entryPoints: Iterable, - packageName: string, - outputPath: string, -): Promise { - const { scanEntryPointDependencies } = await import('./entry-point-scanner'); - const graph = new EntryPointGraph(); - - for (const entryPoint of entryPoints) { - graph.addNode(entryPoint); - - const { displayName, bundleName } = entryPoint; - graph.upstreamDtsPaths[displayName] = [ - toPosixPath(path.join(outputPath, TYPES_OUTPUT_DIR, `${bundleName}.d.ts`)), - ]; - } - - const fileCache = new Map>(); - const resolutionCache = new Map>(); - const directoryCache = new Map>>(); - - // Analyze source files of all entry points concurrently - await Promise.all( - Array.from(graph.nodes.values(), async ({ entryPoint }) => { - const dependencies = await scanEntryPointDependencies( - entryPoint, - packageName, - fileCache, - resolutionCache, - directoryCache, - ); - - for (const dep of dependencies) { - if (!graph.nodes.has(dep)) { - throw new Error( - `Entry point '${dep}' imported by '${entryPoint.name}' does not exist in 'package.json' exports.`, - ); - } - - graph.addDependency(entryPoint.name, dep); - } - }), - ); - - return graph; -} diff --git a/packages/angular/build/src/builders/library/pipeline/entry-point-scanner.ts b/packages/angular/build/src/builders/library/pipeline/entry-point-scanner.ts deleted file mode 100644 index 8c0119a7ce03..000000000000 --- a/packages/angular/build/src/builders/library/pipeline/entry-point-scanner.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * @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 fs from 'node:fs/promises'; -import path from 'node:path'; -import ts from 'typescript'; -import type { NormalizedEntryPoint } from '../options'; - -const FILE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.d.ts', '.d.mts', '.d.cts'] as const; -const INDEX_FILES = ['index.ts', 'index.tsx', 'index.mts', 'index.cts', 'index.d.ts'] as const; - -export interface ScannedFileInfo { - readonly packageImports: readonly string[]; - readonly relativeDependencies: readonly string[]; -} - -/** - * Retrieves the directory entries for a given directory, cached in a Map. - */ -function getDirectoryEntries( - dir: string, - directoryCache: Map>>, -): Promise> { - let entriesPromise = directoryCache.get(dir); - if (!entriesPromise) { - entriesPromise = fs - .readdir(dir) - .then((entries) => new Set(entries)) - .catch(() => new Set()); - - directoryCache.set(dir, entriesPromise); - } - - return entriesPromise; -} - -/** - * Resolves a relative module import specifier to an existing TypeScript candidate file on disk. - * - * @param dir Directory of the containing file. - * @param fileName Relative module specifier. - * @param resolutionCache Cache of in-flight and resolved module candidate paths. - * @param directoryCache Cache of directory entries to avoid repeated filesystem accesses. - * @returns Absolute path to candidate file if found, otherwise undefined. - */ -async function resolveCandidate( - dir: string, - fileName: string, - resolutionCache: Map>, - directoryCache: Map>>, -): Promise { - if (/\.[mc]?tsx?$/.test(fileName)) { - return path.resolve(dir, fileName); - } - - const basePath = path.resolve(dir, fileName.replace(/\.[mc]?js$/, '')); - let resolvePromise = resolutionCache.get(basePath); - if (resolvePromise) { - return resolvePromise; - } - - resolvePromise = (async () => { - const parentDir = path.dirname(basePath); - const baseName = path.basename(basePath); - const parentEntries = await getDirectoryEntries(parentDir, directoryCache); - - for (const ext of FILE_EXTENSIONS) { - const candidateName = baseName + ext; - if (parentEntries.has(candidateName)) { - return path.join(parentDir, candidateName); - } - } - - if (parentEntries.has(baseName)) { - const subDirEntries = await getDirectoryEntries(basePath, directoryCache); - for (const indexFile of INDEX_FILES) { - if (subDirEntries.has(indexFile)) { - return path.join(basePath, indexFile); - } - } - } - - return undefined; - })(); - - resolutionCache.set(basePath, resolvePromise); - - return resolvePromise; -} - -/** - * Reads a TypeScript file, extracts its module imports via preProcessFile, - * and resolves its relative dependencies. - */ -async function scanFile( - filePath: string, - resolutionCache: Map>, - directoryCache: Map>>, -): Promise { - let content: string; - try { - content = await fs.readFile(filePath, 'utf8'); - } catch { - return undefined; - } - - if (!content.includes('import') && !content.includes('export') && !content.includes('///')) { - return { packageImports: [], relativeDependencies: [] }; - } - - const { importedFiles, typeReferenceDirectives, referencedFiles } = ts.preProcessFile( - content, - true, - false, - ); - - const dir = path.dirname(filePath); - const packageImports = new Set(); - const relativeImports = new Set(); - - for (const { fileName } of [...importedFiles, ...typeReferenceDirectives, ...referencedFiles]) { - if (fileName[0] === '.') { - relativeImports.add(fileName); - } else { - packageImports.add(fileName); - } - } - - const relativeCandidates = await Promise.all( - Array.from(relativeImports, (rel) => - resolveCandidate(dir, rel, resolutionCache, directoryCache), - ), - ); - - const relativeDependencies: string[] = []; - for (const candidate of relativeCandidates) { - if (candidate !== undefined) { - relativeDependencies.push(candidate); - } - } - - return { packageImports: Array.from(packageImports), relativeDependencies }; -} - -/** - * Retrieves scanned file info with promise-level caching to avoid reading - * or preprocessing the same file multiple times. - */ -function getScannedFileInfo( - filePath: string, - fileCache: Map>, - resolutionCache: Map>, - directoryCache: Map>>, -): Promise { - let scanPromise = fileCache.get(filePath); - if (!scanPromise) { - scanPromise = scanFile(filePath, resolutionCache, directoryCache); - fileCache.set(filePath, scanPromise); - } - - return scanPromise; -} - -/** - * Recursively traverses a TypeScript file and its relative dependencies, - * invoking a callback for every external or sibling package import found. - * - * @param filePath Absolute path to the file being scanned. - * @param visited Set of already visited file paths to prevent infinite recursion. - * @param fileCache Cache of preprocessed file imports and dependencies. - * @param resolutionCache Cache of module candidate resolutions. - * @param onImport Callback invoked for each encountered module import specifier. - * @param directoryCache Optional cache of directory entries. - */ -export async function scanImports( - filePath: string, - visited: Set, - fileCache: Map>, - resolutionCache: Map>, - onImport: (importPath: string) => void, - directoryCache = new Map>>(), -): Promise { - if (visited.has(filePath)) { - return; - } - - visited.add(filePath); - - const fileInfo = await getScannedFileInfo(filePath, fileCache, resolutionCache, directoryCache); - if (!fileInfo) { - return; - } - - for (const importPath of fileInfo.packageImports) { - onImport(importPath); - } - - await Promise.all( - fileInfo.relativeDependencies.map((depPath) => - scanImports(depPath, visited, fileCache, resolutionCache, onImport, directoryCache), - ), - ); -} - -/** - * Scans an entry point's source files and returns all referenced sibling entry point names. - * - * @param entryPoint The normalized entry point to scan. - * @param packageName The root package name (e.g. `@my/lib`). - * @param fileCache Cache of preprocessed file imports and dependencies. - * @param resolutionCache Cache of module candidate resolutions. - * @param directoryCache Cache of directory entries. - * @returns An array of sibling entry point names referenced by this entry point. - */ -export async function scanEntryPointDependencies( - entryPoint: NormalizedEntryPoint, - packageName: string, - fileCache: Map>, - resolutionCache: Map>, - directoryCache: Map>>, -): Promise { - const { name: epName, entryFilePath, isPrimary } = entryPoint; - const visitedFiles = new Set(); - const siblingDependencies: string[] = []; - - await scanImports( - entryFilePath, - visitedFiles, - fileCache, - resolutionCache, - (importPath) => { - if (importPath === packageName) { - if (isPrimary) { - throw new Error(`Entry point '.' has a circular dependency on itself.`); - } - - siblingDependencies.push('.'); - } else if (importPath.startsWith(`${packageName}/`)) { - const subpath = importPath.slice(packageName.length + 1); - if (subpath === epName) { - throw new Error(`Entry point '${epName}' has a circular dependency on itself.`); - } - - siblingDependencies.push(subpath); - } - }, - directoryCache, - ); - - return siblingDependencies; -} 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 2659c85c6dfa..8b5c7708bcec 100644 --- a/packages/angular/build/src/builders/library/pipeline/package-manifests.ts +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts @@ -8,7 +8,6 @@ import path from 'node:path'; import type { NormalizedLibraryOptions, PackageJsonData } from '../options'; -import type { EntryPointGraph } from './entry-point-graph'; import { FESM_OUTPUT_DIR, type MemoryOutputFile, @@ -20,15 +19,13 @@ import { * Generates the APF package.json and secondary entry point package.json manifests. * * @param options The normalized library options. - * @param graph The entry points dependency graph. * @param isWatchMode Whether the builder is running in watch mode. * @returns An array of memory output files containing generated package manifests and .npmignore. */ -export async function generatePackageManifests( +export function generatePackageManifests( options: NormalizedLibraryOptions, - graph: EntryPointGraph, isWatchMode: boolean, -): Promise { +): MemoryOutputFile[] { const { packageJson: rawPackageJson, keepLifecycleScripts, compilationMode } = options; const { @@ -42,16 +39,18 @@ export async function generatePackageManifests( } = rawPackageJson; const exportsMap: Record = { - ...(typeof userExports === 'object' && userExports !== null ? userExports : {}), + ...(typeof userExports === 'object' && userExports !== null && !Array.isArray(userExports) + ? userExports + : {}), './package.json': { default: './package.json' }, }; - const primaryNode = graph.nodes.get('.'); - if (!primaryNode) { - throw new Error(`Primary entry point '.' was not found in the graph.`); + const primaryEntryPoint = options.entryPoints.get('.'); + if (!primaryEntryPoint) { + throw new Error(`Primary entry point '.' was not found in entryPoints.`); } - const primaryName = primaryNode.entryPoint.bundleName; + const primaryName = primaryEntryPoint.bundleName; // Configure primary entry point const primaryFesm = `./${FESM_OUTPUT_DIR}/${primaryName}.mjs`; @@ -96,7 +95,7 @@ export async function generatePackageManifests( const nestedPackageJsonDirs: string[] = []; const filesToEmit: MemoryOutputFile[] = []; - for (const { entryPoint } of graph.nodes.values()) { + for (const entryPoint of options.entryPoints.values()) { if (entryPoint.isPrimary) { continue; } @@ -126,7 +125,7 @@ export async function generatePackageManifests( ); } - // Write or append to .npmignore to prevent publishing nested secondary package.json files + // Write .npmignore to prevent publishing nested secondary package.json files if (nestedPackageJsonDirs.length > 0) { const entryPointsJsonPaths = nestedPackageJsonDirs.map((d) => `/${d}/package.json`); @@ -153,7 +152,9 @@ function createExportConditions( fesmPath: string, ): Record { const existing = - typeof existingConditions === 'object' && existingConditions !== null + typeof existingConditions === 'object' && + existingConditions !== null && + !Array.isArray(existingConditions) ? (existingConditions as Record) : {}; 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 62f11523cd80..d5bc3a60ab0b 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 @@ -7,25 +7,54 @@ */ import assert from 'node:assert'; -import { mkdtemp, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import type { NormalizedLibraryOptions, PackageJsonData } from '../options'; -import { EntryPointGraph } from './entry-point-graph'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions, PackageJsonData } from '../options'; import { generatePackageManifests } from './package-manifests'; -import { type MemoryOutputFile, getEntryPointBundleName, getFileText } from './utils'; +import { type MemoryOutputFile, getEntryPointBundleName } from './utils'; describe('generatePackageManifests', () => { - let tempDir: string; + const tempDir = '/workspace/my-lib'; function getRootPackageJson(files: MemoryOutputFile[]): PackageJsonData { const file = files.find((f) => f.path === 'package.json'); assert(file, 'package.json must be present in emitted files'); - return JSON.parse(getFileText(file.contents)) as PackageJsonData; + return JSON.parse(String(file.contents)) as PackageJsonData; + } + + function createEntryPoints( + packageName = 'my-lib', + includeSecondary = false, + ): Map { + const entryPoints = new Map(); + const primaryBundleName = getEntryPointBundleName(packageName); + entryPoints.set('.', { + subpath: '.', + name: '.', + displayName: packageName, + bundleName: primaryBundleName, + entryFilePath: join(tempDir, 'src/public-api.ts'), + isPrimary: true, + }); + + if (includeSecondary) { + const secondaryBundleName = getEntryPointBundleName(packageName, 'testing'); + entryPoints.set('testing', { + subpath: './testing', + name: 'testing', + displayName: `${packageName}/testing`, + bundleName: secondaryBundleName, + entryFilePath: join(tempDir, 'testing/src/public-api.ts'), + isPrimary: false, + }); + } + + return entryPoints; } function createOptions( overrides: Partial = {}, + includeSecondary = false, ): NormalizedLibraryOptions { const packageName = (overrides.packageJson?.name as string | undefined) ?? overrides.packageName ?? 'my-lib'; @@ -41,7 +70,7 @@ describe('generatePackageManifests', () => { deleteOutputPath: true, packageJsonPath: join(tempDir, 'package.json'), tsConfigPath: join(tempDir, 'tsconfig.lib.json'), - entryPoints: new Map(), + entryPoints: overrides.entryPoints ?? createEntryPoints(packageName, includeSecondary), inlineStyleLanguage: 'css', styleIncludePaths: [], assets: [], @@ -63,58 +92,7 @@ describe('generatePackageManifests', () => { }; } - function createGraph( - packageNameOrOptions?: NormalizedLibraryOptions | string | boolean, - includeSecondary = false, - ): EntryPointGraph { - let packageName = 'my-lib'; - let secondary = includeSecondary; - - if (typeof packageNameOrOptions === 'boolean') { - secondary = packageNameOrOptions; - } else if (typeof packageNameOrOptions === 'string') { - packageName = packageNameOrOptions; - } else if (packageNameOrOptions && typeof packageNameOrOptions === 'object') { - packageName = packageNameOrOptions.packageName; - } - - const primaryBundleName = getEntryPointBundleName(packageName, '', true); - const graph = new EntryPointGraph(); - graph.addNode({ - subpath: '.', - name: '.', - displayName: packageName, - bundleName: primaryBundleName, - entryFilePath: join(tempDir, 'src/public-api.ts'), - isPrimary: true, - }); - - if (secondary) { - const secondaryBundleName = getEntryPointBundleName(packageName, 'testing', false); - graph.addNode({ - subpath: './testing', - name: 'testing', - displayName: `${packageName}/testing`, - bundleName: secondaryBundleName, - entryFilePath: join(tempDir, 'testing/src/public-api.ts'), - isPrimary: false, - }); - } - - return graph; - } - - beforeEach(async () => { - const TMP_DIR = process.env['TEST_TMPDIR']; - assert(TMP_DIR, 'TEST_TMPDIR must be set'); - tempDir = await mkdtemp(join(TMP_DIR, 'pkg-json-spec-')); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - it('should generate a valid APF package.json for an unscoped package', async () => { + it('should generate a valid APF package.json for an unscoped package', () => { const options = createOptions({ packageJson: { name: 'my-lib', @@ -128,9 +106,8 @@ describe('generatePackageManifests', () => { }, }, }); - const graph = createGraph(); - const files = await generatePackageManifests(options, graph, false); + const files = generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result).toEqual({ @@ -153,16 +130,15 @@ describe('generatePackageManifests', () => { }); }); - it('should sanitize scoped package names in fesm and types paths', async () => { + it('should sanitize scoped package names in fesm and types paths', () => { const options = createOptions({ packageJson: { name: '@my-scope/my-lib', version: '2.1.0', }, }); - const graph = createGraph(options); - const files = await generatePackageManifests(options, graph, false); + const files = generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result).toEqual( @@ -181,7 +157,7 @@ describe('generatePackageManifests', () => { ); }); - it('should retain scripts when keepLifecycleScripts is true', async () => { + it('should retain scripts when keepLifecycleScripts is true', () => { const options = createOptions({ keepLifecycleScripts: true, packageJson: { @@ -192,23 +168,24 @@ describe('generatePackageManifests', () => { }, }, }); - const graph = createGraph(); - const files = await generatePackageManifests(options, graph, false); + const files = generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result.scripts).toEqual({ postinstall: 'echo done' }); }); - it('should configure secondary entry points and create secondary manifests', async () => { - const options = createOptions({ - packageJson: { - name: '@my-scope/my-lib', - version: '1.0.0', + it('should configure secondary entry points and create secondary manifests', () => { + const options = createOptions( + { + packageJson: { + name: '@my-scope/my-lib', + version: '1.0.0', + }, }, - }); - const graph = createGraph(options, true); + true, + ); - const files = await generatePackageManifests(options, graph, false); + const files = generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result.exports).toEqual( @@ -221,7 +198,7 @@ describe('generatePackageManifests', () => { ); const secondaryPkgFile = files.find((f) => f.path === 'testing/package.json'); - const secondaryPkg = JSON.parse(getFileText(secondaryPkgFile?.contents ?? '')); + const secondaryPkg = JSON.parse(String(secondaryPkgFile?.contents ?? '')); expect(secondaryPkg).toEqual({ module: '../fesm2022/my-scope-my-lib-testing.mjs', typings: '../types/my-scope-my-lib-testing.d.ts', @@ -232,35 +209,34 @@ describe('generatePackageManifests', () => { expect(npmignoreFile?.contents).toContain('/testing/package.json'); }); - it('should inject watch version when isWatchMode is true', async () => { + it('should inject watch version when isWatchMode is true', () => { const options = createOptions({ packageJson: { name: 'my-lib', version: '1.0.0', }, }); - const graph = createGraph(); - const files = await generatePackageManifests(options, graph, true); + const files = generatePackageManifests(options, true); const result = getRootPackageJson(files); expect(result.version).toMatch(/^0\.0\.0-watch\+\d+$/); }); - it('should throw an error if primary entry point is missing from graph', async () => { + it('should throw an error if primary entry point is missing', () => { const options = createOptions({ packageJson: { name: 'my-lib', version: '1.0.0', }, + entryPoints: new Map(), }); - const graph = new EntryPointGraph(); // No primary node - await expectAsync(generatePackageManifests(options, graph, false)).toBeRejectedWithError( - /Primary entry point '\.' was not found in the graph\./, + expect(() => generatePackageManifests(options, false)).toThrowError( + /Primary entry point '\.' was not found in entryPoints\./, ); }); - it('should inject prepublishOnly guard script when compilationMode is full', async () => { + it('should inject prepublishOnly guard script when compilationMode is full', () => { const options = createOptions({ compilationMode: 'full', packageJson: { @@ -268,16 +244,15 @@ describe('generatePackageManifests', () => { version: '1.0.0', }, }); - const graph = createGraph(); - const files = await generatePackageManifests(options, graph, false); + const files = generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result.scripts?.['prepublishOnly']).toContain( 'Trying to publish a package that has been compiled in full compilation mode', ); }); - it('should preserve custom user exports in package.json and merge subpath conditions', async () => { + it('should preserve custom user exports in package.json and merge subpath conditions', () => { const options = createOptions({ packageJson: { name: 'my-lib', @@ -291,9 +266,8 @@ describe('generatePackageManifests', () => { }, }, }); - const graph = createGraph(); - const files = await generatePackageManifests(options, graph, false); + const files = generatePackageManifests(options, false); const result = getRootPackageJson(files); expect(result.exports).toEqual({ @@ -308,20 +282,19 @@ describe('generatePackageManifests', () => { }); }); - it('should default sideEffects to false if not specified, and preserve when set', async () => { - const files1 = await generatePackageManifests( + it('should default sideEffects to false if not specified, and preserve when set', () => { + const files1 = generatePackageManifests( createOptions({ packageJson: { name: 'my-lib', version: '1.0.0', }, }), - createGraph(), false, ); expect(getRootPackageJson(files1).sideEffects).toBeFalse(); - const files2 = await generatePackageManifests( + const files2 = generatePackageManifests( createOptions({ packageJson: { name: 'my-lib', @@ -329,7 +302,6 @@ describe('generatePackageManifests', () => { sideEffects: ['*.css'], }, }), - createGraph(), false, ); expect(getRootPackageJson(files2).sideEffects).toEqual(['*.css']); diff --git a/packages/angular/build/src/builders/library/pipeline/utils.ts b/packages/angular/build/src/builders/library/pipeline/utils.ts index 8513a9a6207d..b07f0764f48b 100644 --- a/packages/angular/build/src/builders/library/pipeline/utils.ts +++ b/packages/angular/build/src/builders/library/pipeline/utils.ts @@ -6,9 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { TextDecoder } from 'node:util'; - -let textDecoder: TextDecoder | undefined; const IS_DTS_FILE_REGEXP = /\.d\.[cm]?ts$/i; const IS_DTS_MAP_FILE_REGEXP = /\.d\.[cm]?ts\.map$/i; @@ -26,15 +23,11 @@ export const TYPES_OUTPUT_DIR = 'types'; * Computes the base bundle file name for an entry point. * * @param packageName The package name from package.json. - * @param entryPointName The entry point subpath name. - * @param isPrimary Whether this is the primary entry point. + * @param entryPointName The entry point subpath name (defaults to '.'). * @returns The sanitized bundle base name. */ -export function getEntryPointBundleName( - packageName: string, - entryPointName: string, - isPrimary: boolean, -): string { +export function getEntryPointBundleName(packageName: string, entryPointName = '.'): string { + const isPrimary = !entryPointName || entryPointName === '.'; const pkgName = packageName[0] === '@' ? packageName.slice(1) : packageName; const epName = isPrimary ? pkgName : `${pkgName}-${entryPointName}`; @@ -64,7 +57,7 @@ export interface DiskOutputFile { source: string; /** The destination path where the file should be copied. */ - destination: string; + path: string; } /** @@ -76,14 +69,14 @@ export type OutputFile = MemoryOutputFile | DiskOutputFile; * Creates an output file descriptor for an existing file on disk. * * @param source The path to the source file on disk. - * @param destination The destination path where the file should be copied. + * @param path The destination path where the file should be copied. * @returns A {@link DiskOutputFile} descriptor. */ -export function createDiskOutputFile(source: string, destination: string): DiskOutputFile { +export function createDiskOutputFile(source: string, path: string): DiskOutputFile { return { type: 'disk', source, - destination, + path, }; } @@ -108,19 +101,6 @@ export function createMemoryOutputFile( }; } -/** - * Gets the text content of a file. - */ -export function getFileText(contents: string | Uint8Array): string { - if (typeof contents === 'string') { - return contents; - } - - textDecoder ??= new TextDecoder(); - - return textDecoder.decode(contents); -} - /** * Determines whether a file path represents a TypeScript declaration file (`.d.ts`, `.d.mts`, or `.d.cts`). * diff --git a/packages/angular/build/src/builders/library/schema.json b/packages/angular/build/src/builders/library/schema.json index 3aefc5e4cd65..bac9944091f6 100644 --- a/packages/angular/build/src/builders/library/schema.json +++ b/packages/angular/build/src/builders/library/schema.json @@ -81,7 +81,7 @@ "default": "partial" }, "allowedNonPeerDependencies": { - "description": "A list of package names allowed in the 'dependencies' section of package.json. Values can be regular expression patterns.", + "description": "A list of package names allowed in the 'dependencies' and 'optionalDependencies' sections of package.json. Values can be regular expression patterns.", "type": "array", "items": { "type": "string" diff --git a/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts index 26c0b6ac8969..b119f7f47dbc 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts @@ -7,11 +7,11 @@ */ import { executeLibraryBuilder } from '../../builder'; -import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; +import { LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { describe('Behavior: "Secondary Entry Points and Intra-Dependencies"', () => { - it('should build secondary entry points with intra-dependencies in topological order', async () => { + it('should build secondary entry points with intra-library dependencies', async () => { await harness.writeFiles({ 'projects/lib/shared/src/public-api.ts': ` import { Injectable } from '@angular/core'; @@ -118,33 +118,5 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => expect(npmignore).toContain('/feature-b/package.json'); expect(npmignore).toContain('/sub-module/package.json'); }); - - it('should throw an error when a circular dependency exists between secondary entry points', async () => { - await harness.writeFiles({ - 'projects/lib/ep-one/src/public-api.ts': ` - import { EpTwoService } from 'lib/ep-two'; - export const VAL_ONE = 'one'; - `, - 'projects/lib/ep-two/src/public-api.ts': ` - import { VAL_ONE } from 'lib/ep-one'; - export class EpTwoService {} - `, - }); - - await harness.modifyFile('projects/lib/package.json', (content) => { - const pkg = JSON.parse(content); - pkg.exports = { - '.': './src/public-api.ts', - './ep-one': './ep-one/src/public-api.ts', - './ep-two': './ep-two/src/public-api.ts', - }; - - return JSON.stringify(pkg, null, 2); - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBeFalse(); - expect(result?.error).toContain('Circular dependency detected'); - }); }); }); diff --git a/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts index f50f49b4a696..cf1bb1b6e7b7 100644 --- a/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts +++ b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts @@ -7,7 +7,7 @@ */ import { executeLibraryBuilder } from '../../builder'; -import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; +import { LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { describe('Package.json "exports" entry points', () => { diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts index ebc761bface8..d766680d1674 100644 --- a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts @@ -53,6 +53,7 @@ export abstract class AngularCompilation { tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType?: 'application' | 'library', ): Promise; emitAffectedFiles(): Iterable | Promise> { diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts index b42b1273592d..e4047a3e3e1b 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -61,10 +61,12 @@ export class AotCompilation extends TypeScriptCompilation { super(); } + // eslint-disable-next-line max-lines-per-function async initialize( tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', ): Promise { // Dynamically load the Angular compiler CLI package const { NgtscProgram, OptimizeFor } = await TypeScriptCompilation.loadCompilerCli(); @@ -75,7 +77,7 @@ export class AotCompilation extends TypeScriptCompilation { rootNames, errors: configurationDiagnostics, warnings, - } = await this.loadConfiguration(tsconfig, compilerOptionOverrides); + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); const useTypeScriptTranspilation = (compilerOptions['_useTypeScriptTranspilation'] as boolean | undefined) ?? @@ -332,9 +334,11 @@ export class AotCompilation extends TypeScriptCompilation { useTypeScriptTranspilation, } = this.#state; const compilerOptions = typeScriptProgram.getCompilerOptions(); + const isLibraryEmit = !!compilerOptions.declaration; const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; - const emittedFiles = new Map(); + const emittedFiles = new Map(); + const emittedSourceFiles = new Set(); const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) { // Save builder info contents to specified location @@ -350,17 +354,21 @@ export class AotCompilation extends TypeScriptCompilation { } angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); - emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); + emittedSourceFiles.add(sourceFile); + const targetFilename = isLibraryEmit ? filename : sourceFile.fileName; + emittedFiles.set(targetFilename, { filename: targetFilename, contents }); }; const transformers = angularCompiler.prepareEmit().transformers; - transformers.before ??= []; - transformers.before.push( - replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()), - webWorkerTransform, - ); + if (!isLibraryEmit) { + transformers.before ??= []; + transformers.before.push( + replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()), + webWorkerTransform, + ); - if (!this.browserOnlyBuild) { - transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + if (!this.browserOnlyBuild) { + transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + } } // Emit is handled in write file callback when using TypeScript @@ -394,7 +402,7 @@ export class AotCompilation extends TypeScriptCompilation { // Angular may have files that must be emitted but TypeScript does not consider affected for (const sourceFile of typeScriptProgram.getSourceFiles()) { - if (emittedFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) { + if (emittedSourceFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) { continue; } @@ -410,7 +418,8 @@ export class AotCompilation extends TypeScriptCompilation { } if (useTypeScriptTranspilation) { - typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, undefined, transformers); + const emitOnly = affectedFiles.has(sourceFile) ? undefined : false; + typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, emitOnly, transformers); continue; } @@ -451,13 +460,14 @@ export class AotCompilation extends TypeScriptCompilation { contents += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; } else if (compilerOptions.sourceMap) { const mapFilename = sourceFile.fileName + '.map'; - emittedFiles.set(sourceFile, { filename: mapFilename, contents: printResult.map }); + emittedFiles.set(mapFilename, { filename: mapFilename, contents: printResult.map }); } } } angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); - emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); + emittedSourceFiles.add(sourceFile); + emittedFiles.set(sourceFile.fileName, { filename: sourceFile.fileName, contents }); } return emittedFiles.values(); diff --git a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts index 45af80eae6d3..e3f43eb2b6d6 100644 --- a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts +++ b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts @@ -21,6 +21,9 @@ export interface CompilerOptionOverrides { includeTestMetadata?: boolean; customConditions?: string[]; rootFiles?: string[]; + declarationMap?: boolean; + compilationMode?: 'full' | 'partial'; + paths?: Record; } export function transformCompilerOptions( @@ -28,9 +31,11 @@ export function transformCompilerOptions( baseCompilerOptions: ng.CompilerOptions, overrides?: CompilerOptionOverrides, tsconfig?: string, + buildType: 'application' | 'library' = 'application', ): { compilerOptions: ng.CompilerOptions; warnings: PartialMessage[] } { const compilerOptions = { ...baseCompilerOptions }; const warnings: PartialMessage[] = []; + const isLibrary = buildType === 'library'; if ( compilerOptions.target === undefined || @@ -57,13 +62,15 @@ export function transformCompilerOptions( }); } - if (compilerOptions.compilationMode === 'partial') { + if (!isLibrary && compilerOptions.compilationMode === 'partial') { warnings.push({ text: 'Angular partial compilation mode is not supported when building applications.', location: null, notes: [{ text: 'Full compilation mode will be used instead.' }], }); compilerOptions.compilationMode = 'full'; + } else if (overrides?.compilationMode) { + compilerOptions.compilationMode = overrides.compilationMode; } // Enable incremental compilation by default if caching is enabled and incremental is not explicitly disabled @@ -72,7 +79,7 @@ export function transformCompilerOptions( // Set the build info file location to the configured cache directory compilerOptions.tsBuildInfoFile = path.join(overrides.cachePath, '.tsbuildinfo'); } else { - compilerOptions.incremental = false; + compilerOptions.incremental = isLibrary; } if ( @@ -101,6 +108,16 @@ export function transformCompilerOptions( }); } + if (isLibrary) { + compilerOptions.target = typeScript.ScriptTarget.ES2022; + compilerOptions.module = typeScript.ModuleKind.ES2022; + compilerOptions.moduleResolution = typeScript.ModuleResolutionKind.Bundler; + compilerOptions.importHelpers = true; + compilerOptions.declaration = true; + compilerOptions.declarationMap = overrides?.declarationMap; + compilerOptions.declarationDir = undefined; + } + // Synchronize custom resolve conditions. // Set if using the supported bundler resolution mode (bundler is the default in new projects) if ( @@ -116,21 +133,25 @@ export function transformCompilerOptions( noEmitOnError: false, composite: false, inlineSources: !!overrides?.sourcemap, - inlineSourceMap: !!overrides?.sourcemap, - sourceMap: undefined, + inlineSourceMap: !isLibrary && !!overrides?.sourcemap, + sourceMap: isLibrary ? !!overrides?.sourcemap : undefined, mapRoot: undefined, sourceRoot: undefined, preserveSymlinks: overrides?.preserveSymlinks, externalRuntimeStyles: overrides?.externalRuntimeStyles, _enableHmr: !!overrides?.enableHmr, // TypeScript transpilation is forced if: + // - Building a library (TypeScript emits both .js and .d.ts in a single pass). // - isolatedModules is disabled (TS needs full module types to emit JS). // - Karma code coverage is active (the coverage instrumentation transformer is Babel-based // and cannot parse raw TypeScript code; Vitest handles coverage instrumentation downstream). _useTypeScriptTranspilation: - !compilerOptions.isolatedModules || !!overrides?.instrumentForCoverage, - supportTestBed: !!overrides?.includeTestMetadata, - supportJitMode: !!overrides?.includeTestMetadata, + isLibrary || !compilerOptions.isolatedModules || !!overrides?.instrumentForCoverage, + supportTestBed: isLibrary ? undefined : !!overrides?.includeTestMetadata, + supportJitMode: isLibrary ? undefined : !!overrides?.includeTestMetadata, + paths: overrides?.paths + ? { ...baseCompilerOptions.paths, ...overrides.paths } + : baseCompilerOptions.paths, }, warnings, }; diff --git a/packages/angular/build/src/tools/angular/compilation/index.ts b/packages/angular/build/src/tools/angular/compilation/index.ts index ea7755863fbd..268abec678ca 100644 --- a/packages/angular/build/src/tools/angular/compilation/index.ts +++ b/packages/angular/build/src/tools/angular/compilation/index.ts @@ -16,4 +16,3 @@ export { } from './angular-compilation'; export type { CompilerOptionOverrides } from './compiler-options'; export { createAngularCompilation, type AngularCompilationMode } from './factory'; -export { LibraryCompilation, type LibraryCompilationOptions } from './library-compilation'; diff --git a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts index e89db1e75aa4..773279e236a9 100644 --- a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts @@ -43,6 +43,7 @@ export class JitCompilation extends TypeScriptCompilation { tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', ): Promise { // Dynamically load the Angular compiler CLI package const { constructorParametersDownlevelTransform } = @@ -54,7 +55,7 @@ export class JitCompilation extends TypeScriptCompilation { rootNames, errors: configurationDiagnostics, warnings, - } = await this.loadConfiguration(tsconfig, compilerOptionOverrides); + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); if (hostOptions.modifiedFiles) { this.invalidateFiles(hostOptions.modifiedFiles); diff --git a/packages/angular/build/src/tools/angular/compilation/library-compilation.ts b/packages/angular/build/src/tools/angular/compilation/library-compilation.ts deleted file mode 100644 index 76d55dde9857..000000000000 --- a/packages/angular/build/src/tools/angular/compilation/library-compilation.ts +++ /dev/null @@ -1,451 +0,0 @@ -/** - * @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 * as ng from '@angular/compiler-cli'; -import assert from 'node:assert'; -import path from 'node:path'; -import ts from 'typescript'; -import { toPosixPath } from '../../../utils/path'; -import { profileAsync, profileSync } from '../../../utils/profiling'; -import { - type AngularCompilerHost, - type AngularHostOptions, - createAngularCompilerHost, - ensureSourceFileVersions, -} from '../angular-host'; -import { - type AngularCompilationResult, - DiagnosticModes, - type EmitFileResult, -} from './angular-compilation'; -import type { CompilerOptionOverrides } from './compiler-options'; -import { TypeScriptCompilation } from './typescript-compilation'; - -/** - * Options for configuring a library compilation. - */ -export interface LibraryCompilationOptions { - entryFilePath: string; - compilationMode?: 'partial' | 'full'; - declarationMap?: boolean; - - /** Map of entry point module specifiers to target .d.ts paths for compilerOptions.paths. */ - upstreamDtsPaths?: Record; - - /** Map of .d.ts file paths to in-memory contents for compiler host resolution. */ - upstreamDtsFiles?: Map; - basePath?: string; - rootDir?: string; - tsBuildInfoFile?: string; - sourceFileCache?: Map; -} - -class LibraryCompilationState { - constructor( - public readonly angularProgram: ng.NgtscProgram, - public readonly compilerHost: AngularCompilerHost, - public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, - public readonly configurationDiagnostics: readonly ts.Diagnostic[], - public readonly affectedFiles: ReadonlySet, - public readonly optimizeFor: ng.OptimizeFor, - public readonly diagnosticCache = new WeakMap(), - ) {} - - get angularCompiler() { - return this.angularProgram.compiler; - } -} - -/** - * An Angular compilation implementation specifically tailored for library building - * according to the Angular Package Format (APF). Supports partial/full compilation modes, - * in-memory declaration emitting, upstream entry point path mapping, and incremental builds. - */ -export class LibraryCompilation extends TypeScriptCompilation { - #state?: LibraryCompilationState; - #cachedConfig?: { - compilerOptions: ng.CompilerOptions; - parsedRootNames: string[]; - configurationDiagnostics: readonly ts.Diagnostic[]; - }; - - constructor(private readonly libraryOptions: LibraryCompilationOptions) { - super(libraryOptions.sourceFileCache); - } - - updateLibraryOptions(options: Partial): void { - Object.assign(this.libraryOptions, options); - } - - #loadConfiguration( - tsconfig: string, - hostOptions: AngularHostOptions, - compilerOptionOverrides: CompilerOptionOverrides | undefined, - readConfiguration: (project: string, options?: ng.CompilerOptions) => ng.ParsedConfiguration, - ) { - const shouldReloadConfig = - !this.#cachedConfig || hostOptions.modifiedFiles?.has(toPosixPath(tsconfig)); - - if (shouldReloadConfig) { - const { - compilationMode = 'partial', - declarationMap = false, - basePath, - rootDir, - tsBuildInfoFile, - } = this.libraryOptions; - - const { - options: rawCompilerOptions, - rootNames: parsedRootNames, - errors: configurationDiagnostics, - } = profileSync('NG_READ_CONFIG', () => - readConfiguration(tsconfig, { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.ES2022, - moduleResolution: ts.ModuleResolutionKind.Bundler, - importHelpers: true, - composite: false, - sourceMap: true, - inlineSources: true, - inlineSourceMap: false, - outDir: '', - declaration: true, - declarationMap, - allowEmptyCodegenFiles: false, - annotationsAs: 'decorators', - enableResourceInlining: true, - noEmitOnError: false, - suppressOutputPathCheck: true, - compilationMode, - basePath, - rootDir, - tsBuildInfoFile, - preserveSymlinks: compilerOptionOverrides?.preserveSymlinks, - // Disable removing of comments as TS is quite aggressive with these and can - // remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */. - removeComments: false, - }), - ); - - this.#cachedConfig = { - compilerOptions: rawCompilerOptions, - parsedRootNames, - configurationDiagnostics, - }; - } - - assert(this.#cachedConfig); - - return this.#cachedConfig; - } - - async initialize( - tsconfig: string, - hostOptions: AngularHostOptions, - compilerOptionOverrides?: CompilerOptionOverrides, - ): Promise { - const { NgtscProgram, OptimizeFor, readConfiguration } = - await TypeScriptCompilation.loadCompilerCli(); - - const { upstreamDtsPaths, upstreamDtsFiles, entryFilePath, tsBuildInfoFile } = - this.libraryOptions; - - const { - compilerOptions: rawCompilerOptions, - parsedRootNames, - configurationDiagnostics, - } = this.#loadConfiguration(tsconfig, hostOptions, compilerOptionOverrides, readConfiguration); - - const compilerOptions = { ...rawCompilerOptions }; - - if (upstreamDtsPaths) { - compilerOptions.paths = { - ...compilerOptions.paths, - ...upstreamDtsPaths, - }; - } - - if (tsBuildInfoFile) { - compilerOptions.incremental = true; - compilerOptions.tsBuildInfoFile = tsBuildInfoFile; - } else if (compilerOptionOverrides?.cachePath && compilerOptions.incremental !== false) { - const safeEntryName = toPosixPath(entryFilePath) - .replace(/[:\\/]/g, '_') - .replace(/\.[^.]+$/, ''); - compilerOptions.incremental = true; - compilerOptions.tsBuildInfoFile = path.join( - compilerOptionOverrides.cachePath, - 'tsbuildinfo', - `${safeEntryName}.tsbuildinfo`, - ); - } else { - compilerOptions.incremental = false; - } - - const packageJsonCache = this.#state?.compilerHost - .getModuleResolutionCache?.() - ?.getPackageJsonInfoCache(); - - if (hostOptions.modifiedFiles) { - this.invalidateFiles(hostOptions.modifiedFiles); - } - - const host = createAngularCompilerHost( - ts, - compilerOptions, - hostOptions, - packageJsonCache, - this.sourceFiles, - ); - - if (upstreamDtsFiles && upstreamDtsFiles.size > 0) { - const originalFileExists = host.fileExists.bind(host); - host.fileExists = (fileName: string) => { - if (upstreamDtsFiles.has(toPosixPath(fileName))) { - return true; - } - - return originalFileExists(fileName); - }; - - const originalReadFile = host.readFile.bind(host); - host.readFile = (fileName: string) => { - const content = upstreamDtsFiles.get(toPosixPath(fileName)); - if (content !== undefined) { - return content; - } - - return originalReadFile(fileName); - }; - - if (host.realpath) { - const originalRealpath = host.realpath.bind(host); - host.realpath = (fileName: string) => { - if (upstreamDtsFiles.has(toPosixPath(fileName))) { - return fileName; - } - - return originalRealpath(fileName); - }; - } - } - - const rootNames = [ - entryFilePath, - ...parsedRootNames.filter((file) => /\.d\.[cm]?ts$/i.test(file)), - ]; - - const angularProgram = profileSync( - 'NG_CREATE_PROGRAM', - () => new NgtscProgram(rootNames, compilerOptions, host, this.#state?.angularProgram), - ); - const angularCompiler = angularProgram.compiler; - const angularTypeScriptProgram = angularProgram.getTsProgram(); - ensureSourceFileVersions(angularTypeScriptProgram); - - let oldProgram = this.#state?.typeScriptProgram; - if (!oldProgram && compilerOptions.tsBuildInfoFile) { - oldProgram = ts.readBuilderProgram(compilerOptions, host); - } - - const typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram( - angularTypeScriptProgram, - host, - oldProgram, - configurationDiagnostics.length ? configurationDiagnostics : undefined, - ); - - await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync()); - - const affectedFiles = new Set(); - // eslint-disable-next-line no-constant-condition - while (true) { - const result = typeScriptProgram.getSemanticDiagnosticsOfNextAffectedFile( - undefined, - (sourceFile) => { - if ( - angularCompiler.ignoreForDiagnostics.has(sourceFile) && - sourceFile.fileName.endsWith('.ngtypecheck.ts') - ) { - const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts'; - const originalSourceFile = typeScriptProgram.getSourceFile(originalFilename); - if (originalSourceFile) { - affectedFiles.add(originalSourceFile); - } - - return true; - } - - return false; - }, - ); - if (!result) { - break; - } - if (result.affected && 'fileName' in result.affected) { - affectedFiles.add(result.affected); - } - } - - const diagnosticCache = - this.#state?.diagnosticCache ?? new WeakMap(); - - const referencedFiles: string[] = []; - for (const sourceFile of typeScriptProgram.getSourceFiles()) { - if (angularCompiler.ignoreForEmit.has(sourceFile) || sourceFile.isDeclarationFile) { - continue; - } - - referencedFiles.push(sourceFile.fileName); - const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile); - if (resourceDependencies.length > 0) { - referencedFiles.push(...resourceDependencies); - if (this.#state && hostOptions.modifiedFiles?.size) { - for (const resourceDependency of resourceDependencies) { - if (hostOptions.modifiedFiles.has(resourceDependency)) { - diagnosticCache.delete(sourceFile); - affectedFiles.add(sourceFile); - } - } - } - } - } - - const optimizeFor = - affectedFiles.size === 1 ? OptimizeFor.SingleFile : OptimizeFor.WholeProgram; - - this.#state = new LibraryCompilationState( - angularProgram, - host, - typeScriptProgram, - configurationDiagnostics, - affectedFiles, - optimizeFor, - diagnosticCache, - ); - - return { - compilerOptions, - referencedFiles, - }; - } - - protected override *collectDiagnostics(modes: DiagnosticModes): Iterable { - assert(this.#state, 'Library compilation must be initialized prior to collecting diagnostics.'); - const { - angularProgram, - typeScriptProgram, - configurationDiagnostics, - affectedFiles, - optimizeFor, - diagnosticCache, - } = this.#state; - const angularCompiler = angularProgram.compiler; - - const syntactic = modes & DiagnosticModes.Syntactic; - const semantic = modes & DiagnosticModes.Semantic; - - if (modes & DiagnosticModes.Option) { - yield* configurationDiagnostics; - yield* angularCompiler.getOptionDiagnostics(); - yield* typeScriptProgram.getOptionsDiagnostics(); - yield* typeScriptProgram.getConfigFileParsingDiagnostics(); - } - - if (syntactic) { - yield* typeScriptProgram.getGlobalDiagnostics(); - } - - for (const sourceFile of typeScriptProgram.getSourceFiles()) { - if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) { - continue; - } - - if (syntactic) { - yield* typeScriptProgram.getSyntacticDiagnostics(sourceFile); - } - - if (!semantic) { - continue; - } - - yield* typeScriptProgram.getSemanticDiagnostics(sourceFile); - - if (sourceFile.isDeclarationFile) { - continue; - } - - if (affectedFiles.has(sourceFile)) { - const diagnostics = angularCompiler.getDiagnosticsForFile(sourceFile, optimizeFor); - diagnosticCache.set(sourceFile, diagnostics); - yield* diagnostics; - } else { - const cachedDiagnostics = diagnosticCache.get(sourceFile); - if (cachedDiagnostics) { - yield* cachedDiagnostics; - } - } - } - } - - override emitAffectedFiles(): Iterable { - assert(this.#state, 'Library compilation must be initialized prior to emitting files.'); - const { angularProgram, compilerHost, typeScriptProgram } = this.#state; - const angularCompiler = angularProgram.compiler; - const compilerOptions = typeScriptProgram.getCompilerOptions(); - const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; - - const emittedFiles: EmitFileResult[] = []; - const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { - if ( - !sourceFiles?.length && - (filename.endsWith('.tsbuildinfo') || filename.endsWith(buildInfoFilename)) - ) { - compilerHost.writeFile(filename, contents, false); - - return; - } - - emittedFiles.push({ filename, contents }); - }; - - const transformers = angularCompiler.prepareEmit().transformers; - - for (const sourceFile of typeScriptProgram.getSourceFiles()) { - if (angularCompiler.ignoreForEmit.has(sourceFile)) { - continue; - } - - if (sourceFile.isDeclarationFile) { - continue; - } - - if ( - angularCompiler.incrementalCompilation?.safeToSkipEmit(sourceFile) && - !this.#state.affectedFiles.has(sourceFile) - ) { - continue; - } - - typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, undefined, transformers); - angularCompiler.incrementalCompilation?.recordSuccessfulEmit(sourceFile); - } - - if (compilerOptions.tsBuildInfoFile) { - const programWithGetState = typeScriptProgram.getProgram() as ts.Program & { - emitBuildInfo?(writeFileCallback?: ts.WriteFileCallback): void; - }; - if (typeof programWithGetState.emitBuildInfo === 'function') { - programWithGetState.emitBuildInfo(writeFileCallback); - } - } - - return emittedFiles; - } -} diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts index 0b58423a418b..6e4debf62501 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts @@ -52,6 +52,7 @@ export class ParallelCompilation extends AngularCompilation { tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', ): Promise { const stylesheetChannel = new MessageChannel(); // The request identifier is required because Angular can issue multiple concurrent requests @@ -94,6 +95,7 @@ export class ParallelCompilation extends AngularCompilation { jit: this.jit, browserOnlyBuild: this.browserOnlyBuild, compilerOptionOverrides, + buildType, stylesheetPort: stylesheetChannel.port2, webWorkerPort: webWorkerChannel.port2, webWorkerSignal, diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts index 5bf293da727d..30c52f3a7a0c 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts @@ -27,6 +27,7 @@ export interface InitRequest { tsconfig: string; fileReplacements?: Record; compilerOptionOverrides?: CompilerOptionOverrides; + buildType?: 'application' | 'library'; stylesheetPort: MessagePort; webWorkerPort: MessagePort; webWorkerSignal: Int32Array; @@ -111,6 +112,7 @@ export async function initialize(request: InitRequest): Promise { const { readConfiguration } = await TypeScriptCompilation.loadCompilerCli(); @@ -78,6 +79,7 @@ export abstract class TypeScriptCompilation extends AngularCompilation { originalCompilerOptions, compilerOptionOverrides, tsconfig, + buildType, ); return { @@ -88,9 +90,7 @@ export abstract class TypeScriptCompilation extends AngularCompilation { }; } - constructor(protected readonly sourceFiles: Map = new Map()) { - super(); - } + protected readonly sourceFiles = new Map(); protected invalidateFiles(files: Iterable): void { for (const file of files) { From 7928f11c1928486f034e6488116c4294035e89a4 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Sat, 26 Sep 2026 07:35:12 +0000 Subject: [PATCH 7/8] fixup! feat(@angular/build): add library builder --- .../builders/library/pipeline/build-action.ts | 10 +- .../src/builders/library/pipeline/bundler.ts | 126 +++++++++++++----- .../builders/library/pipeline/compilation.ts | 38 ++++++ .../src/builders/library/pipeline/utils.ts | 91 +++++++++++-- 4 files changed, 213 insertions(+), 52 deletions(-) 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 8c96292d60d3..fa136cbe919b 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -13,17 +13,11 @@ import { emitFilesToDisk } from '../../../tools/esbuild/utils'; import { toPosixPath } from '../../../utils/path'; import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; import { collectAssetsToEmit } from './assets'; -import { - type BundleEntryPointInput, - type BundleResult, - type EntryPointLookup, - bundleEntryPoints, - createEntryDirectoryLookup, -} from './bundler'; +import { type BundleEntryPointInput, type BundleResult, bundleEntryPoints } from './bundler'; import { type SingleProgramCache, compileLibrary } from './compilation'; import { generatePackageManifests } from './package-manifests'; import type { createComponentStylesheetBundlerForLibrary } from './stylesheet-bundler'; -import type { OutputFile } from './utils'; +import { type EntryPointLookup, type OutputFile, createEntryDirectoryLookup } from './utils'; /** * State preserved across incremental builds in watch mode. diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts index 9a7ba51e15a3..c20d7e293c2c 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -10,7 +10,6 @@ import assert from 'node:assert'; import path from 'node:path'; import { type OutputChunk, - type OutputOptions, type Plugin, type RolldownOutput, type RolldownPluginOption, @@ -20,9 +19,11 @@ import { dts } from 'rolldown-plugin-dts'; import { toPosixPath } from '../../../utils/path'; import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; import { + type EntryPointLookup, FESM_OUTPUT_DIR, type MemoryOutputFile, TYPES_OUTPUT_DIR, + createEntryDirectoryLookup, createMemoryOutputFile, } from './utils'; @@ -37,31 +38,58 @@ export interface BundleResult { dtsModuleIds: ReadonlySet; } +/** + * Output of the entry point bundling process. + */ export interface BundleEntryPointsOutput { + /** In-memory files produced by bundling to be written to disk. */ filesToEmit: MemoryOutputFile[]; + + /** Map of entry point names to their bundle results containing bundled module IDs. */ bundleResults: Map; } +/** + * Input state for an individual entry point to be bundled. + */ export interface BundleEntryPointInput { + /** The normalized entry point to bundle. */ entryPoint: NormalizedEntryPoint; + + /** Whether the entry point has changed ESM files requiring rebundling. */ hasEsmChanges: boolean; + + /** Whether the entry point has changed TypeScript declaration files requiring rebundling. */ hasDtsChanges: boolean; + + /** The previous bundle result from a prior build iteration, if available. */ previousBundleResult?: BundleResult; } const ESM_EXTENSIONS = ['.js', '.mjs', '/index.js', '/index.mjs'] as const; const DTS_EXTENSIONS = ['.d.ts', '.d.mts', '/index.d.ts', '/index.d.mts'] as const; -export type EntryPointLookup = (filePath: string) => NormalizedEntryPoint | undefined; - +/** + * Output of a multi-entry-point Rolldown bundling invocation. + */ interface MultiBundleOutput { + /** In-memory files generated by the bundle invocation. */ filesToEmit: MemoryOutputFile[]; + + /** Map of bundle entry names to the set of virtual module IDs included in the bundle. */ moduleIdsByBundle: Map>; } /** * Bundles the compiled in-memory JavaScript and declaration files for all dirty entry points * using at most 2 Rolldown instances total (1 for all .mjs bundles, 1 for all .d.ts bundles). + * + * @param items The collection of entry point inputs with change statuses. + * @param esmFiles Map of virtual ESM output file paths to their content. + * @param dtsFiles Map of virtual TypeScript declaration file paths to their content. + * @param options The normalized library builder options. + * @param findEntryPoint Optional lookup function to find an entry point by file path. + * @returns A promise resolving to the bundled output files and bundle cache results. */ export async function bundleEntryPoints( items: readonly BundleEntryPointInput[], @@ -112,34 +140,15 @@ export async function bundleEntryPoints( }; } -export function createEntryDirectoryLookup( - entryPoints: Iterable, -): EntryPointLookup { - const dirs = Array.from(entryPoints, (ep) => { - const dir = toPosixPath(path.dirname(ep.entryFilePath)); - - return { - ep, - dir, - dirSlash: dir.endsWith('/') ? dir : `${dir}/`, - }; - }).sort((a, b) => b.dir.length - a.dir.length); - - const cache = new Map(); - - return (filePath: string): NormalizedEntryPoint | undefined => { - const posix = toPosixPath(filePath); - const cached = cache.get(posix); - if (cached !== undefined || cache.has(posix)) { - return cached; - } - const found = dirs.find(({ dir, dirSlash }) => posix === dir || posix.startsWith(dirSlash))?.ep; - cache.set(posix, found); - - return found; - }; -} +export { type EntryPointLookup, createEntryDirectoryLookup }; +/** + * Generates the Rolldown input mapping object from the specified entry points. + * + * @param entryPoints The entry points to include in the bundle input map. + * @param dtsMode Whether the input map is for declaration files (.d.ts) rather than ESM files (.js). + * @returns A record mapping bundle names to virtual entry file paths. + */ function resolveEntryInputMap( entryPoints: readonly NormalizedEntryPoint[], dtsMode: boolean, @@ -155,6 +164,16 @@ function resolveEntryInputMap( return input; } +/** + * Creates a Rolldown plugin that loads modules from an in-memory file map + * and prevents illegal cross-entry-point sibling imports. + * + * @param files Map of virtual file paths to their content. + * @param extensions Candidate extensions to probe when resolving extensionless module specifiers. + * @param includeMap Whether source map files should also be loaded from the memory map. + * @param findEntryPoint Lookup function to identify the entry point owning a file path. + * @returns A Rolldown plugin instance. + */ function createMemoryFileLoaderPlugin( files: ReadonlyMap, extensions: readonly string[], @@ -229,6 +248,13 @@ function createMemoryFileLoaderPlugin( }; } +/** + * Resolves the bundle prefix name for a shared chunk based on the entry point owning its modules. + * + * @param findEntryPoint Lookup function to identify entry points of module IDs. + * @param moduleIds List of module IDs contained within the chunk. + * @returns The bundle name if a containing entry point was found, otherwise undefined. + */ function resolveChunkBundleName( findEntryPoint: EntryPointLookup, moduleIds: readonly string[], @@ -243,6 +269,14 @@ function resolveChunkBundleName( return undefined; } +/** + * Processes Rolldown output assets and chunks, extracting emitted files and computing + * the transitive set of module IDs belonging to each entry point bundle. + * + * @param output The array of output chunks and assets from Rolldown. + * @param dir The destination output directory prefix. + * @returns The processed multi-bundle output. + */ function processRolldownOutput(output: RolldownOutput['output'], dir: string): MultiBundleOutput { const filesToEmit: MemoryOutputFile[] = []; const moduleIdsByBundle = new Map>(); @@ -302,6 +336,17 @@ function processRolldownOutput(output: RolldownOutput['output'], dir: string): M return { filesToEmit, moduleIdsByBundle }; } +/** + * Executes a Rolldown bundle build across all provided entry inputs. + * + * @param input Map of bundle names to virtual entry file paths. + * @param plugins Rolldown plugins to use during bundling. + * @param preserveSymlinks Whether to preserve symlinks during module resolution. + * @param extension Output file extension ('mjs' or 'd.ts'). + * @param sourcemap Whether to emit sourcemaps. + * @param findEntryPoint Lookup function to resolve chunk entry point ownership. + * @returns A promise resolving to the multi-bundle output. + */ async function executeMultiBundle( input: Record, plugins: RolldownPluginOption[], @@ -312,7 +357,6 @@ async function executeMultiBundle( ): Promise { const isDts = extension === 'd.ts'; const dir = isDts ? TYPES_OUTPUT_DIR : FESM_OUTPUT_DIR; - const comments: OutputOptions['comments'] = isDts ? false : { legal: true, annotation: true }; const bundle = await rolldown({ context: 'this', input, @@ -338,7 +382,7 @@ async function executeMultiBundle( }, sourcemap, hoistTransitiveImports: false, - comments, + comments: { jsdoc: isDts, legal: true, annotation: true }, }); return processRolldownOutput(output, dir); @@ -347,6 +391,15 @@ async function executeMultiBundle( } } +/** + * Bundles JavaScript ESM output files for all provided entry points. + * + * @param entryPoints The entry points to bundle. + * @param esmFiles Map of virtual ESM output file paths to contents. + * @param options The normalized library options. + * @param findEntryPoint Lookup function to resolve entry point ownership. + * @returns A promise resolving to the multi-bundle output. + */ async function bundleAllEsm( entryPoints: readonly NormalizedEntryPoint[], esmFiles: ReadonlyMap, @@ -367,6 +420,15 @@ async function bundleAllEsm( ); } +/** + * Bundles TypeScript declaration (.d.ts) output files for all provided entry points. + * + * @param entryPoints The entry points to bundle. + * @param dtsFiles Map of virtual declaration output file paths to contents. + * @param options The normalized library options. + * @param findEntryPoint Lookup function to resolve entry point ownership. + * @returns A promise resolving to the multi-bundle output. + */ async function bundleAllDts( entryPoints: readonly NormalizedEntryPoint[], dtsFiles: ReadonlyMap, diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts index 0df655cb6f2a..49356bbb3a90 100644 --- a/packages/angular/build/src/builders/library/pipeline/compilation.ts +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -24,9 +24,16 @@ 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; } @@ -34,17 +41,37 @@ export interface SingleProgramCache { * Output of the unified library compilation step. */ export interface LibraryCompilationOutput { + /** Read-only map of emitted JavaScript files. */ readonly esmFiles: ReadonlyMap; + + /** Read-only map of emitted TypeScript declaration files. */ readonly dtsFiles: ReadonlyMap; + + /** Set of JavaScript file paths that were modified or newly emitted in this iteration. */ readonly changedEsmFiles: ReadonlySet; + + /** Set of declaration file paths that were modified or newly emitted in this iteration. */ readonly changedDtsFiles: ReadonlySet; + + /** Set of all source and dependency file paths referenced during compilation. */ readonly referencedFiles: ReadonlySet; + + /** Cached compilation state for subsequent incremental builds. */ readonly cache: SingleProgramCache; + + /** Promise that resolves to formatted compilation warnings or rejects on diagnostic errors. */ readonly diagnosePromise: Promise; } /** * Compiles all library entry points in a single TypeScript and Angular compilation pass. + * + * @param entryPoints The collection of normalized library entry points to compile. + * @param options The normalized library builder options. + * @param stylesheetBundler The component stylesheet bundler instance. + * @param cached Optional cached compilation state from a previous build iteration. + * @param modifiedFiles Optional set of modified file paths for incremental compilation. + * @returns A promise resolving to the compilation output containing emitted files and diagnostics. */ export async function compileLibrary( entryPoints: Iterable, @@ -219,6 +246,16 @@ export async function compileLibrary( } } +/** + * Validates stylesheet bundling results and runs TypeScript/Angular diagnostics. + * + * @param compilationInstance The active Angular compilation instance. + * @param stylesheetErrors List of stylesheet bundling errors. + * @param stylesheetWarnings List of stylesheet bundling warnings. + * @param colors Whether diagnostic messages should be formatted with ANSI colors. + * @returns A promise resolving to formatted warning messages. + * @throws If stylesheet bundling errors or compilation errors occur. + */ async function runDiagnosticsAndFormat( compilationInstance: AngularCompilation, stylesheetErrors: PartialMessage[], @@ -227,6 +264,7 @@ async function runDiagnosticsAndFormat( ): Promise { if (stylesheetErrors.length > 0) { const formatted = await formatMessages(stylesheetErrors, { kind: 'error', color: colors }); + throw new Error(`Failed to bundle stylesheet:\n${formatted.join('\n')}`); } diff --git a/packages/angular/build/src/builders/library/pipeline/utils.ts b/packages/angular/build/src/builders/library/pipeline/utils.ts index b07f0764f48b..ec9d42beec9c 100644 --- a/packages/angular/build/src/builders/library/pipeline/utils.ts +++ b/packages/angular/build/src/builders/library/pipeline/utils.ts @@ -6,6 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ +import path from 'node:path'; +import { toPosixPath } from '../../../utils/path'; +import type { NormalizedEntryPoint } from '../options'; + const IS_DTS_FILE_REGEXP = /\.d\.[cm]?ts$/i; const IS_DTS_MAP_FILE_REGEXP = /\.d\.[cm]?ts\.map$/i; @@ -20,19 +24,9 @@ export const FESM_OUTPUT_DIR = 'fesm2022'; export const TYPES_OUTPUT_DIR = 'types'; /** - * Computes the base bundle file name for an entry point. - * - * @param packageName The package name from package.json. - * @param entryPointName The entry point subpath name (defaults to '.'). - * @returns The sanitized bundle base name. + * Function that resolves a file path to its containing normalized library entry point, if any. */ -export function getEntryPointBundleName(packageName: string, entryPointName = '.'): string { - const isPrimary = !entryPointName || entryPointName === '.'; - const pkgName = packageName[0] === '@' ? packageName.slice(1) : packageName; - const epName = isPrimary ? pkgName : `${pkgName}-${entryPointName}`; - - return epName.replaceAll('/', '-'); -} +export type EntryPointLookup = (filePath: string) => NormalizedEntryPoint | undefined; /** * Represents an in-memory file to be emitted to disk. @@ -120,3 +114,76 @@ export function isDeclarationFile(path: string): boolean { export function isDeclarationSourceMapFile(path: string): boolean { return IS_DTS_MAP_FILE_REGEXP.test(path); } + +/** + * Creates an optimized, memoized lookup function that maps arbitrary file paths + * to their closest containing library entry point. + * + * Entry point root directories are sorted in descending order of path length so that + * more specific, nested sub-entry points take precedence over shallower or primary + * entry points. Lookups are cached to avoid repeated path scanning. + * + * @example + * Given two entry points: + * - Primary entry point `.` at `/project/src/public-api.ts` (dir: `/project/src`) + * - Secondary entry point `./testing` at `/project/src/testing/public-api.ts` (dir: `/project/src/testing`) + * + * ```ts + * const findEntryPoint = createEntryDirectoryLookup(entryPoints); + * + * // Resolves to the 'testing' sub-entry point because /project/src/testing is the longest matching prefix: + * findEntryPoint('/project/src/testing/test-bed.ts'); // -> NormalizedEntryPoint ('./testing') + * + * // Resolves to the primary entry point: + * findEntryPoint('/project/src/button.ts'); // -> NormalizedEntryPoint ('.') + * + * // Returns undefined for files located outside any entry point directory: + * findEntryPoint('/project/shared/utils.ts'); // -> undefined + * ``` + * + * @param entryPoints Iterable of normalized entry points. + * @returns An {@link EntryPointLookup} function that returns the owning {@link NormalizedEntryPoint}, + * or `undefined` if the file does not belong to any entry point directory. + */ +export function createEntryDirectoryLookup( + entryPoints: Iterable, +): EntryPointLookup { + const dirs = Array.from(entryPoints, (ep) => { + const dir = toPosixPath(path.dirname(ep.entryFilePath)); + + return { + ep, + dir, + dirSlash: dir.endsWith('/') ? dir : `${dir}/`, + }; + }).sort((a, b) => b.dir.length - a.dir.length); + + const cache = new Map(); + + return (filePath: string): NormalizedEntryPoint | undefined => { + const posix = toPosixPath(filePath); + const cached = cache.get(posix); + if (cached !== undefined || cache.has(posix)) { + return cached; + } + const found = dirs.find(({ dir, dirSlash }) => posix === dir || posix.startsWith(dirSlash))?.ep; + cache.set(posix, found); + + return found; + }; +} + +/** + * Computes the base bundle file name for an entry point. + * + * @param packageName The package name from package.json. + * @param entryPointName The entry point subpath name (defaults to '.'). + * @returns The sanitized bundle base name. + */ +export function getEntryPointBundleName(packageName: string, entryPointName = '.'): string { + const isPrimary = !entryPointName || entryPointName === '.'; + const pkgName = packageName[0] === '@' ? packageName.slice(1) : packageName; + const epName = isPrimary ? pkgName : `${pkgName}-${entryPointName}`; + + return epName.replaceAll('/', '-'); +} From f14355570a87f1a11f91e17e2835b2ddad87b89b Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:44:28 +0000 Subject: [PATCH 8/8] fixup! feat(@angular/build): add library builder --- .../build/src/builders/library/options.ts | 22 +--- .../builders/library/pipeline/build-action.ts | 3 +- .../src/builders/library/pipeline/bundler.ts | 3 +- .../builders/library/pipeline/entry-points.ts | 108 ++++++++++++++++++ .../pipeline/package-manifests_spec.ts | 3 +- .../src/builders/library/pipeline/utils.ts | 82 ------------- 6 files changed, 115 insertions(+), 106 deletions(-) create mode 100644 packages/angular/build/src/builders/library/pipeline/entry-points.ts diff --git a/packages/angular/build/src/builders/library/options.ts b/packages/angular/build/src/builders/library/options.ts index 246d1bc05c16..7f74954de60b 100644 --- a/packages/angular/build/src/builders/library/options.ts +++ b/packages/angular/build/src/builders/library/options.ts @@ -22,28 +22,10 @@ import { loadPostcssConfiguration, } from '../../utils/postcss-configuration'; import { getProjectRootPaths } from '../../utils/project-metadata'; -import { getEntryPointBundleName } from './pipeline/utils'; +import { type NormalizedEntryPoint, getEntryPointBundleName } from './pipeline/entry-points'; import type { Schema as LibraryBuilderOptions } from './schema'; -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; -} +export type { NormalizedEntryPoint } from './pipeline/entry-points'; export interface PackageJsonData { name: string; 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 fa136cbe919b..b411351d5a10 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -15,9 +15,10 @@ import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options' import { collectAssetsToEmit } from './assets'; import { type BundleEntryPointInput, type BundleResult, bundleEntryPoints } from './bundler'; import { type SingleProgramCache, compileLibrary } from './compilation'; +import { type EntryPointLookup, createEntryDirectoryLookup } from './entry-points'; import { generatePackageManifests } from './package-manifests'; import type { createComponentStylesheetBundlerForLibrary } from './stylesheet-bundler'; -import { type EntryPointLookup, type OutputFile, createEntryDirectoryLookup } from './utils'; +import type { OutputFile } from './utils'; /** * State preserved across incremental builds in watch mode. diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts index c20d7e293c2c..1ad5a25cb3f0 100644 --- a/packages/angular/build/src/builders/library/pipeline/bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -18,12 +18,11 @@ import { import { dts } from 'rolldown-plugin-dts'; import { toPosixPath } from '../../../utils/path'; import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; +import { type EntryPointLookup, createEntryDirectoryLookup } from './entry-points'; import { - type EntryPointLookup, FESM_OUTPUT_DIR, type MemoryOutputFile, TYPES_OUTPUT_DIR, - createEntryDirectoryLookup, createMemoryOutputFile, } from './utils'; diff --git a/packages/angular/build/src/builders/library/pipeline/entry-points.ts b/packages/angular/build/src/builders/library/pipeline/entry-points.ts new file mode 100644 index 000000000000..9ec7e8357933 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/entry-points.ts @@ -0,0 +1,108 @@ +/** + * @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 path from 'node:path'; +import { toPosixPath } from '../../../utils/path'; + +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; +} + +/** + * Function that resolves a file path to its containing normalized library entry point, if any. + */ +export type EntryPointLookup = (filePath: string) => NormalizedEntryPoint | undefined; + +/** + * Creates an optimized, memoized lookup function that maps arbitrary file paths + * to their closest containing library entry point. + * + * Entry point root directories are sorted in descending order of path length so that + * more specific, nested sub-entry points take precedence over shallower or primary + * entry points. Lookups are cached to avoid repeated path scanning. + * + * @example + * Given two entry points: + * - Primary entry point `.` at `/project/src/public-api.ts` (dir: `/project/src`) + * - Secondary entry point `./testing` at `/project/src/testing/public-api.ts` (dir: `/project/src/testing`) + * + * ```ts + * const findEntryPoint = createEntryDirectoryLookup(entryPoints); + * + * // Resolves to the 'testing' sub-entry point because /project/src/testing is the longest matching prefix: + * findEntryPoint('/project/src/testing/test-bed.ts'); // -> NormalizedEntryPoint ('./testing') + * + * // Resolves to the primary entry point: + * findEntryPoint('/project/src/button.ts'); // -> NormalizedEntryPoint ('.') + * + * // Returns undefined for files located outside any entry point directory: + * findEntryPoint('/project/shared/utils.ts'); // -> undefined + * ``` + * + * @param entryPoints Iterable of normalized entry points. + * @returns An {@link EntryPointLookup} function that returns the owning {@link NormalizedEntryPoint}, + * or `undefined` if the file does not belong to any entry point directory. + */ +export function createEntryDirectoryLookup( + entryPoints: Iterable, +): EntryPointLookup { + const dirs = Array.from(entryPoints, (ep) => { + const dir = toPosixPath(path.dirname(ep.entryFilePath)); + + return { + ep, + dir, + dirSlash: dir.endsWith('/') ? dir : `${dir}/`, + }; + }).sort((a, b) => b.dir.length - a.dir.length); + + const cache = new Map(); + + return (filePath: string): NormalizedEntryPoint | undefined => { + const posix = toPosixPath(filePath); + const cached = cache.get(posix); + if (cached !== undefined || cache.has(posix)) { + return cached; + } + const found = dirs.find(({ dir, dirSlash }) => posix === dir || posix.startsWith(dirSlash))?.ep; + cache.set(posix, found); + + return found; + }; +} + +/** + * Computes the base bundle file name for an entry point. + * + * @param packageName The package name from package.json. + * @param entryPointName The entry point subpath name (defaults to '.'). + * @returns The sanitized bundle base name. + */ +export function getEntryPointBundleName(packageName: string, entryPointName = '.'): string { + const isPrimary = !entryPointName || entryPointName === '.'; + const pkgName = packageName[0] === '@' ? packageName.slice(1) : packageName; + const epName = isPrimary ? pkgName : `${pkgName}-${entryPointName}`; + + return epName.replaceAll('/', '-'); +} 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 d5bc3a60ab0b..112234ca89d3 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 @@ -9,8 +9,9 @@ import assert from 'node:assert'; import { join } from 'node:path'; import type { NormalizedEntryPoint, NormalizedLibraryOptions, PackageJsonData } from '../options'; +import { getEntryPointBundleName } from './entry-points'; import { generatePackageManifests } from './package-manifests'; -import { type MemoryOutputFile, getEntryPointBundleName } from './utils'; +import type { MemoryOutputFile } from './utils'; describe('generatePackageManifests', () => { const tempDir = '/workspace/my-lib'; diff --git a/packages/angular/build/src/builders/library/pipeline/utils.ts b/packages/angular/build/src/builders/library/pipeline/utils.ts index ec9d42beec9c..6020973a90e1 100644 --- a/packages/angular/build/src/builders/library/pipeline/utils.ts +++ b/packages/angular/build/src/builders/library/pipeline/utils.ts @@ -6,10 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import path from 'node:path'; -import { toPosixPath } from '../../../utils/path'; -import type { NormalizedEntryPoint } from '../options'; - const IS_DTS_FILE_REGEXP = /\.d\.[cm]?ts$/i; const IS_DTS_MAP_FILE_REGEXP = /\.d\.[cm]?ts\.map$/i; @@ -23,11 +19,6 @@ export const FESM_OUTPUT_DIR = 'fesm2022'; */ export const TYPES_OUTPUT_DIR = 'types'; -/** - * Function that resolves a file path to its containing normalized library entry point, if any. - */ -export type EntryPointLookup = (filePath: string) => NormalizedEntryPoint | undefined; - /** * Represents an in-memory file to be emitted to disk. */ @@ -114,76 +105,3 @@ export function isDeclarationFile(path: string): boolean { export function isDeclarationSourceMapFile(path: string): boolean { return IS_DTS_MAP_FILE_REGEXP.test(path); } - -/** - * Creates an optimized, memoized lookup function that maps arbitrary file paths - * to their closest containing library entry point. - * - * Entry point root directories are sorted in descending order of path length so that - * more specific, nested sub-entry points take precedence over shallower or primary - * entry points. Lookups are cached to avoid repeated path scanning. - * - * @example - * Given two entry points: - * - Primary entry point `.` at `/project/src/public-api.ts` (dir: `/project/src`) - * - Secondary entry point `./testing` at `/project/src/testing/public-api.ts` (dir: `/project/src/testing`) - * - * ```ts - * const findEntryPoint = createEntryDirectoryLookup(entryPoints); - * - * // Resolves to the 'testing' sub-entry point because /project/src/testing is the longest matching prefix: - * findEntryPoint('/project/src/testing/test-bed.ts'); // -> NormalizedEntryPoint ('./testing') - * - * // Resolves to the primary entry point: - * findEntryPoint('/project/src/button.ts'); // -> NormalizedEntryPoint ('.') - * - * // Returns undefined for files located outside any entry point directory: - * findEntryPoint('/project/shared/utils.ts'); // -> undefined - * ``` - * - * @param entryPoints Iterable of normalized entry points. - * @returns An {@link EntryPointLookup} function that returns the owning {@link NormalizedEntryPoint}, - * or `undefined` if the file does not belong to any entry point directory. - */ -export function createEntryDirectoryLookup( - entryPoints: Iterable, -): EntryPointLookup { - const dirs = Array.from(entryPoints, (ep) => { - const dir = toPosixPath(path.dirname(ep.entryFilePath)); - - return { - ep, - dir, - dirSlash: dir.endsWith('/') ? dir : `${dir}/`, - }; - }).sort((a, b) => b.dir.length - a.dir.length); - - const cache = new Map(); - - return (filePath: string): NormalizedEntryPoint | undefined => { - const posix = toPosixPath(filePath); - const cached = cache.get(posix); - if (cached !== undefined || cache.has(posix)) { - return cached; - } - const found = dirs.find(({ dir, dirSlash }) => posix === dir || posix.startsWith(dirSlash))?.ep; - cache.set(posix, found); - - return found; - }; -} - -/** - * Computes the base bundle file name for an entry point. - * - * @param packageName The package name from package.json. - * @param entryPointName The entry point subpath name (defaults to '.'). - * @returns The sanitized bundle base name. - */ -export function getEntryPointBundleName(packageName: string, entryPointName = '.'): string { - const isPrimary = !entryPointName || entryPointName === '.'; - const pkgName = packageName[0] === '@' ? packageName.slice(1) : packageName; - const epName = isPrimary ? pkgName : `${pkgName}-${entryPointName}`; - - return epName.replaceAll('/', '-'); -}