From c09260457df3e4fb58b97127bae13f0477a02b40 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:20:05 +0000 Subject: [PATCH] feat(@angular/build): add library builder Add a new native `@angular/build:library` builder providing a modern, high-performance compilation and packaging pipeline. --- packages/angular/build/BUILD.bazel | 40 ++ packages/angular/build/builders.json | 5 + packages/angular/build/package.json | 1 + .../build/src/builders/library/builder.ts | 344 +++++++++++++++ .../build/src/builders/library/index.ts | 17 + .../build/src/builders/library/options.ts | 381 ++++++++++++++++ .../src/builders/library/pipeline/assets.ts | 127 ++++++ .../builders/library/pipeline/build-action.ts | 281 ++++++++++++ .../src/builders/library/pipeline/bundler.ts | 410 ++++++++++++++++++ .../builders/library/pipeline/compilation.ts | 257 +++++++++++ .../library/pipeline/package-manifests.ts | 168 +++++++ .../pipeline/package-manifests_spec.ts | 309 +++++++++++++ .../library/pipeline/stylesheet-bundler.ts | 66 +++ .../src/builders/library/pipeline/types.d.ts | 54 +++ .../src/builders/library/pipeline/utils.ts | 122 ++++++ .../build/src/builders/library/schema.json | 168 +++++++ .../library/tests/behavior/apf_spec.ts | 112 +++++ .../library/tests/behavior/build_spec.ts | 51 +++ .../library/tests/behavior/core_spec.ts | 101 +++++ .../library/tests/behavior/secondary_spec.ts | 122 ++++++ .../library/tests/behavior/styles_spec.ts | 145 +++++++ .../library/tests/behavior/watch_spec.ts | 404 +++++++++++++++++ .../allowed-non-peer-dependencies_spec.ts | 68 +++ .../library/tests/options/assets_spec.ts | 54 +++ .../tests/options/compilation-mode_spec.ts | 41 ++ .../tests/options/declaration-map_spec.ts | 43 ++ .../tests/options/delete-output-path_spec.ts | 43 ++ .../tests/options/entry-points_spec.ts | 86 ++++ .../options/keep-lifecycle-scripts_spec.ts | 69 +++ .../library/tests/options/output-path_spec.ts | 36 ++ .../build/src/builders/library/tests/setup.ts | 83 ++++ .../build/src/builders/unit-test/builder.ts | 33 +- .../tests/behavior/library-target_spec.ts | 73 ++++ .../tests/behavior/vitest-zone-init_spec.ts | 34 ++ .../compilation/angular-compilation.ts | 2 + .../angular/compilation/aot-compilation.ts | 38 +- .../angular/compilation/compiler-options.ts | 33 +- .../angular/compilation/jit-compilation.ts | 3 +- .../compilation/parallel-compilation.ts | 2 + .../angular/compilation/parallel-worker.ts | 2 + .../compilation/typescript-compilation.ts | 2 + .../cli/lib/config/workspace-schema.json | 23 + pnpm-lock.yaml | 294 +++++++++++++ 43 files changed, 4725 insertions(+), 22 deletions(-) create mode 100644 packages/angular/build/src/builders/library/builder.ts create mode 100644 packages/angular/build/src/builders/library/index.ts create mode 100644 packages/angular/build/src/builders/library/options.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/assets.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/build-action.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/bundler.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/compilation.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/package-manifests.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/types.d.ts create mode 100644 packages/angular/build/src/builders/library/pipeline/utils.ts create mode 100644 packages/angular/build/src/builders/library/schema.json create mode 100644 packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/build_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/core_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/styles_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/allowed-non-peer-dependencies_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/assets_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/compilation-mode_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/declaration-map_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/delete-output-path_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/options/output-path_spec.ts create mode 100644 packages/angular/build/src/builders/library/tests/setup.ts create mode 100644 packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index d648331b70de..19eee52bcf7c 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -24,6 +24,11 @@ ts_json_schema( src = "src/builders/extract-i18n/schema.json", ) +ts_json_schema( + name = "library_schema", + src = "src/builders/library/schema.json", +) + ts_json_schema( name = "ng_karma_schema", src = "src/builders/karma/schema.json", @@ -72,6 +77,7 @@ ts_project( "//packages/angular/build:src/builders/dev-server/schema.ts", "//packages/angular/build:src/builders/extract-i18n/schema.ts", "//packages/angular/build:src/builders/karma/schema.ts", + "//packages/angular/build:src/builders/library/schema.ts", "//packages/angular/build:src/builders/ng-packagr/schema.ts", "//packages/angular/build:src/builders/unit-test/schema.ts", ], @@ -104,6 +110,7 @@ ts_project( ":node_modules/piscina", ":node_modules/postcss", ":node_modules/rolldown", + ":node_modules/rolldown-plugin-dts", ":node_modules/rollup", ":node_modules/sass", ":node_modules/sass-embedded", @@ -287,6 +294,32 @@ ts_project( ], ) +ts_project( + name = "library_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/library/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # Base dependencies for the library in hello-world-lib. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + ], +) + jasmine_test( name = "application_integration_tests", size = "medium", @@ -328,6 +361,13 @@ jasmine_test( shard_count = 5, ) +jasmine_test( + name = "library_integration_tests", + size = "medium", + data = [":library_integration_test_lib"], + shard_count = 4, +) + genrule( name = "license", srcs = ["//:LICENSE"], diff --git a/packages/angular/build/builders.json b/packages/angular/build/builders.json index 7be59263804c..d73c7a18fe58 100644 --- a/packages/angular/build/builders.json +++ b/packages/angular/build/builders.json @@ -20,6 +20,11 @@ "schema": "./src/builders/karma/schema.json", "description": "Run Karma unit tests." }, + "library": { + "implementation": "./src/builders/library/index", + "schema": "./src/builders/library/schema.json", + "description": "Build an Angular library package conforming to the Angular Package Format (APF)." + }, "ng-packagr": { "implementation": "./src/builders/ng-packagr/index", "schema": "./src/builders/ng-packagr/schema.json", diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index 9a7908005064..d6020ea59c10 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..6eb450879ed7 --- /dev/null +++ b/packages/angular/build/src/builders/library/builder.ts @@ -0,0 +1,344 @@ +/** + * @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 fs from 'node:fs/promises'; +import { + resetSassWorkerPoolCaches, + shutdownSassWorkerPool, +} from '../../tools/esbuild/stylesheets/sass-language'; +import { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target'; +import { withNoProgress, withSpinner } from '../../tools/esbuild/utils'; +import { deleteOutputDir } from '../../utils/delete-output-dir'; +import { assertIsError } from '../../utils/error'; +import { initializeHash } from '../../utils/hash'; +import { toPosixPath } from '../../utils/path'; +import { logCumulativeDurations } from '../../utils/profiling'; +import { purgeStaleBuildCache } from '../../utils/purge-cache'; +import { getSupportedBrowsers } from '../../utils/supported-browsers'; +import { assertCompatibleAngularVersion } from '../../utils/version'; +import type { BuildWatcher } from '../../utils/watcher'; +import { + type NormalizedLibraryOptions, + type PackageJsonData, + normalizeLibraryOptions, +} from './options'; +import type { SingleBuildState } from './pipeline/build-action'; +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, createSingleBuildState, hasModifiedWatchedFile }, + { createComponentStylesheetBundlerForLibrary }, + ] = await Promise.all([ + import('./pipeline/build-action'), + import('./pipeline/stylesheet-bundler'), + ]); + + let stylesheetBundler: ReturnType | undefined; + let watcher: BuildWatcher | undefined; + const buildState = createSingleBuildState(); + + try { + const browsers = getSupportedBrowsers(projectRoot, logger); + const target = transformSupportedBrowsersToTargets(browsers); + stylesheetBundler = createComponentStylesheetBundlerForLibrary( + normalizedOptions, + isWatchMode, + target, + ); + + // Track all referenced files for watch mode + const allWatchedFiles = new Set([ + toPosixPath(tsConfigPath), + toPosixPath(packageJsonPath), + ]); + + for (const entryPoint of normalizedOptions.entryPoints.values()) { + allWatchedFiles.add(toPosixPath(entryPoint.entryFilePath)); + } + + if (isWatchMode) { + if (progress) { + logger.info('Watch mode enabled. Watching for file changes...'); + } + + const { setupWatcher } = await import('../../utils/watcher'); + watcher = await setupWatcher({ + workspaceRoot, + projectRoot, + outputPath, + cacheOptions, + poll, + preserveSymlinks, + signal, + watchFiles: allWatchedFiles, + }); + + context.addTeardown?.(() => void watcher?.close()); + } + + // Execute initial build + const initialResult = await executeBuild( + 'Building...', + { + options: normalizedOptions, + stylesheetBundler, + allWatchedFiles, + isWatchMode, + context, + buildState, + }, + withProgress, + watcher, + buildAction, + ); + yield initialResult; + + if (!isWatchMode || !watcher) { + return; + } + + yield* runWatchLoop( + watcher, + normalizedOptions, + stylesheetBundler, + allWatchedFiles, + context, + withProgress, + buildState, + buildAction, + hasModifiedWatchedFile, + signal, + ); + } finally { + logCumulativeDurations(); + shutdownSassWorkerPool(); + + await Promise.allSettled([ + watcher?.close(), + stylesheetBundler?.dispose(), + buildState.singleProgramCache?.compilationInstance.close?.(), + ]); + } +} + +async function executeBuild( + message: string, + actionContext: import('./pipeline/build-action').BuildActionContext, + withProgress: typeof withSpinner, + watcher: BuildWatcher | undefined, + buildAction: typeof import('./pipeline/build-action').buildAction, +): Promise { + const startTime = process.hrtime.bigint(); + const { context, allWatchedFiles, isWatchMode } = actionContext; + + try { + await withProgress(message, () => buildAction(actionContext)); + logBuildResult(context.logger, startTime, true); + if (isWatchMode) { + logCumulativeDurations(); + } + + return { success: true }; + } catch (error) { + assertIsError(error); + logBuildResult(context.logger, startTime, false); + + return { success: false, error: error.message }; + } finally { + watcher?.add(Array.from(allWatchedFiles)); + } +} + +/** + * Runs the watch loop, rebuilding the library as watched files are modified. + */ +async function* runWatchLoop( + watcher: BuildWatcher, + options: NormalizedLibraryOptions, + stylesheetBundler: ReturnType, + allWatchedFiles: Set, + context: BuilderContext, + withProgress: typeof withSpinner, + buildState: SingleBuildState, + buildAction: typeof import('./pipeline/build-action').buildAction, + hasModifiedWatchedFile: typeof import('./pipeline/build-action').hasModifiedWatchedFile, + signal?: AbortSignal, +): AsyncIterableIterator { + const { checkAssetChanges } = await import('./pipeline/assets'); + + const { workspaceRoot, packageJsonPath, assets, clearScreen } = options; + const posixPackageJsonPath = toPosixPath(packageJsonPath); + + for await (const changes of watcher) { + if (signal?.aborted) { + break; + } + + if (clearScreen) { + // eslint-disable-next-line no-console + console.clear(); + } + + const changedFiles = new Set(); + let hasStyleChanges = false; + let hasSassChanges = false; + for (const file of changes.all) { + const posixFile = toPosixPath(file); + changedFiles.add(posixFile); + if (/\.(?:scss|sass)$/i.test(posixFile)) { + hasStyleChanges = true; + hasSassChanges = true; + } else if (/\.(?:less|css)$/i.test(posixFile)) { + hasStyleChanges = true; + } + } + + if (hasStyleChanges) { + if (hasSassChanges) { + resetSassWorkerPoolCaches(); + } + const invalidatedStyles = stylesheetBundler.invalidate(changedFiles); + if (invalidatedStyles) { + for (const styleFile of invalidatedStyles) { + changedFiles.add(toPosixPath(styleFile)); + } + } + } + + // Check if package.json was modified + let hasPackageJsonChanges = false; + if (changedFiles.has(posixPackageJsonPath)) { + try { + const packageJson = await loadPackageJson(packageJsonPath); + options.packageJson = packageJson; + hasPackageJsonChanges = true; + } catch (error) { + assertIsError(error); + await buildState.singleProgramCache?.compilationInstance.update?.(changedFiles); + buildState.hasEmittedManifests = false; + yield { + success: false, + error: `Failed to reload 'package.json': ${error.message}`, + }; + continue; + } + } + + const hasSourceChanges = + !buildState.singleProgramCache || + Boolean(buildState.hasCompilationError) || + hasModifiedWatchedFile(changedFiles, allWatchedFiles, posixPackageJsonPath); + + if ( + !hasSourceChanges && + !hasPackageJsonChanges && + !checkAssetChanges(assets, workspaceRoot, changedFiles) + ) { + continue; + } + + yield await executeBuild( + 'Changes detected. Rebuilding...', + { + options, + stylesheetBundler, + allWatchedFiles, + isWatchMode: true, + context, + buildState, + modifiedFiles: changedFiles, + }, + withProgress, + watcher, + buildAction, + ); + } +} + +/** + * Loads and parses a JSON file from disk. + */ +async function loadPackageJson(packageJsonPath: string): Promise { + const content = await fs.readFile(packageJsonPath, 'utf-8'); + + return JSON.parse(content) as PackageJsonData; +} + +/** + * Logs the build completion time and status. + */ +function logBuildResult(logger: logging.LoggerApi, startTime: bigint, success: boolean): void { + const durationMs = Number(process.hrtime.bigint() - startTime) / 1_000_000; + const durationSec = (durationMs / 1000).toFixed(2); + + if (success) { + logger.info(`Build at: ${new Date().toISOString()} - Time: ${durationMs.toFixed(0)}ms`); + logger.info(`Built Angular library in ${durationSec}s.`); + } else { + logger.error(`Build failed after ${durationSec}s.`); + } +} 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..246d1bc05c16 --- /dev/null +++ b/packages/angular/build/src/builders/library/options.ts @@ -0,0 +1,381 @@ +/** + * @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; + + /** 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?: string | Record; + scripts?: Record; + workspaces?: unknown; + dependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + [key: string]: unknown; +} + +export interface NormalizedLibraryOptions { + workspaceRoot: string; + projectRoot: string; + packageName: string; + packageJson: PackageJsonData; + outputPath: string; + deleteOutputPath: boolean; + packageJsonPath: string; + tsConfigPath: string; + entryPoints: Map; + inlineStyleLanguage: 'css' | 'less' | 'sass' | 'scss'; + styleIncludePaths: string[]; + sass?: StylesheetPluginsass; + assets: ReturnType; + compilationMode: 'partial' | 'full'; + declarationMap: boolean; + allowedNonPeerDependencies: RegExp[]; + keepLifecycleScripts: boolean; + watch: boolean; + poll?: number; + preserveSymlinks: boolean; + progress: boolean; + clearScreen?: boolean; + cacheOptions: ReturnType; + postcssConfiguration?: { config: PostcssConfiguration; configPath: string }; + tailwindConfiguration?: { file: string; package: string }; + colors: boolean; +} + +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, + 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( + packageJson.exports, + projectRoot, + packageJsonPath, + packageName, + ); + + const allowedNonPeerDependencies: RegExp[] = [/^tslib$/]; + 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 assets = normalizeAssetPatterns( + [...defaultAssets, ...(rawAssets ?? [])], + 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 package.json exports (e.g. '.' or './testing'). + * @param posixKey Normalized POSIX key without trailing slashes. + * @param isPrimary Whether this is the primary entry point. + * @param targetPath The relative file path string from exports. + * @param projectRoot The library project root directory. + * @param packageName The root package name (e.g. `@my/lib`). + * @returns The normalized entry point descriptor. + */ +function normalizeEntryPoint( + key: string, + posixKey: string, + isPrimary: boolean, + targetPath: string, + projectRoot: string, + packageName: string, +): NormalizedEntryPoint { + const name = isPrimary + ? '.' + : posixKey[0] === '.' && posixKey[1] === '/' + ? posixKey.slice(2) + : posixKey; + + if (name !== '.' && (path.posix.isAbsolute(name) || name.includes('..'))) { + throw new Error( + `Invalid entry point key '${key}'. Entry point keys must be relative subpaths without '..' (e.g. './testing').`, + ); + } + + const subpath = isPrimary ? '.' : `./${name}`; + const displayName = isPrimary ? packageName : `${packageName}/${name}`; + const bundleName = getEntryPointBundleName(packageName, name); + + const entryFilePath = path.resolve(projectRoot, targetPath); + + if (!/(? { + if (!rawExports || (typeof rawExports !== 'string' && typeof rawExports !== 'object')) { + throw new Error( + `The 'package.json' at '${packageJsonPath}' must contain an 'exports' field defining the primary entry point ('.').`, + ); + } + + const exportsRecord = typeof rawExports === 'string' ? { '.': rawExports } : rawExports; + + const entryPoints = new Map(); + let hasPrimary = false; + + for (const [key, value] of Object.entries(exportsRecord)) { + let target: string | undefined; + + if (typeof value === 'string') { + target = value; + } else if ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record)['default'] === 'string' + ) { + target = (value as Record)['default'] as string; + } + + const posixKey = toPosixPath(key).replace(/\/+$/, ''); + const isPrimary = posixKey === '.' || posixKey === ''; + + if (!target) { + if (isPrimary) { + throw new Error( + `The primary entry point '.' in '${packageJsonPath}' must specify a string path ` + + `or a 'default' condition pointing to a TypeScript file.`, + ); + } + + // Non-JS/TS conditional export (e.g., sass/style-only subpath); preserve in package.json without compiling. + continue; + } + + if (!isPrimary && !/\.m?ts$/.test(target)) { + // Static asset, stylesheet, or package.json export; preserve in package.json without compiling. + continue; + } + + const entryPoint = normalizeEntryPoint( + key, + posixKey, + isPrimary, + target, + projectRoot, + packageName, + ); + + if (entryPoints.has(entryPoint.name)) { + throw new Error( + `Duplicate entry point detected: '${key}' resolves to the same name ('${entryPoint.name}') as an existing entry point.`, + ); + } + + entryPoints.set(entryPoint.name, entryPoint); + + if (entryPoint.isPrimary) { + hasPrimary = true; + } + } + + if (!hasPrimary) { + throw new Error( + `The 'exports' field in '${packageJsonPath}' must contain a primary entry point with key '.'.`, + ); + } + + return entryPoints; +} diff --git a/packages/angular/build/src/builders/library/pipeline/assets.ts b/packages/angular/build/src/builders/library/pipeline/assets.ts new file mode 100644 index 000000000000..27688ab0b634 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/assets.ts @@ -0,0 +1,127 @@ +/** + * @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 { statSync } from 'node:fs'; +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 []; + } + + if (modifiedFiles) { + if (modifiedFiles.size === 0) { + return []; + } + + const matchers = createAssetMatchers(assets, workspaceRoot); + const filesToEmit: DiskOutputFile[] = []; + + for (const file of modifiedFiles) { + const resolvedFile = path.isAbsolute(file) ? file : path.resolve(workspaceRoot, file); + const posixFile = toPosixPath(resolvedFile); + + for (const { asset, posixInputPrefix, isMatch } of matchers) { + if (!posixFile.startsWith(posixInputPrefix)) { + continue; + } + + const relative = posixFile.slice(posixInputPrefix.length); + if (!isMatch(relative)) { + continue; + } + + if (statSync(resolvedFile, { throwIfNoEntry: false })?.isFile()) { + filesToEmit.push(createDiskOutputFile(resolvedFile, path.join(asset.output, relative))); + allWatchedFiles.add(posixFile); + } + } + } + + return filesToEmit; + } + + const resolvedAssets = await resolveAssets(assets, workspaceRoot); + const filesToEmit: DiskOutputFile[] = []; + + for (const { source, destination } of resolvedAssets) { + filesToEmit.push(createDiskOutputFile(source, destination)); + allWatchedFiles.add(toPosixPath(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 = createAssetMatchers(assets, workspaceRoot); + + 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; +} + +function createAssetMatchers(assets: NormalizedLibraryOptions['assets'], workspaceRoot: string) { + return assets.map((asset) => { + const absInput = path.resolve(workspaceRoot, asset.input); + const posixInput = toPosixPath(absInput).replace(/\/+$/, ''); + const isMatch = picomatch(asset.glob, { + dot: true, + ignore: [...DEFAULT_ASSET_IGNORE, ...(asset.ignore ?? [])], + }); + + return { asset, posixInputPrefix: `${posixInput}/`, isMatch }; + }); +} diff --git a/packages/angular/build/src/builders/library/pipeline/build-action.ts b/packages/angular/build/src/builders/library/pipeline/build-action.ts new file mode 100644 index 000000000000..8c96292d60d3 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -0,0 +1,281 @@ +/** + * @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 { constants, copyFile, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { emitFilesToDisk } from '../../../tools/esbuild/utils'; +import { toPosixPath } from '../../../utils/path'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; +import { collectAssetsToEmit } from './assets'; +import { + type BundleEntryPointInput, + type BundleResult, + type EntryPointLookup, + bundleEntryPoints, + createEntryDirectoryLookup, +} from './bundler'; +import { type SingleProgramCache, compileLibrary } from './compilation'; +import { generatePackageManifests } from './package-manifests'; +import type { createComponentStylesheetBundlerForLibrary } from './stylesheet-bundler'; +import type { OutputFile } from './utils'; + +/** + * State preserved across incremental builds in watch mode. + */ +export interface SingleBuildState { + singleProgramCache?: SingleProgramCache; + previousBundleResults: Map; + pendingChangedEsmFiles: Set; + pendingChangedDtsFiles: Set; + hasCompilationError?: boolean; + hasEmittedManifests?: boolean; + hasEmittedAssets?: boolean; + entryDirectoryLookup?: EntryPointLookup; + directoryExists: Set; +} + +/** + * Creates a fresh {@link SingleBuildState} instance. + */ +export function createSingleBuildState(): SingleBuildState { + return { + previousBundleResults: new Map(), + pendingChangedEsmFiles: new Set(), + pendingChangedDtsFiles: new Set(), + directoryExists: new Set(), + }; +} + +/** + * Context required to execute a single library build iteration. + */ +export interface BuildActionContext { + options: NormalizedLibraryOptions; + context: BuilderContext; + stylesheetBundler: ReturnType; + isWatchMode: boolean; + allWatchedFiles: Set; + buildState: SingleBuildState; + modifiedFiles?: Set; +} + +/** + * Executes a single iteration of the library build pipeline, including + * single-program Angular compilation, parallel typechecking, 2-instance Rolldown bundling, + * manifest generation, and asset copying. + * + * @param actionContext The build action state and configuration. + */ +export async function buildAction(actionContext: BuildActionContext): Promise { + const { + options, + context, + stylesheetBundler, + isWatchMode, + allWatchedFiles, + buildState, + modifiedFiles, + } = actionContext; + + const posixPackageJsonPath = toPosixPath(options.packageJsonPath); + const { pendingChangedEsmFiles, pendingChangedDtsFiles, directoryExists } = buildState; + + if (!modifiedFiles || modifiedFiles.has(posixPackageJsonPath)) { + buildState.hasEmittedManifests = false; + } + + const shouldCompileEntryPoints = + !modifiedFiles || + !buildState.singleProgramCache || + Boolean(buildState.hasCompilationError) || + pendingChangedEsmFiles.size > 0 || + pendingChangedDtsFiles.size > 0 || + hasModifiedWatchedFile(modifiedFiles, allWatchedFiles, posixPackageJsonPath); + const shouldGenerateManifests = !buildState.hasEmittedManifests; + + if (shouldGenerateManifests) { + verifyAllowedDependencies(options); + } + + const filesToEmit: OutputFile[] = []; + + if (shouldCompileEntryPoints) { + buildState.hasCompilationError = true; + + const { + esmFiles, + dtsFiles, + changedEsmFiles, + changedDtsFiles, + referencedFiles, + cache, + diagnosePromise, + } = await compileLibrary( + options.entryPoints.values(), + options, + stylesheetBundler, + buildState.singleProgramCache, + modifiedFiles, + ); + buildState.singleProgramCache = cache; + + for (const file of referencedFiles) { + allWatchedFiles.add(file); + } + + for (const file of changedEsmFiles) { + pendingChangedEsmFiles.add(file); + } + for (const file of changedDtsFiles) { + pendingChangedDtsFiles.add(file); + } + + const findEntryPoint = (buildState.entryDirectoryLookup ??= createEntryDirectoryLookup( + options.entryPoints.values(), + )); + const itemsToBundle: BundleEntryPointInput[] = []; + + for (const entryPoint of options.entryPoints.values()) { + const previousBundleResult = buildState.previousBundleResults.get(entryPoint.name); + const hasEsmChanges = + !previousBundleResult || + hasEntryPointChanges( + entryPoint, + previousBundleResult.esmModuleIds, + findEntryPoint, + pendingChangedEsmFiles, + ); + const hasDtsChanges = + !previousBundleResult || + hasEntryPointChanges( + entryPoint, + previousBundleResult.dtsModuleIds, + findEntryPoint, + pendingChangedDtsFiles, + ); + + if (hasEsmChanges || hasDtsChanges) { + context.logger.info(`Compiling ${entryPoint.displayName}...`); + itemsToBundle.push({ + entryPoint, + hasEsmChanges, + hasDtsChanges, + previousBundleResult, + }); + } + } + + let bundleOutput: Awaited>; + let warnings: string[]; + try { + [bundleOutput, warnings] = await Promise.all([ + bundleEntryPoints(itemsToBundle, esmFiles, dtsFiles, options, findEntryPoint), + diagnosePromise, + ]); + } catch (error) { + // Prioritize TypeScript/Angular diagnostic errors over secondary bundler failures. + await diagnosePromise; + throw error; + } + + buildState.hasCompilationError = false; + pendingChangedEsmFiles.clear(); + pendingChangedDtsFiles.clear(); + + for (const warning of warnings) { + context.logger.warn(warning); + } + + filesToEmit.push(...bundleOutput.filesToEmit); + for (const [name, bundleResult] of bundleOutput.bundleResults) { + buildState.previousBundleResults.set(name, bundleResult); + } + } + + if (shouldGenerateManifests) { + filesToEmit.push(...generatePackageManifests(options, isWatchMode)); + } + + filesToEmit.push( + ...(await collectAssetsToEmit( + options.assets, + options.workspaceRoot, + allWatchedFiles, + buildState.hasEmittedAssets ? modifiedFiles : undefined, + )), + ); + + await emitFilesToDisk(filesToEmit, async (file) => { + const fullFilePath = path.join(options.outputPath, file.path); + const fileBasePath = path.dirname(fullFilePath); + if (fileBasePath && !directoryExists.has(fileBasePath)) { + await mkdir(fileBasePath, { recursive: true }); + directoryExists.add(fileBasePath); + } + + if (file.type === 'memory') { + await writeFile(fullFilePath, file.contents); + } else { + await copyFile(file.source, fullFilePath, constants.COPYFILE_FICLONE); + } + }); + + buildState.hasEmittedManifests = true; + buildState.hasEmittedAssets = true; +} + +export function hasModifiedWatchedFile( + modifiedFiles: ReadonlySet, + allWatchedFiles: ReadonlySet, + posixPackageJsonPath: string, +): boolean { + for (const file of modifiedFiles) { + if (file !== posixPackageJsonPath && allWatchedFiles.has(file)) { + return true; + } + } + + return false; +} + +function hasEntryPointChanges( + entryPoint: NormalizedEntryPoint, + moduleIds: ReadonlySet, + findEntryPoint: EntryPointLookup, + changedFiles: ReadonlySet, +): boolean { + if (changedFiles.size === 0) { + return false; + } + + for (const file of changedFiles) { + if (moduleIds.has(file) || findEntryPoint(file) === entryPoint) { + return true; + } + } + + return false; +} + +function verifyAllowedDependencies(options: NormalizedLibraryOptions): void { + const { packageJson, allowedNonPeerDependencies } = options; + const dependencies = { + ...(packageJson.dependencies ?? {}), + ...(packageJson.optionalDependencies ?? {}), + }; + + for (const dep of Object.keys(dependencies)) { + if (!allowedNonPeerDependencies.some((regex) => regex.test(dep))) { + throw new Error( + `Dependency '${dep}' must be explicitly allowed using the 'allowedNonPeerDependencies' option, ` + + `or moved to 'peerDependencies' in 'package.json'.`, + ); + } + } +} diff --git a/packages/angular/build/src/builders/library/pipeline/bundler.ts b/packages/angular/build/src/builders/library/pipeline/bundler.ts new file mode 100644 index 000000000000..9a7ba51e15a3 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/bundler.ts @@ -0,0 +1,410 @@ +/** + * @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 path from 'node:path'; +import { + type OutputChunk, + type OutputOptions, + type Plugin, + type RolldownOutput, + type RolldownPluginOption, + rolldown, +} from 'rolldown'; +import { dts } from 'rolldown-plugin-dts'; +import { toPosixPath } from '../../../utils/path'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; +import { + FESM_OUTPUT_DIR, + type MemoryOutputFile, + TYPES_OUTPUT_DIR, + createMemoryOutputFile, +} from './utils'; + +/** + * Cached module ID sets for a bundled entry point. + */ +export interface BundleResult { + /** Exact set of virtual ESM module IDs bundled into this entry point. */ + esmModuleIds: ReadonlySet; + + /** Exact set of virtual DTS module IDs bundled into this entry point. */ + dtsModuleIds: ReadonlySet; +} + +export interface BundleEntryPointsOutput { + filesToEmit: MemoryOutputFile[]; + bundleResults: Map; +} + +export interface BundleEntryPointInput { + entryPoint: NormalizedEntryPoint; + hasEsmChanges: boolean; + hasDtsChanges: boolean; + previousBundleResult?: BundleResult; +} + +const ESM_EXTENSIONS = ['.js', '.mjs', '/index.js', '/index.mjs'] as const; +const DTS_EXTENSIONS = ['.d.ts', '.d.mts', '/index.d.ts', '/index.d.mts'] as const; + +export type EntryPointLookup = (filePath: string) => NormalizedEntryPoint | undefined; + +interface MultiBundleOutput { + filesToEmit: MemoryOutputFile[]; + moduleIdsByBundle: Map>; +} + +/** + * Bundles the compiled in-memory JavaScript and declaration files for all dirty entry points + * using at most 2 Rolldown instances total (1 for all .mjs bundles, 1 for all .d.ts bundles). + */ +export async function bundleEntryPoints( + items: readonly BundleEntryPointInput[], + esmFiles: ReadonlyMap, + dtsFiles: ReadonlyMap, + options: NormalizedLibraryOptions, + findEntryPoint: EntryPointLookup = createEntryDirectoryLookup(options.entryPoints.values()), +): Promise { + const bundleResults = new Map(); + if (items.length === 0) { + return { filesToEmit: [], bundleResults }; + } + + const esmEntryPoints: NormalizedEntryPoint[] = []; + const dtsEntryPoints: NormalizedEntryPoint[] = []; + + for (const item of items) { + if (item.hasEsmChanges || !item.previousBundleResult) { + esmEntryPoints.push(item.entryPoint); + } + if (item.hasDtsChanges || !item.previousBundleResult) { + dtsEntryPoints.push(item.entryPoint); + } + } + + const [esmOutput, dtsOutput] = await Promise.all([ + bundleAllEsm(esmEntryPoints, esmFiles, options, findEntryPoint), + bundleAllDts(dtsEntryPoints, dtsFiles, options, findEntryPoint), + ]); + + for (const { entryPoint, previousBundleResult } of items) { + const { bundleName, name } = entryPoint; + bundleResults.set(name, { + esmModuleIds: + esmOutput.moduleIdsByBundle.get(bundleName) ?? + previousBundleResult?.esmModuleIds ?? + new Set(), + dtsModuleIds: + dtsOutput.moduleIdsByBundle.get(bundleName) ?? + previousBundleResult?.dtsModuleIds ?? + new Set(), + }); + } + + return { + filesToEmit: [...esmOutput.filesToEmit, ...dtsOutput.filesToEmit], + bundleResults, + }; +} + +export function createEntryDirectoryLookup( + entryPoints: Iterable, +): EntryPointLookup { + const dirs = Array.from(entryPoints, (ep) => { + const dir = toPosixPath(path.dirname(ep.entryFilePath)); + + return { + ep, + dir, + dirSlash: dir.endsWith('/') ? dir : `${dir}/`, + }; + }).sort((a, b) => b.dir.length - a.dir.length); + + const cache = new Map(); + + return (filePath: string): NormalizedEntryPoint | undefined => { + const posix = toPosixPath(filePath); + const cached = cache.get(posix); + if (cached !== undefined || cache.has(posix)) { + return cached; + } + const found = dirs.find(({ dir, dirSlash }) => posix === dir || posix.startsWith(dirSlash))?.ep; + cache.set(posix, found); + + return found; + }; +} + +function resolveEntryInputMap( + entryPoints: readonly NormalizedEntryPoint[], + dtsMode: boolean, +): Record { + const input: Record = {}; + for (const { bundleName, entryFilePath } of entryPoints) { + const posixPath = toPosixPath(entryFilePath); + input[bundleName] = dtsMode + ? posixPath.replace(/\.([cm]?ts)$/, '.d.$1') + : posixPath.replace(/\.([cm]?)ts$/, '.$1js'); + } + + return input; +} + +function createMemoryFileLoaderPlugin( + files: ReadonlyMap, + extensions: readonly string[], + includeMap: boolean, + findEntryPoint: EntryPointLookup, +): Plugin { + return { + name: 'memory-file-loader', + resolveId: { + order: 'pre', + handler(id, importer) { + if (id[0] === '\0') { + return undefined; + } + + if (!importer) { + return files.has(id) ? { id, external: false } : undefined; + } + + if (id[0] !== '.' && !path.isAbsolute(id)) { + return { id, external: true }; + } + + const posixId = toPosixPath(id); + const importerPosix = toPosixPath(importer); + const resolved = + posixId[0] === '.' + ? path.posix.join(path.posix.dirname(importerPosix), posixId) + : posixId; + + let resolvedCandidate: string | undefined; + if (files.has(resolved)) { + resolvedCandidate = resolved; + } else { + const base = resolved.replace(/\.[cm]?js$/, ''); + for (const ext of extensions) { + const candidate = base + ext; + if (files.has(candidate)) { + resolvedCandidate = candidate; + break; + } + } + } + + const importerEp = findEntryPoint(importerPosix); + const targetEp = findEntryPoint(resolvedCandidate ?? resolved); + if (importerEp && targetEp && importerEp.name !== targetEp.name) { + throw new Error( + `Entry point '${importerEp.name}' cannot import '${id}' from sibling entry point directly. ` + + `Import using the entry point package name instead.`, + ); + } + + if (resolvedCandidate) { + return { id: resolvedCandidate, external: false }; + } + + return { id, external: true }; + }, + }, + load(id) { + const code = files.get(id); + if (code === undefined) { + return null; + } + + return { + code, + map: includeMap ? files.get(`${id}.map`) : undefined, + }; + }, + }; +} + +function resolveChunkBundleName( + findEntryPoint: EntryPointLookup, + moduleIds: readonly string[], +): string | undefined { + for (const modId of moduleIds) { + const ep = findEntryPoint(modId); + if (ep) { + return ep.bundleName; + } + } + + return undefined; +} + +function processRolldownOutput(output: RolldownOutput['output'], dir: string): MultiBundleOutput { + const filesToEmit: MemoryOutputFile[] = []; + const moduleIdsByBundle = new Map>(); + const chunksByFileName = new Map(); + const entryChunks: OutputChunk[] = []; + + for (const item of output) { + filesToEmit.push( + createMemoryOutputFile( + path.posix.join(dir, item.fileName), + item.type === 'chunk' ? item.code : item.source, + ), + ); + + if (item.type === 'chunk') { + chunksByFileName.set(item.fileName, item); + if (item.isEntry) { + entryChunks.push(item); + } + } + } + + for (const entryChunk of entryChunks) { + const modSet = new Set(); + moduleIdsByBundle.set(entryChunk.name, modSet); + + const visited = new Set(); + const queue: OutputChunk[] = [entryChunk]; + + while (queue.length) { + const chunk = queue.pop(); + if (!chunk) { + break; + } + + if (visited.has(chunk)) { + continue; + } + + visited.add(chunk); + + for (const modId of chunk.moduleIds) { + if (modId[0] !== '\0') { + modSet.add(toPosixPath(modId)); + } + } + + for (const depFile of [...chunk.imports, ...chunk.dynamicImports]) { + const depChunk = chunksByFileName.get(depFile); + if (depChunk && !visited.has(depChunk)) { + queue.push(depChunk); + } + } + } + } + + return { filesToEmit, moduleIdsByBundle }; +} + +async function executeMultiBundle( + input: Record, + plugins: RolldownPluginOption[], + preserveSymlinks: boolean, + extension: 'mjs' | 'd.ts', + sourcemap: boolean, + findEntryPoint: EntryPointLookup, +): Promise { + const isDts = extension === 'd.ts'; + const dir = isDts ? TYPES_OUTPUT_DIR : FESM_OUTPUT_DIR; + const comments: OutputOptions['comments'] = isDts ? false : { legal: true, annotation: true }; + const bundle = await rolldown({ + context: 'this', + input, + plugins, + treeshake: false, + resolve: { symlinks: preserveSymlinks }, + checks: { circularDependency: false }, + experimental: { + attachDebugInfo: 'none', + }, + }); + + try { + const { output } = await bundle.generate({ + format: 'es', + dir, + entryFileNames: `[name].${extension}`, + chunkFileNames: (chunk) => { + const bundleName = resolveChunkBundleName(findEntryPoint, chunk.moduleIds); + const prefix = bundleName ? `${bundleName}-` : ''; + + return `${prefix}[name]-[hash].${extension}`; + }, + sourcemap, + hoistTransitiveImports: false, + comments, + }); + + return processRolldownOutput(output, dir); + } finally { + await bundle.close(); + } +} + +async function bundleAllEsm( + entryPoints: readonly NormalizedEntryPoint[], + esmFiles: ReadonlyMap, + options: NormalizedLibraryOptions, + findEntryPoint: EntryPointLookup, +): Promise { + if (entryPoints.length === 0) { + return { filesToEmit: [], moduleIdsByBundle: new Map() }; + } + + return executeMultiBundle( + resolveEntryInputMap(entryPoints, false), + [createMemoryFileLoaderPlugin(esmFiles, ESM_EXTENSIONS, true, findEntryPoint)], + options.preserveSymlinks, + 'mjs', + true, + findEntryPoint, + ); +} + +async function bundleAllDts( + entryPoints: readonly NormalizedEntryPoint[], + dtsFiles: ReadonlyMap, + options: NormalizedLibraryOptions, + findEntryPoint: EntryPointLookup, +): Promise { + if (entryPoints.length === 0) { + return { filesToEmit: [], moduleIdsByBundle: new Map() }; + } + + const dtsSourcemap = options.declarationMap; + // Filter out `rolldown-plugin-dts:resolver` because all `.d.ts` files are already emitted + // in-memory by the Angular/TypeScript compilation and resolved via `createMemoryFileLoaderPlugin`. + // The default `rolldown-plugin-dts:resolver` plugin performs filesystem resolution (`oxc-resolver`) + // and calls `this.load()` on on-disk `.ts` source files, which is unnecessary and causes a + // significant performance regression across multi-entry builds. + const rawDtsPlugins = dts({ + dtsInput: true, + tsconfig: false, + sourcemap: dtsSourcemap, + }); + const dtsPlugins = rawDtsPlugins.filter( + (plugin) => plugin.name !== 'rolldown-plugin-dts:resolver', + ); + assert( + dtsPlugins.length < rawDtsPlugins.length, + 'Expected "rolldown-plugin-dts:resolver" plugin to be present in rolldown-plugin-dts.', + ); + + return executeMultiBundle( + resolveEntryInputMap(entryPoints, true), + [ + createMemoryFileLoaderPlugin(dtsFiles, DTS_EXTENSIONS, dtsSourcemap, findEntryPoint), + ...dtsPlugins, + ], + options.preserveSymlinks, + 'd.ts', + dtsSourcemap, + findEntryPoint, + ); +} diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts new file mode 100644 index 000000000000..0df655cb6f2a --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -0,0 +1,257 @@ +/** + * @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 { + type AngularCompilation, + createAngularCompilation, +} from '../../../tools/angular/compilation'; +import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; +import { useTypeChecking } from '../../../utils/environment-options'; +import { toPosixPath } from '../../../utils/path'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions } from '../options'; +import { isDeclarationFile, isDeclarationSourceMapFile } from './utils'; + +const EMITTED_EXTENSIONS = ['.js', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts']; + +/** + * Cached state for the single unified library compilation. + */ +export interface SingleProgramCache { + readonly compilationInstance: AngularCompilation; + readonly esmFiles: Map; + readonly dtsFiles: Map; + readonly failedFiles?: ReadonlySet; +} + +/** + * Output of the unified library compilation step. + */ +export interface LibraryCompilationOutput { + readonly esmFiles: ReadonlyMap; + readonly dtsFiles: ReadonlyMap; + readonly changedEsmFiles: ReadonlySet; + readonly changedDtsFiles: ReadonlySet; + readonly referencedFiles: ReadonlySet; + readonly cache: SingleProgramCache; + readonly diagnosePromise: Promise; +} + +/** + * Compiles all library entry points in a single TypeScript and Angular compilation pass. + */ +export async function compileLibrary( + entryPoints: Iterable, + options: NormalizedLibraryOptions, + stylesheetBundler: ComponentStylesheetBundler, + cached?: SingleProgramCache, + modifiedFiles?: Set, +): Promise { + const { tsConfigPath, compilationMode, preserveSymlinks, colors, declarationMap } = options; + + const entryPathsMap: Record = {}; + const rootFiles: string[] = []; + for (const ep of entryPoints) { + entryPathsMap[ep.displayName] = [ep.entryFilePath]; + rootFiles.push(ep.entryFilePath); + } + + const compilationInstance = + cached?.compilationInstance ?? (await createAngularCompilation('aot', false)); + + try { + let effectiveModifiedFiles = modifiedFiles; + if (cached?.failedFiles?.size) { + stylesheetBundler.invalidate(cached.failedFiles); + effectiveModifiedFiles = new Set(modifiedFiles); + for (const file of cached.failedFiles) { + effectiveModifiedFiles.add(file); + } + } + + if (effectiveModifiedFiles && effectiveModifiedFiles.size > 0) { + await compilationInstance.update?.(effectiveModifiedFiles); + } + + const allReferencedFiles = new Set(); + const stylesheetWarnings: PartialMessage[] = []; + const stylesheetErrors: PartialMessage[] = []; + const failedFiles = new Set(); + + const hostOptions = { + modifiedFiles: effectiveModifiedFiles, + async transformStylesheet( + data: string, + containingFile: string, + stylesheetFile?: string, + ): Promise { + const result = stylesheetFile + ? await stylesheetBundler.bundleFile(stylesheetFile) + : await stylesheetBundler.bundleInline(data, containingFile); + + result.referencedFiles?.forEach((f) => allReferencedFiles.add(toPosixPath(f))); + if (result.warnings.length > 0) { + stylesheetWarnings.push(...result.warnings); + } + + if (result.errors?.length) { + stylesheetErrors.push(...result.errors); + failedFiles.add(toPosixPath(containingFile)); + if (stylesheetFile) { + failedFiles.add(toPosixPath(stylesheetFile)); + } + + return ''; + } + + return result.contents; + }, + processWebWorker: () => '', + }; + + const { referencedFiles } = await compilationInstance.initialize( + tsConfigPath, + hostOptions, + { + sourcemap: true, + preserveSymlinks, + rootFiles, + declarationMap, + compilationMode, + paths: entryPathsMap, + }, + 'library', + ); + + const emittedFiles = + stylesheetErrors.length === 0 ? await compilationInstance.emitAffectedFiles() : []; + const diagnosePromise = runDiagnosticsAndFormat( + compilationInstance, + stylesheetErrors, + stylesheetWarnings, + colors, + ); + + // Prevent unhandled promise rejection if an error occurs before diagnosePromise is awaited. + diagnosePromise.catch(() => {}); + + const esmFiles = cached?.esmFiles ?? new Map(); + const dtsFiles = cached?.dtsFiles ?? new Map(); + const changedEsmFiles = new Set(); + const changedDtsFiles = new Set(); + + for (const ref of referencedFiles) { + allReferencedFiles.add(toPosixPath(ref)); + } + + if (effectiveModifiedFiles) { + for (const modifiedFile of effectiveModifiedFiles) { + const posixModified = toPosixPath(modifiedFile); + if (allReferencedFiles.has(posixModified)) { + continue; + } + + const basePathWithoutExt = posixModified.replace(/(?:\.d\.[cm]?ts|\.[cm]?[jt]sx?)$/i, ''); + if (basePathWithoutExt === posixModified || existsSync(posixModified)) { + continue; + } + + for (const ext of EMITTED_EXTENSIONS) { + const outputPath = `${basePathWithoutExt}${ext}`; + const mapPath = `${outputPath}.map`; + if (esmFiles.delete(outputPath)) { + changedEsmFiles.add(outputPath); + } + if (dtsFiles.delete(outputPath)) { + changedDtsFiles.add(outputPath); + } + esmFiles.delete(mapPath); + dtsFiles.delete(mapPath); + } + } + } + + for (const { filename, contents } of emittedFiles) { + const normalized = toPosixPath(filename); + if (normalized.endsWith('.map')) { + const isDtsMap = isDeclarationSourceMapFile(normalized); + const targetMap = isDtsMap ? dtsFiles : esmFiles; + if (targetMap.get(normalized) !== contents) { + targetMap.set(normalized, contents); + (isDtsMap ? changedDtsFiles : changedEsmFiles).add(normalized.slice(0, -4)); + } + } else if (isDeclarationFile(normalized)) { + if (dtsFiles.get(normalized) !== contents) { + changedDtsFiles.add(normalized); + dtsFiles.set(normalized, contents); + } + } else if (esmFiles.get(normalized) !== contents) { + changedEsmFiles.add(normalized); + esmFiles.set(normalized, contents); + } + } + + return { + esmFiles, + dtsFiles, + changedEsmFiles, + changedDtsFiles, + referencedFiles: allReferencedFiles, + cache: { + compilationInstance, + esmFiles, + dtsFiles, + failedFiles: failedFiles.size > 0 ? failedFiles : undefined, + }, + diagnosePromise, + }; + } catch (error) { + if (!cached) { + await compilationInstance.close?.(); + } + throw error; + } +} + +async function runDiagnosticsAndFormat( + compilationInstance: AngularCompilation, + stylesheetErrors: PartialMessage[], + stylesheetWarnings: PartialMessage[], + colors: boolean, +): Promise { + if (stylesheetErrors.length > 0) { + const formatted = await formatMessages(stylesheetErrors, { kind: 'error', color: colors }); + throw new Error(`Failed to bundle stylesheet:\n${formatted.join('\n')}`); + } + + const warningsOut: string[] = []; + + 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) { + const formatted = await formatMessages(warnings, { kind: 'warning', color: colors }); + warningsOut.push(...formatted); + } + } + + if (stylesheetWarnings.length > 0) { + const formattedStyleWarnings = await formatMessages(stylesheetWarnings, { + kind: 'warning', + color: colors, + }); + warningsOut.push(...formattedStyleWarnings); + } + + return warningsOut; +} 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..8b5c7708bcec --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts @@ -0,0 +1,168 @@ +/** + * @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 { + 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 isWatchMode Whether the builder is running in watch mode. + * @returns An array of memory output files containing generated package manifests and .npmignore. + */ +export function generatePackageManifests( + options: NormalizedLibraryOptions, + isWatchMode: boolean, +): MemoryOutputFile[] { + 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 && !Array.isArray(userExports) + ? userExports + : {}), + './package.json': { default: './package.json' }, + }; + + const primaryEntryPoint = options.entryPoints.get('.'); + if (!primaryEntryPoint) { + throw new Error(`Primary entry point '.' was not found in entryPoints.`); + } + + const primaryName = primaryEntryPoint.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 options.entryPoints.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 .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 && + !Array.isArray(existingConditions) + ? (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..d5bc3a60ab0b --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests_spec.ts @@ -0,0 +1,309 @@ +/** + * @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 { join } from 'node:path'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions, PackageJsonData } from '../options'; +import { generatePackageManifests } from './package-manifests'; +import { type MemoryOutputFile, getEntryPointBundleName } from './utils'; + +describe('generatePackageManifests', () => { + const tempDir = '/workspace/my-lib'; + + function getRootPackageJson(files: MemoryOutputFile[]): PackageJsonData { + const file = files.find((f) => f.path === 'package.json'); + assert(file, 'package.json must be present in emitted files'); + + return JSON.parse(String(file.contents)) as PackageJsonData; + } + + function createEntryPoints( + packageName = 'my-lib', + includeSecondary = false, + ): Map { + const entryPoints = new Map(); + const primaryBundleName = getEntryPointBundleName(packageName); + entryPoints.set('.', { + subpath: '.', + name: '.', + displayName: packageName, + bundleName: primaryBundleName, + entryFilePath: join(tempDir, 'src/public-api.ts'), + isPrimary: true, + }); + + if (includeSecondary) { + const secondaryBundleName = getEntryPointBundleName(packageName, 'testing'); + entryPoints.set('testing', { + subpath: './testing', + name: 'testing', + displayName: `${packageName}/testing`, + bundleName: secondaryBundleName, + entryFilePath: join(tempDir, 'testing/src/public-api.ts'), + isPrimary: false, + }); + } + + return entryPoints; + } + + function createOptions( + overrides: Partial = {}, + includeSecondary = false, + ): NormalizedLibraryOptions { + const packageName = + (overrides.packageJson?.name as string | undefined) ?? overrides.packageName ?? 'my-lib'; + + return { + workspaceRoot: tempDir, + projectRoot: tempDir, + packageName, + packageJson: { + name: packageName, + }, + outputPath: '', + deleteOutputPath: true, + packageJsonPath: join(tempDir, 'package.json'), + tsConfigPath: join(tempDir, 'tsconfig.lib.json'), + entryPoints: overrides.entryPoints ?? createEntryPoints(packageName, includeSecondary), + 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, + }; + } + + it('should generate a valid APF package.json for an unscoped package', () => { + 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 files = generatePackageManifests(options, 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', () => { + const options = createOptions({ + packageJson: { + name: '@my-scope/my-lib', + version: '2.1.0', + }, + }); + + const files = generatePackageManifests(options, 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', () => { + const options = createOptions({ + keepLifecycleScripts: true, + packageJson: { + name: 'my-lib', + version: '1.0.0', + scripts: { + postinstall: 'echo done', + }, + }, + }); + + const files = generatePackageManifests(options, false); + const result = getRootPackageJson(files); + expect(result.scripts).toEqual({ postinstall: 'echo done' }); + }); + + it('should configure secondary entry points and create secondary manifests', () => { + const options = createOptions( + { + packageJson: { + name: '@my-scope/my-lib', + version: '1.0.0', + }, + }, + true, + ); + + const files = generatePackageManifests(options, 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(String(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', () => { + const options = createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + }, + }); + + const files = generatePackageManifests(options, true); + const result = getRootPackageJson(files); + expect(result.version).toMatch(/^0\.0\.0-watch\+\d+$/); + }); + + it('should throw an error if primary entry point is missing', () => { + const options = createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + }, + entryPoints: new Map(), + }); + + expect(() => generatePackageManifests(options, false)).toThrowError( + /Primary entry point '\.' was not found in entryPoints\./, + ); + }); + + it('should inject prepublishOnly guard script when compilationMode is full', () => { + const options = createOptions({ + compilationMode: 'full', + packageJson: { + name: 'my-lib', + version: '1.0.0', + }, + }); + + const files = generatePackageManifests(options, false); + const result = getRootPackageJson(files); + expect(result.scripts?.['prepublishOnly']).toContain( + 'Trying to publish a package that has been compiled in full compilation mode', + ); + }); + + it('should preserve custom user exports in package.json and merge subpath conditions', () => { + const options = createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + exports: { + './styles.css': './styles.css', + './scss/*': './scss/*', + '.': { + development: './src/index.ts', + }, + }, + }, + }); + + const files = generatePackageManifests(options, 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', () => { + const files1 = generatePackageManifests( + createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + }, + }), + false, + ); + expect(getRootPackageJson(files1).sideEffects).toBeFalse(); + + const files2 = generatePackageManifests( + createOptions({ + packageJson: { + name: 'my-lib', + version: '1.0.0', + sideEffects: ['*.css'], + }, + }), + 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..b07f0764f48b --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/utils.ts @@ -0,0 +1,122 @@ +/** + * @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 + */ + +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 (defaults to '.'). + * @returns The sanitized bundle base name. + */ +export function getEntryPointBundleName(packageName: string, entryPointName = '.'): string { + const isPrimary = !entryPointName || entryPointName === '.'; + const pkgName = packageName[0] === '@' ? packageName.slice(1) : packageName; + const epName = isPrimary ? pkgName : `${pkgName}-${entryPointName}`; + + return epName.replaceAll('/', '-'); +} + +/** + * 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. */ + path: 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 path The destination path where the file should be copied. + * @returns A {@link DiskOutputFile} descriptor. + */ +export function createDiskOutputFile(source: string, path: string): DiskOutputFile { + return { + type: 'disk', + source, + path, + }; +} + +/** + * 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', + }; +} + +/** + * 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..bac9944091f6 --- /dev/null +++ b/packages/angular/build/src/builders/library/schema.json @@ -0,0 +1,168 @@ +{ + "$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": { + "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 during development or 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' and 'optionalDependencies' sections 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"], + "definitions": { + "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..2cdf5108d8fb --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/apf_spec.ts @@ -0,0 +1,112 @@ +/** + * @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', + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + 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..b119f7f47dbc --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/secondary_spec.ts @@ -0,0 +1,122 @@ +/** + * @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 { 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-library dependencies', 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`, + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + './feature-a': './feature-a/src/public-api.ts', + './feature-b': './feature-b/src/public-api.ts', + './sub-module': './sub-module/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result } = await harness.executeOnce(); + 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'); + }); + }); +}); 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..d78ae7e2b731 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -0,0 +1,404 @@ +/** + * @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}\`; + `, + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + './feature': './feature/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + 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';`, + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + 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..cf1bb1b6e7b7 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts @@ -0,0 +1,86 @@ +/** + * @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 { LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Package.json "exports" entry points', () => { + it('should succeed when entry point is a .ts file', async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should succeed when exports is a string shorthand', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = './src/public-api.ts'; + + return JSON.stringify(pkg, null, 2); + }); + + const { result } = await harness.executeOnce(); + 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', + }); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.mts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should fail when entry point is not a .ts or .mts file', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.cts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result, error } = await harness.executeOnce({ + 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 () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.d.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result, error } = await harness.executeOnce({ + 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..4dc3de98cc87 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts @@ -0,0 +1,69 @@ +/** + * @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', + exports: { + '.': './src/public-api.ts', + }, + 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', + exports: { + '.': './src/public-api.ts', + }, + 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..19c529d2a632 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/setup.ts @@ -0,0 +1,83 @@ +/** + * @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({ + 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(); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + }); + + afterEach(() => libHost.restore().toPromise()); + + specDefinitions(harness); + }); +} diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts index bb1da57a7108..b496bd4c5d63 100644 --- a/packages/angular/build/src/builders/unit-test/builder.ts +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -249,6 +249,13 @@ export async function* execute( await context.getTargetOptions(normalizedOptions.buildTarget), builderName, )) as unknown as ApplicationBuilderInternalOptions; + } else if (builderName === '@angular/build:library') { + const libraryOptions = (await context.validateOptions( + await context.getTargetOptions(normalizedOptions.buildTarget), + builderName, + )) as Record; + + buildTargetOptions = transformLibraryOptions(libraryOptions); } else if (builderName === '@angular/build:ng-packagr') { const ngPackagrOptions = await context.validateOptions( await context.getTargetOptions(normalizedOptions.buildTarget), @@ -263,7 +270,8 @@ export async function* execute( } else { context.logger.warn( `The 'buildTarget' is configured to use '${builderName}', which is not supported. ` + - `The 'unit-test' builder is designed to work with '@angular/build:application' or '@angular/build:ng-packagr'. ` + + `The 'unit-test' builder is designed to work with '@angular/build:application', ` + + `'@angular/build:library', or '@angular/build:ng-packagr'. ` + 'Unexpected behavior or build failures may occur.', ); @@ -390,3 +398,26 @@ async function transformNgPackagrOptions( inlineStyleLanguage, } as ApplicationBuilderInternalOptions; } + +/** + * Transforms library builder options into internal application builder options for testing. + * + * @param options The raw validated options from the library build target. + * @returns Application builder options suitable for running tests. + */ +function transformLibraryOptions( + options: Record, +): ApplicationBuilderInternalOptions { + const { stylePreprocessorOptions, assets, inlineStyleLanguage, preserveSymlinks, tsConfig } = + options; + + return { + stylePreprocessorOptions: + stylePreprocessorOptions as ApplicationBuilderInternalOptions['stylePreprocessorOptions'], + assets: Array.isArray(assets) && assets.length ? assets : undefined, + inlineStyleLanguage: + inlineStyleLanguage as ApplicationBuilderInternalOptions['inlineStyleLanguage'], + preserveSymlinks: typeof preserveSymlinks === 'boolean' ? preserveSymlinks : undefined, + tsConfig: typeof tsConfig === 'string' ? tsConfig : undefined, + } as ApplicationBuilderInternalOptions; +} diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts new file mode 100644 index 000000000000..d6d604d9f311 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts @@ -0,0 +1,73 @@ +/** + * @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', + inlineStyleLanguage: 'scss', + stylePreprocessorOptions: { + includePaths: ['src/styles'], + }, + }, + { + builderName: '@angular/build:library', + }, + ); + + await harness.writeFiles({ + 'src/styles/_vars.scss': '$primary-color: #123456;', + 'src/public-api.ts': `export * from './lib/lib.component';`, + 'src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-comp', + standalone: true, + template: '

lib

', + styles: [\` + @use 'vars'; + p { color: vars.$primary-color; } + \`], + }) + export class LibComponent {} + `, + 'src/lib/lib.component.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { describe, it, expect } from 'vitest'; + import { LibComponent } from './lib.component'; + + describe('LibComponent', () => { + it('creates component with scss styles', () => { + TestBed.configureTestingModule({ + imports: [LibComponent], + }); + const fixture = TestBed.createComponent(LibComponent); + expect(fixture).toBeTruthy(); + }); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/lib/**/*.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts index 147cfc31c53e..e19ef884aef5 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts @@ -212,5 +212,39 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { expect(result?.success).toBeTrue(); expectNoLog(logs, /Zone\.js polyfills are being automatically injected/); }); + + it('should load Zone and Zone testing support when testing a library using @angular/build:library and zone.js is installed', async () => { + harness.withBuilderTarget( + 'build', + async () => ({ success: true }), + { + tsConfig: 'src/tsconfig.lib.json', + }, + { + 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..d766680d1674 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; @@ -52,6 +53,7 @@ export abstract class AngularCompilation { tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType?: 'application' | 'library', ): Promise; emitAffectedFiles(): Iterable | Promise> { diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts index b42b1273592d..e4047a3e3e1b 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -61,10 +61,12 @@ export class AotCompilation extends TypeScriptCompilation { super(); } + // eslint-disable-next-line max-lines-per-function async initialize( tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', ): Promise { // Dynamically load the Angular compiler CLI package const { NgtscProgram, OptimizeFor } = await TypeScriptCompilation.loadCompilerCli(); @@ -75,7 +77,7 @@ export class AotCompilation extends TypeScriptCompilation { rootNames, errors: configurationDiagnostics, warnings, - } = await this.loadConfiguration(tsconfig, compilerOptionOverrides); + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); const useTypeScriptTranspilation = (compilerOptions['_useTypeScriptTranspilation'] as boolean | undefined) ?? @@ -332,9 +334,11 @@ export class AotCompilation extends TypeScriptCompilation { useTypeScriptTranspilation, } = this.#state; const compilerOptions = typeScriptProgram.getCompilerOptions(); + const isLibraryEmit = !!compilerOptions.declaration; const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; - const emittedFiles = new Map(); + const emittedFiles = new Map(); + const emittedSourceFiles = new Set(); const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) { // Save builder info contents to specified location @@ -350,17 +354,21 @@ export class AotCompilation extends TypeScriptCompilation { } angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); - emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); + emittedSourceFiles.add(sourceFile); + const targetFilename = isLibraryEmit ? filename : sourceFile.fileName; + emittedFiles.set(targetFilename, { filename: targetFilename, contents }); }; const transformers = angularCompiler.prepareEmit().transformers; - transformers.before ??= []; - transformers.before.push( - replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()), - webWorkerTransform, - ); + if (!isLibraryEmit) { + transformers.before ??= []; + transformers.before.push( + replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()), + webWorkerTransform, + ); - if (!this.browserOnlyBuild) { - transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + if (!this.browserOnlyBuild) { + transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + } } // Emit is handled in write file callback when using TypeScript @@ -394,7 +402,7 @@ export class AotCompilation extends TypeScriptCompilation { // Angular may have files that must be emitted but TypeScript does not consider affected for (const sourceFile of typeScriptProgram.getSourceFiles()) { - if (emittedFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) { + if (emittedSourceFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) { continue; } @@ -410,7 +418,8 @@ export class AotCompilation extends TypeScriptCompilation { } if (useTypeScriptTranspilation) { - typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, undefined, transformers); + const emitOnly = affectedFiles.has(sourceFile) ? undefined : false; + typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, emitOnly, transformers); continue; } @@ -451,13 +460,14 @@ export class AotCompilation extends TypeScriptCompilation { contents += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; } else if (compilerOptions.sourceMap) { const mapFilename = sourceFile.fileName + '.map'; - emittedFiles.set(sourceFile, { filename: mapFilename, contents: printResult.map }); + emittedFiles.set(mapFilename, { filename: mapFilename, contents: printResult.map }); } } } angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); - emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); + emittedSourceFiles.add(sourceFile); + emittedFiles.set(sourceFile.fileName, { filename: sourceFile.fileName, contents }); } return emittedFiles.values(); diff --git a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts index 45af80eae6d3..1720a8bb0516 100644 --- a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts +++ b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts @@ -21,6 +21,9 @@ export interface CompilerOptionOverrides { includeTestMetadata?: boolean; customConditions?: string[]; rootFiles?: string[]; + declarationMap?: boolean; + compilationMode?: 'full' | 'partial'; + paths?: Record; } export function transformCompilerOptions( @@ -28,9 +31,11 @@ export function transformCompilerOptions( baseCompilerOptions: ng.CompilerOptions, overrides?: CompilerOptionOverrides, tsconfig?: string, + buildType: 'application' | 'library' = 'application', ): { compilerOptions: ng.CompilerOptions; warnings: PartialMessage[] } { const compilerOptions = { ...baseCompilerOptions }; const warnings: PartialMessage[] = []; + const isLibrary = buildType === 'library'; if ( compilerOptions.target === undefined || @@ -57,13 +62,15 @@ export function transformCompilerOptions( }); } - if (compilerOptions.compilationMode === 'partial') { + if (!isLibrary && compilerOptions.compilationMode === 'partial') { warnings.push({ text: 'Angular partial compilation mode is not supported when building applications.', location: null, notes: [{ text: 'Full compilation mode will be used instead.' }], }); compilerOptions.compilationMode = 'full'; + } else if (overrides?.compilationMode) { + compilerOptions.compilationMode = overrides.compilationMode; } // Enable incremental compilation by default if caching is enabled and incremental is not explicitly disabled @@ -101,6 +108,16 @@ export function transformCompilerOptions( }); } + if (isLibrary) { + compilerOptions.target = typeScript.ScriptTarget.ES2022; + compilerOptions.module = typeScript.ModuleKind.ES2022; + compilerOptions.moduleResolution = typeScript.ModuleResolutionKind.Bundler; + compilerOptions.importHelpers = true; + compilerOptions.declaration = true; + compilerOptions.declarationMap = overrides?.declarationMap; + compilerOptions.declarationDir = undefined; + } + // Synchronize custom resolve conditions. // Set if using the supported bundler resolution mode (bundler is the default in new projects) if ( @@ -116,21 +133,25 @@ export function transformCompilerOptions( noEmitOnError: false, composite: false, inlineSources: !!overrides?.sourcemap, - inlineSourceMap: !!overrides?.sourcemap, - sourceMap: undefined, + inlineSourceMap: !isLibrary && !!overrides?.sourcemap, + sourceMap: isLibrary ? !!overrides?.sourcemap : undefined, mapRoot: undefined, sourceRoot: undefined, preserveSymlinks: overrides?.preserveSymlinks, externalRuntimeStyles: overrides?.externalRuntimeStyles, _enableHmr: !!overrides?.enableHmr, // TypeScript transpilation is forced if: + // - Building a library (TypeScript emits both .js and .d.ts in a single pass). // - isolatedModules is disabled (TS needs full module types to emit JS). // - Karma code coverage is active (the coverage instrumentation transformer is Babel-based // and cannot parse raw TypeScript code; Vitest handles coverage instrumentation downstream). _useTypeScriptTranspilation: - !compilerOptions.isolatedModules || !!overrides?.instrumentForCoverage, - supportTestBed: !!overrides?.includeTestMetadata, - supportJitMode: !!overrides?.includeTestMetadata, + isLibrary || !compilerOptions.isolatedModules || !!overrides?.instrumentForCoverage, + supportTestBed: isLibrary ? undefined : !!overrides?.includeTestMetadata, + supportJitMode: isLibrary ? undefined : !!overrides?.includeTestMetadata, + paths: overrides?.paths + ? { ...baseCompilerOptions.paths, ...overrides.paths } + : baseCompilerOptions.paths, }, warnings, }; diff --git a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts index e89db1e75aa4..773279e236a9 100644 --- a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts @@ -43,6 +43,7 @@ export class JitCompilation extends TypeScriptCompilation { tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', ): Promise { // Dynamically load the Angular compiler CLI package const { constructorParametersDownlevelTransform } = @@ -54,7 +55,7 @@ export class JitCompilation extends TypeScriptCompilation { rootNames, errors: configurationDiagnostics, warnings, - } = await this.loadConfiguration(tsconfig, compilerOptionOverrides); + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); if (hostOptions.modifiedFiles) { this.invalidateFiles(hostOptions.modifiedFiles); diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts index 0b58423a418b..6e4debf62501 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts @@ -52,6 +52,7 @@ export class ParallelCompilation extends AngularCompilation { tsconfig: string, hostOptions: AngularHostOptions, compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', ): Promise { const stylesheetChannel = new MessageChannel(); // The request identifier is required because Angular can issue multiple concurrent requests @@ -94,6 +95,7 @@ export class ParallelCompilation extends AngularCompilation { jit: this.jit, browserOnlyBuild: this.browserOnlyBuild, compilerOptionOverrides, + buildType, stylesheetPort: stylesheetChannel.port2, webWorkerPort: webWorkerChannel.port2, webWorkerSignal, diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts index 5bf293da727d..30c52f3a7a0c 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts @@ -27,6 +27,7 @@ export interface InitRequest { tsconfig: string; fileReplacements?: Record; compilerOptionOverrides?: CompilerOptionOverrides; + buildType?: 'application' | 'library'; stylesheetPort: MessagePort; webWorkerPort: MessagePort; webWorkerSignal: Int32Array; @@ -111,6 +112,7 @@ export async function initialize(request: InitRequest): Promise { const { readConfiguration } = await TypeScriptCompilation.loadCompilerCli(); @@ -78,6 +79,7 @@ export abstract class TypeScriptCompilation extends AngularCompilation { originalCompilerOptions, compilerOptionOverrides, tsconfig, + buildType, ); return { 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 2837e5199bd2..d8e4117ad67c 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 @@ -1029,6 +1032,7 @@ packages: '@angular/animations@22.2.0-rc.0': resolution: {integrity: sha512-MnkNstor6AH92M9ps7RZfcCyUo24SqBZU71soDgYC3aXFJhSYOtJFPKE1/KgrpVt416lTzbhHe/68kAZ3cH9MQ==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} + deprecated: '@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.' peerDependencies: '@angular/core': 22.2.0-rc.0 @@ -4014,136 +4018,271 @@ packages: cpu: [arm64] os: [android] + '@yuku-codegen/binding-android-arm64@0.9.5': + resolution: {integrity: sha512-jrOY5WM+AaAqkv51fHP1x28ifto4WgcZVzodLUyMU1jMWn5Sq+VciRdk/n8E0Ey5w4p4cWWE+m5GfXWOYh7Kzw==} + cpu: [arm64] + os: [android] + '@yuku-codegen/binding-darwin-arm64@0.10.2': resolution: {integrity: sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A==} cpu: [arm64] os: [darwin] + '@yuku-codegen/binding-darwin-arm64@0.9.5': + resolution: {integrity: sha512-4O4lkCQIzPZGjiNB1GORSI6hBECbiiSBS/APZXPvNKYH+nhY4uuqv03LNXA+SET3hoBjvr95P5rIhY8KQaQUBA==} + cpu: [arm64] + os: [darwin] + '@yuku-codegen/binding-darwin-x64@0.10.2': resolution: {integrity: sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA==} cpu: [x64] os: [darwin] + '@yuku-codegen/binding-darwin-x64@0.9.5': + resolution: {integrity: sha512-9EvoUO0SEhD6/d8VHdWuPerepPMSR1y84+UEgz8Un1Ope14Oe7xMvePpEQLTLovoIFZ8Zg3iZ8z8E11pZMqC3g==} + cpu: [x64] + os: [darwin] + '@yuku-codegen/binding-freebsd-x64@0.10.2': resolution: {integrity: sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w==} cpu: [x64] os: [freebsd] + '@yuku-codegen/binding-freebsd-x64@0.9.5': + resolution: {integrity: sha512-HqT78WwgHTmp8lwujoUa9CrIortX4DdpuiVC18ZPSGvuuJf4ylpIEI6QrQEM78Zwz3muhAWAOXZ5irdiYY+AyA==} + cpu: [x64] + os: [freebsd] + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': resolution: {integrity: sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A==} cpu: [arm] os: [linux] libc: [glibc] + '@yuku-codegen/binding-linux-arm-gnu@0.9.5': + resolution: {integrity: sha512-QJXwIW6Ms3QIawbcBIedmrmXnfdpuAOjZJ/eAABq5XTzWvSxZ7lutu9W5yIHahlaTaFnBo7ikrVMD1szLTpPUw==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@yuku-codegen/binding-linux-arm-musl@0.10.2': resolution: {integrity: sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q==} cpu: [arm] os: [linux] libc: [musl] + '@yuku-codegen/binding-linux-arm-musl@0.9.5': + resolution: {integrity: sha512-eHbFy3IHGb+IYarrYwAo0yWSEQV3eAmEn6VrsKA6I1Wy1YPJxWqSJlV69JhqsHtJuJlljUIF3ex0y46yaKIu9w==} + cpu: [arm] + os: [linux] + libc: [musl] + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': resolution: {integrity: sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw==} cpu: [arm64] os: [linux] libc: [glibc] + '@yuku-codegen/binding-linux-arm64-gnu@0.9.5': + resolution: {integrity: sha512-ByoJMbySTaDhjAXSu8q6Lh7HKg3YoesXpcT72aYk0Aiw4PCznmY4ybpLTq0RCvp0RIPhFm/6yECFiGBwyCC1nw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': resolution: {integrity: sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA==} cpu: [arm64] os: [linux] libc: [musl] + '@yuku-codegen/binding-linux-arm64-musl@0.9.5': + resolution: {integrity: sha512-gD9vfXIoBw1toSxhO9rgmSu/FEfy3PMznJAxVjIuH8DrWEiDKXmJO0pJKfj1Ltbe/TmtWVZR+TjISqeSIGQzMg==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': resolution: {integrity: sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg==} cpu: [x64] os: [linux] libc: [glibc] + '@yuku-codegen/binding-linux-x64-gnu@0.9.5': + resolution: {integrity: sha512-BARvdnvqMGjOr5Iel2JH+9H5vAIE0R6H0Z2fsT02xrvmI/1ZXNB8lkuX+ZGevPhZb3zJ9WeC/R8JZstrsUOK5g==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@yuku-codegen/binding-linux-x64-musl@0.10.2': resolution: {integrity: sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q==} cpu: [x64] os: [linux] libc: [musl] + '@yuku-codegen/binding-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-x8flcevS1fbb7ESrmpOY/pON4eInSaE/7Ktjqx2udOE2W33BNSZmJuwpyUgo54AoHNqrcaM7szqydyJn5fNvew==} + cpu: [x64] + os: [linux] + libc: [musl] + '@yuku-codegen/binding-win32-arm64@0.10.2': resolution: {integrity: sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw==} cpu: [arm64] os: [win32] + '@yuku-codegen/binding-win32-arm64@0.9.5': + resolution: {integrity: sha512-KOL/rBatWqH4ZpCNoF8ZtSNdOJbAxBJLmk/VeRciepGROPTbPum1A9t67GpFgOU7qrkUWlPJdJ+KHKbvbmOt+w==} + cpu: [arm64] + os: [win32] + '@yuku-codegen/binding-win32-x64@0.10.2': resolution: {integrity: sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA==} cpu: [x64] os: [win32] + '@yuku-codegen/binding-win32-x64@0.9.5': + resolution: {integrity: sha512-FxENahEjWSan59Syh/us/Kf4wNq8qrJLFJ3R2N4Oiwtb6yNdz/rWLNTIoNSssnsp7IoWJdUP6o/Z8ppg7lXMcg==} + cpu: [x64] + os: [win32] + '@yuku-parser/binding-android-arm64@0.10.2': resolution: {integrity: sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw==} cpu: [arm64] os: [android] + '@yuku-parser/binding-android-arm64@0.9.5': + resolution: {integrity: sha512-A2JCFCSHfnficqYEw4Iujpx7XrkMM3UfFcgJFmYpZSo9zM4nyhmdIIE0FogSduuU60lhM0/UcfuUBXIVNBMlGQ==} + cpu: [arm64] + os: [android] + '@yuku-parser/binding-darwin-arm64@0.10.2': resolution: {integrity: sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA==} cpu: [arm64] os: [darwin] + '@yuku-parser/binding-darwin-arm64@0.9.5': + resolution: {integrity: sha512-3PiyU+Eare4YuKaQ22N98/yAiROPY5o/NQJHraICzDvk4pgS+m+bgLKOvkGBRn33OnV95Vdv1Mn38b+MQHpULQ==} + cpu: [arm64] + os: [darwin] + '@yuku-parser/binding-darwin-x64@0.10.2': resolution: {integrity: sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ==} cpu: [x64] os: [darwin] + '@yuku-parser/binding-darwin-x64@0.9.5': + resolution: {integrity: sha512-blFMAFI7AInI83XaiOF8cIeiRM46Nz9EfpZtZPRLbKxSA9aAr5v8aHYpmfN6NoyXxAv/10pwS4gsAuc1W6fy5Q==} + cpu: [x64] + os: [darwin] + '@yuku-parser/binding-freebsd-x64@0.10.2': resolution: {integrity: sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ==} cpu: [x64] os: [freebsd] + '@yuku-parser/binding-freebsd-x64@0.9.5': + resolution: {integrity: sha512-TsNuL4qsZdO0tHp5GW43Y5fHeLPpPHA675wdcPNTcdq2ZO5AvsQWFFoiDg8CH4oBktfsQ9tdvKhjLnLYwKvp4g==} + cpu: [x64] + os: [freebsd] + '@yuku-parser/binding-linux-arm-gnu@0.10.2': resolution: {integrity: sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA==} cpu: [arm] os: [linux] libc: [glibc] + '@yuku-parser/binding-linux-arm-gnu@0.9.5': + resolution: {integrity: sha512-b0afYK5gHeV8RdmOcqAlgM8ONsye4cax4DMnIBaoJyPMd1UTGvTbpkqQcPVdhmHlcfcGVGMV1laeVovlr/dscw==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-arm-musl@0.10.2': resolution: {integrity: sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg==} cpu: [arm] os: [linux] libc: [musl] + '@yuku-parser/binding-linux-arm-musl@0.9.5': + resolution: {integrity: sha512-fD3lKzl+r6j6n8DwiMY53qnh5DLqD8KJjCp+NbVud54Nnh3Q9Wprqss3YzyMHP3NSJk663Wlg1E1X5qOuFVFig==} + cpu: [arm] + os: [linux] + libc: [musl] + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': resolution: {integrity: sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ==} cpu: [arm64] os: [linux] libc: [glibc] + '@yuku-parser/binding-linux-arm64-gnu@0.9.5': + resolution: {integrity: sha512-aRU/aCphV1MWCl/lvI6NX8LgucHQ68Fx+vz7NBb/5MEr2EuzCxiPOQqeFcMdWh+2AED/p0AvAREch0c+cSnlhA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-arm64-musl@0.10.2': resolution: {integrity: sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A==} cpu: [arm64] os: [linux] libc: [musl] + '@yuku-parser/binding-linux-arm64-musl@0.9.5': + resolution: {integrity: sha512-5+Guro0l8H473YXlEjVNBRLN/IbPbJdnQh1zo0OLt49xxnON+XMJRRaEyTOTfkn9QSRg0+BhEKGR4W9Gu2uZJQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@yuku-parser/binding-linux-x64-gnu@0.10.2': resolution: {integrity: sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg==} cpu: [x64] os: [linux] libc: [glibc] + '@yuku-parser/binding-linux-x64-gnu@0.9.5': + resolution: {integrity: sha512-pwwSyV9q+GlvzSXlsMZBMlgk22L5bud714/vqZCdeUTivzeUVltQzUaf3IQXOGXdpc59JYTeuMCtbGquaAUoCA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@yuku-parser/binding-linux-x64-musl@0.10.2': resolution: {integrity: sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w==} cpu: [x64] os: [linux] libc: [musl] + '@yuku-parser/binding-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-z18j6JN3lBHH8vzN7gG1M8fI0nlgItSfnC9PfbmUbt97iHViKfw2iA2G9fGwRMe5LyPRZBrejIlapbygPbpdaw==} + cpu: [x64] + os: [linux] + libc: [musl] + '@yuku-parser/binding-win32-arm64@0.10.2': resolution: {integrity: sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw==} cpu: [arm64] os: [win32] + '@yuku-parser/binding-win32-arm64@0.9.5': + resolution: {integrity: sha512-s6Gwttb1dQvtPX6Bgkw+UPC9IO3UBDXc6Zezog8MMgvu3UfJBBB9TQqKBX/2quu0Eyh+lLSAyNlIyyecaagUPg==} + cpu: [arm64] + os: [win32] + '@yuku-parser/binding-win32-x64@0.10.2': resolution: {integrity: sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA==} cpu: [x64] os: [win32] + '@yuku-parser/binding-win32-x64@0.9.5': + resolution: {integrity: sha512-FrERt9YWatY3bJfSUdi4YWY+6iQcyo3MzCC821BxlWM5TFZEHijnpz/bknR79VHlXY/9CvXz8ZlaAGPsTtN3nw==} + cpu: [x64] + os: [win32] + '@yuku-toolchain/types@0.10.2': resolution: {integrity: sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q==} + '@yuku-toolchain/types@0.9.5': + resolution: {integrity: sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w==} + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -7381,6 +7520,25 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true + rolldown-plugin-dts@0.28.5: + resolution: {integrity: sha512-yYd3C9CeJwqjOc9X23m0Tyxcqic491uLZlfg51szT287S8zCCqLR2uoySoElgqy2CLn7PdXcEo1dlkBs4n1WHg==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.2.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + rolldown-plugin-dts@0.28.6: resolution: {integrity: sha512-qKrFtBfRfR2hP233m7Ic9zf3wv6MSYdZghWKMdEO6dRYZGlNfINJAa1P5ZVvyHh3mrcd7IJQvIY8L9vnm+v5rQ==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} @@ -8622,12 +8780,21 @@ packages: yuku-ast@0.10.2: resolution: {integrity: sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ==} + yuku-ast@0.9.5: + resolution: {integrity: sha512-Q8qW8WwQnN5Cm0ZZivdRIfv0sRLTjUq0YumXJkw8CYN1aCdICH9rk4C47/4n6kA5EqDtlj+F608twoTSV2MwdQ==} + yuku-codegen@0.10.2: resolution: {integrity: sha512-hentl2dtrF6cPjiAirtkfxfFZBA6Hdm61UqRJ7cA0qrlDNMk9PZGOjw5w1mx3E0hu/6pHcJF4dJW+D0SXHPZOA==} + yuku-codegen@0.9.5: + resolution: {integrity: sha512-zGUVyDpSK4b6c9B0yyxi2P0wujn1XZTbG9dCb7d6gyAoP63EMgyLzUwPUvwxPG5jgYqhlI7w+S3oCyapqGr4PA==} + yuku-parser@0.10.2: resolution: {integrity: sha512-CgaU0/PPjCAIEZ3WQroosOxTY3eeKldAN3h+vk8pMNz4+jl1CZzBR4pW+K/rRPF112VooCL5FdjJoiMNjDrL2A==} + yuku-parser@0.9.5: + resolution: {integrity: sha512-IBnAdNVMswWbJcM7woPluOudectJzFjJ1N8jVRxP9Uq4CIv5hZdBIcdmtRbFDYF/Ph2j2gVup4YJ4N9mh0jYFA==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -11947,77 +12114,151 @@ snapshots: '@yuku-codegen/binding-android-arm64@0.10.2': optional: true + '@yuku-codegen/binding-android-arm64@0.9.5': + optional: true + '@yuku-codegen/binding-darwin-arm64@0.10.2': optional: true + '@yuku-codegen/binding-darwin-arm64@0.9.5': + optional: true + '@yuku-codegen/binding-darwin-x64@0.10.2': optional: true + '@yuku-codegen/binding-darwin-x64@0.9.5': + optional: true + '@yuku-codegen/binding-freebsd-x64@0.10.2': optional: true + '@yuku-codegen/binding-freebsd-x64@0.9.5': + optional: true + '@yuku-codegen/binding-linux-arm-gnu@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm-gnu@0.9.5': + optional: true + '@yuku-codegen/binding-linux-arm-musl@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm-musl@0.9.5': + optional: true + '@yuku-codegen/binding-linux-arm64-gnu@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm64-gnu@0.9.5': + optional: true + '@yuku-codegen/binding-linux-arm64-musl@0.10.2': optional: true + '@yuku-codegen/binding-linux-arm64-musl@0.9.5': + optional: true + '@yuku-codegen/binding-linux-x64-gnu@0.10.2': optional: true + '@yuku-codegen/binding-linux-x64-gnu@0.9.5': + optional: true + '@yuku-codegen/binding-linux-x64-musl@0.10.2': optional: true + '@yuku-codegen/binding-linux-x64-musl@0.9.5': + optional: true + '@yuku-codegen/binding-win32-arm64@0.10.2': optional: true + '@yuku-codegen/binding-win32-arm64@0.9.5': + optional: true + '@yuku-codegen/binding-win32-x64@0.10.2': optional: true + '@yuku-codegen/binding-win32-x64@0.9.5': + optional: true + '@yuku-parser/binding-android-arm64@0.10.2': optional: true + '@yuku-parser/binding-android-arm64@0.9.5': + optional: true + '@yuku-parser/binding-darwin-arm64@0.10.2': optional: true + '@yuku-parser/binding-darwin-arm64@0.9.5': + optional: true + '@yuku-parser/binding-darwin-x64@0.10.2': optional: true + '@yuku-parser/binding-darwin-x64@0.9.5': + optional: true + '@yuku-parser/binding-freebsd-x64@0.10.2': optional: true + '@yuku-parser/binding-freebsd-x64@0.9.5': + optional: true + '@yuku-parser/binding-linux-arm-gnu@0.10.2': optional: true + '@yuku-parser/binding-linux-arm-gnu@0.9.5': + optional: true + '@yuku-parser/binding-linux-arm-musl@0.10.2': optional: true + '@yuku-parser/binding-linux-arm-musl@0.9.5': + optional: true + '@yuku-parser/binding-linux-arm64-gnu@0.10.2': optional: true + '@yuku-parser/binding-linux-arm64-gnu@0.9.5': + optional: true + '@yuku-parser/binding-linux-arm64-musl@0.10.2': optional: true + '@yuku-parser/binding-linux-arm64-musl@0.9.5': + optional: true + '@yuku-parser/binding-linux-x64-gnu@0.10.2': optional: true + '@yuku-parser/binding-linux-x64-gnu@0.9.5': + optional: true + '@yuku-parser/binding-linux-x64-musl@0.10.2': optional: true + '@yuku-parser/binding-linux-x64-musl@0.9.5': + optional: true + '@yuku-parser/binding-win32-arm64@0.10.2': optional: true + '@yuku-parser/binding-win32-arm64@0.9.5': + optional: true + '@yuku-parser/binding-win32-x64@0.10.2': optional: true + '@yuku-parser/binding-win32-x64@0.9.5': + optional: true + '@yuku-toolchain/types@0.10.2': {} + '@yuku-toolchain/types@0.9.5': {} + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -15666,6 +15907,20 @@ snapshots: dependencies: glob: 10.5.0 + rolldown-plugin-dts@0.28.5(rolldown@1.2.8)(typescript@6.0.3): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.6 + obug: 2.2.1 + rolldown: 1.2.8 + yuku-ast: 0.9.5 + yuku-codegen: 0.9.5 + yuku-parser: 0.9.5 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + rolldown-plugin-dts@0.28.6(rolldown@1.2.8)(typescript@6.0.3): dependencies: dts-resolver: 3.0.0 @@ -17067,6 +17322,10 @@ snapshots: dependencies: '@yuku-toolchain/types': 0.10.2 + yuku-ast@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + yuku-codegen@0.10.2: dependencies: '@yuku-toolchain/types': 0.10.2 @@ -17084,6 +17343,23 @@ snapshots: '@yuku-codegen/binding-win32-arm64': 0.10.2 '@yuku-codegen/binding-win32-x64': 0.10.2 + yuku-codegen@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.9.5 + '@yuku-codegen/binding-darwin-arm64': 0.9.5 + '@yuku-codegen/binding-darwin-x64': 0.9.5 + '@yuku-codegen/binding-freebsd-x64': 0.9.5 + '@yuku-codegen/binding-linux-arm-gnu': 0.9.5 + '@yuku-codegen/binding-linux-arm-musl': 0.9.5 + '@yuku-codegen/binding-linux-arm64-gnu': 0.9.5 + '@yuku-codegen/binding-linux-arm64-musl': 0.9.5 + '@yuku-codegen/binding-linux-x64-gnu': 0.9.5 + '@yuku-codegen/binding-linux-x64-musl': 0.9.5 + '@yuku-codegen/binding-win32-arm64': 0.9.5 + '@yuku-codegen/binding-win32-x64': 0.9.5 + yuku-parser@0.10.2: dependencies: '@yuku-toolchain/types': 0.10.2 @@ -17102,6 +17378,24 @@ snapshots: '@yuku-parser/binding-win32-arm64': 0.10.2 '@yuku-parser/binding-win32-x64': 0.10.2 + yuku-parser@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + yuku-ast: 0.9.5 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.9.5 + '@yuku-parser/binding-darwin-arm64': 0.9.5 + '@yuku-parser/binding-darwin-x64': 0.9.5 + '@yuku-parser/binding-freebsd-x64': 0.9.5 + '@yuku-parser/binding-linux-arm-gnu': 0.9.5 + '@yuku-parser/binding-linux-arm-musl': 0.9.5 + '@yuku-parser/binding-linux-arm64-gnu': 0.9.5 + '@yuku-parser/binding-linux-arm64-musl': 0.9.5 + '@yuku-parser/binding-linux-x64-gnu': 0.9.5 + '@yuku-parser/binding-linux-x64-musl': 0.9.5 + '@yuku-parser/binding-win32-arm64': 0.9.5 + '@yuku-parser/binding-win32-x64': 0.9.5 + zod@3.25.76: {} zod@4.6.5: {}