Skip to content

Commit cc246d5

Browse files
committed
feat(@angular-devkit/build-angular): allow customization of output locations
This update introduces the ability for users to define the locations for storing `media`, `browser`, and `server` files. You can achieve this by utilizing the extended `outputPath` option. ```json { "projects": { "my-app": { "architect": { "build": { "builder": "@angular-devkit/build-angular:application", "options": { "outputPath": { "base": "dist/my-app", "browser": "", "server": "node-server", "media": "resources" } } } } } } } ``` While not recommended, choosing to set the `browser` option empty will result in files being output directly under the specified `base` path. It's important to note that this action will generate certain files like `stats.json` and `prerendered-routes.json` that aren't intended for deployment in this directory. **Validation rules:** - `browser` and `server` are relative to the configuration set in the `base` option. - When SSR is enabled, `browser` cannot be set to an empty string, and cannot be the same as `server`. - `media` is relative to the value specified in the `browser` option. - `media` cannot be set to an empty string. - `browser`, `media`, or `server` cannot contain slashes. Closes: #26632 and closes: #26057
1 parent 66edac4 commit cc246d5

File tree

9 files changed

+505
-95
lines changed

9 files changed

+505
-95
lines changed

goldens/public-api/angular_devkit/build_angular/index.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export interface ApplicationBuilderOptions {
4646
namedChunks?: boolean;
4747
optimization?: OptimizationUnion_2;
4848
outputHashing?: OutputHashing_2;
49-
outputPath: string;
49+
outputPath: OutputPathUnion;
5050
poll?: number;
5151
polyfills?: string[];
5252
prerender?: PrerenderUnion;

packages/angular_devkit/build_angular/src/builders/application/build-action.ts

+13-9
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,18 @@ import { withNoProgress, withSpinner, writeResultFiles } from '../../tools/esbui
1717
import { deleteOutputDir } from '../../utils/delete-output-dir';
1818
import { shouldWatchRoot } from '../../utils/environment-options';
1919
import { NormalizedCachedOptions } from '../../utils/normalize-cache';
20+
import { NormalizedOutputOptions } from './options';
2021

2122
export async function* runEsBuildBuildAction(
2223
action: (rebuildState?: RebuildState) => ExecutionResult | Promise<ExecutionResult>,
2324
options: {
2425
workspaceRoot: string;
2526
projectRoot: string;
26-
outputPath: string;
27+
outputOptions: NormalizedOutputOptions;
2728
logger: logging.LoggerApi;
2829
cacheOptions: NormalizedCachedOptions;
29-
writeToFileSystem?: boolean;
30-
writeToFileSystemFilter?: (file: BuildOutputFile) => boolean;
30+
writeToFileSystem: boolean;
31+
writeToFileSystemFilter: ((file: BuildOutputFile) => boolean) | undefined;
3132
watch?: boolean;
3233
verbose?: boolean;
3334
progress?: boolean;
@@ -39,13 +40,13 @@ export async function* runEsBuildBuildAction(
3940
): AsyncIterable<(ExecutionResult['outputWithFiles'] | ExecutionResult['output']) & BuilderOutput> {
4041
const {
4142
writeToFileSystemFilter,
42-
writeToFileSystem = true,
43+
writeToFileSystem,
4344
watch,
4445
poll,
4546
logger,
4647
deleteOutputPath,
4748
cacheOptions,
48-
outputPath,
49+
outputOptions,
4950
verbose,
5051
projectRoot,
5152
workspaceRoot,
@@ -54,7 +55,10 @@ export async function* runEsBuildBuildAction(
5455
} = options;
5556

5657
if (deleteOutputPath && writeToFileSystem) {
57-
await deleteOutputDir(workspaceRoot, outputPath, ['browser', 'server']);
58+
await deleteOutputDir(workspaceRoot, outputOptions.base, [
59+
outputOptions.browser,
60+
outputOptions.server,
61+
]);
5862
}
5963

6064
const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress;
@@ -79,7 +83,7 @@ export async function* runEsBuildBuildAction(
7983

8084
const ignored: string[] = [
8185
// Ignore the output and cache paths to avoid infinite rebuild cycles
82-
outputPath,
86+
outputOptions.base,
8387
cacheOptions.basePath,
8488
`${workspaceRoot.replace(/\\/g, '/')}/**/.*/**`,
8589
];
@@ -137,7 +141,7 @@ export async function* runEsBuildBuildAction(
137141
// unit tests which execute the builder and modify the file system programmatically.
138142
if (writeToFileSystem) {
139143
// Write output files
140-
await writeResultFiles(result.outputFiles, result.assetFiles, outputPath);
144+
await writeResultFiles(result.outputFiles, result.assetFiles, outputOptions);
141145

142146
yield result.output;
143147
} else {
@@ -191,7 +195,7 @@ export async function* runEsBuildBuildAction(
191195
const filesToWrite = writeToFileSystemFilter
192196
? result.outputFiles.filter(writeToFileSystemFilter)
193197
: result.outputFiles;
194-
await writeResultFiles(filesToWrite, result.assetFiles, outputPath);
198+
await writeResultFiles(filesToWrite, result.assetFiles, outputOptions);
195199

196200
yield result.output;
197201
} else {

packages/angular_devkit/build_angular/src/builders/application/index.ts

+36-14
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,47 @@ export async function* buildApplicationInternal(
3131
},
3232
extensions?: ApplicationBuilderExtensions,
3333
): AsyncIterable<ApplicationBuilderOutput> {
34+
const { workspaceRoot, logger, target } = context;
35+
3436
// Check Angular version.
35-
assertCompatibleAngularVersion(context.workspaceRoot);
37+
assertCompatibleAngularVersion(workspaceRoot);
3638

3739
// Purge old build disk cache.
3840
await purgeStaleBuildCache(context);
3941

4042
// Determine project name from builder context target
41-
const projectName = context.target?.project;
43+
const projectName = target?.project;
4244
if (!projectName) {
43-
context.logger.error(`The 'application' builder requires a target to be specified.`);
45+
yield { success: false, error: `The 'application' builder requires a target to be specified.` };
4446

4547
return;
4648
}
4749

4850
const normalizedOptions = await normalizeOptions(context, projectName, options, extensions);
51+
const writeToFileSystem = infrastructureSettings?.write ?? true;
52+
const writeServerBundles =
53+
writeToFileSystem && !!(normalizedOptions.ssrOptions && normalizedOptions.serverEntryPoint);
54+
55+
if (writeServerBundles) {
56+
const { browser, server } = normalizedOptions.outputOptions;
57+
if (browser === '') {
58+
yield {
59+
success: false,
60+
error: `'outputPath.browser' cannot be configured to an empty string when SSR is enabled.`,
61+
};
62+
63+
return;
64+
}
65+
66+
if (browser === server) {
67+
yield {
68+
success: false,
69+
error: `'outputPath.browser' and 'outputPath.server' cannot be configured to the same value.`,
70+
};
71+
72+
return;
73+
}
74+
}
4975

5076
// Setup an abort controller with a builder teardown if no signal is present
5177
let signal = context.signal;
@@ -58,14 +84,11 @@ export async function* buildApplicationInternal(
5884
yield* runEsBuildBuildAction(
5985
async (rebuildState) => {
6086
const startTime = process.hrtime.bigint();
61-
6287
const result = await executeBuild(normalizedOptions, context, rebuildState);
6388

6489
const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9;
6590
const status = result.errors.length > 0 ? 'failed' : 'complete';
66-
context.logger.info(
67-
`Application bundle generation ${status}. [${buildTime.toFixed(3)} seconds]`,
68-
);
91+
logger.info(`Application bundle generation ${status}. [${buildTime.toFixed(3)} seconds]`);
6992

7093
return result;
7194
},
@@ -75,19 +98,18 @@ export async function* buildApplicationInternal(
7598
poll: normalizedOptions.poll,
7699
deleteOutputPath: normalizedOptions.deleteOutputPath,
77100
cacheOptions: normalizedOptions.cacheOptions,
78-
outputPath: normalizedOptions.outputPath,
101+
outputOptions: normalizedOptions.outputOptions,
79102
verbose: normalizedOptions.verbose,
80103
projectRoot: normalizedOptions.projectRoot,
81104
workspaceRoot: normalizedOptions.workspaceRoot,
82105
progress: normalizedOptions.progress,
83-
writeToFileSystem: infrastructureSettings?.write,
106+
writeToFileSystem,
84107
// For app-shell and SSG server files are not required by users.
85108
// Omit these when SSR is not enabled.
86-
writeToFileSystemFilter:
87-
normalizedOptions.ssrOptions && normalizedOptions.serverEntryPoint
88-
? undefined
89-
: (file) => file.type !== BuildOutputFileType.Server,
90-
logger: context.logger,
109+
writeToFileSystemFilter: writeServerBundles
110+
? undefined
111+
: (file) => file.type !== BuildOutputFileType.Server,
112+
logger,
91113
signal,
92114
},
93115
);

packages/angular_devkit/build_angular/src/builders/application/options.ts

+53-27
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@ import { normalizeCacheOptions } from '../../utils/normalize-cache';
2323
import { generateEntryPoints } from '../../utils/package-chunk-sort';
2424
import { findTailwindConfigurationFile } from '../../utils/tailwind';
2525
import { getIndexInputFile, getIndexOutputFile } from '../../utils/webpack-browser-config';
26-
import { Schema as ApplicationBuilderOptions, I18NTranslation, OutputHashing } from './schema';
26+
import {
27+
Schema as ApplicationBuilderOptions,
28+
I18NTranslation,
29+
OutputHashing,
30+
OutputPathClass,
31+
} from './schema';
2732

33+
export type NormalizedOutputOptions = Required<OutputPathClass>;
2834
export type NormalizedApplicationBuildOptions = Awaited<ReturnType<typeof normalizeOptions>>;
2935

3036
export interface ApplicationBuilderExtensions {
@@ -125,23 +131,33 @@ export async function normalizeOptions(
125131

126132
const entryPoints = normalizeEntryPoints(workspaceRoot, options.browser, options.entryPoints);
127133
const tsconfig = path.join(workspaceRoot, options.tsConfig);
128-
const outputPath = normalizeDirectoryPath(path.join(workspaceRoot, options.outputPath));
129134
const optimizationOptions = normalizeOptimization(options.optimization);
130135
const sourcemapOptions = normalizeSourceMaps(options.sourceMap ?? false);
131136
const assets = options.assets?.length
132137
? normalizeAssetPatterns(options.assets, workspaceRoot, projectRoot, projectSourceRoot)
133138
: undefined;
134139

140+
const outputPath = options.outputPath;
141+
const outputOptions: NormalizedOutputOptions = {
142+
browser: 'browser',
143+
server: 'server',
144+
media: 'media',
145+
...(typeof outputPath === 'string' ? undefined : outputPath),
146+
base: normalizeDirectoryPath(
147+
path.join(workspaceRoot, typeof outputPath === 'string' ? outputPath : outputPath.base),
148+
),
149+
};
150+
135151
const outputNames = {
136152
bundles:
137153
options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Bundles
138154
? '[name]-[hash]'
139155
: '[name]',
140156
media:
141-
'media/' +
157+
outputOptions.media +
142158
(options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Media
143-
? '[name]-[hash]'
144-
: '[name]'),
159+
? '/[name]-[hash]'
160+
: '/[name]'),
145161
};
146162

147163
let fileReplacements: Record<string, string> | undefined;
@@ -191,26 +207,6 @@ export async function normalizeOptions(
191207
}
192208
}
193209

194-
let tailwindConfiguration: { file: string; package: string } | undefined;
195-
const tailwindConfigurationPath = await findTailwindConfigurationFile(workspaceRoot, projectRoot);
196-
if (tailwindConfigurationPath) {
197-
// Create a node resolver at the project root as a directory
198-
const resolver = createRequire(projectRoot + '/');
199-
try {
200-
tailwindConfiguration = {
201-
file: tailwindConfigurationPath,
202-
package: resolver.resolve('tailwindcss'),
203-
};
204-
} catch {
205-
const relativeTailwindConfigPath = path.relative(workspaceRoot, tailwindConfigurationPath);
206-
context.logger.warn(
207-
`Tailwind CSS configuration file found (${relativeTailwindConfigPath})` +
208-
` but the 'tailwindcss' package is not installed.` +
209-
` To enable Tailwind CSS, please install the 'tailwindcss' package.`,
210-
);
211-
}
212-
}
213-
214210
let indexHtmlOptions;
215211
// index can never have a value of `true` but in the schema it's of type `boolean`.
216212
if (typeof options.index !== 'boolean') {
@@ -318,7 +314,7 @@ export async function normalizeOptions(
318314
workspaceRoot,
319315
entryPoints,
320316
optimizationOptions,
321-
outputPath,
317+
outputOptions,
322318
outExtension,
323319
sourcemapOptions,
324320
tsconfig,
@@ -331,7 +327,7 @@ export async function normalizeOptions(
331327
serviceWorker:
332328
typeof serviceWorker === 'string' ? path.join(workspaceRoot, serviceWorker) : undefined,
333329
indexHtmlOptions,
334-
tailwindConfiguration,
330+
tailwindConfiguration: await getTailwindConfig(workspaceRoot, projectRoot, context),
335331
i18nOptions,
336332
namedChunks,
337333
budgets: budgets?.length ? budgets : undefined,
@@ -341,6 +337,36 @@ export async function normalizeOptions(
341337
};
342338
}
343339

340+
async function getTailwindConfig(
341+
workspaceRoot: string,
342+
projectRoot: string,
343+
context: BuilderContext,
344+
): Promise<{ file: string; package: string } | undefined> {
345+
const tailwindConfigurationPath = await findTailwindConfigurationFile(workspaceRoot, projectRoot);
346+
347+
if (!tailwindConfigurationPath) {
348+
return undefined;
349+
}
350+
351+
// Create a node resolver at the project root as a directory
352+
const resolver = createRequire(projectRoot + '/');
353+
try {
354+
return {
355+
file: tailwindConfigurationPath,
356+
package: resolver.resolve('tailwindcss'),
357+
};
358+
} catch {
359+
const relativeTailwindConfigPath = path.relative(workspaceRoot, tailwindConfigurationPath);
360+
context.logger.warn(
361+
`Tailwind CSS configuration file found (${relativeTailwindConfigPath})` +
362+
` but the 'tailwindcss' package is not installed.` +
363+
` To enable Tailwind CSS, please install the 'tailwindcss' package.`,
364+
);
365+
}
366+
367+
return undefined;
368+
}
369+
344370
/**
345371
* Normalize entry point options. To maintain compatibility with the legacy browser builder, we need a single `browser`
346372
* option which defines a single entry point. However, we also want to support multiple entry points as an internal option.

packages/angular_devkit/build_angular/src/builders/application/schema.json

+35-2
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,41 @@
220220
"default": []
221221
},
222222
"outputPath": {
223-
"type": "string",
224-
"description": "The full path for the new output directory, relative to the current workspace."
223+
"description": "Specify the output path relative to workspace root.",
224+
"oneOf": [
225+
{
226+
"type": "object",
227+
"properties": {
228+
"base": {
229+
"type": "string",
230+
"description": "Specify the output path relative to workspace root."
231+
},
232+
"browser": {
233+
"type": "string",
234+
"pattern": "^[-\\w\\.]*$",
235+
"default": "browser",
236+
"description": "The output directory name of your browser build within the output path base. Defaults to 'browser'."
237+
},
238+
"server": {
239+
"type": "string",
240+
"pattern": "^[-\\w\\.]*$",
241+
"default": "server",
242+
"description": "The output directory name of your server build within the output path base. Defaults to 'server'."
243+
},
244+
"media": {
245+
"type": "string",
246+
"pattern": "^[-\\w\\.]+$",
247+
"default": "media",
248+
"description": "The output directory name of your media files within the output browser directory. Defaults to 'media'."
249+
}
250+
},
251+
"required": ["base"],
252+
"additionalProperties": false
253+
},
254+
{
255+
"type": "string"
256+
}
257+
]
225258
},
226259
"aot": {
227260
"type": "boolean",

0 commit comments

Comments
 (0)