/** * @license * 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 */ import { JsonObject, JsonParseMode, join, normalize, parseJsonAst, relative, strings, } from '@angular-devkit/core'; import { MergeStrategy, Rule, SchematicContext, SchematicsException, Tree, apply, applyTemplates, chain, filter, mergeWith, move, noop, schematic, url, } from '@angular-devkit/schematics'; import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; import { styleToFileExtention } from '../component/index'; import { Schema as ComponentOptions } from '../component/schema'; import { Schema as E2eOptions } from '../e2e/schema'; import { addProjectToWorkspace, getWorkspace, } from '../utility/config'; import { NodeDependencyType, addPackageJsonDependency } from '../utility/dependencies'; import { findPropertyInAstObject, insertPropertyInAstObjectInOrder } from '../utility/json-utils'; import { latestVersions } from '../utility/latest-versions'; import { applyLintFix } from '../utility/lint-fix'; import { validateProjectName } from '../utility/validation'; import { Builders, ProjectType, WorkspaceProject, WorkspaceSchema, } from '../utility/workspace-models'; import { Schema as ApplicationOptions, Style } from './schema'; // TODO: use JsonAST // function appendPropertyInAstObject( // recorder: UpdateRecorder, // node: JsonAstObject, // propertyName: string, // value: JsonValue, // indent = 4, // ) { // const indentStr = '\n' + new Array(indent + 1).join(' '); // if (node.properties.length > 0) { // // Insert comma. // const last = node.properties[node.properties.length - 1]; // recorder.insertRight(last.start.offset + last.text.replace(/\s+$/, '').length, ','); // } // recorder.insertLeft( // node.end.offset - 1, // ' ' // + `"${propertyName}": ${JSON.stringify(value, null, 2).replace(/\n/g, indentStr)}` // + indentStr.slice(0, -2), // ); // } 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 addPostInstallScript() { return (host: Tree) => { const pkgJsonPath = '/package.json'; const buffer = host.read(pkgJsonPath); if (!buffer) { throw new SchematicsException('Could not read package.json.'); } const packageJsonAst = parseJsonAst(buffer.toString(), JsonParseMode.Strict); if (packageJsonAst.kind !== 'object') { throw new SchematicsException('Invalid package.json. Was expecting an object.'); } const scriptsNode = findPropertyInAstObject(packageJsonAst, 'scripts'); if (scriptsNode && scriptsNode.kind === 'object') { const recorder = host.beginUpdate(pkgJsonPath); const postInstall = findPropertyInAstObject(scriptsNode, 'postinstall'); if (!postInstall) { // postinstall script not found, add it. insertPropertyInAstObjectInOrder( recorder, scriptsNode, 'postinstall', 'ivy-ngcc', 4, ); } host.commitUpdate(recorder); } }; } function addAppToWorkspaceFile(options: ApplicationOptions, workspace: WorkspaceSchema): Rule { // TODO: use JsonAST // const workspacePath = '/angular.json'; // const workspaceBuffer = host.read(workspacePath); // if (workspaceBuffer === null) { // throw new SchematicsException(`Configuration file (${workspacePath}) not found.`); // } // const workspaceJson = parseJson(workspaceBuffer.toString()); // if (workspaceJson.value === null) { // throw new SchematicsException(`Unable to parse configuration file (${workspacePath}).`); // } let projectRoot = options.projectRoot !== undefined ? options.projectRoot : `${workspace.newProjectRoot || ''}/${options.name}`; if (projectRoot !== '' && !projectRoot.endsWith('/')) { projectRoot += '/'; } const rootFilesRoot = options.projectRoot === undefined ? projectRoot : projectRoot + 'src/'; const schematics: JsonObject = {}; if (options.inlineTemplate === true || options.inlineStyle === true || options.style !== Style.Css) { const componentSchematicsOptions: JsonObject = {}; if (options.inlineTemplate === true) { componentSchematicsOptions.inlineTemplate = true; } if (options.inlineStyle === true) { componentSchematicsOptions.inlineStyle = true; } if (options.style && options.style !== Style.Css) { componentSchematicsOptions.style = options.style; } schematics['@schematics/angular:component'] = componentSchematicsOptions; } if (options.skipTests === true) { ['class', 'component', 'directive', 'guard', 'module', 'pipe', 'service'].forEach((type) => { if (!(`@schematics/angular:${type}` in schematics)) { schematics[`@schematics/angular:${type}`] = {}; } (schematics[`@schematics/angular:${type}`] as JsonObject).skipTests = true; }); } const styleExt = styleToFileExtention(options.style); const project: WorkspaceProject = { root: projectRoot, sourceRoot: join(normalize(projectRoot), 'src'), projectType: ProjectType.Application, prefix: options.prefix || 'app', schematics, architect: { build: { builder: Builders.Browser, options: { outputPath: `dist/${options.name}`, index: `${projectRoot}src/index.html`, main: `${projectRoot}src/main.ts`, polyfills: `${projectRoot}src/polyfills.ts`, tsConfig: `${rootFilesRoot}tsconfig.app.json`, assets: [ join(normalize(projectRoot), 'src', 'favicon.ico'), join(normalize(projectRoot), 'src', 'assets'), ], styles: [ `${projectRoot}src/styles.${styleExt}`, ], scripts: [], es5BrowserSupport: true, }, configurations: { production: { fileReplacements: [{ replace: `${projectRoot}src/environments/environment.ts`, with: `${projectRoot}src/environments/environment.prod.ts`, }], optimization: true, outputHashing: 'all', sourceMap: false, extractCss: true, namedChunks: false, aot: true, extractLicenses: true, vendorChunk: false, buildOptimizer: true, budgets: [{ type: 'initial', maximumWarning: '2mb', maximumError: '5mb', }], }, }, }, serve: { builder: Builders.DevServer, options: { browserTarget: `${options.name}:build`, }, configurations: { production: { browserTarget: `${options.name}:build:production`, }, }, }, 'extract-i18n': { builder: Builders.ExtractI18n, options: { browserTarget: `${options.name}:build`, }, }, test: { builder: Builders.Karma, options: { main: `${projectRoot}src/test.ts`, polyfills: `${projectRoot}src/polyfills.ts`, tsConfig: `${rootFilesRoot}tsconfig.spec.json`, karmaConfig: `${rootFilesRoot}karma.conf.js`, styles: [ `${projectRoot}src/styles.${styleExt}`, ], scripts: [], assets: [ join(normalize(projectRoot), 'src', 'favicon.ico'), join(normalize(projectRoot), 'src', 'assets'), ], }, }, lint: { builder: Builders.TsLint, options: { tsConfig: [ `${rootFilesRoot}tsconfig.app.json`, `${rootFilesRoot}tsconfig.spec.json`, ], exclude: [ '**/node_modules/**', ], }, }, }, }; // tslint:disable-next-line:no-any // const projects: JsonObject = ( workspaceAst.value).projects || {}; // tslint:disable-next-line:no-any // if (!( workspaceAst.value).projects) { // // tslint:disable-next-line:no-any // ( workspaceAst.value).projects = projects; // } return addProjectToWorkspace(workspace, options.name, project); } function minimalPathFilter(path: string): boolean { const toRemoveList = /(test.ts|tsconfig.spec.json|karma.conf.js).template$/; return !toRemoveList.test(path); } export default function (options: ApplicationOptions): Rule { return (host: Tree, context: SchematicContext) => { if (!options.name) { throw new SchematicsException(`Invalid options, "name" is required.`); } validateProjectName(options.name); const prefix = options.prefix || 'app'; const appRootSelector = `${prefix}-root`; const componentOptions: Partial = !options.minimal ? { inlineStyle: options.inlineStyle, inlineTemplate: options.inlineTemplate, skipTests: options.skipTests, style: options.style, viewEncapsulation: options.viewEncapsulation, } : { inlineStyle: true, inlineTemplate: true, skipTests: true, style: options.style, }; const workspace = getWorkspace(host); let newProjectRoot = workspace.newProjectRoot || ''; let appDir = `${newProjectRoot}/${options.name}`; let sourceRoot = `${appDir}/src`; let sourceDir = `${sourceRoot}/app`; let relativePathToWorkspaceRoot = appDir.split('/').map(x => '..').join('/'); const rootInSrc = options.projectRoot !== undefined; if (options.projectRoot !== undefined) { newProjectRoot = options.projectRoot; appDir = `${newProjectRoot}/src`; sourceRoot = appDir; sourceDir = `${sourceRoot}/app`; relativePathToWorkspaceRoot = relative(normalize('/' + sourceRoot), normalize('/')); if (relativePathToWorkspaceRoot === '') { relativePathToWorkspaceRoot = '.'; } } const tsLintRoot = appDir; const e2eOptions: E2eOptions = { relatedAppName: options.name, rootSelector: appRootSelector, }; const styleExt = styleToFileExtention(options.style); return chain([ addAppToWorkspaceFile(options, workspace), mergeWith( apply(url('./files/src'), [ options.minimal ? filter(minimalPathFilter) : noop(), applyTemplates({ utils: strings, ...options, 'dot': '.', relativePathToWorkspaceRoot, styleExt, }), move(sourceRoot), ])), mergeWith( apply(url('./files/root'), [ options.minimal ? filter(minimalPathFilter) : noop(), applyTemplates({ utils: strings, ...options, 'dot': '.', relativePathToWorkspaceRoot, rootInSrc, appName: options.name, }), move(appDir), ])), options.minimal ? noop() : mergeWith( apply(url('./files/lint'), [ applyTemplates({ utils: strings, ...options, tsLintRoot, relativePathToWorkspaceRoot, prefix, }), // TODO: Moving should work but is bugged right now. // The __tsLintRoot__ is being used meanwhile. // Otherwise the tslint.json file could be inside of the root folder and // this block and the lint folder could be removed. ])), 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('./other-files'), [ componentOptions.inlineTemplate ? filter(path => !path.endsWith('.html.template')) : noop(), componentOptions.skipTests ? filter(path => !/[.|-]spec.ts.template$/.test(path)) : noop(), applyTemplates({ utils: strings, ...options as any, // tslint:disable-line:no-any selector: appRootSelector, ...componentOptions, styleExt, }), move(sourceDir), ]), MergeStrategy.Overwrite), options.minimal ? noop() : schematic('e2e', e2eOptions), options.enableIvy ? addPostInstallScript() : noop(), options.skipPackageJson ? noop() : addDependenciesToPackageJson(options), options.lintFix ? applyLintFix(sourceDir) : noop(), ]); }; }