Skip to content

Commit 246b229

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 left empty. - `media` is relative to the value specified in the `browser` option. - Both `media` and `server` cannot be set to an empty strings. - `browser`, `media`, or `server` can contain slashes. Closes: angular#26632 and closes: angular#26057
1 parent 66edac4 commit 246b229

File tree

10 files changed

+439
-95
lines changed

10 files changed

+439
-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

+10-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 { NormalizedOutputPathOptions } 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+
outputPathOptions: NormalizedOutputPathOptions;
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+
outputPathOptions,
4950
verbose,
5051
projectRoot,
5152
workspaceRoot,
@@ -54,7 +55,7 @@ export async function* runEsBuildBuildAction(
5455
} = options;
5556

5657
if (deleteOutputPath && writeToFileSystem) {
57-
await deleteOutputDir(workspaceRoot, outputPath, ['browser', 'server']);
58+
await deleteOutputDir(workspaceRoot, outputPathOptions);
5859
}
5960

6061
const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress;
@@ -79,7 +80,7 @@ export async function* runEsBuildBuildAction(
7980

8081
const ignored: string[] = [
8182
// Ignore the output and cache paths to avoid infinite rebuild cycles
82-
outputPath,
83+
outputPathOptions.base,
8384
cacheOptions.basePath,
8485
`${workspaceRoot.replace(/\\/g, '/')}/**/.*/**`,
8586
];
@@ -137,7 +138,7 @@ export async function* runEsBuildBuildAction(
137138
// unit tests which execute the builder and modify the file system programmatically.
138139
if (writeToFileSystem) {
139140
// Write output files
140-
await writeResultFiles(result.outputFiles, result.assetFiles, outputPath);
141+
await writeResultFiles(result.outputFiles, result.assetFiles, outputPathOptions);
141142

142143
yield result.output;
143144
} else {
@@ -191,7 +192,7 @@ export async function* runEsBuildBuildAction(
191192
const filesToWrite = writeToFileSystemFilter
192193
? result.outputFiles.filter(writeToFileSystemFilter)
193194
: result.outputFiles;
194-
await writeResultFiles(filesToWrite, result.assetFiles, outputPath);
195+
await writeResultFiles(filesToWrite, result.assetFiles, outputPathOptions);
195196

196197
yield result.output;
197198
} else {

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

+27-14
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,38 @@ 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 = !!(normalizedOptions.ssrOptions && normalizedOptions.serverEntryPoint);
53+
54+
if (
55+
writeToFileSystem &&
56+
writeServerBundles &&
57+
normalizedOptions.outputPathOptions.browser === ''
58+
) {
59+
yield {
60+
success: false,
61+
error: `'outputPath.browser' cannot be configured to an empty string when SSR is enabled.`,
62+
};
63+
64+
return;
65+
}
4966

5067
// Setup an abort controller with a builder teardown if no signal is present
5168
let signal = context.signal;
@@ -58,14 +75,11 @@ export async function* buildApplicationInternal(
5875
yield* runEsBuildBuildAction(
5976
async (rebuildState) => {
6077
const startTime = process.hrtime.bigint();
61-
6278
const result = await executeBuild(normalizedOptions, context, rebuildState);
6379

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

7084
return result;
7185
},
@@ -75,19 +89,18 @@ export async function* buildApplicationInternal(
7589
poll: normalizedOptions.poll,
7690
deleteOutputPath: normalizedOptions.deleteOutputPath,
7791
cacheOptions: normalizedOptions.cacheOptions,
78-
outputPath: normalizedOptions.outputPath,
92+
outputPathOptions: normalizedOptions.outputPathOptions,
7993
verbose: normalizedOptions.verbose,
8094
projectRoot: normalizedOptions.projectRoot,
8195
workspaceRoot: normalizedOptions.workspaceRoot,
8296
progress: normalizedOptions.progress,
83-
writeToFileSystem: infrastructureSettings?.write,
97+
writeToFileSystem,
8498
// For app-shell and SSG server files are not required by users.
8599
// 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,
100+
writeToFileSystemFilter: writeServerBundles
101+
? undefined
102+
: (file) => file.type !== BuildOutputFileType.Server,
103+
logger,
91104
signal,
92105
},
93106
);

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

+52-25
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 NormalizedOutputPathOptions = Required<OutputPathClass>;
2834
export type NormalizedApplicationBuildOptions = Awaited<ReturnType<typeof normalizeOptions>>;
2935

3036
export interface ApplicationBuilderExtensions {
@@ -125,20 +131,31 @@ 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 outputPathOptions: NormalizedOutputPathOptions = {
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+
outputPathOptions.media +
158+
'/' +
142159
(options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Media
143160
? '[name]-[hash]'
144161
: '[name]'),
@@ -191,26 +208,6 @@ export async function normalizeOptions(
191208
}
192209
}
193210

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-
214211
let indexHtmlOptions;
215212
// index can never have a value of `true` but in the schema it's of type `boolean`.
216213
if (typeof options.index !== 'boolean') {
@@ -318,7 +315,7 @@ export async function normalizeOptions(
318315
workspaceRoot,
319316
entryPoints,
320317
optimizationOptions,
321-
outputPath,
318+
outputPathOptions,
322319
outExtension,
323320
sourcemapOptions,
324321
tsconfig,
@@ -331,7 +328,7 @@ export async function normalizeOptions(
331328
serviceWorker:
332329
typeof serviceWorker === 'string' ? path.join(workspaceRoot, serviceWorker) : undefined,
333330
indexHtmlOptions,
334-
tailwindConfiguration,
331+
tailwindConfiguration: await getTailwindConfig(workspaceRoot, projectRoot, context),
335332
i18nOptions,
336333
namedChunks,
337334
budgets: budgets?.length ? budgets : undefined,
@@ -341,6 +338,36 @@ export async function normalizeOptions(
341338
};
342339
}
343340

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