diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index 9a4de3b5aa64..ff7a592ac8f4 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", @@ -286,6 +293,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", @@ -327,6 +360,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 9e0b0b7c4ff1..d41b8d05782f 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.8", + "rolldown-plugin-dts": "0.28.5", "sass": "1.104.1", "sass-embedded": "1.104.1", "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 755b91c40544..005b411f7f23 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.', ); @@ -389,3 +397,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 3caf15a2cf3f..76c53d8fd392 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 @@ -110,5 +110,42 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); }); + + 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 71061031f3a4..d4c71c69f80d 100644 --- a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts @@ -75,7 +75,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/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts index 71b1c0e4768b..41ae41d66b9b 100644 --- a/packages/angular/build/src/utils/resolve-assets.ts +++ b/packages/angular/build/src/utils/resolve-assets.ts @@ -10,6 +10,11 @@ import path from 'node:path'; import { glob } from 'tinyglobby'; import { isSubDirectory } from './path'; +/** + * Default glob ignore patterns for assets. + */ +export const DEFAULT_ASSET_IGNORE = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db'] as const; + export async function resolveAssets( entries: { glob: string; @@ -21,8 +26,6 @@ export async function resolveAssets( }[], root: string, ): Promise<{ source: string; destination: string }[]> { - const defaultIgnore = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db']; - const outputFiles: { source: string; destination: string }[] = []; for (const entry of entries) { @@ -35,7 +38,7 @@ export async function resolveAssets( const files = await glob(entry.glob, { cwd, dot: true, - ignore: entry.ignore ? defaultIgnore.concat(entry.ignore) : defaultIgnore, + ignore: entry.ignore ? [...DEFAULT_ASSET_IGNORE, ...entry.ignore] : DEFAULT_ASSET_IGNORE, followSymbolicLinks: entry.followSymlinks, }); 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 44df5d7bd1b1..a0e02fc810b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -534,6 +534,9 @@ importers: rolldown: specifier: 1.2.8 version: 1.2.8 + rolldown-plugin-dts: + specifier: 0.28.5 + version: 0.28.5(rolldown@1.2.8)(typescript@6.0.3) sass: specifier: 1.104.1 version: 1.104.1