diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/README.md b/npm/ng-packs/packages/schematics/src/utils/angular/README.md new file mode 100644 index 0000000000..8d98f50e2d --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/README.md @@ -0,0 +1,5 @@ +**DISCLAIMER** + +This directory is a direct copy of https://github.com/angular/angular-cli/tree/master/packages/schematics/angular/utility and is used under terms and permissions by the MIT license granted by Google, Inc. + +All credits go to Angular team for building these utilities. diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/ast-utils.ts b/npm/ng-packs/packages/schematics/src/utils/angular/ast-utils.ts new file mode 100644 index 0000000000..6883e73360 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/ast-utils.ts @@ -0,0 +1,753 @@ +/** + * @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 * as ts from 'typescript'; +import { Change, InsertChange, NoopChange } from './change'; + + +/** + * Add Import `import { symbolName } from fileName` if the import doesn't exit + * already. Assumes fileToEdit can be resolved and accessed. + * @param fileToEdit (file we want to add import to) + * @param symbolName (item to import) + * @param fileName (path to the file) + * @param isDefault (if true, import follows style for importing default exports) + * @return Change + */ +export function insertImport(source: ts.SourceFile, fileToEdit: string, symbolName: string, + fileName: string, isDefault = false): Change { + const rootNode = source; + const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + + // get nodes that map to import statements from the file fileName + const relevantImports = allImports.filter(node => { + // StringLiteral of the ImportDeclaration is the import file (fileName in this case). + const importFiles = node.getChildren() + .filter(ts.isStringLiteral) + .map(n => n.text); + + return importFiles.filter(file => file === fileName).length === 1; + }); + + if (relevantImports.length > 0) { + let importsAsterisk = false; + // imports from import file + const imports: ts.Node[] = []; + relevantImports.forEach(n => { + Array.prototype.push.apply(imports, findNodes(n, ts.SyntaxKind.Identifier)); + if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) { + importsAsterisk = true; + } + }); + + // if imports * from fileName, don't add symbolName + if (importsAsterisk) { + return new NoopChange(); + } + + const importTextNodes = imports.filter(n => (n as ts.Identifier).text === symbolName); + + // insert import if it's not there + if (importTextNodes.length === 0) { + const fallbackPos = + findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].getStart() || + findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].getStart(); + + return insertAfterLastOccurrence(imports, `, ${symbolName}`, fileToEdit, fallbackPos); + } + + return new NoopChange(); + } + + // no such import declaration exists + const useStrict = findNodes(rootNode, ts.isStringLiteral) + .filter((n) => n.text === 'use strict'); + let fallbackPos = 0; + if (useStrict.length > 0) { + fallbackPos = useStrict[0].end; + } + const open = isDefault ? '' : '{ '; + const close = isDefault ? '' : ' }'; + // if there are no imports or 'use strict' statement, insert import at beginning of file + const insertAtBeginning = allImports.length === 0 && useStrict.length === 0; + const separator = insertAtBeginning ? '' : ';\n'; + const toInsert = `${separator}import ${open}${symbolName}${close}` + + ` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`; + + return insertAfterLastOccurrence( + allImports, + toInsert, + fileToEdit, + fallbackPos, + ts.SyntaxKind.StringLiteral, + ); +} + + +/** + * Find all nodes from the AST in the subtree of node of SyntaxKind kind. + * @param node + * @param kind + * @param max The maximum number of items to return. + * @param recursive Continue looking for nodes of kind recursive until end + * the last child even when node of kind has been found. + * @return all nodes of kind, or [] if none is found + */ +export function findNodes(node: ts.Node, kind: ts.SyntaxKind, max?: number, recursive?: boolean): ts.Node[]; + +/** + * Find all nodes from the AST in the subtree that satisfy a type guard. + * @param node + * @param guard + * @param max The maximum number of items to return. + * @param recursive Continue looking for nodes of kind recursive until end + * the last child even when node of kind has been found. + * @return all nodes that satisfy the type guard, or [] if none is found + */ +export function findNodes(node: ts.Node, guard: (node: ts.Node) => node is T, max?: number, recursive?: boolean): T[]; + +export function findNodes( + node: ts.Node, + kindOrGuard: ts.SyntaxKind | ((node: ts.Node) => node is T), + max = Infinity, + recursive = false, +): T[] { + if (!node || max == 0) { + return []; + } + + const test = + typeof kindOrGuard === 'function' + ? kindOrGuard + : (node: ts.Node): node is T => node.kind === kindOrGuard; + + const arr: T[] = []; + if (test(node)) { + arr.push(node); + max--; + } + if (max > 0 && (recursive || !test(node))) { + for (const child of node.getChildren()) { + findNodes(child, test, max).forEach((node) => { + if (max > 0) { + arr.push(node); + } + max--; + }); + + if (max <= 0) { + break; + } + } + } + + return arr; +} + + +/** + * Get all the nodes from a source. + * @param sourceFile The source file object. + * @returns {Array} An array of all the nodes in the source. + */ +export function getSourceNodes(sourceFile: ts.SourceFile): ts.Node[] { + const nodes: ts.Node[] = [sourceFile]; + const result = []; + + while (nodes.length > 0) { + const node = nodes.shift(); + + if (node) { + result.push(node); + if (node.getChildCount(sourceFile) >= 0) { + nodes.unshift(...node.getChildren()); + } + } + } + + return result; +} + +export function findNode(node: ts.Node, kind: ts.SyntaxKind, text: string): ts.Node | null { + if (node.kind === kind && node.getText() === text) { + // throw new Error(node.getText()); + return node; + } + + let foundNode: ts.Node | null = null; + ts.forEachChild(node, childNode => { + foundNode = foundNode || findNode(childNode, kind, text); + }); + + return foundNode; +} + + +/** + * Helper for sorting nodes. + * @return function to sort nodes in increasing order of position in sourceFile + */ +function nodesByPosition(first: ts.Node, second: ts.Node): number { + return first.getStart() - second.getStart(); +} + + +/** + * Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]` + * or after the last of occurence of `syntaxKind` if the last occurence is a sub child + * of ts.SyntaxKind[nodes[i].kind] and save the changes in file. + * + * @param nodes insert after the last occurence of nodes + * @param toInsert string to insert + * @param file file to insert changes into + * @param fallbackPos position to insert if toInsert happens to be the first occurence + * @param syntaxKind the ts.SyntaxKind of the subchildren to insert after + * @return Change instance + * @throw Error if toInsert is first occurence but fall back is not set + */ +export function insertAfterLastOccurrence(nodes: ts.Node[], + toInsert: string, + file: string, + fallbackPos: number, + syntaxKind?: ts.SyntaxKind): Change { + let lastItem: ts.Node | undefined; + for (const node of nodes) { + if (!lastItem || lastItem.getStart() < node.getStart()) { + lastItem = node; + } + } + if (syntaxKind && lastItem) { + lastItem = findNodes(lastItem, syntaxKind).sort(nodesByPosition).pop(); + } + if (!lastItem && fallbackPos == undefined) { + throw new Error(`tried to insert ${toInsert} as first occurence with no fallback position`); + } + const lastItemPosition: number = lastItem ? lastItem.getEnd() : fallbackPos; + + return new InsertChange(file, lastItemPosition, toInsert); +} + + +export function getContentOfKeyLiteral(_source: ts.SourceFile, node: ts.Node): string | null { + if (node.kind == ts.SyntaxKind.Identifier) { + return (node as ts.Identifier).text; + } else if (node.kind == ts.SyntaxKind.StringLiteral) { + return (node as ts.StringLiteral).text; + } else { + return null; + } +} + + +function _angularImportsFromNode(node: ts.ImportDeclaration, + _sourceFile: ts.SourceFile): {[name: string]: string} { + const ms = node.moduleSpecifier; + let modulePath: string; + switch (ms.kind) { + case ts.SyntaxKind.StringLiteral: + modulePath = (ms as ts.StringLiteral).text; + break; + default: + return {}; + } + + if (!modulePath.startsWith('@angular/')) { + return {}; + } + + if (node.importClause) { + if (node.importClause.name) { + // This is of the form `import Name from 'path'`. Ignore. + return {}; + } else if (node.importClause.namedBindings) { + const nb = node.importClause.namedBindings; + if (nb.kind == ts.SyntaxKind.NamespaceImport) { + // This is of the form `import * as name from 'path'`. Return `name.`. + return { + [(nb as ts.NamespaceImport).name.text + '.']: modulePath, + }; + } else { + // This is of the form `import {a,b,c} from 'path'` + const namedImports = nb as ts.NamedImports; + + return namedImports.elements + .map((is: ts.ImportSpecifier) => is.propertyName ? is.propertyName.text : is.name.text) + .reduce((acc: {[name: string]: string}, curr: string) => { + acc[curr] = modulePath; + + return acc; + }, {}); + } + } + + return {}; + } else { + // This is of the form `import 'path';`. Nothing to do. + return {}; + } +} + + +export function getDecoratorMetadata(source: ts.SourceFile, identifier: string, + module: string): ts.Node[] { + const angularImports = findNodes(source, ts.isImportDeclaration) + .map((node) => _angularImportsFromNode(node, source)) + .reduce((acc, current) => { + for (const key of Object.keys(current)) { + acc[key] = current[key]; + } + + return acc; + }, {}); + + return getSourceNodes(source) + .filter(node => { + return node.kind == ts.SyntaxKind.Decorator + && (node as ts.Decorator).expression.kind == ts.SyntaxKind.CallExpression; + }) + .map(node => (node as ts.Decorator).expression as ts.CallExpression) + .filter(expr => { + if (expr.expression.kind == ts.SyntaxKind.Identifier) { + const id = expr.expression as ts.Identifier; + + return id.text == identifier && angularImports[id.text] === module; + } else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) { + // This covers foo.NgModule when importing * as foo. + const paExpr = expr.expression as ts.PropertyAccessExpression; + // If the left expression is not an identifier, just give up at that point. + if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) { + return false; + } + + const id = paExpr.name.text; + const moduleId = (paExpr.expression as ts.Identifier).text; + + return id === identifier && (angularImports[moduleId + '.'] === module); + } + + return false; + }) + .filter(expr => expr.arguments[0] + && expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression) + .map(expr => expr.arguments[0] as ts.ObjectLiteralExpression); +} + +function findClassDeclarationParent(node: ts.Node): ts.ClassDeclaration|undefined { + if (ts.isClassDeclaration(node)) { + return node; + } + + return node.parent && findClassDeclarationParent(node.parent); +} + +/** + * Given a source file with @NgModule class(es), find the name of the first @NgModule class. + * + * @param source source file containing one or more @NgModule + * @returns the name of the first @NgModule, or `undefined` if none is found + */ +export function getFirstNgModuleName(source: ts.SourceFile): string|undefined { + // First, find the @NgModule decorators. + const ngModulesMetadata = getDecoratorMetadata(source, 'NgModule', '@angular/core'); + if (ngModulesMetadata.length === 0) { + return undefined; + } + + // Then walk parent pointers up the AST, looking for the ClassDeclaration parent of the NgModule + // metadata. + const moduleClass = findClassDeclarationParent(ngModulesMetadata[0]); + if (!moduleClass || !moduleClass.name) { + return undefined; + } + + // Get the class name of the module ClassDeclaration. + return moduleClass.name.text; +} + +export function getMetadataField( + node: ts.ObjectLiteralExpression, + metadataField: string, +): ts.ObjectLiteralElement[] { + return node.properties + .filter(ts.isPropertyAssignment) + // Filter out every fields that's not "metadataField". Also handles string literals + // (but not expressions). + .filter(({ name }) => { + return (ts.isIdentifier(name) || ts.isStringLiteral(name)) + && name.getText() === metadataField; + }); +} + +export function addSymbolToNgModuleMetadata( + source: ts.SourceFile, + ngModulePath: string, + metadataField: string, + symbolName: string, + importPath: string | null = null, +): Change[] { + const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core'); + let node: any = nodes[0]; // tslint:disable-line:no-any + + // Find the decorator declaration. + if (!node) { + return []; + } + + // Get all the children property assignment of object literals. + const matchingProperties = getMetadataField( + node as ts.ObjectLiteralExpression, + metadataField, + ); + + // Get the last node of the array literal. + if (!matchingProperties) { + return []; + } + if (matchingProperties.length == 0) { + // We haven't found the field in the metadata declaration. Insert a new field. + const expr = node as ts.ObjectLiteralExpression; + let position: number; + let toInsert: string; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = ` ${metadataField}: [${symbolName}]\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd(); + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n\s*/); + if (matches && matches.length > 0) { + toInsert = `,${matches[0]}${metadataField}: [${symbolName}]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + if (importPath !== null) { + return [ + new InsertChange(ngModulePath, position, toInsert), + insertImport(source, ngModulePath, symbolName.replace(/\..*$/, ''), importPath), + ]; + } else { + return [new InsertChange(ngModulePath, position, toInsert)]; + } + } + const assignment = matchingProperties[0] as ts.PropertyAssignment; + + // If it's not an array, nothing we can do really. + if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { + return []; + } + + const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression; + if (arrLiteral.elements.length == 0) { + // Forward the property. + node = arrLiteral; + } else { + node = arrLiteral.elements; + } + + if (!node) { + // tslint:disable-next-line: no-console + console.error('No app module found. Please add your new class to your component.'); + + return []; + } + + if (Array.isArray(node)) { + const nodeArray = node as {} as Array; + const symbolsArray = nodeArray.map(node => node.getText()); + if (symbolsArray.includes(symbolName)) { + return []; + } + + node = node[node.length - 1]; + } + + let toInsert: string; + let position = node.getEnd(); + if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) { + // We haven't found the field in the metadata declaration. Insert a new + // field. + const expr = node as ts.ObjectLiteralExpression; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = ` ${symbolName}\n`; + } else { + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.match(/^\r?\r?\n/)) { + toInsert = `,${text.match(/^\r?\n\s*/)[0]}${symbolName}`; + } else { + toInsert = `, ${symbolName}`; + } + } + } else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) { + // We found the field but it's empty. Insert it just before the `]`. + position--; + toInsert = `${symbolName}`; + } else { + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.match(/^\r?\n/)) { + toInsert = `,${text.match(/^\r?\n(\r?)\s*/)[0]}${symbolName}`; + } else { + toInsert = `, ${symbolName}`; + } + } + if (importPath !== null) { + return [ + new InsertChange(ngModulePath, position, toInsert), + insertImport(source, ngModulePath, symbolName.replace(/\..*$/, ''), importPath), + ]; + } + + return [new InsertChange(ngModulePath, position, toInsert)]; +} + +/** + * Custom function to insert a declaration (component, pipe, directive) + * into NgModule declarations. It also imports the component. + */ +export function addDeclarationToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + return addSymbolToNgModuleMetadata( + source, modulePath, 'declarations', classifiedName, importPath); +} + +/** + * Custom function to insert an NgModule into NgModule imports. It also imports the module. + */ +export function addImportToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + + return addSymbolToNgModuleMetadata(source, modulePath, 'imports', classifiedName, importPath); +} + +/** + * Custom function to insert a provider into NgModule. It also imports it. + */ +export function addProviderToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + return addSymbolToNgModuleMetadata(source, modulePath, 'providers', classifiedName, importPath); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addExportToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + return addSymbolToNgModuleMetadata(source, modulePath, 'exports', classifiedName, importPath); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addBootstrapToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + return addSymbolToNgModuleMetadata(source, modulePath, 'bootstrap', classifiedName, importPath); +} + +/** + * Custom function to insert an entryComponent into NgModule. It also imports it. + * @deprecated - Since version 9.0.0 with Ivy, entryComponents is no longer necessary. + */ +export function addEntryComponentToModule(source: ts.SourceFile, + modulePath: string, classifiedName: string, + importPath: string): Change[] { + return addSymbolToNgModuleMetadata( + source, modulePath, + 'entryComponents', classifiedName, importPath, + ); +} + +/** + * Determine if an import already exists. + */ +export function isImported(source: ts.SourceFile, + classifiedName: string, + importPath: string): boolean { + const allNodes = getSourceNodes(source); + const matchingNodes = allNodes + .filter(ts.isImportDeclaration) + .filter( + (imp) => ts.isStringLiteral(imp.moduleSpecifier) && imp.moduleSpecifier.text === importPath, + ) + .filter((imp) => { + if (!imp.importClause) { + return false; + } + const nodes = findNodes(imp.importClause, ts.isImportSpecifier).filter( + (n) => n.getText() === classifiedName, + ); + + return nodes.length > 0; + }); + + return matchingNodes.length > 0; +} + +/** + * This function returns the name of the environment export + * whether this export is aliased or not. If the environment file + * is not imported, then it will return `null`. + */ +export function getEnvironmentExportName(source: ts.SourceFile): string | null { + // Initial value is `null` as we don't know yet if the user + // has imported `environment` into the root module or not. + let environmentExportName: string | null = null; + + const allNodes = getSourceNodes(source); + + allNodes + .filter(ts.isImportDeclaration) + .filter( + (declaration) => + declaration.moduleSpecifier.kind === ts.SyntaxKind.StringLiteral && + declaration.importClause !== undefined, + ) + .map((declaration) => + // If `importClause` property is defined then the first + // child will be `NamedImports` object (or `namedBindings`). + (declaration.importClause as ts.ImportClause).getChildAt(0), + ) + // Find those `NamedImports` object that contains `environment` keyword + // in its text. E.g. `{ environment as env }`. + .filter(ts.isNamedImports) + .filter((namedImports) => namedImports.getText().includes('environment')) + .forEach((namedImports) => { + for (const specifier of namedImports.elements) { + // `propertyName` is defined if the specifier + // has an aliased import. + const name = specifier.propertyName || specifier.name; + + // Find specifier that contains `environment` keyword in its text. + // Whether it's `environment` or `environment as env`. + if (name.text.includes('environment')) { + environmentExportName = specifier.name.text; + } + } + }); + + return environmentExportName; +} + +/** + * Returns the RouterModule declaration from NgModule metadata, if any. + */ +export function getRouterModuleDeclaration(source: ts.SourceFile): ts.Expression | undefined { + const result = getDecoratorMetadata(source, 'NgModule', '@angular/core') as ts.Node[]; + const node = result[0] as ts.ObjectLiteralExpression; + const matchingProperties = getMetadataField(node, 'imports'); + + if (!matchingProperties) { + return; + } + + const assignment = matchingProperties[0] as ts.PropertyAssignment; + + if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { + return; + } + + const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression; + + return arrLiteral.elements + .filter(el => el.kind === ts.SyntaxKind.CallExpression) + .find(el => (el as ts.Identifier).getText().startsWith('RouterModule')); +} + +/** + * Adds a new route declaration to a router module (i.e. has a RouterModule declaration) + */ +export function addRouteDeclarationToModule( + source: ts.SourceFile, + fileToAdd: string, + routeLiteral: string, +): Change { + const routerModuleExpr = getRouterModuleDeclaration(source); + if (!routerModuleExpr) { + throw new Error(`Couldn't find a route declaration in ${fileToAdd}.`); + } + const scopeConfigMethodArgs = (routerModuleExpr as ts.CallExpression).arguments; + if (!scopeConfigMethodArgs.length) { + const { line } = source.getLineAndCharacterOfPosition(routerModuleExpr.getStart()); + throw new Error( + `The router module method doesn't have arguments ` + + `at line ${line} in ${fileToAdd}`, + ); + } + + let routesArr: ts.ArrayLiteralExpression | undefined; + const routesArg = scopeConfigMethodArgs[0]; + + // Check if the route declarations array is + // an inlined argument of RouterModule or a standalone variable + if (ts.isArrayLiteralExpression(routesArg)) { + routesArr = routesArg; + } else { + const routesVarName = routesArg.getText(); + let routesVar; + if (routesArg.kind === ts.SyntaxKind.Identifier) { + routesVar = source.statements + .filter(ts.isVariableStatement) + .find((v) => { + return v.declarationList.declarations[0].name.getText() === routesVarName; + }); + } + + if (!routesVar) { + const { line } = source.getLineAndCharacterOfPosition(routesArg.getStart()); + throw new Error( + `No route declaration array was found that corresponds ` + + `to router module at line ${line} in ${fileToAdd}`, + ); + } + + routesArr = findNodes(routesVar, ts.SyntaxKind.ArrayLiteralExpression, 1)[0] as ts.ArrayLiteralExpression; + } + + const occurrencesCount = routesArr.elements.length; + const text = routesArr.getFullText(source); + + let route: string = routeLiteral; + let insertPos = routesArr.elements.pos; + + if (occurrencesCount > 0) { + const lastRouteLiteral = [...routesArr.elements].pop() as ts.Expression; + const lastRouteIsWildcard = ts.isObjectLiteralExpression(lastRouteLiteral) + && lastRouteLiteral + .properties + .some(n => ( + ts.isPropertyAssignment(n) + && ts.isIdentifier(n.name) + && n.name.text === 'path' + && ts.isStringLiteral(n.initializer) + && n.initializer.text === '**' + )); + + const indentation = text.match(/\r?\n(\r?)\s*/) || []; + const routeText = `${indentation[0] || ' '}${routeLiteral}`; + + // Add the new route before the wildcard route + // otherwise we'll always redirect to the wildcard route + if (lastRouteIsWildcard) { + insertPos = lastRouteLiteral.pos; + route = `${routeText},`; + } else { + insertPos = lastRouteLiteral.end; + route = `,${routeText}`; + } + } + + return new InsertChange(fileToAdd, insertPos, route); +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/change.ts b/npm/ng-packs/packages/schematics/src/utils/angular/change.ts new file mode 100644 index 0000000000..12556352ab --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/change.ts @@ -0,0 +1,127 @@ +/** + * @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 + */ +export interface Host { + write(path: string, content: string): Promise; + read(path: string): Promise; +} + + +export interface Change { + apply(host: Host): Promise; + + // The file this change should be applied to. Some changes might not apply to + // a file (maybe the config). + readonly path: string | null; + + // The order this change should be applied. Normally the position inside the file. + // Changes are applied from the bottom of a file to the top. + readonly order: number; + + // The description of this change. This will be outputted in a dry or verbose run. + readonly description: string; +} + + +/** + * An operation that does nothing. + */ +export class NoopChange implements Change { + description = 'No operation.'; + order = Infinity; + path = null; + apply() { return Promise.resolve(); } +} + + +/** + * Will add text to the source code. + */ +export class InsertChange implements Change { + + order: number; + description: string; + + constructor(public path: string, public pos: number, public toAdd: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Inserted ${toAdd} into position ${pos} of ${path}`; + this.order = pos; + } + + /** + * This method does not insert spaces if there is none in the original string. + */ + apply(host: Host) { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos); + + return host.write(this.path, `${prefix}${this.toAdd}${suffix}`); + }); + } +} + +/** + * Will remove text from the source code. + */ +export class RemoveChange implements Change { + + order: number; + description: string; + + constructor(public path: string, private pos: number, private toRemove: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.order = pos; + } + + apply(host: Host): Promise { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos + this.toRemove.length); + + // TODO: throw error if toRemove doesn't match removed string. + return host.write(this.path, `${prefix}${suffix}`); + }); + } +} + +/** + * Will replace text from the source code. + */ +export class ReplaceChange implements Change { + order: number; + description: string; + + constructor(public path: string, private pos: number, private oldText: string, + private newText: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`; + this.order = pos; + } + + apply(host: Host): Promise { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos + this.oldText.length); + const text = content.substring(this.pos, this.pos + this.oldText.length); + + if (text !== this.oldText) { + return Promise.reject(new Error(`Invalid replace: "${text}" != "${this.oldText}".`)); + } + + // TODO: throw error if oldText doesn't match removed string. + return host.write(this.path, `${prefix}${this.newText}${suffix}`); + }); + } +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/config.ts b/npm/ng-packs/packages/schematics/src/utils/angular/config.ts new file mode 100644 index 0000000000..ce0d15320b --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/config.ts @@ -0,0 +1,532 @@ +/** + * @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 { JsonParseMode, parseJson } from '@angular-devkit/core'; +import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; +import { ProjectType, WorkspaceProject, WorkspaceSchema } from './workspace-models'; + +// The interfaces below are generated from the Angular CLI configuration schema +// https://github.com/angular/angular-cli/blob/master/packages/@angular/cli/lib/config/schema.json +export interface AppConfig { + /** + * Name of the app. + */ + name?: string; + /** + * Directory where app files are placed. + */ + appRoot?: string; + /** + * The root directory of the app. + */ + root?: string; + /** + * The output directory for build results. + */ + outDir?: string; + /** + * List of application assets. + */ + assets?: (string | { + /** + * The pattern to match. + */ + glob?: string; + /** + * The dir to search within. + */ + input?: string; + /** + * The output path (relative to the outDir). + */ + output?: string; + })[]; + /** + * URL where files will be deployed. + */ + deployUrl?: string; + /** + * Base url for the application being built. + */ + baseHref?: string; + /** + * The runtime platform of the app. + */ + platform?: ('browser' | 'server'); + /** + * The name of the start HTML file. + */ + index?: string; + /** + * The name of the main entry-point file. + */ + main?: string; + /** + * The name of the polyfills file. + */ + polyfills?: string; + /** + * The name of the test entry-point file. + */ + test?: string; + /** + * The name of the TypeScript configuration file. + */ + tsconfig?: string; + /** + * The name of the TypeScript configuration file for unit tests. + */ + testTsconfig?: string; + /** + * The prefix to apply to generated selectors. + */ + prefix?: string; + /** + * Experimental support for a service worker from @angular/service-worker. + */ + serviceWorker?: boolean; + /** + * Global styles to be included in the build. + */ + styles?: (string | { + input?: string; + [name: string]: any; // tslint:disable-line:no-any + })[]; + /** + * Options to pass to style preprocessors + */ + stylePreprocessorOptions?: { + /** + * Paths to include. Paths will be resolved to project root. + */ + includePaths?: string[]; + }; + /** + * Global scripts to be included in the build. + */ + scripts?: (string | { + input: string; + [name: string]: any; // tslint:disable-line:no-any + })[]; + /** + * Source file for environment config. + */ + environmentSource?: string; + /** + * Name and corresponding file for environment config. + */ + environments?: { + [name: string]: any; // tslint:disable-line:no-any + }; + appShell?: { + app: string; + route: string; + }; + budgets?: { + /** + * The type of budget + */ + type?: ('bundle' | 'initial' | 'allScript' | 'all' | 'anyScript' | 'any' | 'anyComponentStyle'); + /** + * The name of the bundle + */ + name?: string; + /** + * The baseline size for comparison. + */ + baseline?: string; + /** + * The maximum threshold for warning relative to the baseline. + */ + maximumWarning?: string; + /** + * The maximum threshold for error relative to the baseline. + */ + maximumError?: string; + /** + * The minimum threshold for warning relative to the baseline. + */ + minimumWarning?: string; + /** + * The minimum threshold for error relative to the baseline. + */ + minimumError?: string; + /** + * The threshold for warning relative to the baseline (min & max). + */ + warning?: string; + /** + * The threshold for error relative to the baseline (min & max). + */ + error?: string; + }[]; +} + +export interface CliConfig { + $schema?: string; + /** + * The global configuration of the project. + */ + project?: { + /** + * The name of the project. + */ + name?: string; + /** + * Whether or not this project was ejected. + */ + ejected?: boolean; + }; + /** + * Properties of the different applications in this project. + */ + apps?: AppConfig[]; + /** + * Configuration for end-to-end tests. + */ + e2e?: { + protractor?: { + /** + * Path to the config file. + */ + config?: string; + }; + }; + /** + * Properties to be passed to TSLint. + */ + lint?: { + /** + * File glob(s) to lint. + */ + files?: (string | string[]); + /** + * Location of the tsconfig.json project file. + * Will also use as files to lint if 'files' property not present. + */ + project: string; + /** + * Location of the tslint.json configuration. + */ + tslintConfig?: string; + /** + * File glob(s) to ignore. + */ + exclude?: (string | string[]); + }[]; + /** + * Configuration for unit tests. + */ + test?: { + karma?: { + /** + * Path to the karma config file. + */ + config?: string; + }; + codeCoverage?: { + /** + * Globs to exclude from code coverage. + */ + exclude?: string[]; + }; + }; + /** + * Specify the default values for generating. + */ + defaults?: { + /** + * The file extension to be used for style files. + */ + styleExt?: string; + /** + * How often to check for file updates. + */ + poll?: number; + /** + * Use lint to fix files after generation + */ + lintFix?: boolean; + /** + * Options for generating a class. + */ + class?: { + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + }; + /** + * Options for generating a component. + */ + component?: { + /** + * Flag to indicate if a directory is created. + */ + flat?: boolean; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + /** + * Specifies if the style will be in the ts file. + */ + inlineStyle?: boolean; + /** + * Specifies if the template will be in the ts file. + */ + inlineTemplate?: boolean; + /** + * Specifies the view encapsulation strategy. + */ + viewEncapsulation?: ('Emulated' | 'Native' | 'None'); + /** + * Specifies the change detection strategy. + */ + changeDetection?: ('Default' | 'OnPush'); + }; + /** + * Options for generating a directive. + */ + directive?: { + /** + * Flag to indicate if a directory is created. + */ + flat?: boolean; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + }; + /** + * Options for generating a guard. + */ + guard?: { + /** + * Flag to indicate if a directory is created. + */ + flat?: boolean; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + }; + /** + * Options for generating an interface. + */ + interface?: { + /** + * Prefix to apply to interface names. (i.e. I) + */ + prefix?: string; + }; + /** + * Options for generating a module. + */ + module?: { + /** + * Flag to indicate if a directory is created. + */ + flat?: boolean; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + }; + /** + * Options for generating a pipe. + */ + pipe?: { + /** + * Flag to indicate if a directory is created. + */ + flat?: boolean; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + }; + /** + * Options for generating a service. + */ + service?: { + /** + * Flag to indicate if a directory is created. + */ + flat?: boolean; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + }; + /** + * Properties to be passed to the build command. + */ + build?: { + /** + * Output sourcemaps. + */ + sourcemaps?: boolean; + /** + * Base url for the application being built. + */ + baseHref?: string; + /** + * The ssl key used by the server. + */ + progress?: boolean; + /** + * Enable and define the file watching poll time period (milliseconds). + */ + poll?: number; + /** + * Delete output path before build. + */ + deleteOutputPath?: boolean; + /** + * Do not use the real path when resolving modules. + */ + preserveSymlinks?: boolean; + /** + * Show circular dependency warnings on builds. + */ + showCircularDependencies?: boolean; + /** + * Use a separate bundle containing code used across multiple bundles. + */ + commonChunk?: boolean; + /** + * Use file name for lazy loaded chunks. + */ + namedChunks?: boolean; + }; + /** + * Properties to be passed to the serve command. + */ + serve?: { + /** + * The port the application will be served on. + */ + port?: number; + /** + * The host the application will be served on. + */ + host?: string; + /** + * Enables ssl for the application. + */ + ssl?: boolean; + /** + * The ssl key used by the server. + */ + sslKey?: string; + /** + * The ssl certificate used by the server. + */ + sslCert?: string; + /** + * Proxy configuration file. + */ + proxyConfig?: string; + }; + /** + * Properties about schematics. + */ + schematics?: { + /** + * The schematics collection to use. + */ + collection?: string; + /** + * The new app schematic. + */ + newApp?: string; + }; + }; + /** + * Specify which package manager tool to use. + */ + packageManager?: ('npm' | 'cnpm' | 'yarn' | 'default'); + /** + * Allow people to disable console warnings. + */ + warnings?: { + versionMismatch?: boolean; + }; +} + +export function getWorkspacePath(host: Tree): string { + const possibleFiles = [ '/angular.json', '/.angular.json' ]; + const path = possibleFiles.filter(path => host.exists(path))[0]; + + return path; +} + +export function getWorkspaceSchema(host: Tree): WorkspaceSchema { + const path = getWorkspacePath(host); + const configBuffer = host.read(path); + if (configBuffer === null) { + throw new SchematicsException(`Could not find (${path})`); + } + const content = configBuffer.toString(); + + return parseJson(content, JsonParseMode.Loose) as {} as WorkspaceSchema; +} + +export function addProjectToWorkspace( + workspace: WorkspaceSchema, + name: string, + project: WorkspaceProject, +): Rule { + return (_host: Tree, _context: SchematicContext) => { + + if (workspace.projects[name]) { + throw new Error(`Project '${name}' already exists in workspace.`); + } + + // Add project to workspace. + workspace.projects[name] = project; + + if (!workspace.defaultProject && Object.keys(workspace.projects).length === 1) { + // Make the new project the default one. + workspace.defaultProject = name; + } + + return updateWorkspaceSchema(workspace); + }; +} + +export function updateWorkspaceSchema(workspace: WorkspaceSchema): Rule { + return (host: Tree, _context: SchematicContext) => { + host.overwrite(getWorkspacePath(host), JSON.stringify(workspace, null, 2)); + }; +} + +export const configPath = '/.angular-cli.json'; + +export function getConfig(host: Tree): CliConfig { + const configBuffer = host.read(configPath); + if (configBuffer === null) { + throw new SchematicsException('Could not find .angular-cli.json'); + } + + const config = parseJson(configBuffer.toString(), JsonParseMode.Loose) as {} as CliConfig; + + return config; +} + +export function getAppFromConfig(config: CliConfig, appIndexOrName: string): AppConfig | null { + if (!config.apps) { + return null; + } + + if (parseInt(appIndexOrName) >= 0) { + return config.apps[parseInt(appIndexOrName)]; + } + + return config.apps.filter((app) => app.name === appIndexOrName)[0]; +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/dependencies.ts b/npm/ng-packs/packages/schematics/src/utils/angular/dependencies.ts new file mode 100644 index 0000000000..76a60f53e6 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/dependencies.ts @@ -0,0 +1,76 @@ +/** + * @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 { Tree } from '@angular-devkit/schematics'; +import { JSONFile } from './json-file'; + +const PKG_JSON_PATH = '/package.json'; +export enum NodeDependencyType { + Default = 'dependencies', + Dev = 'devDependencies', + Peer = 'peerDependencies', + Optional = 'optionalDependencies', +} + +export interface NodeDependency { + type: NodeDependencyType; + name: string; + version: string; + overwrite?: boolean; +} + +const ALL_DEPENDENCY_TYPE = [ + NodeDependencyType.Default, + NodeDependencyType.Dev, + NodeDependencyType.Optional, + NodeDependencyType.Peer, +]; + +export function addPackageJsonDependency(tree: Tree, dependency: NodeDependency, pkgJsonPath = PKG_JSON_PATH): void { + const json = new JSONFile(tree, pkgJsonPath); + if (json.error) { + throw json.error; + } + + const { overwrite, type, name, version } = dependency; + const path = [type, name]; + if (overwrite || !json.get(path)) { + json.modify(path, version); + } +} + +export function removePackageJsonDependency(tree: Tree, name: string, pkgJsonPath = PKG_JSON_PATH): void { + const json = new JSONFile(tree, pkgJsonPath); + if (json.error) { + throw json.error; + } + + for (const depType of ALL_DEPENDENCY_TYPE) { + json.remove([depType, name]); + } +} + +export function getPackageJsonDependency(tree: Tree, name: string, pkgJsonPath = PKG_JSON_PATH): NodeDependency | null { + const json = new JSONFile(tree, pkgJsonPath); + if (json.error) { + throw json.error; + } + + for (const depType of ALL_DEPENDENCY_TYPE) { + const version = json.get([depType, name]); + + if (typeof version === 'string') { + return { + type: depType, + name: name, + version, + }; + } + } + + return null; +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/find-module.ts b/npm/ng-packs/packages/schematics/src/utils/angular/find-module.ts new file mode 100644 index 0000000000..cf2a50379a --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/find-module.ts @@ -0,0 +1,151 @@ +/** + * @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 { + dirname, + join, + normalize, NormalizedRoot, + Path, + relative +} from '@angular-devkit/core'; +import { DirEntry, Tree } from '@angular-devkit/schematics'; + + +export interface ModuleOptions { + project?: string; // added this + module?: string; + name: string; + flat?: boolean; + path?: string; + route?: string; // added this + selector?: string; // added this + skipImport?: boolean; + moduleExt?: string; + routingModuleExt?: string; +} + +export const MODULE_EXT = '.module.ts'; +export const ROUTING_MODULE_EXT = '-routing.module.ts'; + +/** + * Find the module referred by a set of options passed to the schematics. + */ +export function findModuleFromOptions(host: Tree, options: ModuleOptions): Path | undefined { + if (options.hasOwnProperty('skipImport') && options.skipImport) { + return undefined; + } + + const moduleExt = options.moduleExt || MODULE_EXT; + const routingModuleExt = options.routingModuleExt || ROUTING_MODULE_EXT; + + if (!options.module) { + const pathToCheck = (options.path || '') + '/' + options.name; + + return normalize(findModule(host, pathToCheck, moduleExt, routingModuleExt)); + } else { + const modulePath = normalize(`/${options.path}/${options.module}`); + const componentPath = normalize(`/${options.path}/${options.name}`); + const moduleBaseName = normalize(modulePath).split('/').pop(); + + const candidateSet = new Set([ + normalize(options.path || '/'), + ]); + + for (let dir = modulePath; dir != NormalizedRoot; dir = dirname(dir)) { + candidateSet.add(dir); + } + for (let dir = componentPath; dir != NormalizedRoot; dir = dirname(dir)) { + candidateSet.add(dir); + } + + const candidatesDirs = [...candidateSet].sort((a, b) => b.length - a.length); + for (const c of candidatesDirs) { + const candidateFiles = [ + '', + `${moduleBaseName}.ts`, + `${moduleBaseName}${moduleExt}`, + ].map(x => join(c, x)); + + for (const sc of candidateFiles) { + if (host.exists(sc)) { + return normalize(sc); + } + } + } + + throw new Error( + `Specified module '${options.module}' does not exist.\n` + + `Looked in the following directories:\n ${candidatesDirs.join('\n ')}`, + ); + } +} + +/** + * Function to find the "closest" module to a generated file's path. + */ +export function findModule(host: Tree, generateDir: string, + moduleExt = MODULE_EXT, routingModuleExt = ROUTING_MODULE_EXT): Path { + + let dir: DirEntry | null = host.getDir('/' + generateDir); + let foundRoutingModule = false; + + while (dir) { + const allMatches = dir.subfiles.filter(p => p.endsWith(moduleExt)); + const filteredMatches = allMatches.filter(p => !p.endsWith(routingModuleExt)); + + foundRoutingModule = foundRoutingModule || allMatches.length !== filteredMatches.length; + + if (filteredMatches.length == 1) { + return join(dir.path, filteredMatches[0]); + } else if (filteredMatches.length > 1) { + throw new Error( + 'More than one module matches. Use the skip-import option to skip importing ' + + 'the component into the closest module or use the module option to specify a module.'); + } + + dir = dir.parent; + } + + const errorMsg = foundRoutingModule ? 'Could not find a non Routing NgModule.' + + `\nModules with suffix '${routingModuleExt}' are strictly reserved for routing.` + + '\nUse the skip-import option to skip importing in NgModule.' + : 'Could not find an NgModule. Use the skip-import option to skip importing in NgModule.'; + + throw new Error(errorMsg); +} + +/** + * Build a relative path from one file path to another file path. + */ +export function buildRelativePath(from: string, to: string): string { + from = normalize(from); + to = normalize(to); + + // Convert to arrays. + const fromParts = from.split('/'); + const toParts = to.split('/'); + + // Remove file names (preserving destination) + fromParts.pop(); + const toFileName = toParts.pop(); + + const relativePath = relative(normalize(fromParts.join('/') || '/'), + normalize(toParts.join('/') || '/')); + let pathPrefix = ''; + + // Set the path prefix for same dir or child dir, parent dir starts with `..` + if (!relativePath) { + pathPrefix = '.'; + } else if (!relativePath.startsWith('.')) { + pathPrefix = `./`; + } + if (pathPrefix && !pathPrefix.endsWith('/')) { + pathPrefix += '/'; + } + + return pathPrefix + (relativePath ? relativePath + '/' : '') + toFileName; +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/index.ts b/npm/ng-packs/packages/schematics/src/utils/angular/index.ts new file mode 100644 index 0000000000..fec78af448 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/index.ts @@ -0,0 +1,17 @@ +export * from './ast-utils'; +export * from './change'; +export * from './config'; +export * from './dependencies'; +export * from './find-module'; +export * from './json-file'; +export * from './json-utils'; +export * from './latest-versions'; +export * from './lint-fix'; +export * from './ng-ast-utils'; +export * from './parse-name'; +export * from './paths'; +export * from './project-targets'; +export * from './tsconfig'; +export * from './validation'; +export * from './workspace'; +export * from './workspace-models'; diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/json-file.ts b/npm/ng-packs/packages/schematics/src/utils/angular/json-file.ts new file mode 100644 index 0000000000..1832f38c5c --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/json-file.ts @@ -0,0 +1,82 @@ +/** + * @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 { JsonValue } from '@angular-devkit/core'; +import { Tree } from '@angular-devkit/schematics'; +import { Node, applyEdits, findNodeAtLocation, getNodeValue, modify, parseTree } from 'jsonc-parser'; + +export type JSONPath = (string | number)[]; + +/** @internal */ +export class JSONFile { + private content: string; + error: undefined | Error; + + constructor( + private readonly host: Tree, + private readonly path: string, + ) { + const buffer = this.host.read(this.path); + if (buffer) { + this.content = buffer.toString(); + } else { + this.error = new Error(`Could not read ${path}.`); + } + } + + private _jsonAst: Node | undefined; + private get JsonAst(): Node { + if (this._jsonAst) { + return this._jsonAst; + } + + this._jsonAst = parseTree(this.content); + + return this._jsonAst; + } + + get(jsonPath: JSONPath): unknown { + if (jsonPath.length === 0) { + return getNodeValue(this.JsonAst); + } + + const node = findNodeAtLocation(this.JsonAst, jsonPath); + + return node === undefined ? undefined : getNodeValue(node); + } + + modify(jsonPath: JSONPath, value: JsonValue | undefined, getInsertionIndex?: (properties: string[]) => number): void { + if (!getInsertionIndex) { + const property = jsonPath.slice(-1)[0]; + getInsertionIndex = properties => [...properties, property].sort().findIndex(p => p === property); + } + + const edits = modify( + this.content, + jsonPath, + value, + { + getInsertionIndex, + formattingOptions: { + insertSpaces: true, + tabSize: 2, + }, + }, + ); + + this.content = applyEdits(this.content, edits); + this.host.overwrite(this.path, this.content); + this._jsonAst = undefined; + } + + remove(jsonPath: JSONPath): void { + if (this.get(jsonPath) !== undefined) { + this.modify(jsonPath, undefined); + } + } +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/json-utils.ts b/npm/ng-packs/packages/schematics/src/utils/angular/json-utils.ts new file mode 100644 index 0000000000..cf1754a9a4 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/json-utils.ts @@ -0,0 +1,231 @@ +/** + * @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 { + JsonAstArray, + JsonAstKeyValue, + JsonAstNode, + JsonAstObject, + JsonValue, +} from '@angular-devkit/core'; +import { UpdateRecorder } from '@angular-devkit/schematics'; + +export function appendPropertyInAstObject( + recorder: UpdateRecorder, + node: JsonAstObject, + propertyName: string, + value: JsonValue, + indent: number, +) { + const indentStr = _buildIndent(indent); + let index = node.start.offset + 1; + if (node.properties.length > 0) { + // Insert comma. + const last = node.properties[node.properties.length - 1]; + const { text, end } = last; + const commaIndex = text.endsWith('\n') ? end.offset - 1 : end.offset; + recorder.insertRight(commaIndex, ','); + index = end.offset; + } + const content = _stringifyContent(value, indentStr); + recorder.insertRight( + index, + (node.properties.length === 0 && indent ? '\n' : '') + + ' '.repeat(indent) + + `"${propertyName}":${indent ? ' ' : ''}${content}` + + indentStr.slice(0, -indent), + ); +} + +export function insertPropertyInAstObjectInOrder( + recorder: UpdateRecorder, + node: JsonAstObject, + propertyName: string, + value: JsonValue, + indent: number, +) { + + if (node.properties.length === 0) { + appendPropertyInAstObject(recorder, node, propertyName, value, indent); + + return; + } + + // Find insertion info. + let insertAfterProp: JsonAstKeyValue | null = null; + let prev: JsonAstKeyValue | null = null; + let isLastProp = false; + const last = node.properties[node.properties.length - 1]; + for (const prop of node.properties) { + if (prop.key.value > propertyName) { + if (prev) { + insertAfterProp = prev; + } + break; + } + if (prop === last) { + isLastProp = true; + insertAfterProp = last; + } + prev = prop; + } + + if (isLastProp) { + appendPropertyInAstObject(recorder, node, propertyName, value, indent); + + return; + } + + const indentStr = _buildIndent(indent); + const insertIndex = insertAfterProp === null + ? node.start.offset + 1 + : insertAfterProp.end.offset + 1; + const content = _stringifyContent(value, indentStr); + recorder.insertRight( + insertIndex, + indentStr + + `"${propertyName}":${indent ? ' ' : ''}${content}` + + ',', + ); +} + +export function removePropertyInAstObject( + recorder: UpdateRecorder, + node: JsonAstObject, + propertyName: string, +) { + // Find the property inside the object. + const propIdx = node.properties.findIndex(prop => prop.key.value === propertyName); + + if (propIdx === -1) { + // There's nothing to remove. + return; + } + + if (node.properties.length === 1) { + // This is a special case. Everything should be removed, including indentation. + recorder.remove(node.start.offset, node.end.offset - node.start.offset); + recorder.insertRight(node.start.offset, '{}'); + + return; + } + + // The AST considers commas and indentation to be part of the preceding property. + // To get around messy comma and identation management, we can work over the range between + // two properties instead. + const previousProp = node.properties[propIdx - 1]; + const targetProp = node.properties[propIdx]; + const nextProp = node.properties[propIdx + 1]; + + let start, end; + if (previousProp) { + // Given the object below, and intending to remove the `m` property: + // "{\n \"a\": \"a\",\n \"m\": \"m\",\n \"z\": \"z\"\n}" + // ^---------------^ + // Removing the range above results in: + // "{\n \"a\": \"a\",\n \"z\": \"z\"\n}" + start = previousProp.end; + end = targetProp.end; + } else { + // If there's no previousProp there is a nextProp, since we've specialcased the 1 length case. + // Given the object below, and intending to remove the `a` property: + // "{\n \"a\": \"a\",\n \"m\": \"m\",\n \"z\": \"z\"\n}" + // ^---------------^ + // Removing the range above results in: + // "{\n \"m\": \"m\",\n \"z\": \"z\"\n}" + start = targetProp.start; + end = nextProp.start; + } + + recorder.remove(start.offset, end.offset - start.offset); + if (!nextProp) { + recorder.insertRight(start.offset, '\n'); + } +} + + +export function appendValueInAstArray( + recorder: UpdateRecorder, + node: JsonAstArray, + value: JsonValue, + indent = 4, +) { + let indentStr = _buildIndent(indent); + let index = node.start.offset + 1; + // tslint:disable-next-line: no-any + let newNodes: any[] | undefined; + + if (node.elements.length > 0) { + // Insert comma. + const { end } = node.elements[node.elements.length - 1]; + const isClosingOnSameLine = node.end.offset - end.offset === 1; + + if (isClosingOnSameLine && indent) { + // Reformat the entire array + recorder.remove(node.start.offset, node.end.offset - node.start.offset); + newNodes = [ + ...node.elements.map(({ value }) => value), + value, + ]; + index = node.start.offset; + // In case we are generating the entire node we need to reduce the spacing as + // otherwise we'd end up having incorrect double spacing + indent = indent - 2; + indentStr = _buildIndent(indent); + } else { + recorder.insertRight(end.offset, ','); + index = end.offset; + } + } + + recorder.insertRight( + index, + (newNodes ? '' : indentStr) + + _stringifyContent(newNodes || value, indentStr) + + (node.elements.length === 0 && indent ? indentStr.substr(0, -indent) + '\n' : ''), + ); +} + + +export function findPropertyInAstObject( + node: JsonAstObject, + propertyName: string, +): JsonAstNode | null { + let maybeNode: JsonAstNode | null = null; + for (const property of node.properties) { + if (property.key.value == propertyName) { + maybeNode = property.value; + } + } + + return maybeNode; +} + +function _buildIndent(count: number): string { + return count ? '\n' + ' '.repeat(count) : ''; +} + +function _stringifyContent(value: JsonValue, indentStr: string): string { + // TODO: Add snapshot tests + + // The 'space' value is 2, because we want to add 2 additional + // indents from the 'key' node. + + // If we use the indent provided we will have double indents: + // "budgets": [ + // { + // "type": "initial", + // "maximumWarning": "2mb", + // "maximumError": "5mb" + // }, + // { + // "type": "anyComponentStyle", + // 'maximumWarning": "5kb" + // } + // ] + return JSON.stringify(value, null, 2).replace(/\n/g, indentStr); +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions.ts b/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions.ts new file mode 100644 index 0000000000..bead0e6282 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/latest-versions.ts @@ -0,0 +1,26 @@ +/** + * @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 + */ + +export const latestVersions = { + // These versions should be kept up to date with latest Angular peer dependencies. + Angular: '~10.0.0-rc.0', + RxJs: '~6.6.0', + ZoneJs: '~0.10.2', + TypeScript: '~3.9.5', + TsLib: '^2.0.0', + + // The versions below must be manually updated when making a new devkit release. + // For our e2e tests, these versions must match the latest tag present on the branch. + // During RC periods they will not match the latest RC until there's a new git tag, and + // should not be updated. + DevkitBuildAngular: '~0.1000.0-rc.0', + DevkitBuildNgPackagr: '~0.1000.0-rc.0', + DevkitBuildWebpack: '~0.1000.0-rc.0', + + ngPackagr: '^10.0.0', +}; diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/lint-fix.ts b/npm/ng-packs/packages/schematics/src/utils/angular/lint-fix.ts new file mode 100644 index 0000000000..f794bf681f --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/lint-fix.ts @@ -0,0 +1,51 @@ +/** + * @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 { + DirEntry, + Rule, + SchematicContext, + SchematicsException, + Tree, +} from '@angular-devkit/schematics'; +import { TslintFixTask } from '@angular-devkit/schematics/tasks'; + +export function applyLintFix(path = '/'): Rule { + return (tree: Tree, context: SchematicContext) => { + // Find the closest tslint.json or tslint.yaml + let dir: DirEntry | null = tree.getDir(path.substr(0, path.lastIndexOf('/'))); + + do { + if ((dir.subfiles as string[]).some(f => f === 'tslint.json' || f === 'tslint.yaml')) { + break; + } + + dir = dir.parent; + } while (dir !== null); + + if (dir === null) { + throw new SchematicsException( + 'Asked to run lint fixes, but could not find a tslint.json or tslint.yaml config file.'); + } + + // Only include files that have been touched. + const files = tree.actions.reduce((acc: Set, action) => { + const path = action.path.substr(1); // Remove the starting '/'. + if (path.endsWith('.ts') && dir && action.path.startsWith(dir.path)) { + acc.add(path); + } + + return acc; + }, new Set()); + + context.addTask(new TslintFixTask({ + ignoreErrors: true, + tsConfigPath: 'tsconfig.json', + files: [...files], + })); + }; +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/ng-ast-utils.ts b/npm/ng-packs/packages/schematics/src/utils/angular/ng-ast-utils.ts new file mode 100644 index 0000000000..2c48c9b8bd --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/ng-ast-utils.ts @@ -0,0 +1,87 @@ +/** + * @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 { normalize } from '@angular-devkit/core'; +import { SchematicsException, Tree } from '@angular-devkit/schematics'; +import { dirname } from 'path'; +import * as ts from 'typescript'; +import { findNode, getSourceNodes } from './ast-utils'; + +export function findBootstrapModuleCall(host: Tree, mainPath: string): ts.CallExpression | null { + const mainBuffer = host.read(mainPath); + if (!mainBuffer) { + throw new SchematicsException(`Main file (${mainPath}) not found`); + } + const mainText = mainBuffer.toString('utf-8'); + const source = ts.createSourceFile(mainPath, mainText, ts.ScriptTarget.Latest, true); + + const allNodes = getSourceNodes(source); + + let bootstrapCall: ts.CallExpression | null = null; + + for (const node of allNodes) { + let bootstrapCallNode: ts.Node | null = null; + bootstrapCallNode = findNode(node, ts.SyntaxKind.Identifier, 'bootstrapModule'); + + // Walk up the parent until CallExpression is found. + while ( + bootstrapCallNode && + bootstrapCallNode.parent && + bootstrapCallNode.parent.kind !== ts.SyntaxKind.CallExpression + ) { + bootstrapCallNode = bootstrapCallNode.parent; + } + + if ( + bootstrapCallNode !== null && + bootstrapCallNode.parent !== undefined && + bootstrapCallNode.parent.kind === ts.SyntaxKind.CallExpression + ) { + bootstrapCall = bootstrapCallNode.parent as ts.CallExpression; + break; + } + } + + return bootstrapCall; +} + +export function findBootstrapModulePath(host: Tree, mainPath: string): string { + const bootstrapCall = findBootstrapModuleCall(host, mainPath); + if (!bootstrapCall) { + throw new SchematicsException('Bootstrap call not found'); + } + + const bootstrapModule = bootstrapCall.arguments[0]; + + const mainBuffer = host.read(mainPath); + if (!mainBuffer) { + throw new SchematicsException(`Client app main file (${mainPath}) not found`); + } + const mainText = mainBuffer.toString('utf-8'); + const source = ts.createSourceFile(mainPath, mainText, ts.ScriptTarget.Latest, true); + const allNodes = getSourceNodes(source); + const bootstrapModuleRelativePath = allNodes + .filter(node => node.kind === ts.SyntaxKind.ImportDeclaration) + .filter(imp => { + return findNode(imp, ts.SyntaxKind.Identifier, bootstrapModule.getText()); + }) + .map((imp: ts.ImportDeclaration) => { + const modulePathStringLiteral = imp.moduleSpecifier as ts.StringLiteral; + + return modulePathStringLiteral.text; + })[0]; + + return bootstrapModuleRelativePath; +} + +export function getAppModulePath(host: Tree, mainPath: string): string { + const moduleRelativePath = findBootstrapModulePath(host, mainPath); + const mainDir = dirname(mainPath); + const modulePath = normalize(`/${mainDir}/${moduleRelativePath}.ts`); + + return modulePath; +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/parse-name.ts b/npm/ng-packs/packages/schematics/src/utils/angular/parse-name.ts new file mode 100644 index 0000000000..cac57b0868 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/parse-name.ts @@ -0,0 +1,25 @@ + +/** + * @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 { relative, Path } from "../../../angular_devkit/core/src/virtual-fs"; +import { Path, basename, dirname, join, normalize } from '@angular-devkit/core'; + +export interface Location { + name: string; + path: Path; +} + +export function parseName(path: string, name: string): Location { + const nameWithoutPath = basename(normalize(name)); + const namePath = dirname(join(normalize(path), name) as Path); + + return { + name: nameWithoutPath, + path: normalize('/' + namePath), + }; +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/paths.ts b/npm/ng-packs/packages/schematics/src/utils/angular/paths.ts new file mode 100644 index 0000000000..35729a6417 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/paths.ts @@ -0,0 +1,19 @@ +/** + * @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 { normalize, split } from '@angular-devkit/core'; + +export function relativePathToWorkspaceRoot(projectRoot: string | undefined): string { + const normalizedPath = split(normalize(projectRoot || '')); + + if (normalizedPath.length === 0 || !normalizedPath[0]) { + return '.'; + } else { + return normalizedPath.map(() => '..').join('/'); + } +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/project-targets.ts b/npm/ng-packs/packages/schematics/src/utils/angular/project-targets.ts new file mode 100644 index 0000000000..e99293f10e --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/project-targets.ts @@ -0,0 +1,13 @@ +/** + * @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 { SchematicsException } from '@angular-devkit/schematics'; + +export function targetBuildNotFoundError(): SchematicsException { + return new SchematicsException(`Project target "build" not found.`); +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/tsconfig.ts b/npm/ng-packs/packages/schematics/src/utils/angular/tsconfig.ts new file mode 100644 index 0000000000..4d6679a1d8 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/tsconfig.ts @@ -0,0 +1,70 @@ +/** + * @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 { JsonParseMode, parseJsonAst } from '@angular-devkit/core'; +import { Rule, SchematicsException, Tree } from '@angular-devkit/schematics'; +import { appendValueInAstArray, findPropertyInAstObject } from './json-utils'; + +const SOLUTION_TSCONFIG_PATH = 'tsconfig.json'; + +/** + * Add project references in "Solution Style" tsconfig. + */ +export function addTsConfigProjectReferences(paths: string[]): Rule { + return (host, context) => { + const logger = context.logger; + + // We need to read after each write to avoid missing `,` when appending multiple items. + for (const path of paths) { + const source = host.read(SOLUTION_TSCONFIG_PATH); + if (!source) { + // Solution tsconfig doesn't exist. + logger.warn(`Cannot add reference '${path}' in '${SOLUTION_TSCONFIG_PATH}'. File doesn't exists.`); + + return; + } + + const jsonAst = parseJsonAst(source.toString(), JsonParseMode.Loose); + if (jsonAst?.kind !== 'object') { + // Invalid JSON + throw new SchematicsException(`Invalid JSON AST Object '${SOLUTION_TSCONFIG_PATH}'.`); + } + + // Solutions style tsconfig can contain 2 properties: + // - 'files' with a value of empty array + // - 'references' + const filesAst = findPropertyInAstObject(jsonAst, 'files'); + const referencesAst = findPropertyInAstObject(jsonAst, 'references'); + if ( + filesAst?.kind !== 'array' || + filesAst.elements.length !== 0 || + referencesAst?.kind !== 'array' + ) { + logger.warn(`Cannot add reference '${path}' in '${SOLUTION_TSCONFIG_PATH}'. It appears to be an invalid solution style tsconfig.`); + + return; + } + + // Append new paths + const recorder = host.beginUpdate(SOLUTION_TSCONFIG_PATH); + appendValueInAstArray(recorder, referencesAst, { 'path': `./${path}` }, 4); + host.commitUpdate(recorder); + } + }; +} + +/** + * Throws an exception when the base tsconfig doesn't exists. + */ +export function verifyBaseTsConfigExists(host: Tree): void { + if (host.exists('tsconfig.base.json')) { + return; + } + + throw new SchematicsException(`Cannot find base TypeScript configuration file 'tsconfig.base.json'.`); +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/validation.ts b/npm/ng-packs/packages/schematics/src/utils/angular/validation.ts new file mode 100644 index 0000000000..923b819df9 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/validation.ts @@ -0,0 +1,77 @@ +/** + * @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 { tags } from '@angular-devkit/core'; +import { SchematicsException } from '@angular-devkit/schematics'; + +export function validateName(name: string): void { + if (name && /^\d/.test(name)) { + throw new SchematicsException(tags.oneLine`name (${name}) + can not start with a digit.`); + } +} + +// Must start with a letter, and must contain only alphanumeric characters or dashes. +// When adding a dash the segment after the dash must also start with a letter. +export const htmlSelectorRe = /^[a-zA-Z][.0-9a-zA-Z]*(:?-[a-zA-Z][.0-9a-zA-Z]*)*$/; + +export function validateHtmlSelector(selector: string): void { + if (selector && !htmlSelectorRe.test(selector)) { + throw new SchematicsException(tags.oneLine`Selector (${selector}) + is invalid.`); + } +} + + +export function validateProjectName(projectName: string) { + const errorIndex = getRegExpFailPosition(projectName); + const unsupportedProjectNames: string[] = []; + const packageNameRegex = /^(?:@[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_-]+$/; + if (errorIndex !== null) { + const firstMessage = tags.oneLine` + Project name "${projectName}" is not valid. New project names must + start with a letter, and must contain only alphanumeric characters or dashes. + When adding a dash the segment after the dash must also start with a letter. + `; + const msg = tags.stripIndent` + ${firstMessage} + ${projectName} + ${Array(errorIndex + 1).join(' ') + '^'} + `; + throw new SchematicsException(msg); + } else if (unsupportedProjectNames.indexOf(projectName) !== -1) { + throw new SchematicsException( + `Project name ${JSON.stringify(projectName)} is not a supported name.`); + } else if (!packageNameRegex.test(projectName)) { + throw new SchematicsException(`Project name ${JSON.stringify(projectName)} is invalid.`); + } +} + +function getRegExpFailPosition(str: string): number | null { + const isScope = /^@.*\/.*/.test(str); + if (isScope) { + // Remove starting @ + str = str.replace(/^@/, ''); + // Change / to - for validation + str = str.replace(/\//g, '-'); + } + + const parts = str.indexOf('-') >= 0 ? str.split('-') : [str]; + const matched: string[] = []; + + const projectNameRegexp = /^[a-zA-Z][.0-9a-zA-Z]*(-[.0-9a-zA-Z]*)*$/; + + parts.forEach(part => { + if (part.match(projectNameRegexp)) { + matched.push(part); + } + }); + + const compare = matched.join('-'); + + return (str !== compare) ? compare.length : null; +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/workspace-models.ts b/npm/ng-packs/packages/schematics/src/utils/angular/workspace-models.ts new file mode 100644 index 0000000000..c03a857d43 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/workspace-models.ts @@ -0,0 +1,171 @@ +/** + * @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 { experimental } from '@angular-devkit/core'; + +export enum ProjectType { + Application = 'application', + Library = 'library', +} + +export enum Builders { + AppShell = '@angular-devkit/build-angular:app-shell', + Server = '@angular-devkit/build-angular:server', + Browser = '@angular-devkit/build-angular:browser', + Karma = '@angular-devkit/build-angular:karma', + TsLint = '@angular-devkit/build-angular:tslint', + NgPackagr = '@angular-devkit/build-ng-packagr:build', + DevServer = '@angular-devkit/build-angular:dev-server', + ExtractI18n = '@angular-devkit/build-angular:extract-i18n', + Protractor = '@angular-devkit/build-angular:protractor', +} + +export interface FileReplacements { + replace: string; + with: string; +} + +export interface BrowserBuilderBaseOptions { + main: string; + tsConfig: string; + fileReplacements?: FileReplacements[]; + outputPath?: string; + index?: string; + polyfills: string; + assets?: (object|string)[]; + styles?: (object|string)[]; + scripts?: (object|string)[]; + sourceMap?: boolean; +} + +export type OutputHashing = 'all' | 'media' | 'none' | 'bundles'; + +export interface BrowserBuilderOptions extends BrowserBuilderBaseOptions { + serviceWorker?: boolean; + optimization?: boolean; + outputHashing?: OutputHashing; + resourcesOutputPath?: string; + extractCss?: boolean; + namedChunks?: boolean; + aot?: boolean; + extractLicenses?: boolean; + vendorChunk?: boolean; + buildOptimizer?: boolean; + ngswConfigPath?: string; + budgets?: { + type: string; + maximumWarning?: string; + maximumError?: string; + }[]; + webWorkerTsConfig?: string; +} + +export interface ServeBuilderOptions { + browserTarget: string; +} +export interface LibraryBuilderOptions { + tsConfig: string; + project: string; +} + +export interface ServerBuilderOptions { + outputPath: string; + tsConfig: string; + main: string; + fileReplacements?: FileReplacements[]; + optimization?: { + scripts?: boolean; + styles?: boolean; + }; + sourceMap?: boolean | { + scripts?: boolean; + styles?: boolean; + hidden?: boolean; + vendor?: boolean; + }; +} + +export interface AppShellBuilderOptions { + browserTarget: string; + serverTarget: string; + route: string; +} + +export interface TestBuilderOptions extends Partial { + karmaConfig: string; +} + +export interface LintBuilderOptions { + tsConfig: string[] | string; + exclude?: string[]; +} + +export interface ExtractI18nOptions { + browserTarget: string; +} + +export interface E2EOptions { + protractorConfig: string; + devServerTarget: string; +} + +export interface BuilderTarget { + builder: TBuilder; + options: TOptions; + configurations?: { + production: Partial; + [key: string]: Partial; + }; +} + +export type LibraryBuilderTarget = BuilderTarget; +export type BrowserBuilderTarget = BuilderTarget; +export type ServerBuilderTarget = BuilderTarget; +export type AppShellBuilderTarget = BuilderTarget; +export type LintBuilderTarget = BuilderTarget; +export type TestBuilderTarget = BuilderTarget; +export type ServeBuilderTarget = BuilderTarget; +export type ExtractI18nBuilderTarget = BuilderTarget; +export type E2EBuilderTarget = BuilderTarget; + +export interface WorkspaceSchema extends experimental.workspace.WorkspaceSchema { + projects: { + [key: string]: WorkspaceProject; + }; +} + +export interface WorkspaceProject + extends experimental.workspace.WorkspaceProject { + /** + * Project type. + */ + projectType: ProjectType; + + /** + * Tool options. + */ + architect?: WorkspaceTargets; + /** + * Tool options. + */ + targets?: WorkspaceTargets; +} + +export interface WorkspaceTargets { + build?: TProjectType extends ProjectType.Library ? LibraryBuilderTarget : BrowserBuilderTarget; + server?: ServerBuilderTarget; + lint?: LintBuilderTarget; + test?: TestBuilderTarget; + serve?: ServeBuilderTarget; + e2e?: E2EBuilderTarget; + 'app-shell'?: AppShellBuilderTarget; + 'extract-i18n'?: ExtractI18nBuilderTarget; + // TODO(hans): change this any to unknown when google3 supports TypeScript 3.0. + // tslint:disable-next-line:no-any + [key: string]: any; +} diff --git a/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts b/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts new file mode 100644 index 0000000000..79dbfbb6ad --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/angular/workspace.ts @@ -0,0 +1,91 @@ +/** + * @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 { virtualFs, workspaces } from '@angular-devkit/core'; +import { Rule, Tree } from '@angular-devkit/schematics'; +import { ProjectType } from './workspace-models'; + +function createHost(tree: Tree): workspaces.WorkspaceHost { + return { + async readFile(path: string): Promise { + const data = tree.read(path); + if (!data) { + throw new Error('File not found.'); + } + + return virtualFs.fileBufferToString(data); + }, + async writeFile(path: string, data: string): Promise { + return tree.overwrite(path, data); + }, + async isDirectory(path: string): Promise { + // approximate a directory check + return !tree.exists(path) && tree.getDir(path).subfiles.length > 0; + }, + async isFile(path: string): Promise { + return tree.exists(path); + }, + }; +} + +export function updateWorkspace( + updater: (workspace: workspaces.WorkspaceDefinition) => void | PromiseLike, +): Rule; +export function updateWorkspace( + workspace: workspaces.WorkspaceDefinition, +): Rule; +export function updateWorkspace( + updaterOrWorkspace: workspaces.WorkspaceDefinition + | ((workspace: workspaces.WorkspaceDefinition) => void | PromiseLike), +): Rule { + return async (tree: Tree) => { + const host = createHost(tree); + + if (typeof updaterOrWorkspace === 'function') { + + const { workspace } = await workspaces.readWorkspace('/', host); + + const result = updaterOrWorkspace(workspace); + if (result !== undefined) { + await result; + } + + await workspaces.writeWorkspace(workspace, host); + } else { + await workspaces.writeWorkspace(updaterOrWorkspace, host); + } + }; +} + +export async function getWorkspace(tree: Tree, path = '/') { + const host = createHost(tree); + + const { workspace } = await workspaces.readWorkspace(path, host); + + return workspace; +} + +/** + * Build a default project path for generating. + * @param project The project which will have its default path generated. + */ +export function buildDefaultPath(project: workspaces.ProjectDefinition): string { + const root = project.sourceRoot ? `/${project.sourceRoot}/` : `/${project.root}/src/`; + const projectDirName = project.extensions['projectType'] === ProjectType.Application ? 'app' : 'lib'; + + return `${root}${projectDirName}`; +} + +export async function createDefaultPath(tree: Tree, projectName: string): Promise { + const workspace = await getWorkspace(tree); + const project = workspace.projects.get(projectName); + if (!project) { + throw new Error('Specified project does not exist.'); + } + + return buildDefaultPath(project); +} diff --git a/npm/ng-packs/packages/schematics/src/utils/index.ts b/npm/ng-packs/packages/schematics/src/utils/index.ts new file mode 100644 index 0000000000..f5419fa8d9 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/utils/index.ts @@ -0,0 +1 @@ +export * from './angular';