/** * @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 { JsonObject, join, normalize } from '@angular-devkit/core'; import { MergeStrategy, Rule, SchematicContext, Tree, apply, applyTemplates, chain, filter, mergeWith, move, noop, schematic, strings, url, } from '@angular-devkit/schematics'; import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; import { Schema as ComponentOptions } from '../component/schema'; import { NodeDependencyType, addPackageJsonDependency } from '../utility/dependencies'; import { latestVersions } from '../utility/latest-versions'; import { relativePathToWorkspaceRoot } from '../utility/paths'; import { getWorkspace, updateWorkspace } from '../utility/workspace'; import { Builders, ProjectType } from '../utility/workspace-models'; import { Schema as ApplicationOptions, Style } from './schema'; export default function (options: ApplicationOptions): Rule { return async (host: Tree, context: SchematicContext) => { const { appDir, appRootSelector, componentOptions, folderName, sourceDir } = await getAppOptions(host, options); return chain([ addAppToWorkspaceFile(options, appDir, folderName), options.standalone ? noop() : schematic('module', { name: 'app', commonModule: false, flat: true, routing: options.routing, routingScope: 'Root', path: sourceDir, project: options.name, }), schematic('component', { name: 'app', selector: appRootSelector, flat: true, path: sourceDir, skipImport: true, project: options.name, ...componentOptions, }), mergeWith( apply(url(options.standalone ? './files/standalone-files' : './files/module-files'), [ options.routing ? noop() : filter((path) => !path.endsWith('app.routes.ts.template')), componentOptions.skipTests ? filter((path) => !path.endsWith('.spec.ts.template')) : noop(), applyTemplates({ utils: strings, ...options, ...componentOptions, selector: appRootSelector, relativePathToWorkspaceRoot: relativePathToWorkspaceRoot(appDir), appName: options.name, folderName, }), move(appDir), ]), MergeStrategy.Overwrite, ), mergeWith( apply(url('./files/common-files'), [ options.minimal ? filter((path) => !path.endsWith('tsconfig.spec.json.template')) : noop(), componentOptions.inlineTemplate ? filter((path) => !path.endsWith('component.html.template')) : noop(), applyTemplates({ utils: strings, ...options, selector: appRootSelector, relativePathToWorkspaceRoot: relativePathToWorkspaceRoot(appDir), appName: options.name, folderName, }), move(appDir), ]), MergeStrategy.Overwrite, ), options.ssr ? schematic('ssr', { project: options.name, serverRouting: options.serverRouting, skipInstall: true, }) : noop(), options.skipPackageJson ? noop() : addDependenciesToPackageJson(options), ]); }; } function addDependenciesToPackageJson(options: ApplicationOptions) { return (host: Tree, context: SchematicContext) => { [ { type: NodeDependencyType.Dev, name: '@angular/compiler-cli', version: latestVersions.Angular, }, { type: NodeDependencyType.Dev, name: '@angular-devkit/build-angular', version: latestVersions.DevkitBuildAngular, }, { type: NodeDependencyType.Dev, name: 'typescript', version: latestVersions['typescript'], }, ].forEach((dependency) => addPackageJsonDependency(host, dependency)); if (!options.skipInstall) { context.addTask(new NodePackageInstallTask()); } return host; }; } function addAppToWorkspaceFile( options: ApplicationOptions, appDir: string, folderName: string, ): Rule { let projectRoot = appDir; if (projectRoot) { projectRoot += '/'; } const schematics: JsonObject = {}; if ( options.inlineTemplate || options.inlineStyle || options.minimal || options.style !== Style.Css ) { const componentSchematicsOptions: JsonObject = {}; if (options.inlineTemplate ?? options.minimal) { componentSchematicsOptions.inlineTemplate = true; } if (options.inlineStyle ?? options.minimal) { componentSchematicsOptions.inlineStyle = true; } if (options.style && options.style !== Style.Css) { componentSchematicsOptions.style = options.style; } schematics['@schematics/angular:component'] = componentSchematicsOptions; } if (options.skipTests || options.minimal) { const schematicsWithTests = [ 'class', 'component', 'directive', 'guard', 'interceptor', 'pipe', 'resolver', 'service', ]; schematicsWithTests.forEach((type) => { ((schematics[`@schematics/angular:${type}`] ??= {}) as JsonObject).skipTests = true; }); } if (!options.standalone) { const schematicsWithStandalone = ['component', 'directive', 'pipe']; schematicsWithStandalone.forEach((type) => { ((schematics[`@schematics/angular:${type}`] ??= {}) as JsonObject).standalone = false; }); } const sourceRoot = join(normalize(projectRoot), 'src'); let budgets: { type: string; maximumWarning: string; maximumError: string }[] = []; if (options.strict) { budgets = [ { type: 'initial', maximumWarning: '500kB', maximumError: '1MB', }, { type: 'anyComponentStyle', maximumWarning: '4kB', maximumError: '8kB', }, ]; } else { budgets = [ { type: 'initial', maximumWarning: '2MB', maximumError: '5MB', }, { type: 'anyComponentStyle', maximumWarning: '6kB', maximumError: '10kB', }, ]; } const inlineStyleLanguage = options?.style !== Style.Css ? options.style : undefined; const project = { root: normalize(projectRoot), sourceRoot, projectType: ProjectType.Application, prefix: options.prefix || 'app', schematics, targets: { build: { builder: Builders.Application, defaultConfiguration: 'production', options: { outputPath: `dist/${folderName}`, index: `${sourceRoot}/index.html`, browser: `${sourceRoot}/main.ts`, polyfills: options.experimentalZoneless ? [] : ['zone.js'], tsConfig: `${projectRoot}tsconfig.app.json`, inlineStyleLanguage, assets: [{ 'glob': '**/*', 'input': `${projectRoot}public` }], styles: [`${sourceRoot}/styles.${options.style}`], scripts: [], }, configurations: { production: { budgets, outputHashing: 'all', }, development: { optimization: false, extractLicenses: false, sourceMap: true, }, }, }, serve: { builder: Builders.DevServer, defaultConfiguration: 'development', options: {}, configurations: { production: { buildTarget: `${options.name}:build:production`, }, development: { buildTarget: `${options.name}:build:development`, }, }, }, 'extract-i18n': { builder: Builders.ExtractI18n, }, test: options.minimal ? undefined : { builder: Builders.Karma, options: { polyfills: options.experimentalZoneless ? [] : ['zone.js', 'zone.js/testing'], tsConfig: `${projectRoot}tsconfig.spec.json`, inlineStyleLanguage, assets: [{ 'glob': '**/*', 'input': `${projectRoot}public` }], styles: [`${sourceRoot}/styles.${options.style}`], scripts: [], }, }, }, }; return updateWorkspace((workspace) => { workspace.projects.add({ name: options.name, ...project, }); }); } async function getAppOptions( host: Tree, options: ApplicationOptions, ): Promise<{ appDir: string; appRootSelector: string; componentOptions: Partial; folderName: string; sourceDir: string; }> { const appRootSelector = `${options.prefix}-root`; const componentOptions = getComponentOptions(options); const workspace = await getWorkspace(host); const newProjectRoot = (workspace.extensions.newProjectRoot as string | undefined) || ''; // If scoped project (i.e. "@foo/bar"), convert dir to "foo/bar". let folderName = options.name.startsWith('@') ? options.name.slice(1) : options.name; if (/[A-Z]/.test(folderName)) { folderName = strings.dasherize(folderName); } const appDir = options.projectRoot === undefined ? join(normalize(newProjectRoot), folderName) : normalize(options.projectRoot); const sourceDir = `${appDir}/src/app`; return { appDir, appRootSelector, componentOptions, folderName, sourceDir, }; } function getComponentOptions(options: ApplicationOptions): Partial { const componentOptions: Partial = !options.minimal ? { inlineStyle: options.inlineStyle, inlineTemplate: options.inlineTemplate, skipTests: options.skipTests, style: options.style, viewEncapsulation: options.viewEncapsulation, } : { inlineStyle: options.inlineStyle ?? true, inlineTemplate: options.inlineTemplate ?? true, skipTests: true, style: options.style, viewEncapsulation: options.viewEncapsulation, }; return componentOptions; }