diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index e7fdcce2f8ca..7a5b9a7a016c 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -1,4 +1,15 @@ [ + [ + "packages/angular_devkit/build_angular/src/builders/application/options.ts", + "packages/angular_devkit/build_angular/src/utils/index.ts", + "packages/angular_devkit/build_angular/src/utils/delete-output-dir.ts" + ], + [ + "packages/angular_devkit/build_angular/src/builders/application/options.ts", + "packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts", + "packages/angular_devkit/build_angular/src/utils/index.ts", + "packages/angular_devkit/build_angular/src/utils/delete-output-dir.ts" + ], [ "packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts", "packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts" diff --git a/goldens/public-api/angular_devkit/build_angular/index.md b/goldens/public-api/angular_devkit/build_angular/index.md index 1092f0f0ceee..e8661be2e0d1 100644 --- a/goldens/public-api/angular_devkit/build_angular/index.md +++ b/goldens/public-api/angular_devkit/build_angular/index.md @@ -46,7 +46,7 @@ export interface ApplicationBuilderOptions { namedChunks?: boolean; optimization?: OptimizationUnion_2; outputHashing?: OutputHashing_2; - outputPath: string; + outputPath: OutputPathUnion; poll?: number; polyfills?: string[]; prerender?: PrerenderUnion; diff --git a/packages/angular_devkit/build_angular/src/builders/application/build-action.ts b/packages/angular_devkit/build_angular/src/builders/application/build-action.ts index 7e285e8d5ab3..9001722ec56d 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/build-action.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/build-action.ts @@ -17,17 +17,18 @@ import { withNoProgress, withSpinner, writeResultFiles } from '../../tools/esbui import { deleteOutputDir } from '../../utils/delete-output-dir'; import { shouldWatchRoot } from '../../utils/environment-options'; import { NormalizedCachedOptions } from '../../utils/normalize-cache'; +import { NormalizedOutputOptions } from './options'; export async function* runEsBuildBuildAction( action: (rebuildState?: RebuildState) => ExecutionResult | Promise, options: { workspaceRoot: string; projectRoot: string; - outputPath: string; + outputOptions: NormalizedOutputOptions; logger: logging.LoggerApi; cacheOptions: NormalizedCachedOptions; - writeToFileSystem?: boolean; - writeToFileSystemFilter?: (file: BuildOutputFile) => boolean; + writeToFileSystem: boolean; + writeToFileSystemFilter: ((file: BuildOutputFile) => boolean) | undefined; watch?: boolean; verbose?: boolean; progress?: boolean; @@ -39,13 +40,13 @@ export async function* runEsBuildBuildAction( ): AsyncIterable<(ExecutionResult['outputWithFiles'] | ExecutionResult['output']) & BuilderOutput> { const { writeToFileSystemFilter, - writeToFileSystem = true, + writeToFileSystem, watch, poll, logger, deleteOutputPath, cacheOptions, - outputPath, + outputOptions, verbose, projectRoot, workspaceRoot, @@ -54,7 +55,7 @@ export async function* runEsBuildBuildAction( } = options; if (deleteOutputPath && writeToFileSystem) { - await deleteOutputDir(workspaceRoot, outputPath, ['browser', 'server']); + await deleteOutputDir(workspaceRoot, outputOptions); } const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; @@ -79,7 +80,7 @@ export async function* runEsBuildBuildAction( const ignored: string[] = [ // Ignore the output and cache paths to avoid infinite rebuild cycles - outputPath, + outputOptions.base, cacheOptions.basePath, `${workspaceRoot.replace(/\\/g, '/')}/**/.*/**`, ]; @@ -137,7 +138,7 @@ export async function* runEsBuildBuildAction( // unit tests which execute the builder and modify the file system programmatically. if (writeToFileSystem) { // Write output files - await writeResultFiles(result.outputFiles, result.assetFiles, outputPath); + await writeResultFiles(result.outputFiles, result.assetFiles, outputOptions); yield result.output; } else { @@ -191,7 +192,7 @@ export async function* runEsBuildBuildAction( const filesToWrite = writeToFileSystemFilter ? result.outputFiles.filter(writeToFileSystemFilter) : result.outputFiles; - await writeResultFiles(filesToWrite, result.assetFiles, outputPath); + await writeResultFiles(filesToWrite, result.assetFiles, outputOptions); yield result.output; } else { diff --git a/packages/angular_devkit/build_angular/src/builders/application/index.ts b/packages/angular_devkit/build_angular/src/builders/application/index.ts index ef51b87143ad..db76fbdf9906 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/index.ts @@ -31,21 +31,35 @@ export async function* buildApplicationInternal( }, extensions?: ApplicationBuilderExtensions, ): AsyncIterable { + const { workspaceRoot, logger, target } = context; + // Check Angular version. - assertCompatibleAngularVersion(context.workspaceRoot); + assertCompatibleAngularVersion(workspaceRoot); // Purge old build disk cache. await purgeStaleBuildCache(context); // Determine project name from builder context target - const projectName = context.target?.project; + const projectName = target?.project; if (!projectName) { - context.logger.error(`The 'application' builder requires a target to be specified.`); + yield { success: false, error: `The 'application' builder requires a target to be specified.` }; return; } const normalizedOptions = await normalizeOptions(context, projectName, options, extensions); + const writeToFileSystem = infrastructureSettings?.write ?? true; + const writeServerBundles = + writeToFileSystem && !!(normalizedOptions.ssrOptions && normalizedOptions.serverEntryPoint); + + if (writeServerBundles && normalizedOptions.outputOptions.browser === '') { + yield { + success: false, + error: `'outputPath.browser' cannot be configured to an empty string when SSR is enabled.`, + }; + + return; + } // Setup an abort controller with a builder teardown if no signal is present let signal = context.signal; @@ -58,14 +72,11 @@ export async function* buildApplicationInternal( yield* runEsBuildBuildAction( async (rebuildState) => { const startTime = process.hrtime.bigint(); - const result = await executeBuild(normalizedOptions, context, rebuildState); const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9; const status = result.errors.length > 0 ? 'failed' : 'complete'; - context.logger.info( - `Application bundle generation ${status}. [${buildTime.toFixed(3)} seconds]`, - ); + logger.info(`Application bundle generation ${status}. [${buildTime.toFixed(3)} seconds]`); return result; }, @@ -75,19 +86,18 @@ export async function* buildApplicationInternal( poll: normalizedOptions.poll, deleteOutputPath: normalizedOptions.deleteOutputPath, cacheOptions: normalizedOptions.cacheOptions, - outputPath: normalizedOptions.outputPath, + outputOptions: normalizedOptions.outputOptions, verbose: normalizedOptions.verbose, projectRoot: normalizedOptions.projectRoot, workspaceRoot: normalizedOptions.workspaceRoot, progress: normalizedOptions.progress, - writeToFileSystem: infrastructureSettings?.write, + writeToFileSystem, // For app-shell and SSG server files are not required by users. // Omit these when SSR is not enabled. - writeToFileSystemFilter: - normalizedOptions.ssrOptions && normalizedOptions.serverEntryPoint - ? undefined - : (file) => file.type !== BuildOutputFileType.Server, - logger: context.logger, + writeToFileSystemFilter: writeServerBundles + ? undefined + : (file) => file.type !== BuildOutputFileType.Server, + logger, signal, }, ); diff --git a/packages/angular_devkit/build_angular/src/builders/application/options.ts b/packages/angular_devkit/build_angular/src/builders/application/options.ts index 0f31d12f2b41..490576b3181a 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/options.ts @@ -23,8 +23,14 @@ import { normalizeCacheOptions } from '../../utils/normalize-cache'; import { generateEntryPoints } from '../../utils/package-chunk-sort'; import { findTailwindConfigurationFile } from '../../utils/tailwind'; import { getIndexInputFile, getIndexOutputFile } from '../../utils/webpack-browser-config'; -import { Schema as ApplicationBuilderOptions, I18NTranslation, OutputHashing } from './schema'; +import { + Schema as ApplicationBuilderOptions, + I18NTranslation, + OutputHashing, + OutputPathClass, +} from './schema'; +export type NormalizedOutputOptions = Required; export type NormalizedApplicationBuildOptions = Awaited>; export interface ApplicationBuilderExtensions { @@ -125,23 +131,33 @@ export async function normalizeOptions( const entryPoints = normalizeEntryPoints(workspaceRoot, options.browser, options.entryPoints); const tsconfig = path.join(workspaceRoot, options.tsConfig); - const outputPath = normalizeDirectoryPath(path.join(workspaceRoot, options.outputPath)); const optimizationOptions = normalizeOptimization(options.optimization); const sourcemapOptions = normalizeSourceMaps(options.sourceMap ?? false); const assets = options.assets?.length ? normalizeAssetPatterns(options.assets, workspaceRoot, projectRoot, projectSourceRoot) : undefined; + const outputPath = options.outputPath; + const outputOptions: NormalizedOutputOptions = { + browser: 'browser', + server: 'server', + media: 'media', + ...(typeof outputPath === 'string' ? undefined : outputPath), + base: normalizeDirectoryPath( + path.join(workspaceRoot, typeof outputPath === 'string' ? outputPath : outputPath.base), + ), + }; + const outputNames = { bundles: options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Bundles ? '[name]-[hash]' : '[name]', media: - 'media/' + + outputOptions.media + (options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Media - ? '[name]-[hash]' - : '[name]'), + ? '/[name]-[hash]' + : '/[name]'), }; let fileReplacements: Record | undefined; @@ -191,26 +207,6 @@ export async function normalizeOptions( } } - let tailwindConfiguration: { file: string; package: string } | undefined; - const tailwindConfigurationPath = await findTailwindConfigurationFile(workspaceRoot, projectRoot); - if (tailwindConfigurationPath) { - // Create a node resolver at the project root as a directory - const resolver = createRequire(projectRoot + '/'); - try { - tailwindConfiguration = { - file: tailwindConfigurationPath, - package: resolver.resolve('tailwindcss'), - }; - } catch { - const relativeTailwindConfigPath = path.relative(workspaceRoot, tailwindConfigurationPath); - context.logger.warn( - `Tailwind CSS configuration file found (${relativeTailwindConfigPath})` + - ` but the 'tailwindcss' package is not installed.` + - ` To enable Tailwind CSS, please install the 'tailwindcss' package.`, - ); - } - } - let indexHtmlOptions; // index can never have a value of `true` but in the schema it's of type `boolean`. if (typeof options.index !== 'boolean') { @@ -318,7 +314,7 @@ export async function normalizeOptions( workspaceRoot, entryPoints, optimizationOptions, - outputPath, + outputOptions, outExtension, sourcemapOptions, tsconfig, @@ -331,7 +327,7 @@ export async function normalizeOptions( serviceWorker: typeof serviceWorker === 'string' ? path.join(workspaceRoot, serviceWorker) : undefined, indexHtmlOptions, - tailwindConfiguration, + tailwindConfiguration: await getTailwindConfig(workspaceRoot, projectRoot, context), i18nOptions, namedChunks, budgets: budgets?.length ? budgets : undefined, @@ -341,6 +337,36 @@ export async function normalizeOptions( }; } +async function getTailwindConfig( + workspaceRoot: string, + projectRoot: string, + context: BuilderContext, +): Promise<{ file: string; package: string } | undefined> { + const tailwindConfigurationPath = await findTailwindConfigurationFile(workspaceRoot, projectRoot); + + if (!tailwindConfigurationPath) { + return undefined; + } + + // Create a node resolver at the project root as a directory + const resolver = createRequire(projectRoot + '/'); + try { + return { + file: tailwindConfigurationPath, + package: resolver.resolve('tailwindcss'), + }; + } catch { + const relativeTailwindConfigPath = path.relative(workspaceRoot, tailwindConfigurationPath); + context.logger.warn( + `Tailwind CSS configuration file found (${relativeTailwindConfigPath})` + + ` but the 'tailwindcss' package is not installed.` + + ` To enable Tailwind CSS, please install the 'tailwindcss' package.`, + ); + } + + return undefined; +} + /** * Normalize entry point options. To maintain compatibility with the legacy browser builder, we need a single `browser` * option which defines a single entry point. However, we also want to support multiple entry points as an internal option. diff --git a/packages/angular_devkit/build_angular/src/builders/application/schema.json b/packages/angular_devkit/build_angular/src/builders/application/schema.json index df51a24a980a..f3f9d45168ec 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/application/schema.json @@ -220,8 +220,41 @@ "default": [] }, "outputPath": { - "type": "string", - "description": "The full path for the new output directory, relative to the current workspace." + "description": "Specify the output path relative to workspace root.", + "oneOf": [ + { + "type": "object", + "properties": { + "base": { + "type": "string", + "description": "Specify the output path relative to workspace root." + }, + "browser": { + "type": "string", + "pattern": "^[-\\w\\.]*$", + "default": "browser", + "description": "The output directory name of your browser build, relative to the output root. Defaults to 'browser'." + }, + "server": { + "type": "string", + "pattern": "^[-\\w\\.]*$", + "default": "server", + "description": "The output directory name of your server build, relative to the output root. Defaults to 'server'." + }, + "media": { + "type": "string", + "pattern": "^[-\\w\\.]+$", + "default": "media", + "description": "The output directory name of your media files. This is relative to the browser directory. Defaults to 'media'." + } + }, + "required": ["base"], + "additionalProperties": false + }, + { + "type": "string" + } + ] }, "aot": { "type": "boolean", diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/output-path_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/options/output-path_spec.ts new file mode 100644 index 000000000000..12ec346dece7 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/options/output-path_spec.ts @@ -0,0 +1,287 @@ +/** + * @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.io/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + // Add a media file + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + // Enable SSR + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + // Application code is not needed in this test + await harness.writeFile('src/main.server.ts', `console.log('Hello!');`); + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + await harness.writeFile('src/main.ts', `console.log('Hello!');`); + }); + + describe('Option: "outputPath"', () => { + describe(`when option value is is a string`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + outputPath: 'dist', + styles: ['src/styles.css'], + server: 'src/main.server.ts', + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it(`should emit media files in 'browser/media' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/media/spectrum.png').toExist(); + }); + + it(`should emit server bundles in 'server' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe(`when option value is an object`, () => { + describe(`'media' is set to 'resources'`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + media: 'resource', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it(`should emit media files in 'browser/resource' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/resource/spectrum.png').toExist(); + }); + + it(`should emit server bundles in 'server' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe(`'server' is set to 'node-server'`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + server: 'node-server', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it(`should emit media files in 'browser/media' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/media/spectrum.png').toExist(); + }); + + it(`should emit server bundles in 'node-server' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/node-server/server.mjs').toExist(); + }); + }); + + describe(`'browser' is set to 'public'`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: 'public', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'public' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/public/main.js').toExist(); + }); + + it(`should emit media files in 'public/media' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/public/media/spectrum.png').toExist(); + }); + + it(`should emit server bundles in 'server' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe(`'browser' is set to ''`, () => { + it(`should emit browser bundles in '' directory`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: '', + }, + ssr: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/main.js').toExist(); + }); + + it(`should emit media files in 'media' directory`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: '', + }, + ssr: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/media/spectrum.png').toExist(); + }); + + it(`should error when ssr is enabled`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: '', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expect(result?.error).toContain( + `'outputPath.browser' cannot be configured to an empty string when SSR is enabled`, + ); + }); + }); + + describe(`'server' is set ''`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + server: '', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it(`should emit media files in 'browser/media' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/media/spectrum.png').toExist(); + }); + + it(`should emit server bundles in '' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server.mjs').toExist(); + }); + }); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts index 6b5ce2ac6499..3653a82d3ecb 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -16,7 +16,7 @@ import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; import { emitFilesToDisk } from '../../tools/esbuild/utils'; import { deleteOutputDir } from '../../utils'; import { buildApplicationInternal } from '../application'; -import { Schema as ApplicationBuilderOptions } from '../application/schema'; +import { Schema as ApplicationBuilderOptions, OutputPathClass } from '../application/schema'; import { logBuilderStatusWarnings } from './builder-status-warnings'; import { Schema as BrowserBuilderOptions } from './schema'; @@ -44,10 +44,10 @@ export async function* buildEsbuildBrowser( logBuilderStatusWarnings(userOptions, context); const normalizedOptions = normalizeOptions(userOptions); const { deleteOutputPath, outputPath } = normalizedOptions; - const fullOutputPath = path.join(context.workspaceRoot, outputPath); + const fullOutputPath = path.join(context.workspaceRoot, outputPath.base); if (deleteOutputPath && infrastructureSettings?.write !== false) { - await deleteOutputDir(context.workspaceRoot, outputPath); + await deleteOutputDir(context.workspaceRoot, outputPath.base); } for await (const result of buildApplicationInternal( @@ -76,13 +76,26 @@ export async function* buildEsbuildBrowser( } } -function normalizeOptions(options: BrowserBuilderOptions): ApplicationBuilderOptions { - const { main: browser, ngswConfigPath, serviceWorker, polyfills, ...otherOptions } = options; +function normalizeOptions( + options: BrowserBuilderOptions, +): Omit & { outputPath: OutputPathClass } { + const { + main: browser, + outputPath, + ngswConfigPath, + serviceWorker, + polyfills, + ...otherOptions + } = options; return { browser, serviceWorker: serviceWorker ? ngswConfigPath : false, polyfills: typeof polyfills === 'string' ? [polyfills] : polyfills, + outputPath: { + base: outputPath, + browser: '', + }, ...otherOptions, }; } diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts index ab8dd55e489a..55a471764561 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/bundler-context.ts @@ -17,6 +17,7 @@ import { build, context, } from 'esbuild'; +import assert from 'node:assert'; import { basename, dirname, extname, join, relative } from 'node:path'; import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; import { convertOutputFile } from './utils'; @@ -51,7 +52,6 @@ export enum BuildOutputFileType { export interface BuildOutputFile extends OutputFile { type: BuildOutputFileType; - fullOutputPath: string; clone: () => BuildOutputFile; } @@ -325,10 +325,14 @@ export class BundlerContext { } } - const platformIsServer = this.#esbuildOptions?.platform === 'node'; + assert(this.#esbuildOptions, 'esbuild options cannot be undefined.'); + + const { platform, assetNames = '' } = this.#esbuildOptions; + const platformIsServer = platform === 'node'; + const mediaDirname = dirname(assetNames); const outputFiles = result.outputFiles.map((file) => { let fileType: BuildOutputFileType; - if (dirname(file.path) === 'media') { + if (dirname(file.path) === mediaDirname) { fileType = BuildOutputFileType.Media; } else { fileType = platformIsServer ? BuildOutputFileType.Server : BuildOutputFileType.Browser; diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts index 62958f62112d..cadab9a2e03f 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/utils.ts @@ -11,10 +11,11 @@ import { BuildOptions, Metafile, OutputFile, PartialMessage, formatMessages } fr import { createHash } from 'node:crypto'; import { constants as fsConstants } from 'node:fs'; import fs from 'node:fs/promises'; -import path, { join } from 'node:path'; +import path from 'node:path'; import { promisify } from 'node:util'; import { brotliCompress } from 'node:zlib'; import { coerce } from 'semver'; +import { NormalizedOutputOptions } from '../../builders/application/options'; import { BudgetCalculatorResult } from '../../utils/bundle-calculator'; import { Spinner } from '../../utils/spinner'; import { BundleStats, generateBuildStatsTable } from '../webpack/utils/stats'; @@ -194,36 +195,55 @@ export function getFeatureSupport(target: string[]): BuildOptions['supported'] { export async function writeResultFiles( outputFiles: BuildOutputFile[], assetFiles: BuildOutputAsset[] | undefined, - outputPath: string, + { base, browser, media, server }: NormalizedOutputOptions, ) { const directoryExists = new Set(); - const ensureDirectoryExists = async (basePath: string) => { - if (basePath && !directoryExists.has(basePath)) { - await fs.mkdir(path.join(outputPath, basePath), { recursive: true }); + const ensureDirectoryExists = async (destPath: string) => { + const basePath = path.dirname(destPath); + if (!directoryExists.has(basePath)) { + await fs.mkdir(path.join(base, basePath), { recursive: true }); directoryExists.add(basePath); } }; // Writes the output file to disk and ensures the containing directories are present await emitFilesToDisk(outputFiles, async (file: BuildOutputFile) => { - const fullOutputPath = file.fullOutputPath; + let outputDir: string; + switch (file.type) { + case BuildOutputFileType.Browser: + case BuildOutputFileType.Media: + outputDir = browser; + break; + case BuildOutputFileType.Server: + outputDir = server; + break; + case BuildOutputFileType.Root: + outputDir = ''; + break; + default: + throw new Error( + `Unhandled write for file "${file.path}" with type "${BuildOutputFileType[file.type]}".`, + ); + } + + const destPath = path.join(outputDir, file.path); + // Ensure output subdirectories exist - const basePath = path.dirname(fullOutputPath); - await ensureDirectoryExists(basePath); + await ensureDirectoryExists(destPath); // Write file contents - await fs.writeFile(path.join(outputPath, fullOutputPath), file.contents); + await fs.writeFile(path.join(base, destPath), file.contents); }); if (assetFiles?.length) { await emitFilesToDisk(assetFiles, async ({ source, destination }) => { + const destPath = path.join(browser, destination); + // Ensure output subdirectories exist - const destPath = join('browser', destination); - const basePath = path.dirname(destPath); - await ensureDirectoryExists(basePath); + await ensureDirectoryExists(destPath); // Copy file contents - await fs.copyFile(source, path.join(outputPath, destPath), fsConstants.COPYFILE_FICLONE); + await fs.copyFile(source, path.join(base, destPath), fsConstants.COPYFILE_FICLONE); }); } } @@ -261,9 +281,6 @@ export function createOutputFileFromText( get contents() { return Buffer.from(this.text, 'utf-8'); }, - get fullOutputPath(): string { - return getFullOutputPath(this); - }, clone(): BuildOutputFile { return createOutputFileFromText(this.path, this.text, this.type); }, @@ -287,9 +304,6 @@ export function createOutputFileFromData( get contents() { return data; }, - get fullOutputPath(): string { - return getFullOutputPath(this); - }, clone(): BuildOutputFile { return createOutputFileFromData(this.path, this.contents, this.type); }, @@ -311,27 +325,12 @@ export function convertOutputFile(file: OutputFile, type: BuildOutputFileType): this.contents.byteLength, ).toString('utf-8'); }, - get fullOutputPath(): string { - return getFullOutputPath(this); - }, clone(): BuildOutputFile { return convertOutputFile(this, this.type); }, }; } -export function getFullOutputPath(file: BuildOutputFile): string { - switch (file.type) { - case BuildOutputFileType.Browser: - case BuildOutputFileType.Media: - return join('browser', file.path); - case BuildOutputFileType.Server: - return join('server', file.path); - default: - return file.path; - } -} - /** * Transform browserlists result to esbuild target. * @see https://esbuild.github.io/api/#target diff --git a/packages/angular_devkit/build_angular/src/utils/delete-output-dir.ts b/packages/angular_devkit/build_angular/src/utils/delete-output-dir.ts index 9ca6bc091d18..f206524347e0 100644 --- a/packages/angular_devkit/build_angular/src/utils/delete-output-dir.ts +++ b/packages/angular_devkit/build_angular/src/utils/delete-output-dir.ts @@ -8,23 +8,28 @@ import { readdir, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { NormalizedOutputOptions } from '../builders/application/options'; /** * Delete an output directory, but error out if it's the root of the project. */ export async function deleteOutputDir( root: string, - outputPath: string, - emptyOnlyDirectories?: string[], + outputOptions: NormalizedOutputOptions | string, ): Promise { + const outputPath = typeof outputOptions === 'string' ? outputOptions : outputOptions.base; const resolvedOutputPath = resolve(root, outputPath); if (resolvedOutputPath === root) { throw new Error('Output path MUST not be project root directory!'); } - const directoriesToEmpty = emptyOnlyDirectories - ? new Set(emptyOnlyDirectories.map((directory) => join(resolvedOutputPath, directory))) - : undefined; + let directoriesToEmpty: Set | undefined; + if (typeof outputOptions === 'object') { + const resolvedToEmpty = [outputOptions.server, outputOptions.browser].map((dir) => + join(resolvedOutputPath, dir), + ); + directoriesToEmpty = new Set(resolvedToEmpty); + } // Avoid removing the actual directory to avoid errors in cases where the output // directory is mounted or symlinked. Instead the contents are removed.