');
+ });
+
+ it(`should return a redirect when an asset directory is accessed without a trailing '/'`, async () => {
+ await harness.writeFile(
+ 'src/login/index.html',
+ '
Login page
',
+ );
+
+ setupTarget(harness, {
+ assets: ['src/login'],
+ optimization: {
+ scripts: true,
+ },
+ });
+
+ harness.useTarget('serve', {
+ ...BASE_OPTIONS,
+ });
+
+ const { result, response } = await executeOnceAndFetch(harness, 'login', {
+ request: { redirect: 'manual' },
+ });
+
+ expect(result?.success).toBeTrue();
+ expect(await response?.status).toBe(301);
+ expect(await response?.headers.get('Location')).toBe('/login/');
+ });
+ });
+});
diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts
new file mode 100644
index 000000000000..813796079b17
--- /dev/null
+++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts
@@ -0,0 +1,49 @@
+/**
+ * @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 { executeDevServer } from '../../index';
+import { executeOnceAndFetch } from '../execute-fetch';
+import { describeServeBuilder } from '../jasmine-helpers';
+import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
+
+describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => {
+ describe('Behavior: "buildTarget baseHref"', () => {
+ beforeEach(async () => {
+ setupTarget(harness, {
+ baseHref: '/test/',
+ });
+
+ // Application code is not needed for these tests
+ await harness.writeFile('src/main.ts', 'console.log("foo");');
+ });
+
+ it('uses the baseHref defined in the "buildTarget" options as the serve path', async () => {
+ harness.useTarget('serve', {
+ ...BASE_OPTIONS,
+ });
+
+ const { result, response } = await executeOnceAndFetch(harness, '/test/main.js');
+
+ expect(result?.success).toBeTrue();
+ const baseUrl = new URL(`${result?.baseUrl}/`);
+ expect(baseUrl.pathname).toBe('/test/');
+ expect(await response?.text()).toContain('console.log');
+ });
+
+ it('serves the application from baseHref location without trailing slash', async () => {
+ harness.useTarget('serve', {
+ ...BASE_OPTIONS,
+ });
+
+ const { result, response } = await executeOnceAndFetch(harness, '/test');
+
+ expect(result?.success).toBeTrue();
+ expect(await response?.text()).toContain('';
+
+ rewriter.on('startTag', (tag) => {
+ rewriter.emitStartTag(tag);
+
+ if (tag.tagName === 'body') {
+ rewriter.emitRaw(jsActionContractScript);
+ }
+ });
+
+ return transformedContent();
+}
diff --git a/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts
new file mode 100644
index 000000000000..6c0747730c29
--- /dev/null
+++ b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts
@@ -0,0 +1,26 @@
+/**
+ * @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 { addEventDispatchContract } from './add-event-dispatch-contract';
+
+describe('addEventDispatchContract', () => {
+ it('should inline event dispatcher script', async () => {
+ const result = await addEventDispatchContract(`
+
+
+
+
Hello World!
+
+
+ `);
+
+ expect(result).toMatch(
+ /\s*`);
}
- let linkTags: string[] = [];
+ let headerLinkTags: string[] = [];
+ let bodyLinkTags: string[] = [];
for (const src of stylesheets) {
const attrs = [`rel="stylesheet"`, `href="${deployUrl}${src}"`];
@@ -144,7 +147,7 @@ export async function augmentIndexHtml(
attrs.push(generateSriAttributes(content));
}
- linkTags.push(``);
+ headerLinkTags.push(``);
}
if (params.hints?.length) {
@@ -180,7 +183,14 @@ export async function augmentIndexHtml(
attrs.push(generateSriAttributes(content));
}
- linkTags.push(``);
+ const tag = ``;
+ if (hint.mode === 'modulepreload') {
+ // Module preloads should be placed by the inserted script elements in the body since
+ // they are only useful in combination with the scripts.
+ bodyLinkTags.push(tag);
+ } else {
+ headerLinkTags.push(tag);
+ }
}
}
@@ -190,7 +200,7 @@ export async function augmentIndexHtml(
const foundPreconnects = new Set();
rewriter
- .on('startTag', (tag) => {
+ .on('startTag', (tag, rawTagHtml) => {
switch (tag.tagName) {
case 'html':
// Adjust document locale if specified
@@ -224,6 +234,13 @@ export async function augmentIndexHtml(
foundPreconnects.add(href);
}
}
+ break;
+ default:
+ if (tag.selfClosing && !VALID_SELF_CLOSING_TAGS.has(tag.tagName)) {
+ errors.push(`Invalid self-closing element in index HTML file: '${rawTagHtml}'.`);
+
+ return;
+ }
}
rewriter.emitStartTag(tag);
@@ -231,7 +248,7 @@ export async function augmentIndexHtml(
.on('endTag', (tag) => {
switch (tag.tagName) {
case 'head':
- for (const linkTag of linkTags) {
+ for (const linkTag of headerLinkTags) {
rewriter.emitRaw(linkTag);
}
if (imageDomains) {
@@ -241,9 +258,14 @@ export async function augmentIndexHtml(
}
}
}
- linkTags = [];
+ headerLinkTags = [];
break;
case 'body':
+ for (const linkTag of bodyLinkTags) {
+ rewriter.emitRaw(linkTag);
+ }
+ bodyLinkTags = [];
+
// Add script tags
for (const scriptTag of scriptTags) {
rewriter.emitRaw(scriptTag);
@@ -260,9 +282,9 @@ export async function augmentIndexHtml(
return {
content:
- linkTags.length || scriptTags.length
+ headerLinkTags.length || scriptTags.length
? // In case no body/head tags are not present (dotnet partial templates)
- linkTags.join('') + scriptTags.join('') + content
+ headerLinkTags.join('') + scriptTags.join('') + content
: content,
warnings,
errors,
diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/augment-index-html_spec.ts b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts
similarity index 93%
rename from packages/angular_devkit/build_angular/src/utils/index-file/augment-index-html_spec.ts
rename to packages/angular/build/src/utils/index-file/augment-index-html_spec.ts
index 4203b7f3cb3c..61aaa0674ed8 100644
--- a/packages/angular_devkit/build_angular/src/utils/index-file/augment-index-html_spec.ts
+++ b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { tags } from '@angular-devkit/core';
@@ -296,10 +296,10 @@ describe('augment-index-html', () => {
-
-
+
+
`);
@@ -320,10 +320,10 @@ describe('augment-index-html', () => {
-
-
+
+
`);
@@ -481,7 +481,7 @@ describe('augment-index-html', () => {
it('should add image preconnects if it encounters preconnect elements for other resources', async () => {
const imageDomains = ['https://www.example2.com', 'https://www.example3.com'];
- const { content, warnings } = await augmentIndexHtml({
+ const { content } = await augmentIndexHtml({
...indexGeneratorOptions,
html: '',
imageDomains,
@@ -500,4 +500,38 @@ describe('augment-index-html', () => {
`);
});
+
+ describe('self-closing tags', () => {
+ it('should return an error when used on a not supported element', async () => {
+ const { errors } = await augmentIndexHtml({
+ ...indexGeneratorOptions,
+ html: `
+
+
+
+
+ '
+ `,
+ });
+
+ expect(errors.length).toEqual(1);
+ expect(errors).toEqual([`Invalid self-closing element in index HTML file: ''.`]);
+ });
+
+ it('should not return an error when used on a supported element', async () => {
+ const { errors } = await augmentIndexHtml({
+ ...indexGeneratorOptions,
+ html: `
+
+
+
+
+
+ '
+ `,
+ });
+
+ expect(errors.length).toEqual(0);
+ });
+ });
});
diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/html-rewriting-stream.ts b/packages/angular/build/src/utils/index-file/html-rewriting-stream.ts
similarity index 94%
rename from packages/angular_devkit/build_angular/src/utils/index-file/html-rewriting-stream.ts
rename to packages/angular/build/src/utils/index-file/html-rewriting-stream.ts
index 3cdfc52bd50b..5ae7c397904d 100644
--- a/packages/angular_devkit/build_angular/src/utils/index-file/html-rewriting-stream.ts
+++ b/packages/angular/build/src/utils/index-file/html-rewriting-stream.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { Readable } from 'node:stream';
diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/index-html-generator.ts b/packages/angular/build/src/utils/index-file/index-html-generator.ts
similarity index 66%
rename from packages/angular_devkit/build_angular/src/utils/index-file/index-html-generator.ts
rename to packages/angular/build/src/utils/index-file/index-html-generator.ts
index ff30004569ef..bf40e2e7acac 100644
--- a/packages/angular_devkit/build_angular/src/utils/index-file/index-html-generator.ts
+++ b/packages/angular/build/src/utils/index-file/index-html-generator.ts
@@ -3,22 +3,23 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { NormalizedCachedOptions } from '../normalize-cache';
import { NormalizedOptimizationOptions } from '../normalize-optimization';
+import { addEventDispatchContract } from './add-event-dispatch-contract';
import { CrossOriginValue, Entrypoint, FileInfo, augmentIndexHtml } from './augment-index-html';
import { InlineCriticalCssProcessor } from './inline-critical-css';
import { InlineFontsProcessor } from './inline-fonts';
-import { addStyleNonce } from './style-nonce';
+import { addNonce } from './nonce';
type IndexHtmlGeneratorPlugin = (
html: string,
options: IndexHtmlGeneratorProcessOptions,
-) => Promise;
+) => Promise | string;
export type HintMode = 'prefetch' | 'preload' | 'modulepreload' | 'preconnect' | 'dns-prefetch';
@@ -40,45 +41,80 @@ export interface IndexHtmlGeneratorOptions {
optimization?: NormalizedOptimizationOptions;
cache?: NormalizedCachedOptions;
imageDomains?: string[];
+ generateDedicatedSSRContent?: boolean;
}
export type IndexHtmlTransform = (content: string) => Promise;
-export interface IndexHtmlTransformResult {
+export interface IndexHtmlPluginTransformResult {
content: string;
warnings: string[];
errors: string[];
}
+export interface IndexHtmlProcessResult {
+ csrContent: string;
+ ssrContent?: string;
+ warnings: string[];
+ errors: string[];
+}
+
export class IndexHtmlGenerator {
private readonly plugins: IndexHtmlGeneratorPlugin[];
+ private readonly csrPlugins: IndexHtmlGeneratorPlugin[] = [];
+ private readonly ssrPlugins: IndexHtmlGeneratorPlugin[] = [];
constructor(readonly options: IndexHtmlGeneratorOptions) {
- const extraPlugins: IndexHtmlGeneratorPlugin[] = [];
- if (this.options.optimization?.fonts.inline) {
- extraPlugins.push(inlineFontsPlugin(this));
+ const extraCommonPlugins: IndexHtmlGeneratorPlugin[] = [];
+ if (options?.optimization?.fonts.inline) {
+ extraCommonPlugins.push(inlineFontsPlugin(this), addNonce);
}
- if (this.options.optimization?.styles.inlineCritical) {
- extraPlugins.push(inlineCriticalCssPlugin(this));
+ // Common plugins
+ this.plugins = [augmentIndexHtmlPlugin(this), ...extraCommonPlugins, postTransformPlugin(this)];
+
+ // CSR plugins
+ if (options?.optimization?.styles?.inlineCritical) {
+ this.csrPlugins.push(inlineCriticalCssPlugin(this));
}
- this.plugins = [
- augmentIndexHtmlPlugin(this),
- ...extraPlugins,
- // Runs after the `extraPlugins` to capture any nonce or
- // `style` tags that might've been added by them.
- addStyleNoncePlugin(),
- postTransformPlugin(this),
- ];
+ this.csrPlugins.push(addNoncePlugin());
+
+ // SSR plugins
+ if (options.generateDedicatedSSRContent) {
+ this.ssrPlugins.push(addEventDispatchContractPlugin(), addNoncePlugin());
+ }
}
- async process(options: IndexHtmlGeneratorProcessOptions): Promise {
+ async process(options: IndexHtmlGeneratorProcessOptions): Promise {
let content = await this.readIndex(this.options.indexPath);
const warnings: string[] = [];
const errors: string[] = [];
- for (const plugin of this.plugins) {
+ content = await this.runPlugins(content, this.plugins, options, warnings, errors);
+ const [csrContent, ssrContent] = await Promise.all([
+ this.runPlugins(content, this.csrPlugins, options, warnings, errors),
+ this.ssrPlugins.length
+ ? this.runPlugins(content, this.ssrPlugins, options, warnings, errors)
+ : undefined,
+ ]);
+
+ return {
+ ssrContent,
+ csrContent,
+ warnings,
+ errors,
+ };
+ }
+
+ private async runPlugins(
+ content: string,
+ plugins: IndexHtmlGeneratorPlugin[],
+ options: IndexHtmlGeneratorProcessOptions,
+ warnings: string[],
+ errors: string[],
+ ): Promise {
+ for (const plugin of plugins) {
const result = await plugin(content, options);
if (typeof result === 'string') {
content = result;
@@ -95,11 +131,7 @@ export class IndexHtmlGenerator {
}
}
- return {
- content,
- warnings,
- errors,
- };
+ return content;
}
async readAsset(path: string): Promise {
@@ -160,10 +192,14 @@ function inlineCriticalCssPlugin(generator: IndexHtmlGenerator): IndexHtmlGenera
inlineCriticalCssProcessor.process(html, { outputPath: options.outputPath });
}
-function addStyleNoncePlugin(): IndexHtmlGeneratorPlugin {
- return (html) => addStyleNonce(html);
+function addNoncePlugin(): IndexHtmlGeneratorPlugin {
+ return (html) => addNonce(html);
}
function postTransformPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorPlugin {
return async (html) => (options.postTransform ? options.postTransform(html) : html);
}
+
+function addEventDispatchContractPlugin(): IndexHtmlGeneratorPlugin {
+ return (html) => addEventDispatchContract(html);
+}
diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css.ts b/packages/angular/build/src/utils/index-file/inline-critical-css.ts
similarity index 99%
rename from packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css.ts
rename to packages/angular/build/src/utils/index-file/inline-critical-css.ts
index dc90d24df317..fe68c8abe105 100644
--- a/packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css.ts
+++ b/packages/angular/build/src/utils/index-file/inline-critical-css.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import Critters from 'critters';
diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css_spec.ts b/packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts
similarity index 96%
rename from packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css_spec.ts
rename to packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts
index 240ad91c3572..4c68304cd9d6 100644
--- a/packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css_spec.ts
+++ b/packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { tags } from '@angular-devkit/core';
@@ -125,7 +125,7 @@ describe('InlineCriticalCssProcessor', () => {
'',
);
// Nonces shouldn't be added inside the `noscript` tags.
- expect(content).toContain('');
+ expect(content).toContain('');
expect(content).toContain('
+
+
+
+
+ `);
+
+ expect(result).toContain(``);
+ expect(result).toContain('');
+ expect(result).toContain(``);
+ });
});
diff --git a/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts
new file mode 100644
index 000000000000..bd85b6ee00dd
--- /dev/null
+++ b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts
@@ -0,0 +1,59 @@
+/**
+ * @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
+ */
+
+/** A list of valid self closing HTML elements */
+export const VALID_SELF_CLOSING_TAGS = new Set([
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'link',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+ /** SVG tags */
+ 'circle',
+ 'ellipse',
+ 'line',
+ 'path',
+ 'polygon',
+ 'polyline',
+ 'rect',
+ 'text',
+ 'tspan',
+ 'linearGradient',
+ 'radialGradient',
+ 'stop',
+ 'image',
+ 'pattern',
+ 'defs',
+ 'g',
+ 'marker',
+ 'mask',
+ 'style',
+ 'symbol',
+ 'use',
+ 'view',
+ /** MathML tags */
+ 'mspace',
+ 'mphantom',
+ 'mrow',
+ 'mfrac',
+ 'msqrt',
+ 'mroot',
+ 'mstyle',
+ 'merror',
+ 'mpadded',
+ 'mtable',
+]);
diff --git a/packages/angular/build/src/utils/index.ts b/packages/angular/build/src/utils/index.ts
new file mode 100644
index 000000000000..1a7cb15cd9c3
--- /dev/null
+++ b/packages/angular/build/src/utils/index.ts
@@ -0,0 +1,12 @@
+/**
+ * @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
+ */
+
+export * from './normalize-asset-patterns';
+export * from './normalize-optimization';
+export * from './normalize-source-maps';
+export * from './load-proxy-config';
diff --git a/packages/angular/build/src/utils/load-esm.ts b/packages/angular/build/src/utils/load-esm.ts
new file mode 100644
index 000000000000..6a6220f66288
--- /dev/null
+++ b/packages/angular/build/src/utils/load-esm.ts
@@ -0,0 +1,33 @@
+/**
+ * @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
+ */
+
+/**
+ * Lazily compiled dynamic import loader function.
+ */
+let load: ((modulePath: string | URL) => Promise) | undefined;
+
+/**
+ * This uses a dynamic import to load a module which may be ESM.
+ * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript
+ * will currently, unconditionally downlevel dynamic import into a require call.
+ * require calls cannot load ESM code and will result in a runtime error. To workaround
+ * this, a Function constructor is used to prevent TypeScript from changing the dynamic import.
+ * Once TypeScript provides support for keeping the dynamic import this workaround can
+ * be dropped.
+ *
+ * @param modulePath The path of the module to load.
+ * @returns A Promise that resolves to the dynamically imported module.
+ */
+export function loadEsmModule(modulePath: string | URL): Promise {
+ load ??= new Function('modulePath', `return import(modulePath);`) as Exclude<
+ typeof load,
+ undefined
+ >;
+
+ return load(modulePath);
+}
diff --git a/packages/angular_devkit/build_angular/src/utils/load-proxy-config.ts b/packages/angular/build/src/utils/load-proxy-config.ts
similarity index 96%
rename from packages/angular_devkit/build_angular/src/utils/load-proxy-config.ts
rename to packages/angular/build/src/utils/load-proxy-config.ts
index 1b1939d26b70..2ed21c05ba2a 100644
--- a/packages/angular_devkit/build_angular/src/utils/load-proxy-config.ts
+++ b/packages/angular/build/src/utils/load-proxy-config.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { isDynamicPattern } from 'fast-glob';
@@ -18,8 +18,7 @@ import { loadEsmModule } from './load-esm';
export async function loadProxyConfiguration(
root: string,
proxyConfig: string | undefined,
- normalize = false,
-) {
+): Promise | undefined> {
if (!proxyConfig) {
return undefined;
}
@@ -81,11 +80,7 @@ export async function loadProxyConfiguration(
}
}
- if (normalize) {
- proxyConfiguration = normalizeProxyConfiguration(proxyConfiguration);
- }
-
- return proxyConfiguration;
+ return normalizeProxyConfiguration(proxyConfiguration);
}
/**
diff --git a/packages/angular_devkit/build_angular/src/utils/load-translations.ts b/packages/angular/build/src/utils/load-translations.ts
similarity index 97%
rename from packages/angular_devkit/build_angular/src/utils/load-translations.ts
rename to packages/angular/build/src/utils/load-translations.ts
index d481e6aa83ae..c6afe9a1ecd9 100644
--- a/packages/angular_devkit/build_angular/src/utils/load-translations.ts
+++ b/packages/angular/build/src/utils/load-translations.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import type { Diagnostics } from '@angular/localize/tools';
diff --git a/packages/angular/build/src/utils/normalize-asset-patterns.ts b/packages/angular/build/src/utils/normalize-asset-patterns.ts
new file mode 100644
index 000000000000..246b6190fdf8
--- /dev/null
+++ b/packages/angular/build/src/utils/normalize-asset-patterns.ts
@@ -0,0 +1,82 @@
+/**
+ * @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 'fs';
+import assert from 'node:assert';
+import * as path from 'path';
+import { AssetPattern, AssetPatternClass } from '../builders/application/schema';
+
+export class MissingAssetSourceRootException extends Error {
+ constructor(path: string) {
+ super(`The ${path} asset path must start with the project source root.`);
+ }
+}
+
+export function normalizeAssetPatterns(
+ assetPatterns: AssetPattern[],
+ workspaceRoot: string,
+ projectRoot: string,
+ projectSourceRoot: string | undefined,
+): (AssetPatternClass & { output: string })[] {
+ if (assetPatterns.length === 0) {
+ return [];
+ }
+
+ // When sourceRoot is not available, we default to ${projectRoot}/src.
+ const sourceRoot = projectSourceRoot || path.join(projectRoot, 'src');
+ const resolvedSourceRoot = path.resolve(workspaceRoot, sourceRoot);
+
+ return assetPatterns.map((assetPattern) => {
+ // Normalize string asset patterns to objects.
+ if (typeof assetPattern === 'string') {
+ const assetPath = path.normalize(assetPattern);
+ const resolvedAssetPath = path.resolve(workspaceRoot, assetPath);
+
+ // Check if the string asset is within sourceRoot.
+ if (!resolvedAssetPath.startsWith(resolvedSourceRoot)) {
+ throw new MissingAssetSourceRootException(assetPattern);
+ }
+
+ let glob: string, input: string;
+ let isDirectory = false;
+
+ try {
+ isDirectory = statSync(resolvedAssetPath).isDirectory();
+ } catch {
+ isDirectory = true;
+ }
+
+ if (isDirectory) {
+ // Folders get a recursive star glob.
+ glob = '**/*';
+ // Input directory is their original path.
+ input = assetPath;
+ } else {
+ // Files are their own glob.
+ glob = path.basename(assetPath);
+ // Input directory is their original dirname.
+ input = path.dirname(assetPath);
+ }
+
+ // Output directory for both is the relative path from source root to input.
+ const output = path.relative(resolvedSourceRoot, path.resolve(workspaceRoot, input));
+
+ assetPattern = { glob, input, output };
+ } else {
+ assetPattern.output = path.join('.', assetPattern.output ?? '');
+ }
+
+ assert(assetPattern.output !== undefined);
+
+ if (assetPattern.output.startsWith('..')) {
+ throw new Error('An asset cannot be written to a location outside of the output path.');
+ }
+
+ return assetPattern as AssetPatternClass & { output: string };
+ });
+}
diff --git a/packages/angular/build/src/utils/normalize-cache.ts b/packages/angular/build/src/utils/normalize-cache.ts
new file mode 100644
index 000000000000..9dc7ba6ae2a6
--- /dev/null
+++ b/packages/angular/build/src/utils/normalize-cache.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 { join, resolve } from 'node:path';
+
+/** Version placeholder is replaced during the build process with actual package version */
+const VERSION = '0.0.0-PLACEHOLDER';
+
+export interface NormalizedCachedOptions {
+ /** Whether disk cache is enabled. */
+ enabled: boolean;
+ /** Disk cache path. Example: `/.angular/cache/v12.0.0`. */
+ path: string;
+ /** Disk cache base path. Example: `/.angular/cache`. */
+ basePath: string;
+}
+
+interface CacheMetadata {
+ enabled?: boolean;
+ environment?: 'local' | 'ci' | 'all';
+ path?: string;
+}
+
+function hasCacheMetadata(value: unknown): value is { cli: { cache: CacheMetadata } } {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ 'cli' in value &&
+ !!value['cli'] &&
+ typeof value['cli'] === 'object' &&
+ 'cache' in value['cli']
+ );
+}
+
+export function normalizeCacheOptions(
+ projectMetadata: unknown,
+ worspaceRoot: string,
+): NormalizedCachedOptions {
+ const cacheMetadata = hasCacheMetadata(projectMetadata) ? projectMetadata.cli.cache : {};
+
+ const {
+ // Webcontainers do not currently benefit from persistent disk caching and can lead to increased browser memory usage
+ enabled = !process.versions.webcontainer,
+ environment = 'local',
+ path = '.angular/cache',
+ } = cacheMetadata;
+ const isCI = process.env['CI'] === '1' || process.env['CI']?.toLowerCase() === 'true';
+
+ let cacheEnabled = enabled;
+ if (cacheEnabled) {
+ switch (environment) {
+ case 'ci':
+ cacheEnabled = isCI;
+ break;
+ case 'local':
+ cacheEnabled = !isCI;
+ break;
+ }
+ }
+
+ const cacheBasePath = resolve(worspaceRoot, path);
+
+ return {
+ enabled: cacheEnabled,
+ basePath: cacheBasePath,
+ path: join(cacheBasePath, VERSION),
+ };
+}
diff --git a/packages/angular/build/src/utils/normalize-optimization.ts b/packages/angular/build/src/utils/normalize-optimization.ts
new file mode 100644
index 000000000000..fcd5b556f27f
--- /dev/null
+++ b/packages/angular/build/src/utils/normalize-optimization.ts
@@ -0,0 +1,59 @@
+/**
+ * @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 {
+ FontsClass,
+ OptimizationClass,
+ OptimizationUnion,
+ StylesClass,
+} from '../builders/application/schema';
+
+export type NormalizedOptimizationOptions = Required<
+ Omit
+> & {
+ fonts: FontsClass;
+ styles: StylesClass;
+};
+
+export function normalizeOptimization(
+ optimization: OptimizationUnion = true,
+): NormalizedOptimizationOptions {
+ if (typeof optimization === 'object') {
+ const styleOptimization = !!optimization.styles;
+
+ return {
+ scripts: !!optimization.scripts,
+ styles:
+ typeof optimization.styles === 'object'
+ ? optimization.styles
+ : {
+ minify: styleOptimization,
+ removeSpecialComments: styleOptimization,
+ inlineCritical: styleOptimization,
+ },
+ fonts:
+ typeof optimization.fonts === 'object'
+ ? optimization.fonts
+ : {
+ inline: !!optimization.fonts,
+ },
+ };
+ }
+
+ return {
+ scripts: optimization,
+ styles: {
+ minify: optimization,
+ inlineCritical: optimization,
+ removeSpecialComments: optimization,
+ },
+ fonts: {
+ inline: optimization,
+ },
+ };
+}
diff --git a/packages/angular/build/src/utils/normalize-source-maps.ts b/packages/angular/build/src/utils/normalize-source-maps.ts
new file mode 100644
index 000000000000..ddeb3e5322d4
--- /dev/null
+++ b/packages/angular/build/src/utils/normalize-source-maps.ts
@@ -0,0 +1,23 @@
+/**
+ * @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 { SourceMapClass, SourceMapUnion } from '../builders/application/schema';
+
+export function normalizeSourceMaps(sourceMap: SourceMapUnion): SourceMapClass {
+ const scripts = typeof sourceMap === 'object' ? sourceMap.scripts : sourceMap;
+ const styles = typeof sourceMap === 'object' ? sourceMap.styles : sourceMap;
+ const hidden = (typeof sourceMap === 'object' && sourceMap.hidden) || false;
+ const vendor = (typeof sourceMap === 'object' && sourceMap.vendor) || false;
+
+ return {
+ vendor,
+ hidden,
+ scripts,
+ styles,
+ };
+}
diff --git a/packages/angular_devkit/build_angular/src/utils/postcss-configuration.ts b/packages/angular/build/src/utils/postcss-configuration.ts
similarity index 83%
rename from packages/angular_devkit/build_angular/src/utils/postcss-configuration.ts
rename to packages/angular/build/src/utils/postcss-configuration.ts
index 80b6488b1128..1861f9f2b1db 100644
--- a/packages/angular_devkit/build_angular/src/utils/postcss-configuration.ts
+++ b/packages/angular/build/src/utils/postcss-configuration.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { readFile, readdir } from 'node:fs/promises';
@@ -18,13 +18,19 @@ interface RawPostcssConfiguration {
}
const postcssConfigurationFiles: string[] = ['postcss.config.json', '.postcssrc.json'];
-
-interface SearchDirectory {
+const tailwindConfigFiles: string[] = [
+ 'tailwind.config.js',
+ 'tailwind.config.cjs',
+ 'tailwind.config.mjs',
+ 'tailwind.config.ts',
+];
+
+export interface SearchDirectory {
root: string;
files: Set;
}
-async function generateSearchDirectories(roots: string[]): Promise {
+export async function generateSearchDirectories(roots: string[]): Promise {
return await Promise.all(
roots.map((root) =>
readdir(root, { withFileTypes: true }).then((entries) => ({
@@ -50,6 +56,12 @@ function findFile(
return undefined;
}
+export function findTailwindConfiguration(
+ searchDirectories: SearchDirectory[],
+): string | undefined {
+ return findFile(searchDirectories, tailwindConfigFiles);
+}
+
async function readPostcssConfiguration(
configurationFile: string,
): Promise {
@@ -60,12 +72,8 @@ async function readPostcssConfiguration(
}
export async function loadPostcssConfiguration(
- workspaceRoot: string,
- projectRoot: string,
+ searchDirectories: SearchDirectory[],
): Promise {
- // A configuration file can exist in the project or workspace root
- const searchDirectories = await generateSearchDirectories([projectRoot, workspaceRoot]);
-
const configPath = findFile(searchDirectories, postcssConfigurationFiles);
if (!configPath) {
return undefined;
diff --git a/packages/angular_devkit/build_angular/src/utils/purge-cache.ts b/packages/angular/build/src/utils/purge-cache.ts
similarity index 95%
rename from packages/angular_devkit/build_angular/src/utils/purge-cache.ts
rename to packages/angular/build/src/utils/purge-cache.ts
index 765bef3d1419..5851d052d54a 100644
--- a/packages/angular_devkit/build_angular/src/utils/purge-cache.ts
+++ b/packages/angular/build/src/utils/purge-cache.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { BuilderContext } from '@angular-devkit/architect';
diff --git a/packages/angular/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts
new file mode 100644
index 000000000000..c9732501ce29
--- /dev/null
+++ b/packages/angular/build/src/utils/resolve-assets.ts
@@ -0,0 +1,45 @@
+/**
+ * @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 glob from 'fast-glob';
+import path from 'node:path';
+
+export async function resolveAssets(
+ entries: {
+ glob: string;
+ ignore?: string[];
+ input: string;
+ output: string;
+ flatten?: boolean;
+ followSymlinks?: boolean;
+ }[],
+ root: string,
+): Promise<{ source: string; destination: string }[]> {
+ const defaultIgnore = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db'];
+
+ const outputFiles: { source: string; destination: string }[] = [];
+
+ for (const entry of entries) {
+ const cwd = path.resolve(root, entry.input);
+ const files = await glob(entry.glob, {
+ cwd,
+ dot: true,
+ ignore: entry.ignore ? defaultIgnore.concat(entry.ignore) : defaultIgnore,
+ followSymbolicLinks: entry.followSymlinks,
+ });
+
+ for (const file of files) {
+ const src = path.join(cwd, file);
+ const filePath = entry.flatten ? path.basename(file) : file;
+
+ outputFiles.push({ source: src, destination: path.join(entry.output, filePath) });
+ }
+ }
+
+ return outputFiles;
+}
diff --git a/packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel b/packages/angular/build/src/utils/routes-extractor/BUILD.bazel
similarity index 79%
rename from packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel
rename to packages/angular/build/src/utils/routes-extractor/BUILD.bazel
index 36f352cbddc4..f9c6f8827f1f 100644
--- a/packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel
+++ b/packages/angular/build/src/utils/routes-extractor/BUILD.bazel
@@ -1,14 +1,14 @@
# Copyright Google Inc. 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
+# found in the LICENSE file at https://angular.dev/license
load("//tools:defaults.bzl", "ts_library")
# NOTE This is built as ESM as this is included in the users server bundle.
licenses(["notice"])
-package(default_visibility = ["//packages/angular_devkit/build_angular:__subpackages__"])
+package(default_visibility = ["//packages/angular/build:__subpackages__"])
ts_library(
name = "routes-extractor",
diff --git a/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts b/packages/angular/build/src/utils/routes-extractor/extractor.ts
similarity index 98%
rename from packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts
rename to packages/angular/build/src/utils/routes-extractor/extractor.ts
index 6c6d442e5634..14708c05f705 100644
--- a/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts
+++ b/packages/angular/build/src/utils/routes-extractor/extractor.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import {
diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts
similarity index 97%
rename from packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts
rename to packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts
index d6a2448984f2..ca9e986bbb89 100644
--- a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts
+++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import assert from 'node:assert';
@@ -12,7 +12,6 @@ import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'url';
import { JavaScriptTransformer } from '../../../tools/esbuild/javascript-transformer';
-import { callInitializeIfNeeded } from './node-18-utils';
/**
* Node.js ESM loader to redirect imports to in memory files.
@@ -37,8 +36,6 @@ const javascriptTransformer = new JavaScriptTransformer(
1,
);
-callInitializeIfNeeded(initialize);
-
export function initialize(data: ESMInMemoryFileLoaderWorkerData) {
// This path does not actually exist but is used to overlay the in memory files with the
// actual filesystem for resolution purposes.
diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts
similarity index 85%
rename from packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts
rename to packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts
index cf2bd309eaaf..b23fe297bc19 100644
--- a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts
+++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { register } from 'node:module';
diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/fetch-patch.ts b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts
similarity index 87%
rename from packages/angular_devkit/build_angular/src/utils/server-rendering/fetch-patch.ts
rename to packages/angular/build/src/utils/server-rendering/fetch-patch.ts
index b0eb6c0e9666..5ed2d88270c5 100644
--- a/packages/angular_devkit/build_angular/src/utils/server-rendering/fetch-patch.ts
+++ b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts
@@ -3,14 +3,13 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { lookup as lookupMimeType } from 'mrmime';
import { readFile } from 'node:fs/promises';
import { extname } from 'node:path';
import { workerData } from 'node:worker_threads';
-import { Response, fetch } from 'undici';
/**
* This is passed as workerData when setting up the worker via the `piscina` package.
@@ -25,8 +24,7 @@ const assetsCache: Map; co
const RESOLVE_PROTOCOL = 'resolve:';
export function patchFetchToLoadInMemoryAssets(): void {
- const global = globalThis as unknown as { fetch: typeof fetch };
- const originalFetch = global.fetch;
+ const originalFetch = globalThis.fetch;
const patchedFetch: typeof fetch = async (input, init) => {
let url: URL;
if (input instanceof URL) {
@@ -39,7 +37,8 @@ export function patchFetchToLoadInMemoryAssets(): void {
return originalFetch(input, init);
}
- const { pathname, protocol } = url;
+ const { protocol } = url;
+ const pathname = decodeURIComponent(url.pathname);
if (protocol !== RESOLVE_PROTOCOL || !assetFiles[pathname]) {
// Only handle relative requests or files that are in assets.
@@ -71,5 +70,5 @@ export function patchFetchToLoadInMemoryAssets(): void {
});
};
- global.fetch = patchedFetch;
+ globalThis.fetch = patchedFetch;
}
diff --git a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts
new file mode 100644
index 000000000000..a3a3384545a4
--- /dev/null
+++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts
@@ -0,0 +1,33 @@
+/**
+ * @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 { assertIsError } from '../error';
+import { loadEsmModule } from '../load-esm';
+import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports';
+
+export function loadEsmModuleFromMemory(
+ path: './main.server.mjs',
+): Promise;
+export function loadEsmModuleFromMemory(
+ path: './render-utils.server.mjs',
+): Promise;
+export function loadEsmModuleFromMemory(path: string): Promise {
+ return loadEsmModule(new URL(path, 'memory://')).catch((e) => {
+ assertIsError(e);
+
+ // While the error is an 'instanceof Error', it is extended with non transferable properties
+ // and cannot be transferred from a worker when using `--import`. This results in the error object
+ // displaying as '[Object object]' when read outside of the worker. Therefore, we reconstruct the error message here.
+ const error: Error & { code?: string } = new Error(e.message);
+ error.stack = e.stack;
+ error.name = e.name;
+ error.code = e.code;
+
+ throw error;
+ });
+}
diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts b/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts
similarity index 94%
rename from packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts
rename to packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts
index de5674b3c4c4..eb6f0f0dfb8c 100644
--- a/packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts
+++ b/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import type { ApplicationRef, Type, ɵConsole } from '@angular/core';
diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts
similarity index 80%
rename from packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts
rename to packages/angular/build/src/utils/server-rendering/prerender.ts
index 9ce7e843d6bc..f8796a7ac861 100644
--- a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts
+++ b/packages/angular/build/src/utils/server-rendering/prerender.ts
@@ -3,15 +3,15 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { readFile } from 'node:fs/promises';
-import { extname, posix } from 'node:path';
+import { extname, join, posix } from 'node:path';
+import { pathToFileURL } from 'node:url';
import Piscina from 'piscina';
import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result';
-import { getESMLoaderArgs } from './esm-in-memory-loader/node-18-utils';
import type { RenderResult, ServerContext } from './render-page';
import type { RenderWorkerData } from './render-worker';
import type {
@@ -81,7 +81,11 @@ export async function prerenderPages(
}
// Get routes to prerender
- const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes(
+ const {
+ routes: allRoutes,
+ warnings: routesWarnings,
+ errors: routesErrors,
+ } = await getAllRoutes(
workspaceRoot,
outputFilesForWorker,
assetsReversed,
@@ -92,11 +96,15 @@ export async function prerenderPages(
verbose,
);
+ if (routesErrors?.length) {
+ errors.push(...routesErrors);
+ }
+
if (routesWarnings?.length) {
warnings.push(...routesWarnings);
}
- if (allRoutes.size < 1) {
+ if (allRoutes.size < 1 || errors.length > 0) {
return {
errors,
warnings,
@@ -158,7 +166,12 @@ async function renderPages(
const warnings: string[] = [];
const errors: string[] = [];
- const workerExecArgv = getESMLoaderArgs();
+ const workerExecArgv = [
+ '--import',
+ // Loader cannot be an absolute path on Windows.
+ pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href,
+ ];
+
if (sourcemap) {
workerExecArgv.push('--enable-source-maps');
}
@@ -185,22 +198,27 @@ async function renderPages(
const isAppShellRoute = appShellRoute === route;
const serverContext: ServerContext = isAppShellRoute ? 'app-shell' : 'ssg';
const render: Promise = renderWorker.run({ route, serverContext });
- const renderResult: Promise = render.then(({ content, warnings, errors }) => {
- if (content !== undefined) {
- const outPath = isAppShellRoute
- ? 'index.html'
- : posix.join(removeLeadingSlash(route), 'index.html');
- output[outPath] = content;
- }
-
- if (warnings) {
- warnings.push(...warnings);
- }
-
- if (errors) {
- errors.push(...errors);
- }
- });
+ const renderResult: Promise = render
+ .then(({ content, warnings, errors }) => {
+ if (content !== undefined) {
+ const outPath = isAppShellRoute
+ ? 'index.html'
+ : posix.join(removeLeadingSlash(route), 'index.html');
+ output[outPath] = content;
+ }
+
+ if (warnings) {
+ warnings.push(...warnings);
+ }
+
+ if (errors) {
+ errors.push(...errors);
+ }
+ })
+ .catch((err) => {
+ errors.push(`An error occurred while prerendering route '${route}'.\n\n${err.stack}`);
+ void renderWorker.destroy();
+ });
renderingPromises.push(renderResult);
}
@@ -226,7 +244,7 @@ async function getAllRoutes(
prerenderOptions: PrerenderOptions,
sourcemap: boolean,
verbose: boolean,
-): Promise<{ routes: Set; warnings?: string[] }> {
+): Promise<{ routes: Set; warnings?: string[]; errors?: string[] }> {
const { routesFile, discoverRoutes } = prerenderOptions;
const routes = new RoutesSet();
const { route: appShellRoute } = appShellOptions;
@@ -246,7 +264,12 @@ async function getAllRoutes(
return { routes };
}
- const workerExecArgv = getESMLoaderArgs();
+ const workerExecArgv = [
+ '--import',
+ // Loader cannot be an absolute path on Windows.
+ pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href,
+ ];
+
if (sourcemap) {
workerExecArgv.push('--enable-source-maps');
}
@@ -265,8 +288,12 @@ async function getAllRoutes(
recordTiming: false,
});
+ const errors: string[] = [];
const { routes: extractedRoutes, warnings }: RoutersExtractorWorkerResult = await renderWorker
.run({})
+ .catch((err) => {
+ errors.push(`An error occurred while extracting routes.\n\n${err.stack}`);
+ })
.finally(() => {
void renderWorker.destroy();
});
@@ -275,7 +302,7 @@ async function getAllRoutes(
routes.add(route);
}
- return { routes, warnings };
+ return { routes, warnings, errors };
}
function addLeadingSlash(value: string): string {
diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts b/packages/angular/build/src/utils/server-rendering/render-page.ts
similarity index 80%
rename from packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts
rename to packages/angular/build/src/utils/server-rendering/render-page.ts
index a7cf0af1e577..aaf4509c35a2 100644
--- a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts
+++ b/packages/angular/build/src/utils/server-rendering/render-page.ts
@@ -3,13 +3,13 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import type { ApplicationRef, StaticProvider } from '@angular/core';
import assert from 'node:assert';
import { basename } from 'node:path';
-import { loadEsmModule } from '../load-esm';
+import { loadEsmModuleFromMemory } from './load-esm-from-memory';
import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports';
export interface RenderOptions {
@@ -39,7 +39,7 @@ export async function renderPage({
document,
inlineCriticalCss,
outputFiles,
- loadBundle = loadEsmModule,
+ loadBundle = loadEsmModuleFromMemory,
}: RenderOptions): Promise {
const { default: bootstrapAppFnOrModule } = await loadBundle('./main.server.mjs');
const { ɵSERVER_CONTEXT, renderModule, renderApplication, ɵresetCompiledComponents, ɵConsole } =
@@ -73,26 +73,46 @@ export async function renderPage({
},
];
- let html: string | undefined;
assert(
bootstrapAppFnOrModule,
'The file "./main.server.mjs" does not have a default export for an AppServerModule or a bootstrapping function.',
);
+ let renderAppPromise: Promise;
if (isBootstrapFn(bootstrapAppFnOrModule)) {
- html = await renderApplication(bootstrapAppFnOrModule, {
+ renderAppPromise = renderApplication(bootstrapAppFnOrModule, {
document,
url: route,
platformProviders,
});
} else {
- html = await renderModule(bootstrapAppFnOrModule, {
+ renderAppPromise = renderModule(bootstrapAppFnOrModule, {
document,
url: route,
extraProviders: platformProviders,
});
}
+ // The below should really handled by the framework!!!.
+ // See: https://github.com/angular/angular/issues/51549
+ let timer: NodeJS.Timeout;
+ const renderingTimeout = new Promise(
+ (_, reject) =>
+ (timer = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `Page ${new URL(route, 'resolve://').pathname} did not render in 30 seconds.`,
+ ),
+ ),
+ 30_000,
+ )),
+ );
+
+ const html = await Promise.race([renderAppPromise, renderingTimeout]).finally(() =>
+ clearTimeout(timer),
+ );
+
if (inlineCriticalCss) {
const { InlineCriticalCssProcessor } = await import(
'../../utils/index-file/inline-critical-css'
diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts
similarity index 86%
rename from packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts
rename to packages/angular/build/src/utils/server-rendering/render-worker.ts
index e5c71d31d441..e7e439838a21 100644
--- a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts
+++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts
@@ -3,11 +3,10 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { workerData } from 'node:worker_threads';
-import { loadEsmModule } from '../load-esm';
import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks';
import { patchFetchToLoadInMemoryAssets } from './fetch-patch';
import { RenderResult, ServerContext, renderPage } from './render-page';
@@ -35,7 +34,6 @@ function render(options: RenderOptions): Promise {
outputFiles,
document,
inlineCriticalCss,
- loadBundle: async (path) => await loadEsmModule(new URL(path, 'memory://')),
});
}
diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts
similarity index 83%
rename from packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts
rename to packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts
index 36b46e3fcaed..44dbfb3cb2e3 100644
--- a/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts
+++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts
@@ -3,14 +3,13 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { workerData } from 'node:worker_threads';
-import { loadEsmModule } from '../load-esm';
import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks';
import { patchFetchToLoadInMemoryAssets } from './fetch-patch';
-import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports';
+import { loadEsmModuleFromMemory } from './load-esm-from-memory';
export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData {
document: string;
@@ -30,12 +29,8 @@ const { document, verbose } = workerData as RoutesExtractorWorkerData;
/** Renders an application based on a provided options. */
async function extractRoutes(): Promise {
- const { extractRoutes } = await loadEsmModule(
- new URL('./render-utils.server.mjs', 'memory://'),
- );
- const { default: bootstrapAppFnOrModule } = await loadEsmModule(
- new URL('./main.server.mjs', 'memory://'),
- );
+ const { extractRoutes } = await loadEsmModuleFromMemory('./render-utils.server.mjs');
+ const { default: bootstrapAppFnOrModule } = await loadEsmModuleFromMemory('./main.server.mjs');
const skippedRedirects: string[] = [];
const skippedOthers: string[] = [];
diff --git a/packages/angular_devkit/build_angular/src/utils/service-worker.ts b/packages/angular/build/src/utils/service-worker.ts
similarity index 98%
rename from packages/angular_devkit/build_angular/src/utils/service-worker.ts
rename to packages/angular/build/src/utils/service-worker.ts
index cd013b90fe59..96447012652f 100644
--- a/packages/angular_devkit/build_angular/src/utils/service-worker.ts
+++ b/packages/angular/build/src/utils/service-worker.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import type { Config, Filesystem } from '@angular/service-worker/config';
@@ -180,6 +180,7 @@ export async function augmentAppWithServiceWorkerEsbuild(
workspaceRoot: string,
configPath: string,
baseHref: string,
+ indexHtml: string | undefined,
outputFiles: BuildOutputFile[],
assetFiles: BuildOutputAsset[],
): Promise<{ manifest: string; assetFiles: BuildOutputAsset[] }> {
@@ -188,6 +189,10 @@ export async function augmentAppWithServiceWorkerEsbuild(
try {
const configurationData = await fsPromises.readFile(configPath, 'utf-8');
config = JSON.parse(configurationData) as Config;
+
+ if (indexHtml) {
+ config.index = indexHtml;
+ }
} catch (error) {
assertIsError(error);
if (error.code === 'ENOENT') {
diff --git a/packages/angular/build/src/utils/stats-table.ts b/packages/angular/build/src/utils/stats-table.ts
new file mode 100644
index 000000000000..b007fd7a4aa5
--- /dev/null
+++ b/packages/angular/build/src/utils/stats-table.ts
@@ -0,0 +1,290 @@
+/**
+ * @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 { stripVTControlCharacters } from 'node:util';
+import { BudgetCalculatorResult } from './bundle-calculator';
+import { colors as ansiColors } from './color';
+import { formatSize } from './format-bytes';
+
+export type BundleStatsData = [
+ files: string,
+ names: string,
+ rawSize: number | string,
+ estimatedTransferSize: number | string,
+];
+export interface BundleStats {
+ initial: boolean;
+ stats: BundleStatsData;
+}
+
+export function generateEsbuildBuildStatsTable(
+ [browserStats, serverStats]: [browserStats: BundleStats[], serverStats: BundleStats[]],
+ colors: boolean,
+ showTotalSize: boolean,
+ showEstimatedTransferSize: boolean,
+ budgetFailures?: BudgetCalculatorResult[],
+ verbose?: boolean,
+): string {
+ const bundleInfo = generateBuildStatsData(
+ browserStats,
+ colors,
+ showTotalSize,
+ showEstimatedTransferSize,
+ budgetFailures,
+ verbose,
+ );
+
+ if (serverStats.length) {
+ const m = (x: string) => (colors ? ansiColors.magenta(x) : x);
+ if (browserStats.length) {
+ bundleInfo.unshift([m('Browser bundles')]);
+ // Add seperators between browser and server logs
+ bundleInfo.push([], []);
+ }
+
+ bundleInfo.push(
+ [m('Server bundles')],
+ ...generateBuildStatsData(serverStats, colors, false, false, undefined, verbose),
+ );
+ }
+
+ return generateTableText(bundleInfo, colors);
+}
+
+export function generateBuildStatsTable(
+ data: BundleStats[],
+ colors: boolean,
+ showTotalSize: boolean,
+ showEstimatedTransferSize: boolean,
+ budgetFailures?: BudgetCalculatorResult[],
+): string {
+ const bundleInfo = generateBuildStatsData(
+ data,
+ colors,
+ showTotalSize,
+ showEstimatedTransferSize,
+ budgetFailures,
+ true,
+ );
+
+ return generateTableText(bundleInfo, colors);
+}
+
+function generateBuildStatsData(
+ data: BundleStats[],
+ colors: boolean,
+ showTotalSize: boolean,
+ showEstimatedTransferSize: boolean,
+ budgetFailures?: BudgetCalculatorResult[],
+ verbose?: boolean,
+): (string | number)[][] {
+ if (data.length === 0) {
+ return [];
+ }
+
+ const g = (x: string) => (colors ? ansiColors.green(x) : x);
+ const c = (x: string) => (colors ? ansiColors.cyan(x) : x);
+ const r = (x: string) => (colors ? ansiColors.redBright(x) : x);
+ const y = (x: string) => (colors ? ansiColors.yellowBright(x) : x);
+ const bold = (x: string) => (colors ? ansiColors.bold(x) : x);
+ const dim = (x: string) => (colors ? ansiColors.dim(x) : x);
+
+ const getSizeColor = (name: string, file?: string, defaultColor = c) => {
+ const severity = budgets.get(name) || (file && budgets.get(file));
+ switch (severity) {
+ case 'warning':
+ return y;
+ case 'error':
+ return r;
+ default:
+ return defaultColor;
+ }
+ };
+
+ const changedEntryChunksStats: BundleStatsData[] = [];
+ const changedLazyChunksStats: BundleStatsData[] = [];
+
+ let initialTotalRawSize = 0;
+ let changedLazyChunksCount = 0;
+ let initialTotalEstimatedTransferSize;
+ const maxLazyChunksWithoutBudgetFailures = 15;
+
+ const budgets = new Map();
+ if (budgetFailures) {
+ for (const { label, severity } of budgetFailures) {
+ // In some cases a file can have multiple budget failures.
+ // Favor error.
+ if (label && (!budgets.has(label) || budgets.get(label) === 'warning')) {
+ budgets.set(label, severity);
+ }
+ }
+ }
+
+ // Sort descending by raw size
+ data.sort((a, b) => {
+ if (a.stats[2] > b.stats[2]) {
+ return -1;
+ }
+
+ if (a.stats[2] < b.stats[2]) {
+ return 1;
+ }
+
+ return 0;
+ });
+
+ for (const { initial, stats } of data) {
+ const [files, names, rawSize, estimatedTransferSize] = stats;
+ if (
+ !initial &&
+ !verbose &&
+ changedLazyChunksStats.length >= maxLazyChunksWithoutBudgetFailures &&
+ !budgets.has(names) &&
+ !budgets.has(files)
+ ) {
+ // Limit the number of lazy chunks displayed in the stats table when there is no budget failure and not in verbose mode.
+ changedLazyChunksCount++;
+ continue;
+ }
+
+ const getRawSizeColor = getSizeColor(names, files);
+ let data: BundleStatsData;
+ if (showEstimatedTransferSize) {
+ data = [
+ g(files),
+ dim(names),
+ getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize),
+ c(
+ typeof estimatedTransferSize === 'number'
+ ? formatSize(estimatedTransferSize)
+ : estimatedTransferSize,
+ ),
+ ];
+ } else {
+ data = [
+ g(files),
+ dim(names),
+ getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize),
+ '',
+ ];
+ }
+
+ if (initial) {
+ changedEntryChunksStats.push(data);
+ if (typeof rawSize === 'number') {
+ initialTotalRawSize += rawSize;
+ }
+ if (showEstimatedTransferSize && typeof estimatedTransferSize === 'number') {
+ if (initialTotalEstimatedTransferSize === undefined) {
+ initialTotalEstimatedTransferSize = 0;
+ }
+ initialTotalEstimatedTransferSize += estimatedTransferSize;
+ }
+ } else {
+ changedLazyChunksStats.push(data);
+ changedLazyChunksCount++;
+ }
+ }
+
+ const bundleInfo: (string | number)[][] = [];
+ const baseTitles = ['Names', 'Raw size'];
+
+ if (showEstimatedTransferSize) {
+ baseTitles.push('Estimated transfer size');
+ }
+
+ // Entry chunks
+ if (changedEntryChunksStats.length) {
+ bundleInfo.push(['Initial chunk files', ...baseTitles].map(bold), ...changedEntryChunksStats);
+
+ if (showTotalSize) {
+ const initialSizeTotalColor = getSizeColor('bundle initial', undefined, (x) => x);
+ const totalSizeElements = [
+ ' ',
+ 'Initial total',
+ initialSizeTotalColor(formatSize(initialTotalRawSize)),
+ ];
+ if (showEstimatedTransferSize) {
+ totalSizeElements.push(
+ typeof initialTotalEstimatedTransferSize === 'number'
+ ? formatSize(initialTotalEstimatedTransferSize)
+ : '-',
+ );
+ }
+ bundleInfo.push([], totalSizeElements.map(bold));
+ }
+ }
+
+ // Seperator
+ if (changedEntryChunksStats.length && changedLazyChunksStats.length) {
+ bundleInfo.push([]);
+ }
+
+ // Lazy chunks
+ if (changedLazyChunksStats.length) {
+ bundleInfo.push(['Lazy chunk files', ...baseTitles].map(bold), ...changedLazyChunksStats);
+
+ if (changedLazyChunksCount > changedLazyChunksStats.length) {
+ bundleInfo.push([
+ dim(
+ `...and ${changedLazyChunksCount - changedLazyChunksStats.length} more lazy chunks files. ` +
+ 'Use "--verbose" to show all the files.',
+ ),
+ ]);
+ }
+ }
+
+ return bundleInfo;
+}
+
+function generateTableText(bundleInfo: (string | number)[][], colors: boolean): string {
+ const skipText = (value: string) => value.includes('...and ');
+ const longest: number[] = [];
+ for (const item of bundleInfo) {
+ for (let i = 0; i < item.length; i++) {
+ if (item[i] === undefined) {
+ continue;
+ }
+
+ const currentItem = item[i].toString();
+ if (skipText(currentItem)) {
+ continue;
+ }
+
+ const currentLongest = (longest[i] ??= 0);
+ const currentItemLength = stripVTControlCharacters(currentItem).length;
+ if (currentLongest < currentItemLength) {
+ longest[i] = currentItemLength;
+ }
+ }
+ }
+
+ const seperator = colors ? ansiColors.dim(' | ') : ' | ';
+ const outputTable: string[] = [];
+ for (const item of bundleInfo) {
+ for (let i = 0; i < longest.length; i++) {
+ if (item[i] === undefined) {
+ continue;
+ }
+
+ const currentItem = item[i].toString();
+ if (skipText(currentItem)) {
+ continue;
+ }
+
+ const currentItemLength = stripVTControlCharacters(currentItem).length;
+ const stringPad = ' '.repeat(longest[i] - currentItemLength);
+ // Values in columns at index 2 and 3 (Raw and Estimated sizes) are always right aligned.
+ item[i] = i >= 2 ? stringPad + currentItem : currentItem + stringPad;
+ }
+
+ outputTable.push(item.join(seperator));
+ }
+
+ return outputTable.join('\n');
+}
diff --git a/packages/angular_devkit/build_angular/src/utils/supported-browsers.ts b/packages/angular/build/src/utils/supported-browsers.ts
similarity index 86%
rename from packages/angular_devkit/build_angular/src/utils/supported-browsers.ts
rename to packages/angular/build/src/utils/supported-browsers.ts
index 0dbe083ca14a..79674a62beae 100644
--- a/packages/angular_devkit/build_angular/src/utils/supported-browsers.ts
+++ b/packages/angular/build/src/utils/supported-browsers.ts
@@ -3,13 +3,15 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
-import { logging } from '@angular-devkit/core';
import browserslist from 'browserslist';
-export function getSupportedBrowsers(projectRoot: string, logger: logging.LoggerApi): string[] {
+export function getSupportedBrowsers(
+ projectRoot: string,
+ logger: { warn(message: string): void },
+): string[] {
browserslist.defaults = [
'last 2 Chrome versions',
'last 1 Firefox version',
diff --git a/packages/angular/build/src/utils/tty.ts b/packages/angular/build/src/utils/tty.ts
new file mode 100644
index 000000000000..0d669c0301e3
--- /dev/null
+++ b/packages/angular/build/src/utils/tty.ts
@@ -0,0 +1,22 @@
+/**
+ * @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
+ */
+
+function _isTruthy(value: undefined | string): boolean {
+ // Returns true if value is a string that is anything but 0 or false.
+ return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE';
+}
+
+export function isTTY(): boolean {
+ // If we force TTY, we always return true.
+ const force = process.env['NG_FORCE_TTY'];
+ if (force !== undefined) {
+ return _isTruthy(force);
+ }
+
+ return !!process.stdout.isTTY && !_isTruthy(process.env['CI']);
+}
diff --git a/packages/angular/build/src/utils/url.ts b/packages/angular/build/src/utils/url.ts
new file mode 100644
index 000000000000..d3f1e5791276
--- /dev/null
+++ b/packages/angular/build/src/utils/url.ts
@@ -0,0 +1,16 @@
+/**
+ * @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
+ */
+
+export function urlJoin(...parts: string[]): string {
+ const [p, ...rest] = parts;
+
+ // Remove trailing slash from first part
+ // Join all parts with `/`
+ // Dedupe double slashes from path names
+ return p.replace(/\/$/, '') + ('/' + rest.join('/')).replace(/\/\/+/g, '/');
+}
diff --git a/packages/angular_devkit/build_angular/src/utils/version.ts b/packages/angular/build/src/utils/version.ts
similarity index 83%
rename from packages/angular_devkit/build_angular/src/utils/version.ts
rename to packages/angular/build/src/utils/version.ts
index 00bd3c8fb14a..80c531336bcb 100644
--- a/packages/angular_devkit/build_angular/src/utils/version.ts
+++ b/packages/angular/build/src/utils/version.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
/* eslint-disable no-console */
@@ -56,16 +56,24 @@ export function assertCompatibleAngularVersion(projectRoot: string): void | neve
return;
}
- const supportedAngularSemver = projectRequire('@angular-devkit/build-angular/package.json')[
- 'peerDependencies'
- ]['@angular/compiler-cli'];
+ let supportedAngularSemver;
+ try {
+ supportedAngularSemver = projectRequire('@angular/build/package.json')['peerDependencies'][
+ '@angular/compiler-cli'
+ ];
+ } catch {
+ supportedAngularSemver = projectRequire('@angular-devkit/build-angular/package.json')[
+ 'peerDependencies'
+ ]['@angular/compiler-cli'];
+ }
+
const angularVersion = new SemVer(angularPkgJson['version']);
if (!satisfies(angularVersion, supportedAngularSemver, { includePrerelease: true })) {
console.error(
`This version of CLI is only compatible with Angular versions ${supportedAngularSemver},\n` +
`but Angular version ${angularVersion} was found instead.\n` +
- 'Please visit the link below to find instructions on how to update Angular.\nhttps://update.angular.io/',
+ 'Please visit the link below to find instructions on how to update Angular.\nhttps://update.angular.dev/',
);
process.exit(3);
diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel
index c6d55809442d..bfdcaca10e98 100644
--- a/packages/angular/cli/BUILD.bazel
+++ b/packages/angular/cli/BUILD.bazel
@@ -1,7 +1,7 @@
# Copyright Google Inc. 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
+# found in the LICENSE file at https://angular.dev/license
load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test")
load("//tools:defaults.bzl", "pkg_npm", "ts_library")
@@ -54,8 +54,9 @@ ts_library(
"//packages/angular_devkit/schematics/tasks",
"//packages/angular_devkit/schematics/tools",
"@npm//@angular/core",
+ "@npm//@inquirer/prompts",
+ "@npm//@listr2/prompt-adapter-inquirer",
"@npm//@types/ini",
- "@npm//@types/inquirer",
"@npm//@types/node",
"@npm//@types/npm-package-arg",
"@npm//@types/pacote",
@@ -64,12 +65,11 @@ ts_library(
"@npm//@types/yargs",
"@npm//@types/yarnpkg__lockfile",
"@npm//@yarnpkg/lockfile",
- "@npm//ansi-colors",
"@npm//ini",
"@npm//jsonc-parser",
+ "@npm//listr2",
"@npm//npm-package-arg",
- "@npm//open",
- "@npm//ora",
+ "@npm//npm-pick-manifest",
"@npm//pacote",
"@npm//semver",
"@npm//yargs",
@@ -78,7 +78,9 @@ ts_library(
# @external_begin
CLI_SCHEMA_DATA = [
- "//packages/angular_devkit/build_angular:src/builders/application/schema.json",
+ "//packages/angular/build:src/builders/application/schema.json",
+ "//packages/angular/build:src/builders/dev-server/schema.json",
+ "//packages/angular/build:src/builders/extract-i18n/schema.json",
"//packages/angular_devkit/build_angular:src/builders/app-shell/schema.json",
"//packages/angular_devkit/build_angular:src/builders/browser/schema.json",
"//packages/angular_devkit/build_angular:src/builders/browser-esbuild/schema.json",
diff --git a/packages/angular/cli/bin/bootstrap.js b/packages/angular/cli/bin/bootstrap.js
index 75e454ee74ff..96b978296dcc 100644
--- a/packages/angular/cli/bin/bootstrap.js
+++ b/packages/angular/cli/bin/bootstrap.js
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
/**
diff --git a/packages/angular/cli/bin/ng.js b/packages/angular/cli/bin/ng.js
index 7b2825c9f248..8c39f94f8408 100755
--- a/packages/angular/cli/bin/ng.js
+++ b/packages/angular/cli/bin/ng.js
@@ -4,7 +4,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
/* eslint-disable no-console */
@@ -55,13 +55,13 @@ if (version[0] % 2 === 1) {
);
require('./bootstrap');
-} else if (version[0] < 18 || (version[0] === 18 && version[1] < 13)) {
- // Error and exit if less than 18.13
+} else if (version[0] < 18 || (version[0] === 18 && version[1] < 19)) {
+ // Error and exit if less than 18.19
console.error(
'Node.js version ' +
process.version +
' detected.\n' +
- 'The Angular CLI requires a minimum Node.js version of v18.13.\n\n' +
+ 'The Angular CLI requires a minimum Node.js version of v18.19.\n\n' +
'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n',
);
diff --git a/packages/angular/cli/lib/cli/index.ts b/packages/angular/cli/lib/cli/index.ts
index 70eedbbe5e85..361d44f0bbb5 100644
--- a/packages/angular/cli/lib/cli/index.ts
+++ b/packages/angular/cli/lib/cli/index.ts
@@ -3,14 +3,14 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { logging } from '@angular-devkit/core';
-import { format } from 'util';
+import { format, stripVTControlCharacters } from 'node:util';
import { CommandModuleError } from '../../src/command-builder/command-module';
import { runCommand } from '../../src/command-builder/command-runner';
-import { colors, removeColor } from '../../src/utilities/color';
+import { colors, supportColor } from '../../src/utilities/color';
import { ngDebug } from '../../src/utilities/environment-options';
import { writeErrorToLogFile } from '../../src/utilities/log-file';
@@ -38,20 +38,21 @@ export default async function (options: { cliArgs: string[] }) {
const colorLevels: Record string> = {
info: (s) => s,
debug: (s) => s,
- warn: (s) => colors.bold.yellow(s),
- error: (s) => colors.bold.red(s),
- fatal: (s) => colors.bold.red(s),
+ warn: (s) => colors.bold(colors.yellow(s)),
+ error: (s) => colors.bold(colors.red(s)),
+ fatal: (s) => colors.bold(colors.red(s)),
};
const logger = new logging.IndentLogger('cli-main-logger');
const logInfo = console.log;
const logError = console.error;
+ const useColor = supportColor();
const loggerFinished = logger.forEach((entry) => {
if (!ngDebug && entry.level === 'debug') {
return;
}
- const color = colors.enabled ? colorLevels[entry.level] : removeColor;
+ const color = useColor ? colorLevels[entry.level] : stripVTControlCharacters;
const message = color(entry.message);
switch (entry.level) {
diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json
index 2695b69e9215..650ae3ae18f2 100644
--- a/packages/angular/cli/lib/config/workspace-schema.json
+++ b/packages/angular/cli/lib/config/workspace-schema.json
@@ -354,6 +354,9 @@
"description": "The builder used for this package.",
"not": {
"enum": [
+ "@angular/build:application",
+ "@angular/build:dev-server",
+ "@angular/build:extract-i18n",
"@angular-devkit/build-angular:application",
"@angular-devkit/build-angular:app-shell",
"@angular-devkit/build-angular:browser",
@@ -389,6 +392,28 @@
"additionalProperties": false,
"required": ["builder"]
},
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "builder": {
+ "const": "@angular/build:application"
+ },
+ "defaultConfiguration": {
+ "type": "string",
+ "description": "A default named configuration to use when a target configuration is not provided."
+ },
+ "options": {
+ "$ref": "../../../../angular/build/src/builders/application/schema.json"
+ },
+ "configurations": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "../../../../angular/build/src/builders/application/schema.json"
+ }
+ }
+ }
+ },
{
"type": "object",
"additionalProperties": false,
@@ -401,12 +426,12 @@
"description": "A default named configuration to use when a target configuration is not provided."
},
"options": {
- "$ref": "../../../../angular_devkit/build_angular/src/builders/application/schema.json"
+ "$ref": "../../../../angular/build/src/builders/application/schema.json"
},
"configurations": {
"type": "object",
"additionalProperties": {
- "$ref": "../../../../angular_devkit/build_angular/src/builders/application/schema.json"
+ "$ref": "../../../../angular/build/src/builders/application/schema.json"
}
}
}
@@ -477,6 +502,28 @@
}
}
},
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "builder": {
+ "const": "@angular/build:dev-server"
+ },
+ "defaultConfiguration": {
+ "type": "string",
+ "description": "A default named configuration to use when a target configuration is not provided."
+ },
+ "options": {
+ "$ref": "../../../../angular/build/src/builders/dev-server/schema.json"
+ },
+ "configurations": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "../../../../angular/build/src/builders/dev-server/schema.json"
+ }
+ }
+ }
+ },
{
"type": "object",
"additionalProperties": false,
@@ -499,6 +546,28 @@
}
}
},
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "builder": {
+ "const": "@angular/build:extract-i18n"
+ },
+ "defaultConfiguration": {
+ "type": "string",
+ "description": "A default named configuration to use when a target configuration is not provided."
+ },
+ "options": {
+ "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json"
+ },
+ "configurations": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json"
+ }
+ }
+ }
+ },
{
"type": "object",
"additionalProperties": false,
diff --git a/packages/angular/cli/lib/init.ts b/packages/angular/cli/lib/init.ts
index c23499622c66..dc3d54ab1ded 100644
--- a/packages/angular/cli/lib/init.ts
+++ b/packages/angular/cli/lib/init.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import 'symbol-observable';
diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json
index 2b69df806dbb..90e3249cd004 100644
--- a/packages/angular/cli/package.json
+++ b/packages/angular/cli/package.json
@@ -25,19 +25,18 @@
"@angular-devkit/architect": "0.0.0-EXPERIMENTAL-PLACEHOLDER",
"@angular-devkit/core": "0.0.0-PLACEHOLDER",
"@angular-devkit/schematics": "0.0.0-PLACEHOLDER",
+ "@inquirer/prompts": "5.3.8",
+ "@listr2/prompt-adapter-inquirer": "2.0.15",
"@schematics/angular": "0.0.0-PLACEHOLDER",
"@yarnpkg/lockfile": "1.1.0",
- "ansi-colors": "4.1.3",
- "ini": "4.1.2",
- "inquirer": "9.2.15",
- "jsonc-parser": "3.2.1",
- "npm-package-arg": "11.0.1",
- "npm-pick-manifest": "9.0.0",
- "open": "8.4.2",
- "ora": "5.4.1",
- "pacote": "17.0.6",
+ "ini": "4.1.3",
+ "jsonc-parser": "3.3.1",
+ "listr2": "8.2.4",
+ "npm-package-arg": "11.0.3",
+ "npm-pick-manifest": "9.1.0",
+ "pacote": "18.0.6",
"resolve": "1.22.8",
- "semver": "7.6.0",
+ "semver": "7.6.3",
"symbol-observable": "4.0.0",
"yargs": "17.7.2"
},
@@ -45,6 +44,7 @@
"migrations": "@schematics/angular/migrations/migration-collection.json",
"packageGroup": {
"@angular/cli": "0.0.0-PLACEHOLDER",
+ "@angular/build": "0.0.0-PLACEHOLDER",
"@angular/ssr": "0.0.0-PLACEHOLDER",
"@angular-devkit/architect": "0.0.0-EXPERIMENTAL-PLACEHOLDER",
"@angular-devkit/build-angular": "0.0.0-PLACEHOLDER",
diff --git a/packages/angular/cli/src/analytics/analytics-collector.ts b/packages/angular/cli/src/analytics/analytics-collector.ts
index b43d7e5e16f5..379006bef2b9 100644
--- a/packages/angular/cli/src/analytics/analytics-collector.ts
+++ b/packages/angular/cli/src/analytics/analytics-collector.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { randomUUID } from 'crypto';
@@ -31,7 +31,10 @@ export class AnalyticsCollector {
private readonly requestParameterStringified: string;
private readonly userParameters: Record;
- constructor(private context: CommandContext, userId: string) {
+ constructor(
+ private context: CommandContext,
+ userId: string,
+ ) {
const requestParameters: Partial> = {
[RequestParameter.ProtocolVersion]: 2,
[RequestParameter.ClientId]: userId,
diff --git a/packages/angular/cli/src/analytics/analytics-parameters.mts b/packages/angular/cli/src/analytics/analytics-parameters.mts
new file mode 100644
index 000000000000..0fd81c4531f0
--- /dev/null
+++ b/packages/angular/cli/src/analytics/analytics-parameters.mts
@@ -0,0 +1,105 @@
+/**
+ * @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
+ */
+
+/** This is a copy of analytics-parameters.ts and is needed for `yarn admin validate-user-analytics` due to ts-node. */
+
+/**
+ * GA built-in request parameters
+ * @see https://www.thyngster.com/ga4-measurement-protocol-cheatsheet
+ * @see http://go/depot/google3/analytics/container_tag/templates/common/gold/mpv2_schema.js
+ */
+export enum RequestParameter {
+ ClientId = 'cid',
+ DebugView = '_dbg',
+ GtmVersion = 'gtm',
+ Language = 'ul',
+ NewToSite = '_nsi',
+ NonInteraction = 'ni',
+ PageLocation = 'dl',
+ PageTitle = 'dt',
+ ProtocolVersion = 'v',
+ SessionEngaged = 'seg',
+ SessionId = 'sid',
+ SessionNumber = 'sct',
+ SessionStart = '_ss',
+ TrackingId = 'tid',
+ TrafficType = 'tt',
+ UserAgentArchitecture = 'uaa',
+ UserAgentBitness = 'uab',
+ UserAgentFullVersionList = 'uafvl',
+ UserAgentMobile = 'uamb',
+ UserAgentModel = 'uam',
+ UserAgentPlatform = 'uap',
+ UserAgentPlatformVersion = 'uapv',
+ UserId = 'uid',
+}
+
+/**
+ * User scoped custom dimensions.
+ * @notes
+ * - User custom dimensions limit is 25.
+ * - `up.*` string type.
+ * - `upn.*` number type.
+ * @see https://support.google.com/analytics/answer/10075209?hl=en
+ */
+export enum UserCustomDimension {
+ UserId = 'up.ng_user_id',
+ OsArchitecture = 'up.ng_os_architecture',
+ NodeVersion = 'up.ng_node_version',
+ NodeMajorVersion = 'upn.ng_node_major_version',
+ AngularCLIVersion = 'up.ng_cli_version',
+ AngularCLIMajorVersion = 'upn.ng_cli_major_version',
+ PackageManager = 'up.ng_package_manager',
+ PackageManagerVersion = 'up.ng_pkg_manager_version',
+ PackageManagerMajorVersion = 'upn.ng_pkg_manager_major_v',
+}
+
+/**
+ * Event scoped custom dimensions.
+ * @notes
+ * - Event custom dimensions limit is 50.
+ * - `ep.*` string type.
+ * - `epn.*` number type.
+ * @see https://support.google.com/analytics/answer/10075209?hl=en
+ */
+export enum EventCustomDimension {
+ Command = 'ep.ng_command',
+ SchematicCollectionName = 'ep.ng_schematic_collection_name',
+ SchematicName = 'ep.ng_schematic_name',
+ Standalone = 'ep.ng_standalone',
+ SSR = 'ep.ng_ssr',
+ Style = 'ep.ng_style',
+ Routing = 'ep.ng_routing',
+ InlineTemplate = 'ep.ng_inline_template',
+ InlineStyle = 'ep.ng_inline_style',
+ BuilderTarget = 'ep.ng_builder_target',
+ Aot = 'ep.ng_aot',
+ Optimization = 'ep.ng_optimization',
+}
+
+/**
+ * Event scoped custom mertics.
+ * @notes
+ * - Event scoped custom mertics limit is 50.
+ * - `ep.*` string type.
+ * - `epn.*` number type.
+ * @see https://support.google.com/analytics/answer/10075209?hl=en
+ */
+export enum EventCustomMetric {
+ AllChunksCount = 'epn.ng_all_chunks_count',
+ LazyChunksCount = 'epn.ng_lazy_chunks_count',
+ InitialChunksCount = 'epn.ng_initial_chunks_count',
+ ChangedChunksCount = 'epn.ng_changed_chunks_count',
+ DurationInMs = 'epn.ng_duration_ms',
+ CssSizeInBytes = 'epn.ng_css_size_bytes',
+ JsSizeInBytes = 'epn.ng_js_size_bytes',
+ NgComponentCount = 'epn.ng_component_count',
+ AllProjectsCount = 'epn.all_projects_count',
+ LibraryProjectsCount = 'epn.libs_projects_count',
+ ApplicationProjectsCount = 'epn.apps_projects_count',
+}
diff --git a/packages/angular/cli/src/analytics/analytics-parameters.ts b/packages/angular/cli/src/analytics/analytics-parameters.ts
index 04a6ee188e96..7249131f348e 100644
--- a/packages/angular/cli/src/analytics/analytics-parameters.ts
+++ b/packages/angular/cli/src/analytics/analytics-parameters.ts
@@ -3,9 +3,11 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
+/** Any changes in this file needs to be done in the mts version. */
+
export type PrimitiveTypes = string | number | boolean;
/**
diff --git a/packages/angular/cli/src/analytics/analytics.ts b/packages/angular/cli/src/analytics/analytics.ts
index e928d3469d5e..f107f6f5ca22 100644
--- a/packages/angular/cli/src/analytics/analytics.ts
+++ b/packages/angular/cli/src/analytics/analytics.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { json, tags } from '@angular-devkit/core';
@@ -12,7 +12,7 @@ import type { CommandContext } from '../command-builder/command-module';
import { colors } from '../utilities/color';
import { getWorkspace } from '../utilities/config';
import { analyticsDisabled } from '../utilities/environment-options';
-import { loadEsmModule } from '../utilities/load-esm';
+import { askConfirmation } from '../utilities/prompt';
import { isTTY } from '../utilities/tty';
/* eslint-disable no-console */
@@ -75,24 +75,19 @@ export async function promptAnalytics(
}
if (force || isTTY()) {
- const { default: inquirer } = await loadEsmModule('inquirer');
- const answers = await inquirer.prompt<{ analytics: boolean }>([
- {
- type: 'confirm',
- name: 'analytics',
- message: tags.stripIndents`
- Would you like to share pseudonymous usage data about this project with the Angular Team
- at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more
- details and how to change this setting, see https://angular.io/analytics.
-
- `,
- default: false,
- },
- ]);
-
- await setAnalyticsConfig(global, answers.analytics);
-
- if (answers.analytics) {
+ const answer = await askConfirmation(
+ `
+Would you like to share pseudonymous usage data about this project with the Angular Team
+at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more
+details and how to change this setting, see https://angular.dev/cli/analytics.
+
+ `,
+ false,
+ );
+
+ await setAnalyticsConfig(global, answer);
+
+ if (answer) {
console.log('');
console.log(
tags.stripIndent`
diff --git a/packages/angular/cli/src/command-builder/architect-base-command-module.ts b/packages/angular/cli/src/command-builder/architect-base-command-module.ts
index bf370c8375f0..5835a14101bd 100644
--- a/packages/angular/cli/src/command-builder/architect-base-command-module.ts
+++ b/packages/angular/cli/src/command-builder/architect-base-command-module.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { Architect, Target } from '@angular-devkit/architect';
@@ -41,7 +41,7 @@ export abstract class ArchitectBaseCommandModule
protected readonly missingTargetChoices: MissingTargetChoice[] | undefined;
protected async runSingleTarget(target: Target, options: OtherOptions): Promise {
- const architectHost = await this.getArchitectHost();
+ const architectHost = this.getArchitectHost();
let builderName: string;
try {
diff --git a/packages/angular/cli/src/command-builder/architect-command-module.ts b/packages/angular/cli/src/command-builder/architect-command-module.ts
index a57c74f0eeef..4855b629b360 100644
--- a/packages/angular/cli/src/command-builder/architect-command-module.ts
+++ b/packages/angular/cli/src/command-builder/architect-command-module.ts
@@ -3,9 +3,11 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
+import { Target } from '@angular-devkit/architect';
+import { workspaces } from '@angular-devkit/core';
import { Argv } from 'yargs';
import { getProjectByCwd } from '../utilities/config';
import { memoize } from '../utilities/memoize';
@@ -28,7 +30,33 @@ export abstract class ArchitectCommandModule
{
abstract readonly multiTarget: boolean;
+ findDefaultBuilderName?(
+ project: workspaces.ProjectDefinition,
+ target: Target,
+ ): Promise;
+
async builder(argv: Argv): Promise> {
+ const target = this.getArchitectTarget();
+
+ // Add default builder if target is not in project and a command default is provided
+ if (this.findDefaultBuilderName && this.context.workspace) {
+ for (const [project, projectDefinition] of this.context.workspace.projects) {
+ if (projectDefinition.targets.has(target)) {
+ continue;
+ }
+
+ const defaultBuilder = await this.findDefaultBuilderName(projectDefinition, {
+ project,
+ target,
+ });
+ if (defaultBuilder) {
+ projectDefinition.targets.set(target, {
+ builder: defaultBuilder,
+ });
+ }
+ }
+ }
+
const project = this.getArchitectProject();
const { jsonHelp, getYargsCompletions, help } = this.context.args.options;
@@ -44,7 +72,7 @@ export abstract class ArchitectCommandModule
`One or more named builder configurations as a comma-separated ` +
`list as specified in the "configurations" section in angular.json.\n` +
`The builder uses the named configurations to run the given target.\n` +
- `For more information, see https://angular.io/guide/workspace-config#alternate-build-configurations.`,
+ `For more information, see https://angular.dev/reference/configs/workspace-config#alternate-build-configurations.`,
alias: 'c',
type: 'string',
// Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid.
@@ -60,7 +88,6 @@ export abstract class ArchitectCommandModule
return localYargs;
}
- const target = this.getArchitectTarget();
const schemaOptions = await this.getArchitectTargetOptions({
project,
target,
diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts
index 3e3a13e3ce38..e608c4b1d089 100644
--- a/packages/angular/cli/src/command-builder/command-module.ts
+++ b/packages/angular/cli/src/command-builder/command-module.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { logging, schema, strings } from '@angular-devkit/core';
@@ -176,8 +176,8 @@ export abstract class CommandModule implements CommandModuleI
const userId = await getAnalyticsUserId(
this.context,
- // Don't prompt for `ng update` and `ng analytics` commands.
- ['update', 'analytics'].includes(this.commandName),
+ // Don't prompt on `ng update`, 'ng version' or `ng analytics`.
+ ['version', 'update', 'analytics'].includes(this.commandName),
);
return userId ? new AnalyticsCollector(this.context, userId) : undefined;
diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts
index bacf9ac98626..0c2242414ce1 100644
--- a/packages/angular/cli/src/command-builder/command-runner.ts
+++ b/packages/angular/cli/src/command-builder/command-runner.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { logging } from '@angular-devkit/core';
@@ -19,6 +19,7 @@ import { colors } from '../utilities/color';
import { AngularWorkspace, getWorkspace } from '../utilities/config';
import { assertIsError } from '../utilities/error';
import { PackageManagerUtils } from '../utilities/package-manager';
+import { VERSION } from '../utilities/version';
import { CommandContext, CommandModuleError } from './command-module';
import {
CommandModuleConstructor,
@@ -90,6 +91,11 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis
usageInstance.help = () => jsonHelpUsage();
}
+ // Add default command to support version option when no subcommand is specified
+ localYargs.command('*', false, (builder) =>
+ builder.version('version', 'Show Angular CLI version.', VERSION.full),
+ );
+
await localYargs
.scriptName('ng')
// https://github.com/yargs/yargs/blob/main/docs/advanced.md#customizing-yargs-parser
@@ -118,7 +124,7 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis
'deprecated: %s': colors.yellow('deprecated:') + ' %s',
'Did you mean %s?': 'Unknown command. Did you mean %s?',
})
- .epilogue('For more information, see https://angular.io/cli/.\n')
+ .epilogue('For more information, see https://angular.dev/cli/.\n')
.demandCommand(1, demandCommandFailureMessage)
.recommendCommands()
.middleware(normalizeOptionsMiddleware)
diff --git a/packages/angular/cli/src/command-builder/schematics-command-module.ts b/packages/angular/cli/src/command-builder/schematics-command-module.ts
index f04a028363a3..139f7d89059f 100644
--- a/packages/angular/cli/src/command-builder/schematics-command-module.ts
+++ b/packages/angular/cli/src/command-builder/schematics-command-module.ts
@@ -3,24 +3,22 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
-import { normalize as devkitNormalize, schema } from '@angular-devkit/core';
+import { JsonValue, normalize as devkitNormalize, schema } from '@angular-devkit/core';
import { Collection, UnsuccessfulWorkflowExecution, formats } from '@angular-devkit/schematics';
import {
FileSystemCollectionDescription,
FileSystemSchematicDescription,
NodeWorkflow,
} from '@angular-devkit/schematics/tools';
-import type { CheckboxQuestion, Question } from 'inquirer';
import { relative, resolve } from 'path';
import { Argv } from 'yargs';
import { isPackageNameSafeForAnalytics } from '../analytics/analytics';
import { EventCustomDimension } from '../analytics/analytics-parameters';
import { getProjectByCwd, getSchematicDefaults } from '../utilities/config';
import { assertIsError } from '../utilities/error';
-import { loadEsmModule } from '../utilities/load-esm';
import { memoize } from '../utilities/memoize';
import { isTTY } from '../utilities/tty';
import {
@@ -172,76 +170,104 @@ export abstract class SchematicsCommandModule
if (options.interactive !== false && isTTY()) {
workflow.registry.usePromptProvider(async (definitions: Array) => {
- const questions = definitions
- .filter((definition) => !options.defaults || definition.default === undefined)
- .map((definition) => {
- const question: Question = {
- name: definition.id,
- message: definition.message,
- default: definition.default,
- };
-
- const validator = definition.validator;
- if (validator) {
- question.validate = (input) => validator(input);
-
- // Filter allows transformation of the value prior to validation
- question.filter = async (input) => {
- for (const type of definition.propertyTypes) {
- let value;
- switch (type) {
- case 'string':
- value = String(input);
- break;
- case 'integer':
- case 'number':
- value = Number(input);
- break;
- default:
- value = input;
- break;
+ let prompts: typeof import('@inquirer/prompts') | undefined;
+ const answers: Record = {};
+
+ for (const definition of definitions) {
+ if (options.defaults && definition.default !== undefined) {
+ continue;
+ }
+
+ // Only load prompt package if needed
+ prompts ??= await import('@inquirer/prompts');
+
+ switch (definition.type) {
+ case 'confirmation':
+ answers[definition.id] = await prompts.confirm({
+ message: definition.message,
+ default: definition.default as boolean | undefined,
+ });
+ break;
+ case 'list':
+ if (!definition.items?.length) {
+ continue;
+ }
+
+ answers[definition.id] = await (
+ definition.multiselect ? prompts.checkbox : prompts.select
+ )({
+ message: definition.message,
+ validate: (values) => {
+ if (!definition.validator) {
+ return true;
}
- // Can be a string if validation fails
- const isValid = (await validator(value)) === true;
- if (isValid) {
- return value;
- }
- }
-
- return input;
- };
- }
- switch (definition.type) {
- case 'confirmation':
- question.type = 'confirm';
- break;
- case 'list':
- question.type = definition.multiselect ? 'checkbox' : 'list';
- (question as CheckboxQuestion).choices = definition.items?.map((item) => {
- return typeof item == 'string'
- ? item
+ return definition.validator(Object.values(values).map(({ value }) => value));
+ },
+ default: definition.default,
+ choices: definition.items?.map((item) =>
+ typeof item == 'string'
+ ? {
+ name: item,
+ value: item,
+ }
: {
name: item.label,
value: item.value,
- };
- });
- break;
- default:
- question.type = definition.type;
- break;
- }
+ },
+ ),
+ });
+ break;
+ case 'input': {
+ let finalValue: JsonValue | undefined;
+ answers[definition.id] = await prompts.input({
+ message: definition.message,
+ default: definition.default as string | undefined,
+ async validate(value) {
+ if (definition.validator === undefined) {
+ return true;
+ }
- return question;
- });
+ let lastValidation: ReturnType = false;
+ for (const type of definition.propertyTypes) {
+ let potential;
+ switch (type) {
+ case 'string':
+ potential = String(value);
+ break;
+ case 'integer':
+ case 'number':
+ potential = Number(value);
+ break;
+ default:
+ potential = value;
+ break;
+ }
+ lastValidation = await definition.validator(potential);
+
+ // Can be a string if validation fails
+ if (lastValidation === true) {
+ finalValue = potential;
+
+ return true;
+ }
+ }
- if (questions.length) {
- const { default: inquirer } = await loadEsmModule('inquirer');
+ return lastValidation;
+ },
+ });
- return inquirer.prompt(questions);
- } else {
- return {};
+ // Use validated value if present.
+ // This ensures the correct type is inserted into the final schema options.
+ if (finalValue !== undefined) {
+ answers[definition.id] = finalValue;
+ }
+ break;
+ }
+ }
}
+
+ return answers;
});
}
diff --git a/packages/angular/cli/src/command-builder/utilities/command.ts b/packages/angular/cli/src/command-builder/utilities/command.ts
index 5ba067e38209..04a88c1f7113 100644
--- a/packages/angular/cli/src/command-builder/utilities/command.ts
+++ b/packages/angular/cli/src/command-builder/utilities/command.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { Argv } from 'yargs';
diff --git a/packages/angular/cli/src/command-builder/utilities/json-help.ts b/packages/angular/cli/src/command-builder/utilities/json-help.ts
index 2f1969e1e092..6e673804ed84 100644
--- a/packages/angular/cli/src/command-builder/utilities/json-help.ts
+++ b/packages/angular/cli/src/command-builder/utilities/json-help.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import yargs from 'yargs';
diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema.ts b/packages/angular/cli/src/command-builder/utilities/json-schema.ts
index c8649db75020..2b17c1eb0226 100644
--- a/packages/angular/cli/src/command-builder/utilities/json-schema.ts
+++ b/packages/angular/cli/src/command-builder/utilities/json-schema.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { json } from '@angular-devkit/core';
@@ -165,8 +165,8 @@ export async function parseJsonSchemaToOptions(
const alias = json.isJsonArray(current.aliases)
? [...current.aliases].map((x) => '' + x)
: current.alias
- ? ['' + current.alias]
- : [];
+ ? ['' + current.alias]
+ : [];
const format = typeof current.format == 'string' ? current.format : undefined;
const visible = current.visible === undefined || current.visible === true;
const hidden = !!current.hidden || !visible;
diff --git a/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts
index c19d1c8d3038..709f9e5a7c67 100644
--- a/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts
+++ b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import * as yargs from 'yargs';
diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts
index 1be2e0a9aee1..ed17f97af942 100644
--- a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts
+++ b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts
@@ -3,16 +3,15 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { RuleFactory, SchematicsException, Tree } from '@angular-devkit/schematics';
import { FileSystemCollectionDesc, NodeModulesEngineHost } from '@angular-devkit/schematics/tools';
import { readFileSync } from 'fs';
import { parse as parseJson } from 'jsonc-parser';
-import { createRequire } from 'module';
+import { Module, createRequire } from 'module';
import { dirname, resolve } from 'path';
-import { TextEncoder } from 'util';
import { Script } from 'vm';
import { assertIsError } from '../../utilities/error';
@@ -204,38 +203,24 @@ function wrap(
// Setup a wrapper function to capture the module's exports
const schematicCode = readFileSync(schematicFile, 'utf8');
- // `module` is required due to @angular/localize ng-add being in UMD format
- const headerCode = '(function() {\nvar exports = {};\nvar module = { exports };\n';
- const footerCode = exportName
- ? `\nreturn module.exports['${exportName}'];});`
- : '\nreturn module.exports;});';
-
- const script = new Script(headerCode + schematicCode + footerCode, {
+ const script = new Script(Module.wrap(schematicCode), {
filename: schematicFile,
- lineOffset: 3,
+ lineOffset: 1,
});
-
- const context = {
- __dirname: schematicDirectory,
- __filename: schematicFile,
- Buffer,
- // TextEncoder is used by the compiler to generate i18n message IDs. See:
- // https://github.com/angular/angular/blob/main/packages/compiler/src/i18n/digest.ts#L17
- // It is referenced globally, because it may be run either on the browser or the server.
- // Usually Node exposes it globally, but in order for it to work, our custom context
- // has to expose it too. Issue context: https://github.com/angular/angular/issues/48940.
- TextEncoder,
- console,
- process,
- get global() {
- return this;
- },
- require: customRequire,
+ const schematicModule = new Module(schematicFile);
+ const moduleFactory = script.runInThisContext();
+
+ return () => {
+ moduleFactory(
+ schematicModule.exports,
+ customRequire,
+ schematicModule,
+ schematicFile,
+ schematicDirectory,
+ );
+
+ return exportName ? schematicModule.exports[exportName] : schematicModule.exports;
};
-
- const exportsFactory = script.runInNewContext(context);
-
- return exportsFactory;
}
function loadBuiltinModule(id: string): unknown {
diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts
index 0b056ed64436..f5caa0754d88 100644
--- a/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts
+++ b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts
@@ -3,13 +3,17 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
-import { logging, tags } from '@angular-devkit/core';
+import { logging } from '@angular-devkit/core';
import { NodeWorkflow } from '@angular-devkit/schematics/tools';
import { colors } from '../../utilities/color';
+function removeLeadingSlash(value: string): string {
+ return value[0] === '/' ? value.slice(1) : value;
+}
+
export function subscribeToWorkflow(
workflow: NodeWorkflow,
logger: logging.LoggerApi,
@@ -24,24 +28,21 @@ export function subscribeToWorkflow(
const reporterSubscription = workflow.reporter.subscribe((event) => {
// Strip leading slash to prevent confusion.
- const eventPath = event.path.charAt(0) === '/' ? event.path.substring(1) : event.path;
+ const eventPath = removeLeadingSlash(event.path);
switch (event.kind) {
case 'error':
error = true;
- const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist';
- logger.error(`ERROR! ${eventPath} ${desc}.`);
+ logger.error(
+ `ERROR! ${eventPath} ${event.description == 'alreadyExist' ? 'already exists' : 'does not exist'}.`,
+ );
break;
case 'update':
- logs.push(tags.oneLine`
- ${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes)
- `);
+ logs.push(`${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes)`);
files.add(eventPath);
break;
case 'create':
- logs.push(tags.oneLine`
- ${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes)
- `);
+ logs.push(`${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes)`);
files.add(eventPath);
break;
case 'delete':
@@ -49,8 +50,7 @@ export function subscribeToWorkflow(
files.add(eventPath);
break;
case 'rename':
- const eventToPath = event.to.charAt(0) === '/' ? event.to.substring(1) : event.to;
- logs.push(`${colors.blue('RENAME')} ${eventPath} => ${eventToPath}`);
+ logs.push(`${colors.blue('RENAME')} ${eventPath} => ${removeLeadingSlash(event.to)}`);
files.add(eventPath);
break;
}
diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts
index dc3de137a0d5..ccc830eaa1f0 100644
--- a/packages/angular/cli/src/commands/add/cli.ts
+++ b/packages/angular/cli/src/commands/add/cli.ts
@@ -3,12 +3,13 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
-import { tags } from '@angular-devkit/core';
import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools';
+import { Listr, color, figures } from 'listr2';
import { createRequire } from 'module';
+import assert from 'node:assert';
import npa from 'npm-package-arg';
import { dirname, join } from 'path';
import { Range, compare, intersects, prerelease, satisfies, valid } from 'semver';
@@ -23,7 +24,6 @@ import {
SchematicsCommandArgs,
SchematicsCommandModule,
} from '../../command-builder/schematics-command-module';
-import { colors } from '../../utilities/color';
import { assertIsError } from '../../utilities/error';
import {
NgAddSaveDependency,
@@ -31,11 +31,11 @@ import {
fetchPackageManifest,
fetchPackageMetadata,
} from '../../utilities/package-metadata';
-import { askConfirmation } from '../../utilities/prompt';
-import { Spinner } from '../../utilities/spinner';
import { isTTY } from '../../utilities/tty';
import { VERSION } from '../../utilities/version';
+class CommandError extends Error {}
+
interface AddCommandArgs extends SchematicsCommandArgs {
collection: string;
verbose?: boolean;
@@ -43,6 +43,15 @@ interface AddCommandArgs extends SchematicsCommandArgs {
'skip-confirmation'?: boolean;
}
+interface AddCommandTaskContext {
+ packageIdentifier: npa.Result;
+ usingYarn?: boolean;
+ savePackage?: NgAddSaveDependency;
+ collectionName?: string;
+ executeSchematic: AddCommandModule['executeSchematic'];
+ hasMismatchedPeer: AddCommandModule['hasMismatchedPeer'];
+}
+
/**
* The set of packages that should have certain versions excluded from consideration
* when attempting to find a compatible version for a package.
@@ -92,7 +101,7 @@ export default class AddCommandModule
.strict(false);
const collectionName = await this.getCollectionName();
- const workflow = await this.getOrCreateWorkflowForBuilder(collectionName);
+ const workflow = this.getOrCreateWorkflowForBuilder(collectionName);
try {
const collection = workflow.engine.createCollection(collectionName);
@@ -137,181 +146,226 @@ export default class AddCommandModule
}
}
- const spinner = new Spinner();
-
- spinner.start('Determining package manager...');
- const usingYarn = packageManager.name === PackageManager.Yarn;
- spinner.info(`Using package manager: ${colors.grey(packageManager.name)}`);
-
- if (
- packageIdentifier.name &&
- packageIdentifier.type === 'range' &&
- packageIdentifier.rawSpec === '*'
- ) {
- // only package name provided; search for viable version
- // plus special cases for packages that did not have peer deps setup
- spinner.start('Searching for compatible package version...');
-
- let packageMetadata;
- try {
- packageMetadata = await fetchPackageMetadata(packageIdentifier.name, logger, {
- registry,
- usingYarn,
- verbose,
- });
- } catch (e) {
- assertIsError(e);
- spinner.fail(`Unable to load package information from registry: ${e.message}`);
+ const taskContext: AddCommandTaskContext = {
+ packageIdentifier,
+ executeSchematic: this.executeSchematic.bind(this),
+ hasMismatchedPeer: this.hasMismatchedPeer.bind(this),
+ };
+
+ const tasks = new Listr([
+ {
+ title: 'Determining Package Manager',
+ task(context, task) {
+ context.usingYarn = packageManager.name === PackageManager.Yarn;
+ task.output = `Using package manager: ${color.dim(packageManager.name)}`;
+ },
+ rendererOptions: { persistentOutput: true },
+ },
+ {
+ title: 'Searching for compatible package version',
+ enabled: packageIdentifier.type === 'range' && packageIdentifier.rawSpec === '*',
+ async task(context, task) {
+ assert(
+ context.packageIdentifier.name,
+ 'Registry package identifiers should always have a name.',
+ );
- return 1;
- }
+ // only package name provided; search for viable version
+ // plus special cases for packages that did not have peer deps setup
+ let packageMetadata;
+ try {
+ packageMetadata = await fetchPackageMetadata(context.packageIdentifier.name, logger, {
+ registry,
+ usingYarn: context.usingYarn,
+ verbose,
+ });
+ } catch (e) {
+ assertIsError(e);
+ throw new CommandError(
+ `Unable to load package information from registry: ${e.message}`,
+ );
+ }
- // Start with the version tagged as `latest` if it exists
- const latestManifest = packageMetadata.tags['latest'];
- if (latestManifest) {
- packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version);
- }
+ // Start with the version tagged as `latest` if it exists
+ const latestManifest = packageMetadata.tags['latest'];
+ if (latestManifest) {
+ context.packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version);
+ }
- // Adjust the version based on name and peer dependencies
- if (
- latestManifest?.peerDependencies &&
- Object.keys(latestManifest.peerDependencies).length === 0
- ) {
- spinner.succeed(
- `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`,
- );
- } else if (!latestManifest || (await this.hasMismatchedPeer(latestManifest))) {
- // 'latest' is invalid so search for most recent matching package
-
- // Allow prelease versions if the CLI itself is a prerelease
- const allowPrereleases = prerelease(VERSION.full);
-
- const versionExclusions = packageVersionExclusions[packageMetadata.name];
- const versionManifests = Object.values(packageMetadata.versions).filter(
- (value: PackageManifest) => {
- // Prerelease versions are not stable and should not be considered by default
- if (!allowPrereleases && prerelease(value.version)) {
- return false;
- }
- // Deprecated versions should not be used or considered
- if (value.deprecated) {
- return false;
- }
- // Excluded package versions should not be considered
- if (
- versionExclusions &&
- satisfies(value.version, versionExclusions, { includePrerelease: true })
- ) {
- return false;
+ // Adjust the version based on name and peer dependencies
+ if (
+ latestManifest?.peerDependencies &&
+ Object.keys(latestManifest.peerDependencies).length === 0
+ ) {
+ task.output = `Found compatible package version: ${color.blue(latestManifest.version)}.`;
+ } else if (!latestManifest || (await context.hasMismatchedPeer(latestManifest))) {
+ // 'latest' is invalid so search for most recent matching package
+
+ // Allow prelease versions if the CLI itself is a prerelease
+ const allowPrereleases = prerelease(VERSION.full);
+
+ const versionExclusions = packageVersionExclusions[packageMetadata.name];
+ const versionManifests = Object.values(packageMetadata.versions).filter(
+ (value: PackageManifest) => {
+ // Prerelease versions are not stable and should not be considered by default
+ if (!allowPrereleases && prerelease(value.version)) {
+ return false;
+ }
+ // Deprecated versions should not be used or considered
+ if (value.deprecated) {
+ return false;
+ }
+ // Excluded package versions should not be considered
+ if (
+ versionExclusions &&
+ satisfies(value.version, versionExclusions, { includePrerelease: true })
+ ) {
+ return false;
+ }
+
+ return true;
+ },
+ );
+
+ // Sort in reverse SemVer order so that the newest compatible version is chosen
+ versionManifests.sort((a, b) => compare(b.version, a.version, true));
+
+ let found = false;
+ for (const versionManifest of versionManifests) {
+ const mismatch = await context.hasMismatchedPeer(versionManifest);
+ if (mismatch) {
+ continue;
+ }
+
+ context.packageIdentifier = npa.resolve(
+ versionManifest.name,
+ versionManifest.version,
+ );
+ found = true;
}
- return true;
- },
- );
+ if (!found) {
+ task.output = "Unable to find compatible package. Using 'latest' tag.";
+ } else {
+ task.output = `Found compatible package version: ${color.blue(context.packageIdentifier.toString())}.`;
+ }
+ } else {
+ task.output = `Found compatible package version: ${color.blue(context.packageIdentifier.toString())}.`;
+ }
+ },
+ rendererOptions: { persistentOutput: true },
+ },
+ {
+ title: 'Loading package information from registry',
+ async task(context, task) {
+ let manifest;
+ try {
+ manifest = await fetchPackageManifest(context.packageIdentifier.toString(), logger, {
+ registry,
+ verbose,
+ usingYarn: context.usingYarn,
+ });
+ } catch (e) {
+ assertIsError(e);
+ throw new CommandError(
+ `Unable to fetch package information for '${context.packageIdentifier}': ${e.message}`,
+ );
+ }
- // Sort in reverse SemVer order so that the newest compatible version is chosen
- versionManifests.sort((a, b) => compare(b.version, a.version, true));
+ context.savePackage = manifest['ng-add']?.save;
+ context.collectionName = manifest.name;
- let newIdentifier;
- for (const versionManifest of versionManifests) {
- if (!(await this.hasMismatchedPeer(versionManifest))) {
- newIdentifier = npa.resolve(versionManifest.name, versionManifest.version);
- break;
+ if (await context.hasMismatchedPeer(manifest)) {
+ task.output = color.yellow(
+ figures.warning +
+ ' Package has unmet peer dependencies. Adding the package may not succeed.',
+ );
+ }
+ },
+ rendererOptions: { persistentOutput: true },
+ },
+ {
+ title: 'Confirming installation',
+ enabled: !skipConfirmation,
+ async task(context, task) {
+ if (!isTTY()) {
+ task.output =
+ `'--skip-confirmation' can be used to bypass installation confirmation. ` +
+ `Ensure package name is correct prior to '--skip-confirmation' option usage.`;
+ throw new CommandError('No terminal detected');
}
- }
- if (!newIdentifier) {
- spinner.warn("Unable to find compatible package. Using 'latest' tag.");
- } else {
- packageIdentifier = newIdentifier;
- spinner.succeed(
- `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`,
- );
- }
- } else {
- spinner.succeed(
- `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`,
- );
- }
- }
+ const { ListrInquirerPromptAdapter } = await import('@listr2/prompt-adapter-inquirer');
+ const { confirm } = await import('@inquirer/prompts');
+ const shouldProceed = await task.prompt(ListrInquirerPromptAdapter).run(confirm, {
+ message:
+ `The package ${color.blue(context.packageIdentifier.toString())} will be installed and executed.\n` +
+ 'Would you like to proceed?',
+ default: true,
+ theme: { prefix: '' },
+ });
+
+ if (!shouldProceed) {
+ throw new CommandError('Command aborted');
+ }
+ },
+ rendererOptions: { persistentOutput: true },
+ },
+ {
+ async task(context, task) {
+ // Only show if installation will actually occur
+ task.title = 'Installing package';
+
+ if (context.savePackage === false) {
+ task.title += ' in temporary location';
+
+ // Temporary packages are located in a different directory
+ // Hence we need to resolve them using the temp path
+ const { success, tempNodeModules } = await packageManager.installTemp(
+ context.packageIdentifier.toString(),
+ registry ? [`--registry="${registry}"`] : undefined,
+ );
+ const tempRequire = createRequire(tempNodeModules + '/');
+ assert(context.collectionName, 'Collection name should always be available');
+ const resolvedCollectionPath = tempRequire.resolve(
+ join(context.collectionName, 'package.json'),
+ );
+
+ if (!success) {
+ throw new CommandError('Unable to install package');
+ }
- let collectionName = packageIdentifier.name;
- let savePackage: NgAddSaveDependency | undefined;
+ context.collectionName = dirname(resolvedCollectionPath);
+ } else {
+ const success = await packageManager.install(
+ context.packageIdentifier.toString(),
+ context.savePackage,
+ registry ? [`--registry="${registry}"`] : undefined,
+ undefined,
+ );
+
+ if (!success) {
+ throw new CommandError('Unable to install package');
+ }
+ }
+ },
+ rendererOptions: { bottomBar: Infinity },
+ },
+ // TODO: Rework schematic execution as a task and insert here
+ ]);
try {
- spinner.start('Loading package information from registry...');
- const manifest = await fetchPackageManifest(packageIdentifier.toString(), logger, {
- registry,
- verbose,
- usingYarn,
- });
+ const result = await tasks.run(taskContext);
+ assert(result.collectionName, 'Collection name should always be available');
- savePackage = manifest['ng-add']?.save;
- collectionName = manifest.name;
-
- if (await this.hasMismatchedPeer(manifest)) {
- spinner.warn('Package has unmet peer dependencies. Adding the package may not succeed.');
- } else {
- spinner.succeed(`Package information loaded.`);
- }
+ return this.executeSchematic({ ...options, collection: result.collectionName });
} catch (e) {
- assertIsError(e);
- spinner.fail(`Unable to fetch package information for '${packageIdentifier}': ${e.message}`);
-
- return 1;
- }
-
- if (!skipConfirmation) {
- const confirmationResponse = await askConfirmation(
- `\nThe package ${colors.blue(packageIdentifier.raw)} will be installed and executed.\n` +
- 'Would you like to proceed?',
- true,
- false,
- );
-
- if (!confirmationResponse) {
- if (!isTTY()) {
- logger.error(
- 'No terminal detected. ' +
- `'--skip-confirmation' can be used to bypass installation confirmation. ` +
- `Ensure package name is correct prior to '--skip-confirmation' option usage.`,
- );
- }
-
- logger.error('Command aborted.');
-
- return 1;
- }
- }
-
- if (savePackage === false) {
- // Temporary packages are located in a different directory
- // Hence we need to resolve them using the temp path
- const { success, tempNodeModules } = await packageManager.installTemp(
- packageIdentifier.raw,
- registry ? [`--registry="${registry}"`] : undefined,
- );
- const tempRequire = createRequire(tempNodeModules + '/');
- const resolvedCollectionPath = tempRequire.resolve(join(collectionName, 'package.json'));
-
- if (!success) {
+ if (e instanceof CommandError) {
return 1;
}
- collectionName = dirname(resolvedCollectionPath);
- } else {
- const success = await packageManager.install(
- packageIdentifier.raw,
- savePackage,
- registry ? [`--registry="${registry}"`] : undefined,
- );
-
- if (!success) {
- return 1;
- }
+ throw e;
}
-
- return this.executeSchematic({ ...options, collection: collectionName });
}
private async isProjectVersionValid(packageIdentifier: npa.Result): Promise {
@@ -347,7 +401,17 @@ export default class AddCommandModule
}
private async getCollectionName(): Promise {
- const [, collectionName] = this.context.args.positional;
+ let [, collectionName] = this.context.args.positional;
+
+ // The CLI argument may specify also a version, like `ng add @my/lib@13.0.0`,
+ // but here we need only the name of the package, like `@my/lib`
+ try {
+ const packageIdentifier = npa(collectionName);
+ collectionName = packageIdentifier.name ?? collectionName;
+ } catch (e) {
+ assertIsError(e);
+ this.context.logger.error(e.message);
+ }
return collectionName;
}
@@ -397,10 +461,10 @@ export default class AddCommandModule
});
} catch (e) {
if (e instanceof NodePackageDoesNotSupportSchematics) {
- this.context.logger.error(tags.oneLine`
- The package that you are trying to add does not support schematics. You can try using
- a different version of the package or contact the package author to add ng-add support.
- `);
+ this.context.logger.error(
+ 'The package that you are trying to add does not support schematics.' +
+ 'You can try using a different version of the package or contact the package author to add ng-add support.',
+ );
return 1;
}
diff --git a/packages/angular/cli/src/commands/analytics/cli.ts b/packages/angular/cli/src/commands/analytics/cli.ts
index 8e3753ababb1..56841a95bd6b 100644
--- a/packages/angular/cli/src/commands/analytics/cli.ts
+++ b/packages/angular/cli/src/commands/analytics/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { join } from 'node:path';
diff --git a/packages/angular/cli/src/commands/analytics/info/cli.ts b/packages/angular/cli/src/commands/analytics/info/cli.ts
index bfcba4a3da0e..e4434d35baee 100644
--- a/packages/angular/cli/src/commands/analytics/info/cli.ts
+++ b/packages/angular/cli/src/commands/analytics/info/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { Argv } from 'yargs';
diff --git a/packages/angular/cli/src/commands/analytics/settings/cli.ts b/packages/angular/cli/src/commands/analytics/settings/cli.ts
index ff965e228781..16f07b353d1a 100644
--- a/packages/angular/cli/src/commands/analytics/settings/cli.ts
+++ b/packages/angular/cli/src/commands/analytics/settings/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { Argv } from 'yargs';
diff --git a/packages/angular/cli/src/commands/build/cli.ts b/packages/angular/cli/src/commands/build/cli.ts
index 196585a4b122..b98fc46c48e7 100644
--- a/packages/angular/cli/src/commands/build/cli.ts
+++ b/packages/angular/cli/src/commands/build/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { join } from 'path';
diff --git a/packages/angular/cli/src/commands/build/long-description.md b/packages/angular/cli/src/commands/build/long-description.md
index 3a8885825f9c..b2c14d8f23fe 100644
--- a/packages/angular/cli/src/commands/build/long-description.md
+++ b/packages/angular/cli/src/commands/build/long-description.md
@@ -1,5 +1,5 @@
The command can be used to build a project of type "application" or "library".
-When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, and `watch` options are applied.
+When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, `poll` and `watch` options are applied.
All other options apply only to building applications.
The application builder uses the [esbuild](https://esbuild.github.io/) build tool, with default configuration options specified in the workspace configuration file (`angular.json`) or with a named alternative configuration.
@@ -15,4 +15,4 @@ either by direct editing or with the `ng config` command.
These include `assets`, `styles`, and `scripts` objects that provide runtime-global resources to include in the project.
Resources in CSS, such as images and fonts, are automatically written and fingerprinted at the root of the output folder.
-For further details, see [Workspace Configuration](guide/workspace-config).
+For further details, see [Workspace Configuration](reference/configs/workspace-config).
diff --git a/packages/angular/cli/src/commands/cache/clean/cli.ts b/packages/angular/cli/src/commands/cache/clean/cli.ts
index f07cd5613c96..4ede8b0a60a2 100644
--- a/packages/angular/cli/src/commands/cache/clean/cli.ts
+++ b/packages/angular/cli/src/commands/cache/clean/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { promises as fs } from 'fs';
diff --git a/packages/angular/cli/src/commands/cache/cli.ts b/packages/angular/cli/src/commands/cache/cli.ts
index bc4115d8cfde..046673995846 100644
--- a/packages/angular/cli/src/commands/cache/cli.ts
+++ b/packages/angular/cli/src/commands/cache/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { join } from 'path';
diff --git a/packages/angular/cli/src/commands/cache/info/cli.ts b/packages/angular/cli/src/commands/cache/info/cli.ts
index 15fcf3ba857f..ec1802c65695 100644
--- a/packages/angular/cli/src/commands/cache/info/cli.ts
+++ b/packages/angular/cli/src/commands/cache/info/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { tags } from '@angular-devkit/core';
diff --git a/packages/angular/cli/src/commands/cache/long-description.md b/packages/angular/cli/src/commands/cache/long-description.md
index 8da4bb9e5364..3ebfec598c4e 100644
--- a/packages/angular/cli/src/commands/cache/long-description.md
+++ b/packages/angular/cli/src/commands/cache/long-description.md
@@ -2,7 +2,7 @@ Angular CLI saves a number of cachable operations on disk by default.
When you re-run the same build, the build system restores the state of the previous build and re-uses previously performed operations, which decreases the time taken to build and test your applications and libraries.
-To amend the default cache settings, add the `cli.cache` object to your [Workspace Configuration](guide/workspace-config).
+To amend the default cache settings, add the `cli.cache` object to your [Workspace Configuration](reference/configs/workspace-config).
The object goes under `cli.cache` at the top level of the file, outside the `projects` sections.
```jsonc
@@ -12,13 +12,13 @@ The object goes under `cli.cache` at the top level of the file, outside the `pro
"cli": {
"cache": {
// ...
- }
+ },
},
- "projects": {}
+ "projects": {},
}
```
-For more information, see [cache options](guide/workspace-config#cache-options).
+For more information, see [cache options](reference/configs/workspace-config#cache-options).
### Cache environments
@@ -34,7 +34,7 @@ To change the environment setting to `all`, run the following command:
ng config cli.cache.environment all
```
-For more information, see `environment` in [cache options](guide/workspace-config#cache-options).
+For more information, see `environment` in [cache options](reference/configs/workspace-config#cache-options).
diff --git a/packages/angular/cli/src/commands/cache/settings/cli.ts b/packages/angular/cli/src/commands/cache/settings/cli.ts
index 97e79cd1005b..9a4f654f7ac7 100644
--- a/packages/angular/cli/src/commands/cache/settings/cli.ts
+++ b/packages/angular/cli/src/commands/cache/settings/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { Argv } from 'yargs';
diff --git a/packages/angular/cli/src/commands/cache/utilities.ts b/packages/angular/cli/src/commands/cache/utilities.ts
index c9783e02f942..3f82b2d3a91e 100644
--- a/packages/angular/cli/src/commands/cache/utilities.ts
+++ b/packages/angular/cli/src/commands/cache/utilities.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { isJsonObject } from '@angular-devkit/core';
diff --git a/packages/angular/cli/src/commands/command-config.ts b/packages/angular/cli/src/commands/command-config.ts
index 6bb4fc7d2679..cd048cbb2240 100644
--- a/packages/angular/cli/src/commands/command-config.ts
+++ b/packages/angular/cli/src/commands/command-config.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { CommandModuleConstructor } from '../command-builder/utilities/command';
@@ -16,7 +16,6 @@ export type CommandNames =
| 'completion'
| 'config'
| 'deploy'
- | 'doc'
| 'e2e'
| 'extract-i18n'
| 'generate'
@@ -60,10 +59,7 @@ export const RootCommands: Record<
'deploy': {
factory: () => import('./deploy/cli'),
},
- 'doc': {
- factory: () => import('./doc/cli'),
- aliases: ['d'],
- },
+
'e2e': {
factory: () => import('./e2e/cli'),
aliases: ['e'],
@@ -90,7 +86,7 @@ export const RootCommands: Record<
},
'serve': {
factory: () => import('./serve/cli'),
- aliases: ['s'],
+ aliases: ['dev', 's'],
},
'test': {
factory: () => import('./test/cli'),
@@ -105,10 +101,13 @@ export const RootCommands: Record<
},
};
-export const RootCommandsAliases = Object.values(RootCommands).reduce((prev, current) => {
- current.aliases?.forEach((alias) => {
- prev[alias] = current;
- });
+export const RootCommandsAliases = Object.values(RootCommands).reduce(
+ (prev, current) => {
+ current.aliases?.forEach((alias) => {
+ prev[alias] = current;
+ });
- return prev;
-}, {} as Record);
+ return prev;
+ },
+ {} as Record,
+);
diff --git a/packages/angular/cli/src/commands/completion/cli.ts b/packages/angular/cli/src/commands/completion/cli.ts
index 8c777a9b8d32..4cf0ef89bff8 100644
--- a/packages/angular/cli/src/commands/completion/cli.ts
+++ b/packages/angular/cli/src/commands/completion/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { join } from 'path';
@@ -51,7 +51,7 @@ Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your termi
' Angular CLI. For autocompletion to work, the CLI will need to be on your `$PATH`, which' +
' is typically done with the `-g` flag in `npm install -g @angular/cli`.' +
'\n\n' +
- 'For more information, see https://angular.io/cli/completion#global-install',
+ 'For more information, see https://angular.dev/cli/completion#global-install',
);
}
diff --git a/packages/angular/cli/src/commands/completion/long-description.md b/packages/angular/cli/src/commands/completion/long-description.md
index 26569cff5097..b75803ac9cb0 100644
--- a/packages/angular/cli/src/commands/completion/long-description.md
+++ b/packages/angular/cli/src/commands/completion/long-description.md
@@ -4,7 +4,7 @@ discover and use CLI commands without lots of memorization.

+](assets/images/guide/cli/completion.gif)
## Automated setup
diff --git a/packages/angular/cli/src/commands/config/cli.ts b/packages/angular/cli/src/commands/config/cli.ts
index bb5cee4e66fd..caa3c2504030 100644
--- a/packages/angular/cli/src/commands/config/cli.ts
+++ b/packages/angular/cli/src/commands/config/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { JsonValue } from '@angular-devkit/core';
@@ -185,7 +185,7 @@ function normalizeValue(value: string | undefined | boolean | number): JsonValue
// and convert them into a numberic entities.
// Example: 73b61974-182c-48e4-b4c6-30ddf08c5c98 -> 73.
// These values should never contain comments, therefore using `JSON.parse` is safe.
- return JSON.parse(valueString);
+ return JSON.parse(valueString) as JsonValue;
} catch {
return value;
}
diff --git a/packages/angular/cli/src/commands/config/long-description.md b/packages/angular/cli/src/commands/config/long-description.md
index 94ebfca237eb..db32cb294152 100644
--- a/packages/angular/cli/src/commands/config/long-description.md
+++ b/packages/angular/cli/src/commands/config/long-description.md
@@ -8,6 +8,6 @@ The configurable property names match command option names,
except that in the configuration file, all names must use camelCase,
while on the command line options can be given dash-case.
-For further details, see [Workspace Configuration](guide/workspace-config).
+For further details, see [Workspace Configuration](reference/configs/workspace-config).
For configuration of CLI usage analytics, see [ng analytics](cli/analytics).
diff --git a/packages/angular/cli/src/commands/deploy/cli.ts b/packages/angular/cli/src/commands/deploy/cli.ts
index 6ccb4d0244ea..947dc90af2d4 100644
--- a/packages/angular/cli/src/commands/deploy/cli.ts
+++ b/packages/angular/cli/src/commands/deploy/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { join } from 'node:path';
@@ -15,7 +15,7 @@ export default class DeployCommandModule
extends ArchitectCommandModule
implements CommandModuleImplementation
{
- // The below choices should be kept in sync with the list in https://angular.io/guide/deployment
+ // The below choices should be kept in sync with the list in https://angular.dev/tools/cli/deployment
override missingTargetChoices: MissingTargetChoice[] = [
{
name: 'Amazon S3',
diff --git a/packages/angular/cli/src/commands/deploy/long-description.md b/packages/angular/cli/src/commands/deploy/long-description.md
index 9d13ad2a9890..0436390680a4 100644
--- a/packages/angular/cli/src/commands/deploy/long-description.md
+++ b/packages/angular/cli/src/commands/deploy/long-description.md
@@ -3,7 +3,7 @@ When a project name is not supplied, executes the `deploy` builder for the defau
To use the `ng deploy` command, use `ng add` to add a package that implements deployment capabilities to your favorite platform.
Adding the package automatically updates your workspace configuration, adding a deployment
-[CLI builder](guide/cli-builder).
+[CLI builder](tools/cli/cli-builder).
For example:
```json
diff --git a/packages/angular/cli/src/commands/doc/cli.ts b/packages/angular/cli/src/commands/doc/cli.ts
deleted file mode 100644
index d6f9d571248a..000000000000
--- a/packages/angular/cli/src/commands/doc/cli.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * @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 open from 'open';
-import { Argv } from 'yargs';
-import {
- CommandModule,
- CommandModuleImplementation,
- Options,
-} from '../../command-builder/command-module';
-import { RootCommands } from '../command-config';
-
-interface DocCommandArgs {
- keyword: string;
- search?: boolean;
- version?: string;
-}
-
-export default class DocCommandModule
- extends CommandModule
- implements CommandModuleImplementation
-{
- command = 'doc ';
- aliases = RootCommands['doc'].aliases;
- describe =
- 'Opens the official Angular documentation (angular.io) in a browser, and searches for a given keyword.';
- longDescriptionPath?: string;
-
- builder(localYargs: Argv): Argv {
- return localYargs
- .positional('keyword', {
- description: 'The keyword to search for, as provided in the search bar in angular.io.',
- type: 'string',
- demandOption: true,
- })
- .option('search', {
- description: `Search all of angular.io. Otherwise, searches only API reference documentation.`,
- alias: ['s'],
- type: 'boolean',
- default: false,
- })
- .option('version', {
- description:
- 'The version of Angular to use for the documentation. ' +
- 'If not provided, the command uses your current Angular core version.',
- type: 'string',
- })
- .strict();
- }
-
- async run(options: Options): Promise {
- let domain = 'angular.io';
-
- if (options.version) {
- // version can either be a string containing "next"
- if (options.version === 'next') {
- domain = 'next.angular.io';
- } else if (options.version === 'rc') {
- domain = 'rc.angular.io';
- // or a number where version must be a valid Angular version (i.e. not 0, 1 or 3)
- } else if (!isNaN(+options.version) && ![0, 1, 3].includes(+options.version)) {
- domain = `v${options.version}.angular.io`;
- } else {
- this.context.logger.error(
- 'Version should either be a number (2, 4, 5, 6...), "rc" or "next"',
- );
-
- return 1;
- }
- } else {
- // we try to get the current Angular version of the project
- // and use it if we can find it
- try {
- /* eslint-disable-next-line import/no-extraneous-dependencies */
- const currentNgVersion = (await import('@angular/core')).VERSION.major;
- domain = `v${currentNgVersion}.angular.io`;
- } catch {}
- }
-
- await open(
- options.search
- ? `https://${domain}/docs?search=${options.keyword}`
- : `https://${domain}/api?query=${options.keyword}`,
- );
- }
-}
diff --git a/packages/angular/cli/src/commands/e2e/cli.ts b/packages/angular/cli/src/commands/e2e/cli.ts
index 40df309c6d32..85d9aab173a0 100644
--- a/packages/angular/cli/src/commands/e2e/cli.ts
+++ b/packages/angular/cli/src/commands/e2e/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { MissingTargetChoice } from '../../command-builder/architect-base-command-module';
@@ -16,6 +16,10 @@ export default class E2eCommandModule
implements CommandModuleImplementation
{
override missingTargetChoices: MissingTargetChoice[] = [
+ {
+ name: 'Playwright',
+ value: 'playwright-ng-schematics',
+ },
{
name: 'Cypress',
value: '@cypress/schematic',
diff --git a/packages/angular/cli/src/commands/extract-i18n/cli.ts b/packages/angular/cli/src/commands/extract-i18n/cli.ts
index a0d4bc366dfb..4f3dea2d8e7e 100644
--- a/packages/angular/cli/src/commands/extract-i18n/cli.ts
+++ b/packages/angular/cli/src/commands/extract-i18n/cli.ts
@@ -3,9 +3,12 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
+import { workspaces } from '@angular-devkit/core';
+import { createRequire } from 'node:module';
+import { join } from 'node:path';
import { ArchitectCommandModule } from '../../command-builder/architect-command-module';
import { CommandModuleImplementation } from '../../command-builder/command-module';
@@ -17,4 +20,38 @@ export default class ExtractI18nCommandModule
command = 'extract-i18n [project]';
describe = 'Extracts i18n messages from source code.';
longDescriptionPath?: string | undefined;
+
+ override async findDefaultBuilderName(
+ project: workspaces.ProjectDefinition,
+ ): Promise {
+ // Only application type projects have a default i18n extraction target
+ if (project.extensions['projectType'] !== 'application') {
+ return;
+ }
+
+ const buildTarget = project.targets.get('build');
+ if (!buildTarget) {
+ // No default if there is no build target
+ return;
+ }
+
+ // Provide a default based on the defined builder for the 'build' target
+ switch (buildTarget.builder) {
+ case '@angular-devkit/build-angular:application':
+ case '@angular-devkit/build-angular:browser-esbuild':
+ case '@angular-devkit/build-angular:browser':
+ return '@angular-devkit/build-angular:extract-i18n';
+ case '@angular/build:application':
+ return '@angular/build:extract-i18n';
+ }
+
+ // For other builders, check for `@angular-devkit/build-angular` and use if found.
+ // This package is safer to use since it supports both application builder types.
+ try {
+ const projectRequire = createRequire(join(this.context.root, project.root) + '/');
+ projectRequire.resolve('@angular-devkit/build-angular');
+
+ return '@angular-devkit/build-angular:extract-i18n';
+ } catch {}
+ }
}
diff --git a/packages/angular/cli/src/commands/generate/cli.ts b/packages/angular/cli/src/commands/generate/cli.ts
index 424d609ed19a..4be29c3eaea0 100644
--- a/packages/angular/cli/src/commands/generate/cli.ts
+++ b/packages/angular/cli/src/commands/generate/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { strings } from '@angular-devkit/core';
diff --git a/packages/angular/cli/src/commands/lint/cli.ts b/packages/angular/cli/src/commands/lint/cli.ts
index d6072d5549e6..cb7897284951 100644
--- a/packages/angular/cli/src/commands/lint/cli.ts
+++ b/packages/angular/cli/src/commands/lint/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { join } from 'path';
diff --git a/packages/angular/cli/src/commands/lint/long-description.md b/packages/angular/cli/src/commands/lint/long-description.md
index 1c912b2489d7..5e5fa3da951c 100644
--- a/packages/angular/cli/src/commands/lint/long-description.md
+++ b/packages/angular/cli/src/commands/lint/long-description.md
@@ -1,7 +1,7 @@
The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file.
When a project name is not supplied, executes the `lint` builder for all projects.
-To use the `ng lint` command, use `ng add` to add a package that implements linting capabilities. Adding the package automatically updates your workspace configuration, adding a lint [CLI builder](guide/cli-builder).
+To use the `ng lint` command, use `ng add` to add a package that implements linting capabilities. Adding the package automatically updates your workspace configuration, adding a lint [CLI builder](tools/cli/cli-builder).
For example:
```json
diff --git a/packages/angular/cli/src/commands/make-this-awesome/cli.ts b/packages/angular/cli/src/commands/make-this-awesome/cli.ts
index 0c258a023f7b..6a17c5614b94 100644
--- a/packages/angular/cli/src/commands/make-this-awesome/cli.ts
+++ b/packages/angular/cli/src/commands/make-this-awesome/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { Argv } from 'yargs';
diff --git a/packages/angular/cli/src/commands/new/cli.ts b/packages/angular/cli/src/commands/new/cli.ts
index caa8801fe980..9163708726b6 100644
--- a/packages/angular/cli/src/commands/new/cli.ts
+++ b/packages/angular/cli/src/commands/new/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { join } from 'node:path';
@@ -55,7 +55,7 @@ export default class NewCommandModule
? collectionNameFromArgs
: await this.getCollectionFromConfig();
- const workflow = await this.getOrCreateWorkflowForBuilder(collectionName);
+ const workflow = this.getOrCreateWorkflowForBuilder(collectionName);
const collection = workflow.engine.createCollection(collectionName);
const options = await this.getSchematicOptions(collection, this.schematicName, workflow);
diff --git a/packages/angular/cli/src/commands/run/cli.ts b/packages/angular/cli/src/commands/run/cli.ts
index 5c463eb3674d..bd65dac53fc3 100644
--- a/packages/angular/cli/src/commands/run/cli.ts
+++ b/packages/angular/cli/src/commands/run/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { Target } from '@angular-devkit/architect';
diff --git a/packages/angular/cli/src/commands/serve/cli.ts b/packages/angular/cli/src/commands/serve/cli.ts
index 48a1103355b2..3b38fa122acd 100644
--- a/packages/angular/cli/src/commands/serve/cli.ts
+++ b/packages/angular/cli/src/commands/serve/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { ArchitectCommandModule } from '../../command-builder/architect-command-module';
diff --git a/packages/angular/cli/src/commands/test/cli.ts b/packages/angular/cli/src/commands/test/cli.ts
index 837d57787eb4..fde58fda5d6e 100644
--- a/packages/angular/cli/src/commands/test/cli.ts
+++ b/packages/angular/cli/src/commands/test/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { join } from 'path';
diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts
index fe262dbf968d..b9e991e3ea4a 100644
--- a/packages/angular/cli/src/commands/update/cli.ts
+++ b/packages/angular/cli/src/commands/update/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { SchematicDescription, UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics';
@@ -12,9 +12,10 @@ import {
FileSystemSchematicDescription,
NodeWorkflow,
} from '@angular-devkit/schematics/tools';
-import { SpawnSyncReturns, execSync, spawnSync } from 'child_process';
-import { existsSync, promises as fs } from 'fs';
-import { createRequire } from 'module';
+import { Listr } from 'listr2';
+import { SpawnSyncReturns, execSync, spawnSync } from 'node:child_process';
+import { existsSync, promises as fs } from 'node:fs';
+import { createRequire } from 'node:module';
import npa from 'npm-package-arg';
import pickManifest from 'npm-pick-manifest';
import * as path from 'path';
@@ -30,7 +31,7 @@ import {
} from '../../command-builder/command-module';
import { SchematicEngineHost } from '../../command-builder/utilities/schematic-engine-host';
import { subscribeToWorkflow } from '../../command-builder/utilities/schematic-workflow';
-import { colors } from '../../utilities/color';
+import { colors, figures } from '../../utilities/color';
import { disableVersionCheck } from '../../utilities/environment-options';
import { assertIsError } from '../../utilities/error';
import { writeErrorToLogFile } from '../../utilities/log-file';
@@ -67,21 +68,25 @@ interface MigrationSchematicDescription
extends SchematicDescription {
version?: string;
optional?: boolean;
+ documentation?: string;
}
interface MigrationSchematicDescriptionWithVersion extends MigrationSchematicDescription {
version: string;
}
+class CommandError extends Error {}
+
const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//;
const UPDATE_SCHEMATIC_COLLECTION = path.join(__dirname, 'schematic/collection.json');
export default class UpdateCommandModule extends CommandModule {
override scope = CommandScope.In;
protected override shouldReportAnalytics = false;
+ private readonly resolvePaths = [__dirname, this.context.root];
command = 'update [packages..]';
- describe = 'Updates your workspace and its dependencies. See https://update.angular.io/.';
+ describe = 'Updates your workspace and its dependencies. See https://update.angular.dev/.';
longDescriptionPath = join(__dirname, 'long-description.md');
builder(localYargs: Argv): Argv {
@@ -239,7 +244,7 @@ export default class UpdateCommandModule extends CommandModule favor @schematics/update from this package
// Otherwise, use packages from the active workspace (migrations)
- resolvePaths: [__dirname, this.context.root],
+ resolvePaths: this.resolvePaths,
schemaValidation: true,
engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths),
});
@@ -301,12 +306,12 @@ export default class UpdateCommandModule extends CommandModule };
const binKeys = Object.keys(bin);
if (binKeys.length) {
@@ -1082,12 +1112,11 @@ export default class UpdateCommandModule extends CommandModule 1 ? 's' : ''
} that can be executed.`,
);
- logger.info(''); // Extra trailing newline.
if (!isTTY()) {
for (const migration of optionalMigrations) {
const { title } = getMigrationTitleAndDescription(migration);
- logger.info(colors.cyan(colors.symbols.pointer) + ' ' + colors.bold(title));
+ logger.info(colors.cyan(figures.pointer) + ' ' + colors.bold(title));
logger.info(colors.gray(` ng update ${packageName} --name ${migration.name}`));
logger.info(''); // Extra trailing newline.
}
@@ -1095,13 +1124,18 @@ export default class UpdateCommandModule extends CommandModule {
- const { title } = getMigrationTitleAndDescription(migration);
+ const { title, documentation } = getMigrationTitleAndDescription(migration);
return {
- name: title,
+ name: `[${colors.white(migration.name)}] ${title}${documentation ? ` (${documentation})` : ''}`,
value: migration.name,
};
}),
@@ -1179,11 +1213,15 @@ function coerceVersionNumber(version: string | undefined): string | undefined {
function getMigrationTitleAndDescription(migration: MigrationSchematicDescription): {
title: string;
description: string;
+ documentation?: string;
} {
const [title, ...description] = migration.description.split('. ');
return {
title: title.endsWith('.') ? title : title + '.',
description: description.join('.\n '),
+ documentation: migration.documentation
+ ? new URL(migration.documentation, 'https://angular.dev').href
+ : undefined,
};
}
diff --git a/packages/angular/cli/src/commands/update/long-description.md b/packages/angular/cli/src/commands/update/long-description.md
index 72df66ce35da..612971de0c4d 100644
--- a/packages/angular/cli/src/commands/update/long-description.md
+++ b/packages/angular/cli/src/commands/update/long-description.md
@@ -19,4 +19,4 @@ For example, use the following command to take the latest 10.x.x version and use
ng update @angular/cli@^10 @angular/core@^10
```
-For detailed information and guidance on updating your application, see the interactive [Angular Update Guide](https://update.angular.io/).
+For detailed information and guidance on updating your application, see the interactive [Angular Update Guide](https://update.angular.dev/).
diff --git a/packages/angular/cli/src/commands/update/schematic/index.ts b/packages/angular/cli/src/commands/update/schematic/index.ts
index 6ead29f03e3a..9b56ec01d363 100644
--- a/packages/angular/cli/src/commands/update/schematic/index.ts
+++ b/packages/angular/cli/src/commands/update/schematic/index.ts
@@ -3,15 +3,14 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
-import { logging, tags } from '@angular-devkit/core';
+import { logging } from '@angular-devkit/core';
import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
import * as npa from 'npm-package-arg';
import type { Manifest } from 'pacote';
import * as semver from 'semver';
-import { assertIsError } from '../../../utilities/error';
import {
NgPackageManifestProperties,
NpmRepositoryPackageJson,
@@ -249,9 +248,11 @@ function _validateUpdatePackages(
});
if (!force && peerErrors) {
- throw new SchematicsException(tags.stripIndents`Incompatible peer dependencies found.
- Peer dependency warnings when installing dependencies means that those dependencies might not work correctly together.
- You can use the '--force' option to ignore incompatible peer dependencies and instead address these warnings later.`);
+ throw new SchematicsException(
+ 'Incompatible peer dependencies found.\n' +
+ 'Peer dependency warnings when installing dependencies means that those dependencies might not work correctly together.\n' +
+ `You can use the '--force' option to ignore incompatible peer dependencies and instead address these warnings later.`,
+ );
}
}
@@ -262,7 +263,7 @@ function _performUpdate(
logger: logging.LoggerApi,
migrateOnly: boolean,
): void {
- const packageJsonContent = tree.read('/package.json');
+ const packageJsonContent = tree.read('/package.json')?.toString();
if (!packageJsonContent) {
throw new SchematicsException('Could not find a package.json. Are you in a Node project?');
}
@@ -309,11 +310,12 @@ function _performUpdate(
logger.warn(`Package ${name} was not found in dependencies.`);
}
});
-
- const newContent = JSON.stringify(packageJson, null, 2);
- if (packageJsonContent.toString() != newContent || migrateOnly) {
+ const eofMatches = packageJsonContent.match(/\r?\n$/);
+ const eof = eofMatches?.[0] ?? '';
+ const newContent = JSON.stringify(packageJson, null, 2) + eof;
+ if (packageJsonContent != newContent || migrateOnly) {
if (!migrateOnly) {
- tree.overwrite('/package.json', JSON.stringify(packageJson, null, 2));
+ tree.overwrite('/package.json', newContent);
}
const externalMigrations: {}[] = [];
diff --git a/packages/angular/cli/src/commands/update/schematic/index_spec.ts b/packages/angular/cli/src/commands/update/schematic/index_spec.ts
index 19197195cb4b..3954e3c78254 100644
--- a/packages/angular/cli/src/commands/update/schematic/index_spec.ts
+++ b/packages/angular/cli/src/commands/update/schematic/index_spec.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { normalize, virtualFs } from '@angular-devkit/core';
@@ -282,4 +282,57 @@ describe('@schematics/update', () => {
expect(hasPeerdepMsg('typescript')).toBeTruthy();
expect(hasPeerdepMsg('@angular/localize')).toBeFalsy();
}, 45000);
+
+ it('does not remove newline at the end of package.json', async () => {
+ const newlineStyles = ['\n', '\r\n'];
+ for (const newline of newlineStyles) {
+ const packageJsonContent = `{
+ "name": "blah",
+ "dependencies": {
+ "@angular-devkit-tests/update-base": "1.0.0"
+ }
+ }${newline}`;
+ const inputTree = new UnitTestTree(
+ new HostTree(
+ new virtualFs.test.TestHost({
+ '/package.json': packageJsonContent,
+ }),
+ ),
+ );
+
+ const resultTree = await schematicRunner.runSchematic(
+ 'update',
+ { packages: ['@angular-devkit-tests/update-base'] },
+ inputTree,
+ );
+
+ const resultTreeContent = resultTree.readContent('/package.json');
+ expect(resultTreeContent.endsWith(newline)).toBeTrue();
+ }
+ });
+
+ it('does not add a newline at the end of package.json', async () => {
+ const packageJsonContent = `{
+ "name": "blah",
+ "dependencies": {
+ "@angular-devkit-tests/update-base": "1.0.0"
+ }
+ }`;
+ const inputTree = new UnitTestTree(
+ new HostTree(
+ new virtualFs.test.TestHost({
+ '/package.json': packageJsonContent,
+ }),
+ ),
+ );
+
+ const resultTree = await schematicRunner.runSchematic(
+ 'update',
+ { packages: ['@angular-devkit-tests/update-base'] },
+ inputTree,
+ );
+
+ const resultTreeContent = resultTree.readContent('/package.json');
+ expect(resultTreeContent.endsWith('}')).toBeTrue();
+ });
});
diff --git a/packages/angular/cli/src/commands/version/cli.ts b/packages/angular/cli/src/commands/version/cli.ts
index fe029b6c1321..3e2c27d31a1c 100644
--- a/packages/angular/cli/src/commands/version/cli.ts
+++ b/packages/angular/cli/src/commands/version/cli.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import nodeModule from 'node:module';
@@ -23,7 +23,7 @@ interface PartialPackageInfo {
/**
* Major versions of Node.js that are officially supported by Angular.
*/
-const SUPPORTED_NODE_MAJORS = [18, 20];
+const SUPPORTED_NODE_MAJORS = [18, 20, 22];
const PACKAGE_PATTERNS = [
/^@angular\/.*/,
diff --git a/packages/angular/cli/src/typings-bazel.d.ts b/packages/angular/cli/src/typings-bazel.d.ts
deleted file mode 100644
index 780d1dc372ff..000000000000
--- a/packages/angular/cli/src/typings-bazel.d.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * @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
- */
-
-/* eslint-disable import/no-extraneous-dependencies */
-// Workaround for https://github.com/bazelbuild/rules_nodejs/issues/1033
-// Alternative approach instead of https://github.com/angular/angular/pull/33226
-declare module '@yarnpkg/lockfile' {
- export * from '@types/yarnpkg__lockfile';
-}
diff --git a/packages/angular/cli/src/typings.ts b/packages/angular/cli/src/typings.ts
index e7b7d14c0ca3..0ccb3728b882 100644
--- a/packages/angular/cli/src/typings.ts
+++ b/packages/angular/cli/src/typings.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
declare module 'npm-pick-manifest' {
diff --git a/packages/angular/cli/src/utilities/color.ts b/packages/angular/cli/src/utilities/color.ts
index ff201f3e157a..3915d99ce248 100644
--- a/packages/angular/cli/src/utilities/color.ts
+++ b/packages/angular/cli/src/utilities/color.ts
@@ -3,47 +3,22 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
-import * as ansiColors from 'ansi-colors';
-import { WriteStream } from 'tty';
+import { WriteStream } from 'node:tty';
-function supportColor(): boolean {
- if (process.env.FORCE_COLOR !== undefined) {
- // 2 colors: FORCE_COLOR = 0 (Disables colors), depth 1
- // 16 colors: FORCE_COLOR = 1, depth 4
- // 256 colors: FORCE_COLOR = 2, depth 8
- // 16,777,216 colors: FORCE_COLOR = 3, depth 16
- // See: https://nodejs.org/dist/latest-v12.x/docs/api/tty.html#tty_writestream_getcolordepth_env
- // and https://github.com/nodejs/node/blob/b9f36062d7b5c5039498e98d2f2c180dca2a7065/lib/internal/tty.js#L106;
- switch (process.env.FORCE_COLOR) {
- case '':
- case 'true':
- case '1':
- case '2':
- case '3':
- return true;
- default:
- return false;
- }
- }
+export { color as colors, figures } from 'listr2';
- if (process.stdout instanceof WriteStream) {
- return process.stdout.getColorDepth() > 1;
+export function supportColor(stream: NodeJS.WritableStream = process.stdout): boolean {
+ if (stream instanceof WriteStream) {
+ return stream.hasColors();
}
- return false;
-}
-
-export function removeColor(text: string): string {
- // This has been created because when colors.enabled is false unstyle doesn't work
- // see: https://github.com/doowb/ansi-colors/blob/a4794363369d7b4d1872d248fc43a12761640d8e/index.js#L38
- return text.replace(ansiColors.ansiRegex, '');
+ try {
+ // The hasColors function does not rely on any instance state and should ideally be static
+ return WriteStream.prototype.hasColors();
+ } catch {
+ return process.env['FORCE_COLOR'] !== undefined && process.env['FORCE_COLOR'] !== '0';
+ }
}
-
-// Create a separate instance to prevent unintended global changes to the color configuration
-const colors = ansiColors.create();
-colors.enabled = supportColor();
-
-export { colors };
diff --git a/packages/angular/cli/src/utilities/completion.ts b/packages/angular/cli/src/utilities/completion.ts
index c37609044e7e..07483065caed 100644
--- a/packages/angular/cli/src/utilities/completion.ts
+++ b/packages/angular/cli/src/utilities/completion.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { json, logging } from '@angular-devkit/core';
@@ -16,7 +16,7 @@ import { getWorkspace } from '../utilities/config';
import { forceAutocomplete } from '../utilities/environment-options';
import { isTTY } from '../utilities/tty';
import { assertIsError } from './error';
-import { loadEsmModule } from './load-esm';
+import { askConfirmation } from './prompt';
/** Interface for the autocompletion configuration stored in the global workspace. */
interface CompletionConfig {
@@ -88,7 +88,7 @@ Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your termi
' Angular CLI. For autocompletion to work, the CLI will need to be on your `$PATH`, which' +
' is typically done with the `-g` flag in `npm install -g @angular/cli`.' +
'\n\n' +
- 'For more information, see https://angular.io/cli/completion#global-install',
+ 'For more information, see https://angular.dev/cli/completion#global-install',
);
}
@@ -130,8 +130,8 @@ async function shouldPromptForAutocompletionSetup(
return forceAutocomplete;
}
- // Don't prompt on `ng update` or `ng completion`.
- if (command === 'update' || command === 'completion') {
+ // Don't prompt on `ng update`, 'ng version' or `ng completion`.
+ if (['version', 'update', 'completion'].includes(command)) {
return false;
}
@@ -179,24 +179,17 @@ async function shouldPromptForAutocompletionSetup(
}
async function promptForAutocompletion(): Promise {
- // Dynamically load `inquirer` so users don't have to pay the cost of parsing and executing it for
- // the 99% of builds that *don't* prompt for autocompletion.
- const { default: inquirer } = await loadEsmModule('inquirer');
- const { autocomplete } = await inquirer.prompt<{ autocomplete: boolean }>([
- {
- name: 'autocomplete',
- type: 'confirm',
- message: `
+ const autocomplete = await askConfirmation(
+ `
Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing
Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion
will modify configuration files in your home directory.)
- `
- .split('\n')
- .join(' ')
- .trim(),
- default: true,
- },
- ]);
+ `
+ .split('\n')
+ .join(' ')
+ .trim(),
+ true,
+ );
return autocomplete;
}
diff --git a/packages/angular/cli/src/utilities/config.ts b/packages/angular/cli/src/utilities/config.ts
index b4d3a99729ea..af370a164a35 100644
--- a/packages/angular/cli/src/utilities/config.ts
+++ b/packages/angular/cli/src/utilities/config.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { json, workspaces } from '@angular-devkit/core';
diff --git a/packages/angular/cli/src/utilities/environment-options.ts b/packages/angular/cli/src/utilities/environment-options.ts
index 264984bb432a..0f01ce8b09cb 100644
--- a/packages/angular/cli/src/utilities/environment-options.ts
+++ b/packages/angular/cli/src/utilities/environment-options.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
function isPresent(variable: string | undefined): variable is string {
diff --git a/packages/angular/cli/src/utilities/eol.ts b/packages/angular/cli/src/utilities/eol.ts
index 8e9de0b699d2..02e837649144 100644
--- a/packages/angular/cli/src/utilities/eol.ts
+++ b/packages/angular/cli/src/utilities/eol.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { EOL } from 'node:os';
diff --git a/packages/angular/cli/src/utilities/error.ts b/packages/angular/cli/src/utilities/error.ts
index 3b37aafc9dc3..c00e13e79726 100644
--- a/packages/angular/cli/src/utilities/error.ts
+++ b/packages/angular/cli/src/utilities/error.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import assert from 'assert';
diff --git a/packages/angular/cli/src/utilities/find-up.ts b/packages/angular/cli/src/utilities/find-up.ts
index 3427d7ba15f4..ed0adb0f78bb 100644
--- a/packages/angular/cli/src/utilities/find-up.ts
+++ b/packages/angular/cli/src/utilities/find-up.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { existsSync } from 'fs';
diff --git a/packages/angular/cli/src/utilities/json-file.ts b/packages/angular/cli/src/utilities/json-file.ts
index 1239dbc1cbd9..f960462c4ecf 100644
--- a/packages/angular/cli/src/utilities/json-file.ts
+++ b/packages/angular/cli/src/utilities/json-file.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { JsonValue } from '@angular-devkit/core';
diff --git a/packages/angular/cli/src/utilities/load-esm.ts b/packages/angular/cli/src/utilities/load-esm.ts
index 6f3bd2f73f54..6a6220f66288 100644
--- a/packages/angular/cli/src/utilities/load-esm.ts
+++ b/packages/angular/cli/src/utilities/load-esm.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
/**
diff --git a/packages/angular/cli/src/utilities/log-file.ts b/packages/angular/cli/src/utilities/log-file.ts
index 41dc036fc028..dbccaaf24879 100644
--- a/packages/angular/cli/src/utilities/log-file.ts
+++ b/packages/angular/cli/src/utilities/log-file.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { appendFileSync, mkdtempSync, realpathSync } from 'fs';
diff --git a/packages/angular/cli/src/utilities/memoize.ts b/packages/angular/cli/src/utilities/memoize.ts
index 6994dbf5e9c1..2ae55e4b383a 100644
--- a/packages/angular/cli/src/utilities/memoize.ts
+++ b/packages/angular/cli/src/utilities/memoize.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
/**
@@ -13,41 +13,34 @@
*
* @see https://en.wikipedia.org/wiki/Memoization
*/
-export function memoize(
- target: Object,
- propertyKey: string | symbol,
- descriptor: TypedPropertyDescriptor,
-): TypedPropertyDescriptor {
- const descriptorPropertyName = descriptor.get ? 'get' : 'value';
- const originalMethod: unknown = descriptor[descriptorPropertyName];
-
- if (typeof originalMethod !== 'function') {
+export function memoize(
+ target: (this: This, ...args: Args) => Return,
+ context: ClassMemberDecoratorContext,
+) {
+ if (context.kind !== 'method' && context.kind !== 'getter') {
throw new Error('Memoize decorator can only be used on methods or get accessors.');
}
- const cache = new Map();
+ const cache = new Map();
- return {
- ...descriptor,
- [descriptorPropertyName]: function (this: unknown, ...args: unknown[]) {
- for (const arg of args) {
- if (!isJSONSerializable(arg)) {
- throw new Error(
- `Argument ${isNonPrimitive(arg) ? arg.toString() : arg} is JSON serializable.`,
- );
- }
+ return function (this: This, ...args: Args): Return {
+ for (const arg of args) {
+ if (!isJSONSerializable(arg)) {
+ throw new Error(
+ `Argument ${isNonPrimitive(arg) ? arg.toString() : arg} is JSON serializable.`,
+ );
}
+ }
- const key = JSON.stringify(args);
- if (cache.has(key)) {
- return cache.get(key);
- }
+ const key = JSON.stringify(args);
+ if (cache.has(key)) {
+ return cache.get(key) as Return;
+ }
- const result = originalMethod.apply(this, args);
- cache.set(key, result);
+ const result = target.apply(this, args);
+ cache.set(key, result);
- return result;
- },
+ return result;
};
}
diff --git a/packages/angular/cli/src/utilities/memoize_spec.ts b/packages/angular/cli/src/utilities/memoize_spec.ts
index c1d06fdf4c4e..1c65340764e9 100644
--- a/packages/angular/cli/src/utilities/memoize_spec.ts
+++ b/packages/angular/cli/src/utilities/memoize_spec.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { memoize } from './memoize';
diff --git a/packages/angular/cli/src/utilities/package-manager.ts b/packages/angular/cli/src/utilities/package-manager.ts
index 74710a05df64..28273c698013 100644
--- a/packages/angular/cli/src/utilities/package-manager.ts
+++ b/packages/angular/cli/src/utilities/package-manager.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { isJsonObject, json } from '@angular-devkit/core';
@@ -14,7 +14,6 @@ import { join } from 'path';
import { PackageManager } from '../../lib/config/workspace-schema';
import { AngularWorkspace, getProjectByCwd } from './config';
import { memoize } from './memoize';
-import { Spinner } from './spinner';
interface PackageManagerOptions {
saveDev: string;
@@ -166,9 +165,6 @@ export class PackageManagerUtils {
): Promise {
const { cwd = process.cwd(), silent = false } = options;
- const spinner = new Spinner();
- spinner.start('Installing packages...');
-
return new Promise((resolve) => {
const bufferedOutput: { stream: NodeJS.WriteStream; data: Buffer }[] = [];
@@ -179,12 +175,9 @@ export class PackageManagerUtils {
cwd,
}).on('close', (code: number) => {
if (code === 0) {
- spinner.succeed('Packages successfully installed.');
resolve(true);
} else {
- spinner.stop();
bufferedOutput.forEach(({ stream, data }) => stream.write(data));
- spinner.fail('Packages installation failed, see above.');
resolve(false);
}
});
diff --git a/packages/angular/cli/src/utilities/package-metadata.ts b/packages/angular/cli/src/utilities/package-metadata.ts
index 9eed9b78e9f4..b10292f93e78 100644
--- a/packages/angular/cli/src/utilities/package-metadata.ts
+++ b/packages/angular/cli/src/utilities/package-metadata.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { logging } from '@angular-devkit/core';
diff --git a/packages/angular/cli/src/utilities/package-tree.ts b/packages/angular/cli/src/utilities/package-tree.ts
index 9b082e6c9d9f..923f1c732b4d 100644
--- a/packages/angular/cli/src/utilities/package-tree.ts
+++ b/packages/angular/cli/src/utilities/package-tree.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import * as fs from 'fs';
@@ -44,7 +44,7 @@ export interface PackageTreeNode {
export async function readPackageJson(packageJsonPath: string): Promise {
try {
- return JSON.parse((await fs.promises.readFile(packageJsonPath)).toString());
+ return JSON.parse((await fs.promises.readFile(packageJsonPath)).toString()) as PackageJson;
} catch {
return undefined;
}
diff --git a/packages/angular/cli/src/utilities/project.ts b/packages/angular/cli/src/utilities/project.ts
index 8598859fb6d2..b1c9cb14d458 100644
--- a/packages/angular/cli/src/utilities/project.ts
+++ b/packages/angular/cli/src/utilities/project.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { normalize } from '@angular-devkit/core';
@@ -12,6 +12,11 @@ import * as os from 'os';
import * as path from 'path';
import { findUp } from './find-up';
+interface PackageDependencies {
+ dependencies?: Record;
+ devDependencies?: Record;
+}
+
export function findWorkspaceFile(currentDirectory = process.cwd()): string | null {
const possibleConfigFiles = ['angular.json', '.angular.json'];
const configFilePath = findUp(possibleConfigFiles, currentDirectory);
@@ -27,7 +32,7 @@ export function findWorkspaceFile(currentDirectory = process.cwd()): string | nu
try {
const packageJsonText = fs.readFileSync(packageJsonPath, 'utf-8');
- const packageJson = JSON.parse(packageJsonText);
+ const packageJson = JSON.parse(packageJsonText) as PackageDependencies;
if (!containsCliDep(packageJson)) {
// No CLI dependency
return null;
@@ -41,10 +46,7 @@ export function findWorkspaceFile(currentDirectory = process.cwd()): string | nu
return configFilePath;
}
-function containsCliDep(obj?: {
- dependencies?: Record;
- devDependencies?: Record;
-}): boolean {
+function containsCliDep(obj?: PackageDependencies): boolean {
const pkgName = '@angular/cli';
if (!obj) {
return false;
diff --git a/packages/angular/cli/src/utilities/prompt.ts b/packages/angular/cli/src/utilities/prompt.ts
index 968e14676142..3d4e8c67ce09 100644
--- a/packages/angular/cli/src/utilities/prompt.ts
+++ b/packages/angular/cli/src/utilities/prompt.ts
@@ -3,17 +3,9 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
-import type {
- CheckboxChoiceOptions,
- CheckboxQuestion,
- ListChoiceOptions,
- ListQuestion,
- Question,
-} from 'inquirer';
-import { loadEsmModule } from './load-esm';
import { isTTY } from './tty';
export async function askConfirmation(
@@ -25,23 +17,21 @@ export async function askConfirmation(
return noTTYResponse ?? defaultResponse;
}
- const question: Question = {
- type: 'confirm',
- name: 'confirmation',
- prefix: '',
+ const { confirm } = await import('@inquirer/prompts');
+ const answer = await confirm({
message,
default: defaultResponse,
- };
+ theme: {
+ prefix: '',
+ },
+ });
- const { default: inquirer } = await loadEsmModule('inquirer');
- const answers = await inquirer.prompt([question]);
-
- return answers['confirmation'];
+ return answer;
}
export async function askQuestion(
message: string,
- choices: ListChoiceOptions[],
+ choices: { name: string; value: string | null }[],
defaultResponseIndex: number,
noTTYResponse: null | string,
): Promise {
@@ -49,40 +39,36 @@ export async function askQuestion(
return noTTYResponse;
}
- const question: ListQuestion = {
- type: 'list',
- name: 'answer',
- prefix: '',
+ const { select } = await import('@inquirer/prompts');
+ const answer = await select({
message,
choices,
default: defaultResponseIndex,
- };
-
- const { default: inquirer } = await loadEsmModule('inquirer');
- const answers = await inquirer.prompt([question]);
+ theme: {
+ prefix: '',
+ },
+ });
- return answers['answer'];
+ return answer;
}
export async function askChoices(
message: string,
- choices: CheckboxChoiceOptions[],
+ choices: { name: string; value: string }[],
noTTYResponse: string[] | null,
): Promise {
if (!isTTY()) {
return noTTYResponse;
}
- const question: CheckboxQuestion = {
- type: 'checkbox',
- name: 'answer',
- prefix: '',
+ const { checkbox } = await import('@inquirer/prompts');
+ const answers = await checkbox({
message,
choices,
- };
-
- const { default: inquirer } = await loadEsmModule('inquirer');
- const answers = await inquirer.prompt([question]);
+ theme: {
+ prefix: '',
+ },
+ });
- return answers['answer'];
+ return answers;
}
diff --git a/packages/angular/cli/src/utilities/spinner.ts b/packages/angular/cli/src/utilities/spinner.ts
deleted file mode 100644
index 3deda119aee5..000000000000
--- a/packages/angular/cli/src/utilities/spinner.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * @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 ora from 'ora';
-import { colors } from './color';
-
-export class Spinner {
- private readonly spinner: ora.Ora;
-
- /** When false, only fail messages will be displayed. */
- enabled = true;
-
- constructor(text?: string) {
- this.spinner = ora({
- text,
- // The below 2 options are needed because otherwise CTRL+C will be delayed
- // when the underlying process is sync.
- hideCursor: false,
- discardStdin: false,
- });
- }
-
- set text(text: string) {
- this.spinner.text = text;
- }
-
- succeed(text?: string): void {
- if (this.enabled) {
- this.spinner.succeed(text);
- }
- }
-
- info(text?: string): void {
- this.spinner.info(text);
- }
-
- fail(text?: string): void {
- this.spinner.fail(text && colors.redBright(text));
- }
-
- warn(text?: string): void {
- this.spinner.warn(text && colors.yellowBright(text));
- }
-
- stop(): void {
- this.spinner.stop();
- }
-
- start(text?: string): void {
- if (this.enabled) {
- this.spinner.start(text);
- }
- }
-}
diff --git a/packages/angular/cli/src/utilities/tty.ts b/packages/angular/cli/src/utilities/tty.ts
index 1e5658ebfd57..db6543926941 100644
--- a/packages/angular/cli/src/utilities/tty.ts
+++ b/packages/angular/cli/src/utilities/tty.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
function _isTruthy(value: undefined | string): boolean {
@@ -11,12 +11,12 @@ function _isTruthy(value: undefined | string): boolean {
return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE';
}
-export function isTTY(): boolean {
+export function isTTY(stream: NodeJS.WriteStream = process.stdout): boolean {
// If we force TTY, we always return true.
const force = process.env['NG_FORCE_TTY'];
if (force !== undefined) {
return _isTruthy(force);
}
- return !!process.stdout.isTTY && !_isTruthy(process.env['CI']);
+ return !!stream.isTTY && !_isTruthy(process.env['CI']);
}
diff --git a/packages/angular/cli/src/utilities/version.ts b/packages/angular/cli/src/utilities/version.ts
index 777c3de165f6..71a6f4c70cee 100644
--- a/packages/angular/cli/src/utilities/version.ts
+++ b/packages/angular/cli/src/utilities/version.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { readFileSync } from 'fs';
diff --git a/packages/angular/create/BUILD.bazel b/packages/angular/create/BUILD.bazel
index 50142d83e444..e65dbcb98d1c 100644
--- a/packages/angular/create/BUILD.bazel
+++ b/packages/angular/create/BUILD.bazel
@@ -1,7 +1,7 @@
# Copyright Google Inc. 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
+# found in the LICENSE file at https://angular.dev/license
load("//tools:defaults.bzl", "pkg_npm", "ts_library")
@@ -26,6 +26,9 @@ genrule(
pkg_npm(
name = "npm_package",
+ pkg_deps = [
+ "//packages/angular/cli:package.json",
+ ],
tags = ["release-package"],
visibility = ["//visibility:public"],
deps = [
diff --git a/packages/angular/create/README.md b/packages/angular/create/README.md
index becdfac12ffc..46135476e406 100644
--- a/packages/angular/create/README.md
+++ b/packages/angular/create/README.md
@@ -2,7 +2,7 @@
## Create an Angular CLI workspace
-Scaffold an Angular CLI workspace without needing to install the Angular CLI globally. All of the [ng new](https://angular.io/cli/new) options and features are supported.
+Scaffold an Angular CLI workspace without needing to install the Angular CLI globally. All of the [ng new](https://angular.dev/cli/new) options and features are supported.
## Usage
diff --git a/packages/angular/create/package.json b/packages/angular/create/package.json
index 48f351dfb089..a5ad3fce4ff9 100644
--- a/packages/angular/create/package.json
+++ b/packages/angular/create/package.json
@@ -9,9 +9,7 @@
"code generation",
"schematics"
],
- "bin": {
- "create": "./src/index.js"
- },
+ "bin": "./src/index.js",
"dependencies": {
"@angular/cli": "0.0.0-PLACEHOLDER"
}
diff --git a/packages/angular/create/src/index.ts b/packages/angular/create/src/index.ts
index 7d4aaf280637..15e521ed964d 100644
--- a/packages/angular/create/src/index.ts
+++ b/packages/angular/create/src/index.ts
@@ -4,7 +4,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { spawnSync } from 'child_process';
diff --git a/packages/angular/pwa/BUILD.bazel b/packages/angular/pwa/BUILD.bazel
index 4d3df6fd38c3..5a8a3ecd3cca 100644
--- a/packages/angular/pwa/BUILD.bazel
+++ b/packages/angular/pwa/BUILD.bazel
@@ -1,7 +1,7 @@
# Copyright Google Inc. 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
+# found in the LICENSE file at https://angular.dev/license
load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test")
load("//tools:defaults.bzl", "pkg_npm", "ts_library")
@@ -18,10 +18,11 @@ ts_library(
"pwa/index.ts",
"//packages/angular/pwa:pwa/schema.ts",
],
- data = glob(
+ data = [
+ "collection.json",
+ "pwa/schema.json",
+ ] + glob(
include = [
- "collection.json",
- "pwa/schema.json",
"pwa/files/**/*",
],
),
diff --git a/packages/angular/pwa/README.md b/packages/angular/pwa/README.md
index 952f1f963efa..c7ecbdaa99af 100644
--- a/packages/angular/pwa/README.md
+++ b/packages/angular/pwa/README.md
@@ -1,8 +1,8 @@
# `@angular/pwa`
-This is a [schematic](https://angular.io/guide/schematics) for adding
+This is a [schematic](https://angular.dev/tools/cli/schematics) for adding
[Progressive Web App](https://web.dev/progressive-web-apps/) support to an Angular project. Run the
-schematic with the [Angular CLI](https://angular.io/cli):
+schematic with the [Angular CLI](https://angular.dev/tools/cli):
```shell
ng add @angular/pwa --project
@@ -19,5 +19,5 @@ Executing the command mentioned above will perform the following actions:
1. Installs icon files to support the installed Progressive Web App (PWA).
1. Creates the service worker configuration file called `ngsw-config.json`, specifying caching behaviors and other settings.
-See [Getting started with service workers](https://angular.io/guide/service-worker-getting-started)
+See [Getting started with service workers](https://angular.dev/ecosystem/service-workers/getting-started)
for more information.
diff --git a/packages/angular/pwa/package.json b/packages/angular/pwa/package.json
index 5859ef6811a6..778029e66822 100644
--- a/packages/angular/pwa/package.json
+++ b/packages/angular/pwa/package.json
@@ -17,7 +17,7 @@
"parse5-html-rewriting-stream": "7.0.0"
},
"peerDependencies": {
- "@angular/cli": "^17.0.0 || ^17.3.0-next.0"
+ "@angular/cli": "^18.0.0"
},
"peerDependenciesMeta": {
"@angular/cli": {
diff --git a/packages/angular/pwa/pwa/files/root/manifest.webmanifest b/packages/angular/pwa/pwa/files/assets/manifest.webmanifest
similarity index 71%
rename from packages/angular/pwa/pwa/files/root/manifest.webmanifest
rename to packages/angular/pwa/pwa/files/assets/manifest.webmanifest
index 7d096fae01c5..f8c1e3960511 100644
--- a/packages/angular/pwa/pwa/files/root/manifest.webmanifest
+++ b/packages/angular/pwa/pwa/files/assets/manifest.webmanifest
@@ -8,49 +8,49 @@
"start_url": "./",
"icons": [
{
- "src": "assets/icons/icon-72x72.png",
+ "src": "<%= iconsPath %>/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
- "src": "assets/icons/icon-96x96.png",
+ "src": "<%= iconsPath %>/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
- "src": "assets/icons/icon-128x128.png",
+ "src": "<%= iconsPath %>/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
- "src": "assets/icons/icon-144x144.png",
+ "src": "<%= iconsPath %>/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
- "src": "assets/icons/icon-152x152.png",
+ "src": "<%= iconsPath %>/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
- "src": "assets/icons/icon-192x192.png",
+ "src": "<%= iconsPath %>/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
- "src": "assets/icons/icon-384x384.png",
+ "src": "<%= iconsPath %>/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
- "src": "assets/icons/icon-512x512.png",
+ "src": "<%= iconsPath %>/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
diff --git a/packages/angular/pwa/pwa/index.ts b/packages/angular/pwa/pwa/index.ts
index f817c4764905..550e359e47f8 100644
--- a/packages/angular/pwa/pwa/index.ts
+++ b/packages/angular/pwa/pwa/index.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import {
@@ -104,23 +104,6 @@ export default function (options: PwaOptions): Rule {
}
}
- // Add manifest to asset configuration
- const assetEntry = posix.join(
- project.sourceRoot ?? posix.join(project.root, 'src'),
- 'manifest.webmanifest',
- );
- for (const target of [...buildTargets, ...testTargets]) {
- if (target.options) {
- if (Array.isArray(target.options.assets)) {
- target.options.assets.push(assetEntry);
- } else {
- target.options.assets = [assetEntry];
- }
- } else {
- target.options = { assets: [assetEntry] };
- }
- }
-
// Find all index.html files in build targets
const indexFiles = new Set();
for (const target of buildTargets) {
@@ -146,11 +129,36 @@ export default function (options: PwaOptions): Rule {
const { title, ...swOptions } = options;
await writeWorkspace(host, workspace);
+ let assetsDir = posix.join(sourcePath, 'assets');
+ let iconsPath: string;
+ if (host.exists(assetsDir)) {
+ // Add manifest to asset configuration
+ const assetEntry = posix.join(
+ project.sourceRoot ?? posix.join(project.root, 'src'),
+ 'manifest.webmanifest',
+ );
+ for (const target of [...buildTargets, ...testTargets]) {
+ if (target.options) {
+ if (Array.isArray(target.options.assets)) {
+ target.options.assets.push(assetEntry);
+ } else {
+ target.options.assets = [assetEntry];
+ }
+ } else {
+ target.options = { assets: [assetEntry] };
+ }
+ }
+ iconsPath = 'assets';
+ } else {
+ assetsDir = posix.join(project.root, 'public');
+ iconsPath = 'icons';
+ }
return chain([
externalSchematic('@schematics/angular', 'service-worker', swOptions),
- mergeWith(apply(url('./files/root'), [template({ ...options }), move(sourcePath)])),
- mergeWith(apply(url('./files/assets'), [move(posix.join(sourcePath, 'assets'))])),
+ mergeWith(
+ apply(url('./files/assets'), [template({ ...options, iconsPath }), move(assetsDir)]),
+ ),
...[...indexFiles].map((path) => updateIndexFile(path)),
]);
};
diff --git a/packages/angular/pwa/pwa/index_spec.ts b/packages/angular/pwa/pwa/index_spec.ts
index e6b0d4e576bb..3e0216b8cb2b 100644
--- a/packages/angular/pwa/pwa/index_spec.ts
+++ b/packages/angular/pwa/pwa/index_spec.ts
@@ -3,7 +3,7 @@
* 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
+ * found in the LICENSE file at https://angular.dev/license
*/
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
@@ -54,7 +54,7 @@ describe('PWA Schematic', () => {
it('should create icon files', async () => {
const dimensions = [72, 96, 128, 144, 152, 192, 384, 512];
- const iconPath = '/projects/bar/src/assets/icons/icon-';
+ const iconPath = '/projects/bar/public/icons/icon-';
const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree);
dimensions.forEach((d) => {
@@ -63,6 +63,15 @@ describe('PWA Schematic', () => {
});
});
+ it('should reference the icons in the manifest correctly', async () => {
+ const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree);
+ const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest');
+ const manifest = JSON.parse(manifestText);
+ for (const icon of manifest.icons) {
+ expect(icon.src).toMatch(/^icons\/icon-\d+x\d+.png/);
+ }
+ });
+
it('should run the service worker schematic', async () => {
const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree);
const configText = tree.readContent('/angular.json');
@@ -74,13 +83,13 @@ describe('PWA Schematic', () => {
it('should create a manifest file', async () => {
const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree);
- expect(tree.exists('/projects/bar/src/manifest.webmanifest')).toBeTrue();
+ expect(tree.exists('/projects/bar/public/manifest.webmanifest')).toBeTrue();
});
it('should set the name & short_name in the manifest file', async () => {
const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree);
- const manifestText = tree.readContent('/projects/bar/src/manifest.webmanifest');
+ const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest');
const manifest = JSON.parse(manifestText);
expect(manifest.name).toEqual(defaultOptions.title);
@@ -91,7 +100,7 @@ describe('PWA Schematic', () => {
const options = { ...defaultOptions, title: undefined };
const tree = await schematicRunner.runSchematic('ng-add', options, appTree);
- const manifestText = tree.readContent('/projects/bar/src/manifest.webmanifest');
+ const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest');
const manifest = JSON.parse(manifestText);
expect(manifest.name).toEqual(defaultOptions.project);
@@ -125,17 +134,6 @@ describe('PWA Schematic', () => {
expect(content).toMatch(/