Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): allow customization of output lo…
Browse files Browse the repository at this point in the history
…cations

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
  • Loading branch information
alan-agius4 committed Dec 14, 2023
1 parent 66edac4 commit cc246d5
Show file tree
Hide file tree
Showing 9 changed files with 505 additions and 95 deletions.
2 changes: 1 addition & 1 deletion goldens/public-api/angular_devkit/build_angular/index.md
Expand Up @@ -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;
Expand Down
Expand Up @@ -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<ExecutionResult>,
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;
Expand All @@ -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,
Expand All @@ -54,7 +55,10 @@ export async function* runEsBuildBuildAction(
} = options;

if (deleteOutputPath && writeToFileSystem) {
await deleteOutputDir(workspaceRoot, outputPath, ['browser', 'server']);
await deleteOutputDir(workspaceRoot, outputOptions.base, [
outputOptions.browser,
outputOptions.server,
]);
}

const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress;
Expand All @@ -79,7 +83,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, '/')}/**/.*/**`,
];
Expand Down Expand Up @@ -137,7 +141,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 {
Expand Down Expand Up @@ -191,7 +195,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 {
Expand Down
Expand Up @@ -31,21 +31,47 @@ export async function* buildApplicationInternal(
},
extensions?: ApplicationBuilderExtensions,
): AsyncIterable<ApplicationBuilderOutput> {
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) {
const { browser, server } = normalizedOptions.outputOptions;
if (browser === '') {
yield {
success: false,
error: `'outputPath.browser' cannot be configured to an empty string when SSR is enabled.`,
};

return;
}

if (browser === server) {
yield {
success: false,
error: `'outputPath.browser' and 'outputPath.server' cannot be configured to the same value.`,
};

return;
}
}

// Setup an abort controller with a builder teardown if no signal is present
let signal = context.signal;
Expand All @@ -58,14 +84,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;
},
Expand All @@ -75,19 +98,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,
},
);
Expand Down
Expand Up @@ -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<OutputPathClass>;
export type NormalizedApplicationBuildOptions = Awaited<ReturnType<typeof normalizeOptions>>;

export interface ApplicationBuilderExtensions {
Expand Down Expand Up @@ -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<string, string> | undefined;
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -318,7 +314,7 @@ export async function normalizeOptions(
workspaceRoot,
entryPoints,
optimizationOptions,
outputPath,
outputOptions,
outExtension,
sourcemapOptions,
tsconfig,
Expand All @@ -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,
Expand All @@ -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.
Expand Down
Expand Up @@ -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 within the output path base. Defaults to 'browser'."
},
"server": {
"type": "string",
"pattern": "^[-\\w\\.]*$",
"default": "server",
"description": "The output directory name of your server build within the output path base. Defaults to 'server'."
},
"media": {
"type": "string",
"pattern": "^[-\\w\\.]+$",
"default": "media",
"description": "The output directory name of your media files within the output browser directory. Defaults to 'media'."
}
},
"required": ["base"],
"additionalProperties": false
},
{
"type": "string"
}
]
},
"aot": {
"type": "boolean",
Expand Down

0 comments on commit cc246d5

Please sign in to comment.