From a623e99845de417e2734b030770bb142a2c60cf8 Mon Sep 17 00:00:00 2001 From: timdeschryver <28659384+timdeschryver@users.noreply.github.com> Date: Mon, 29 Jun 2020 13:36:55 +0200 Subject: [PATCH 1/3] feat(store): add ng-add and ng-update schematics --- modules/component-store/migrations/BUILD | 31 + .../component-store/migrations/migration.json | 4 + modules/component-store/package.json | 15 +- .../component-store/schematics-core/index.ts | 92 +++ .../schematics-core/utility/angular-utils.ts | 50 ++ .../schematics-core/utility/ast-utils.ts | 745 ++++++++++++++++++ .../schematics-core/utility/change.ts | 187 +++++ .../schematics-core/utility/config.ts | 150 ++++ .../schematics-core/utility/find-module.ts | 140 ++++ .../schematics-core/utility/json-utilts.ts | 16 + .../schematics-core/utility/libs-version.ts | 1 + .../schematics-core/utility/ngrx-utils.ts | 270 +++++++ .../schematics-core/utility/package.ts | 27 + .../schematics-core/utility/parse-name.ts | 16 + .../schematics-core/utility/project.ts | 52 ++ .../schematics-core/utility/strings.ts | 147 ++++ .../schematics-core/utility/update.ts | 43 + .../schematics-core/utility/visitors.ts | 227 ++++++ modules/component-store/schematics/BUILD | 36 + .../schematics/collection.json | 10 + .../schematics/ng-add/index.spec.ts | 41 + .../schematics/ng-add/index.ts | 34 + .../schematics/ng-add/schema.json | 14 + .../schematics/ng-add/schema.ts | 3 + modules/data/package.json | 3 +- modules/effects/package.json | 3 +- modules/entity/package.json | 3 +- modules/router-store/package.json | 3 +- modules/schematics/package.json | 3 +- modules/store-devtools/package.json | 3 +- modules/store/package.json | 3 +- tsconfig.json | 3 + 32 files changed, 2367 insertions(+), 8 deletions(-) create mode 100644 modules/component-store/migrations/BUILD create mode 100644 modules/component-store/migrations/migration.json create mode 100644 modules/component-store/schematics-core/index.ts create mode 100644 modules/component-store/schematics-core/utility/angular-utils.ts create mode 100644 modules/component-store/schematics-core/utility/ast-utils.ts create mode 100644 modules/component-store/schematics-core/utility/change.ts create mode 100644 modules/component-store/schematics-core/utility/config.ts create mode 100644 modules/component-store/schematics-core/utility/find-module.ts create mode 100644 modules/component-store/schematics-core/utility/json-utilts.ts create mode 100644 modules/component-store/schematics-core/utility/libs-version.ts create mode 100644 modules/component-store/schematics-core/utility/ngrx-utils.ts create mode 100644 modules/component-store/schematics-core/utility/package.ts create mode 100644 modules/component-store/schematics-core/utility/parse-name.ts create mode 100644 modules/component-store/schematics-core/utility/project.ts create mode 100644 modules/component-store/schematics-core/utility/strings.ts create mode 100644 modules/component-store/schematics-core/utility/update.ts create mode 100644 modules/component-store/schematics-core/utility/visitors.ts create mode 100644 modules/component-store/schematics/BUILD create mode 100644 modules/component-store/schematics/collection.json create mode 100644 modules/component-store/schematics/ng-add/index.spec.ts create mode 100644 modules/component-store/schematics/ng-add/index.ts create mode 100644 modules/component-store/schematics/ng-add/schema.json create mode 100644 modules/component-store/schematics/ng-add/schema.ts diff --git a/modules/component-store/migrations/BUILD b/modules/component-store/migrations/BUILD new file mode 100644 index 0000000000..e7d3d63a72 --- /dev/null +++ b/modules/component-store/migrations/BUILD @@ -0,0 +1,31 @@ +load("//tools:defaults.bzl", "pkg_npm", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "migrations", + srcs = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/testing/*.ts", + "**/*.spec.ts", + ], + ), + module_name = "@ngrx/component-store/migrations", + deps = [ + "//modules/component-store/schematics-core", + "@npm//@angular-devkit/schematics", + ], +) + +pkg_npm( + name = "npm_package", + srcs = [ + ":migration.json", + ], + deps = [ + ":migrations", + ], +) diff --git a/modules/component-store/migrations/migration.json b/modules/component-store/migrations/migration.json new file mode 100644 index 0000000000..00b46c7ac6 --- /dev/null +++ b/modules/component-store/migrations/migration.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": {} +} diff --git a/modules/component-store/package.json b/modules/component-store/package.json index dc3029e731..7cbf6acbe6 100644 --- a/modules/component-store/package.json +++ b/modules/component-store/package.json @@ -25,5 +25,18 @@ "@angular/core": "^10.0.0", "rxjs": "^6.5.3" }, - "sideEffects": false + "sideEffects": false, + "ng-update": { + "packageGroup": [ + "@ngrx/store", + "@ngrx/effects", + "@ngrx/entity", + "@ngrx/router-store", + "@ngrx/data", + "@ngrx/schematics", + "@ngrx/store-devtools", + "@ngrx/component-store" + ], + "migrations": "./migrations/migration.json" + } } diff --git a/modules/component-store/schematics-core/index.ts b/modules/component-store/schematics-core/index.ts new file mode 100644 index 0000000000..327bb6460d --- /dev/null +++ b/modules/component-store/schematics-core/index.ts @@ -0,0 +1,92 @@ +import { + dasherize, + decamelize, + camelize, + classify, + underscore, + group, + capitalize, + featurePath, + pluralize, +} from './utility/strings'; + +export { isIvyEnabled } from './utility/angular-utils'; + +export { + findNodes, + getSourceNodes, + getDecoratorMetadata, + getContentOfKeyLiteral, + insertAfterLastOccurrence, + insertImport, + addBootstrapToModule, + addDeclarationToModule, + addExportToModule, + addImportToModule, + addProviderToModule, + replaceImport, + containsProperty, +} from './utility/ast-utils'; + +export { + Host, + Change, + NoopChange, + InsertChange, + RemoveChange, + ReplaceChange, + createReplaceChange, + createChangeRecorder, + commitChanges, +} from './utility/change'; + +export { AppConfig, getWorkspace, getWorkspacePath } from './utility/config'; + +export { + findModule, + findModuleFromOptions, + buildRelativePath, + ModuleOptions, +} from './utility/find-module'; + +export { findPropertyInAstObject } from './utility/json-utilts'; + +export { + addReducerToState, + addReducerToStateInterface, + addReducerImportToNgModule, + addReducerToActionReducerMap, + omit, +} from './utility/ngrx-utils'; + +export { getProjectPath, getProject, isLib } from './utility/project'; + +export const stringUtils = { + dasherize, + decamelize, + camelize, + classify, + underscore, + group, + capitalize, + featurePath, + pluralize, +}; + +export { updatePackage } from './utility/update'; + +export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; + +export { + visitTSSourceFiles, + visitNgModuleImports, + visitNgModuleExports, + visitComponents, + visitDecorator, + visitNgModules, + visitTemplates, +} from './utility/visitors'; diff --git a/modules/component-store/schematics-core/utility/angular-utils.ts b/modules/component-store/schematics-core/utility/angular-utils.ts new file mode 100644 index 0000000000..448eb24784 --- /dev/null +++ b/modules/component-store/schematics-core/utility/angular-utils.ts @@ -0,0 +1,50 @@ +import { + JsonParseMode, + dirname, + normalize, + parseJsonAst, + resolve, +} from '@angular-devkit/core'; +import { Tree } from '@angular-devkit/schematics'; +import { findPropertyInAstObject } from './json-utilts'; + +// https://github.com/angular/angular-cli/blob/master/packages/schematics/angular/migrations/update-9/utils.ts +export function isIvyEnabled(tree: Tree, tsConfigPath: string): boolean { + // In version 9, Ivy is turned on by default + // Ivy is opted out only when 'enableIvy' is set to false. + + const buffer = tree.read(tsConfigPath); + if (!buffer) { + return true; + } + + const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose); + + if (tsCfgAst.kind !== 'object') { + return true; + } + + const ngCompilerOptions = findPropertyInAstObject( + tsCfgAst, + 'angularCompilerOptions' + ); + if (ngCompilerOptions && ngCompilerOptions.kind === 'object') { + const enableIvy = findPropertyInAstObject(ngCompilerOptions, 'enableIvy'); + + if (enableIvy) { + return !!enableIvy.value; + } + } + + const configExtends = findPropertyInAstObject(tsCfgAst, 'extends'); + if (configExtends && configExtends.kind === 'string') { + const extendedTsConfigPath = resolve( + dirname(normalize(tsConfigPath)), + normalize(configExtends.value) + ); + + return isIvyEnabled(tree, extendedTsConfigPath); + } + + return true; +} diff --git a/modules/component-store/schematics-core/utility/ast-utils.ts b/modules/component-store/schematics-core/utility/ast-utils.ts new file mode 100644 index 0000000000..f3f805dbe6 --- /dev/null +++ b/modules/component-store/schematics-core/utility/ast-utils.ts @@ -0,0 +1,745 @@ +/* istanbul ignore file */ +/** + * @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, + createReplaceChange, + ReplaceChange, + RemoveChange, + createRemoveChange, +} from './change'; +import { Path } from '@angular-devkit/core'; + +/** + * 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. + * @return all nodes of kind, or [] if none is found + */ +export function findNodes( + node: ts.Node, + kind: ts.SyntaxKind, + max = Infinity +): ts.Node[] { + if (!node || max == 0) { + return []; + } + + const arr: ts.Node[] = []; + if (node.kind === kind) { + arr.push(node); + max--; + } + if (max > 0) { + for (const child of node.getChildren()) { + findNodes(child, kind, 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 {Observable<ts.Node>} An observable 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; +} + +/** + * 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.pos - second.pos; +} + +/** + * 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 = nodes.sort(nodesByPosition).pop(); + if (!lastItem) { + throw new Error(); + } + if (syntaxKind) { + 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.end : 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: { [name: string]: string } = findNodes( + source, + ts.SyntaxKind.ImportDeclaration + ) + .map((node) => + _angularImportsFromNode(node as ts.ImportDeclaration, source) + ) + .reduce( + ( + acc: { [name: string]: string }, + current: { [name: string]: string } + ) => { + 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.getFullText(source) == identifier && + angularImports[id.getFullText(source)] === 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).getText(source); + + 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 _addSymbolToNgModuleMetadata( + source: ts.SourceFile, + ngModulePath: string, + metadataField: string, + symbolName: string, + importPath: string +): 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: ts.ObjectLiteralElement[] = (node as ts.ObjectLiteralExpression).properties + .filter((prop) => prop.kind == ts.SyntaxKind.PropertyAssignment) + // Filter out every fields that's not "metadataField". Also handles string literals + // (but not expressions). + .filter((prop: any) => { + const name = prop.name; + switch (name.kind) { + case ts.SyntaxKind.Identifier: + return (name as ts.Identifier).getText(source) == metadataField; + case ts.SyntaxKind.StringLiteral: + return (name as ts.StringLiteral).text == metadataField; + } + + return false; + }); + + // 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.length > 0) { + toInsert = `,${matches[0]}${metadataField}: [${symbolName}]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + const newMetadataProperty = new InsertChange( + ngModulePath, + position, + toInsert + ); + const newMetadataImport = insertImport( + source, + ngModulePath, + symbolName.replace(/\..*$/, ''), + importPath + ); + + return [newMetadataProperty, newMetadataImport]; + } + + 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) { + console.log( + 'No app module found. Please add your new class to your component.' + ); + + return []; + } + + if (Array.isArray(node)) { + const nodeArray = (node as {}) as Array<ts.Node>; + const symbolsArray = nodeArray.map((node) => node.getText()); + if (symbolsArray.includes(symbolName)) { + return []; + } + + node = node[node.length - 1]; + + const effectsModule = nodeArray.find( + (node) => + (node.getText().includes('EffectsModule.forRoot') && + symbolName.includes('EffectsModule.forRoot')) || + (node.getText().includes('EffectsModule.forFeature') && + symbolName.includes('EffectsModule.forFeature')) + ); + + if (effectsModule && symbolName.includes('EffectsModule')) { + const effectsArgs = (effectsModule as any).arguments.shift(); + + if ( + effectsArgs && + effectsArgs.kind === ts.SyntaxKind.ArrayLiteralExpression + ) { + const effectsElements = (effectsArgs as ts.ArrayLiteralExpression) + .elements; + const [, effectsSymbol] = (<any>symbolName).match(/\[(.*)\]/); + + let epos; + if (effectsElements.length === 0) { + epos = effectsArgs.getStart() + 1; + return [new InsertChange(ngModulePath, epos, effectsSymbol)]; + } else { + const lastEffect = effectsElements[ + effectsElements.length - 1 + ] as ts.Expression; + epos = lastEffect.getEnd(); + // Get the indentation of the last element, if any. + const text: any = lastEffect.getFullText(source); + + let effectInsert: string; + if (text.match('^\r?\r?\n')) { + effectInsert = `,${text.match(/^\r?\n\s+/)[0]}${effectsSymbol}`; + } else { + effectInsert = `, ${effectsSymbol}`; + } + + return [new InsertChange(ngModulePath, epos, effectInsert)]; + } + } else { + return []; + } + } + } + + 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 = ` ${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); + if (text.match('^\r?\r?\n')) { + toInsert = `,${ + text.match(/^\r?\n\s+/)[0] + }${metadataField}: [${symbolName}]`; + } else { + toInsert = `, ${metadataField}: [${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}`; + } + } + const insert = new InsertChange(ngModulePath, position, toInsert); + const importInsert: Change = insertImport( + source, + ngModulePath, + symbolName.replace(/\..*$/, ''), + importPath + ); + + return [insert, importInsert]; +} + +/** + * 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 a declaration (component, pipe, directive) + * into NgModule declarations. It also imports the component. + */ +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 + ); +} + +/** + * 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((child) => child.kind === ts.SyntaxKind.StringLiteral) + .map((n) => (n as ts.StringLiteral).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.SyntaxKind.StringLiteral).filter( + (n) => n.getText() === '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 + ); +} + +export function replaceImport( + sourceFile: ts.SourceFile, + path: Path, + importFrom: string, + importAsIs: string, + importToBe: string +): (ReplaceChange | RemoveChange)[] { + const imports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter( + ({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile) === `'${importFrom}'` || + moduleSpecifier.getText(sourceFile) === `"${importFrom}"` + ); + + if (imports.length === 0) { + return []; + } + + const importText = (specifier: ts.ImportSpecifier) => { + if (specifier.name.text) { + return specifier.name.text; + } + + // if import is renamed + if (specifier.propertyName && specifier.propertyName.text) { + return specifier.propertyName.text; + } + + return ''; + }; + + const changes = imports.map((p) => { + const importSpecifiers = (p.importClause!.namedBindings! as ts.NamedImports) + .elements; + + const isAlreadyImported = importSpecifiers + .map(importText) + .includes(importToBe); + + const importChanges = importSpecifiers.map((specifier, index) => { + const text = importText(specifier); + + // import is not the one we're looking for, can be skipped + if (text !== importAsIs) { + return undefined; + } + + // identifier has not been imported, simply replace the old text with the new text + if (!isAlreadyImported) { + return createReplaceChange( + sourceFile, + specifier!, + importAsIs, + importToBe + ); + } + + const nextIdentifier = importSpecifiers[index + 1]; + // identifer is not the last, also clean up the comma + if (nextIdentifier) { + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + nextIdentifier.getStart(sourceFile) + ); + } + + // there are no imports following, just remove it + return createRemoveChange( + sourceFile, + specifier, + specifier.getStart(sourceFile), + specifier.getEnd() + ); + }); + + return importChanges.filter(Boolean) as (ReplaceChange | RemoveChange)[]; + }); + + return changes.reduce((imports, curr) => imports.concat(curr), []); +} + +export function containsProperty( + objectLiteral: ts.ObjectLiteralExpression, + propertyName: string +) { + return ( + objectLiteral && + objectLiteral.properties.some( + (prop) => + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === propertyName + ) + ); +} diff --git a/modules/component-store/schematics-core/utility/change.ts b/modules/component-store/schematics-core/utility/change.ts new file mode 100644 index 0000000000..3b8ed0cd2e --- /dev/null +++ b/modules/component-store/schematics-core/utility/change.ts @@ -0,0 +1,187 @@ +import * as ts from 'typescript'; +import { Tree, UpdateRecorder } from '@angular-devkit/schematics'; +import { Path } from '@angular-devkit/core'; + +/* istanbul ignore file */ +/** + * @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<void>; + read(path: string): Promise<string>; +} + +export interface Change { + apply(host: Host): Promise<void>; + + // 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, public pos: number, public end: number) { + if (pos < 0 || end < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Removed text in position ${pos} to ${end} of ${path}`; + this.order = pos; + } + + apply(host: Host): Promise<void> { + return host.read(this.path).then((content) => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.end); + + // 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, + public pos: number, + public oldText: string, + public 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<void> { + 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}`); + }); + } +} + +export function createReplaceChange( + sourceFile: ts.SourceFile, + node: ts.Node, + oldText: string, + newText: string +): ReplaceChange { + return new ReplaceChange( + sourceFile.fileName, + node.getStart(sourceFile), + oldText, + newText + ); +} + +export function createRemoveChange( + sourceFile: ts.SourceFile, + node: ts.Node, + from = node.getStart(sourceFile), + to = node.getEnd() +): RemoveChange { + return new RemoveChange(sourceFile.fileName, from, to); +} + +export function createChangeRecorder( + tree: Tree, + path: string, + changes: Change[] +): UpdateRecorder { + const recorder = tree.beginUpdate(path); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos, change.end - change.pos); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); + } + } + return recorder; +} + +export function commitChanges(tree: Tree, path: string, changes: Change[]) { + if (changes.length === 0) { + return false; + } + + const recorder = createChangeRecorder(tree, path, changes); + tree.commitUpdate(recorder); + return true; +} diff --git a/modules/component-store/schematics-core/utility/config.ts b/modules/component-store/schematics-core/utility/config.ts new file mode 100644 index 0000000000..38826d4aea --- /dev/null +++ b/modules/component-store/schematics-core/utility/config.ts @@ -0,0 +1,150 @@ +import { SchematicsException, Tree } from '@angular-devkit/schematics'; +import { experimental } from '@angular-devkit/core'; + +// 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; + }; +} + +export type WorkspaceSchema = experimental.workspace.WorkspaceSchema; + +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 getWorkspace(host: Tree): WorkspaceSchema { + const path = getWorkspacePath(host); + const configBuffer = host.read(path); + if (configBuffer === null) { + throw new SchematicsException(`Could not find (${path})`); + } + const config = configBuffer.toString(); + + return JSON.parse(config); +} diff --git a/modules/component-store/schematics-core/utility/find-module.ts b/modules/component-store/schematics-core/utility/find-module.ts new file mode 100644 index 0000000000..134a59f0e6 --- /dev/null +++ b/modules/component-store/schematics-core/utility/find-module.ts @@ -0,0 +1,140 @@ +/** + * @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 { + Path, + join, + normalize, + relative, + strings, + basename, + extname, + dirname, +} from '@angular-devkit/core'; +import { DirEntry, Tree } from '@angular-devkit/schematics'; + +export interface ModuleOptions { + module?: string; + name: string; + flat?: boolean; + path?: string; + skipImport?: boolean; +} + +/** + * 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; + } + + if (!options.module) { + const pathToCheck = + (options.path || '') + + (options.flat ? '' : '/' + strings.dasherize(options.name)); + + return normalize(findModule(host, pathToCheck)); + } else { + const modulePath = normalize('/' + options.path + '/' + options.module); + const moduleBaseName = normalize(modulePath).split('/').pop(); + + if (host.exists(modulePath)) { + return normalize(modulePath); + } else if (host.exists(modulePath + '.ts')) { + return normalize(modulePath + '.ts'); + } else if (host.exists(modulePath + '.module.ts')) { + return normalize(modulePath + '.module.ts'); + } else if (host.exists(modulePath + '/' + moduleBaseName + '.module.ts')) { + return normalize(modulePath + '/' + moduleBaseName + '.module.ts'); + } else { + throw new Error(`Specified module path ${modulePath} does not exist`); + } + } +} + +/** + * Function to find the "closest" module to a generated file's path. + */ +export function findModule(host: Tree, generateDir: string): Path { + let dir: DirEntry | null = host.getDir('/' + generateDir); + + const moduleRe = /\.module\.ts$/; + const routingModuleRe = /-routing\.module\.ts/; + + while (dir) { + const matches = dir.subfiles.filter( + (p) => moduleRe.test(p) && !routingModuleRe.test(p) + ); + + if (matches.length == 1) { + return join(dir.path, matches[0]); + } else if (matches.length > 1) { + throw new Error( + 'More than one module matches. Use skip-import option to skip importing ' + + 'the component into the closest module.' + ); + } + + dir = dir.parent; + } + + throw new Error( + 'Could not find an NgModule. Use the skip-import ' + + 'option to skip importing in NgModule.' + ); +} + +/** + * Build a relative path from one file path to another file path. + */ +export function buildRelativePath(from: string, to: string): string { + const { + path: fromPath, + filename: fromFileName, + directory: fromDirectory, + } = parsePath(from); + const { + path: toPath, + filename: toFileName, + directory: toDirectory, + } = parsePath(to); + const relativePath = relative(fromDirectory, toDirectory); + const fixedRelativePath = relativePath.startsWith('.') + ? relativePath + : `./${relativePath}`; + + return !toFileName || toFileName === 'index.ts' + ? fixedRelativePath + : `${ + fixedRelativePath.endsWith('/') + ? fixedRelativePath + : fixedRelativePath + '/' + }${convertToTypeScriptFileName(toFileName)}`; +} + +function parsePath(path: string) { + const pathNormalized = normalize(path) as Path; + const filename = extname(pathNormalized) ? basename(pathNormalized) : ''; + const directory = filename ? dirname(pathNormalized) : pathNormalized; + return { + path: pathNormalized, + filename, + directory, + }; +} +/** + * Strips the typescript extension and clears index filenames + * foo.ts -> foo + * index.ts -> empty + */ +function convertToTypeScriptFileName(filename: string | undefined) { + return filename ? filename.replace(/(\.ts)|(index\.ts)$/, '') : ''; +} diff --git a/modules/component-store/schematics-core/utility/json-utilts.ts b/modules/component-store/schematics-core/utility/json-utilts.ts new file mode 100644 index 0000000000..4edbbc9edc --- /dev/null +++ b/modules/component-store/schematics-core/utility/json-utilts.ts @@ -0,0 +1,16 @@ +import { JsonAstNode, JsonAstObject } from '@angular-devkit/core'; + +// https://github.com/angular/angular-cli/blob/master/packages/schematics/angular/utility/json-utils.ts +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; +} diff --git a/modules/component-store/schematics-core/utility/libs-version.ts b/modules/component-store/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..85a636749c --- /dev/null +++ b/modules/component-store/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^10.0.0-beta.0'; diff --git a/modules/component-store/schematics-core/utility/ngrx-utils.ts b/modules/component-store/schematics-core/utility/ngrx-utils.ts new file mode 100644 index 0000000000..9c96e5a862 --- /dev/null +++ b/modules/component-store/schematics-core/utility/ngrx-utils.ts @@ -0,0 +1,270 @@ +import * as ts from 'typescript'; +import * as stringUtils from './strings'; +import { InsertChange, Change, NoopChange } from './change'; +import { Tree, SchematicsException, Rule } from '@angular-devkit/schematics'; +import { normalize } from '@angular-devkit/core'; +import { buildRelativePath } from './find-module'; +import { addImportToModule, insertImport } from './ast-utils'; + +export function addReducerToState(options: any): Rule { + return (host: Tree) => { + if (!options.reducers) { + return host; + } + + const reducersPath = normalize(`/${options.path}/${options.reducers}`); + + if (!host.exists(reducersPath)) { + throw new Error(`Specified reducers path ${reducersPath} does not exist`); + } + + const text = host.read(reducersPath); + if (text === null) { + throw new SchematicsException(`File ${reducersPath} does not exist.`); + } + + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + reducersPath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const reducerPath = + `/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + (options.group ? 'reducers/' : '') + + stringUtils.dasherize(options.name) + + '.reducer'; + + const relativePath = buildRelativePath(reducersPath, reducerPath); + const reducerImport = insertImport( + source, + reducersPath, + `* as from${stringUtils.classify(options.name)}`, + relativePath, + true + ); + + const stateInterfaceInsert = addReducerToStateInterface( + source, + reducersPath, + options + ); + const reducerMapInsert = addReducerToActionReducerMap( + source, + reducersPath, + options + ); + + const changes = [reducerImport, stateInterfaceInsert, reducerMapInsert]; + const recorder = host.beginUpdate(reducersPath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +/** + * Insert the reducer into the first defined top level interface + */ +export function addReducerToStateInterface( + source: ts.SourceFile, + reducersPath: string, + options: { name: string; plural: boolean } +): Change { + const stateInterface = source.statements.find( + (stm) => stm.kind === ts.SyntaxKind.InterfaceDeclaration + ); + let node = stateInterface as ts.Statement; + + if (!node) { + return new NoopChange(); + } + + const state = options.plural + ? stringUtils.pluralize(options.name) + : stringUtils.camelize(options.name); + + const keyInsert = `[from${stringUtils.classify( + options.name + )}.${stringUtils.camelize(state)}FeatureKey]: from${stringUtils.classify( + options.name + )}.State;`; + const expr = node as any; + let position; + let toInsert; + + if (expr.members.length === 0) { + position = expr.getEnd() - 1; + toInsert = ` ${keyInsert}\n`; + } else { + node = expr.members[expr.members.length - 1]; + position = node.getEnd() + 1; + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n+(\s*)/); + + if (matches!.length > 0) { + toInsert = `${matches![1]}${keyInsert}\n`; + } else { + toInsert = `\n${keyInsert}`; + } + } + + return new InsertChange(reducersPath, position, toInsert); +} + +/** + * Insert the reducer into the ActionReducerMap + */ +export function addReducerToActionReducerMap( + source: ts.SourceFile, + reducersPath: string, + options: { name: string; plural: boolean } +): Change { + let initializer: any; + const actionReducerMap: any = source.statements + .filter((stm) => stm.kind === ts.SyntaxKind.VariableStatement) + .filter((stm: any) => !!stm.declarationList) + .map((stm: any) => { + const { + declarations, + }: { + declarations: ts.SyntaxKind.VariableDeclarationList[]; + } = stm.declarationList; + const variable: any = declarations.find( + (decl: any) => decl.kind === ts.SyntaxKind.VariableDeclaration + ); + const type = variable ? variable.type : {}; + + return { initializer: variable.initializer, type }; + }) + .filter((initWithType) => initWithType.type !== undefined) + .find(({ type }) => type.typeName.text === 'ActionReducerMap'); + + if (!actionReducerMap || !actionReducerMap.initializer) { + return new NoopChange(); + } + + let node = actionReducerMap.initializer; + + const state = options.plural + ? stringUtils.pluralize(options.name) + : stringUtils.camelize(options.name); + + const keyInsert = `[from${stringUtils.classify( + options.name + )}.${stringUtils.camelize(state)}FeatureKey]: from${stringUtils.classify( + options.name + )}.reducer,`; + const expr = node as any; + let position; + let toInsert; + + if (expr.properties.length === 0) { + position = expr.getEnd() - 1; + toInsert = ` ${keyInsert}\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd() + 1; + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n+(\s*)/); + + if (matches.length > 0) { + toInsert = `\n${matches![1]}${keyInsert}`; + } else { + toInsert = `\n${keyInsert}`; + } + } + + return new InsertChange(reducersPath, position, toInsert); +} + +/** + * Add reducer feature to NgModule + */ +export function addReducerImportToNgModule(options: any): Rule { + return (host: Tree) => { + if (!options.module) { + return host; + } + + const modulePath = options.module; + if (!host.exists(options.module)) { + throw new Error(`Specified module path ${modulePath} does not exist`); + } + + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const commonImports = [ + insertImport(source, modulePath, 'StoreModule', '@ngrx/store'), + ]; + + const reducerPath = + `/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + (options.group ? 'reducers/' : '') + + stringUtils.dasherize(options.name) + + '.reducer'; + const relativePath = buildRelativePath(modulePath, reducerPath); + const reducerImport = insertImport( + source, + modulePath, + `* as from${stringUtils.classify(options.name)}`, + relativePath, + true + ); + const state = options.plural + ? stringUtils.pluralize(options.name) + : stringUtils.camelize(options.name); + const [storeNgModuleImport] = addImportToModule( + source, + modulePath, + `StoreModule.forFeature(from${stringUtils.classify( + options.name + )}.${state}FeatureKey, from${stringUtils.classify( + options.name + )}.reducer)`, + relativePath + ); + const changes = [...commonImports, reducerImport, storeNgModuleImport]; + const recorder = host.beginUpdate(modulePath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +export function omit<T extends { [key: string]: any }>( + object: T, + keyToRemove: keyof T +): Partial<T> { + return Object.keys(object) + .filter((key) => key !== keyToRemove) + .reduce((result, key) => Object.assign(result, { [key]: object[key] }), {}); +} diff --git a/modules/component-store/schematics-core/utility/package.ts b/modules/component-store/schematics-core/utility/package.ts new file mode 100644 index 0000000000..ecbc74b463 --- /dev/null +++ b/modules/component-store/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/component-store/schematics-core/utility/parse-name.ts b/modules/component-store/schematics-core/utility/parse-name.ts new file mode 100644 index 0000000000..a48f56b8ca --- /dev/null +++ b/modules/component-store/schematics-core/utility/parse-name.ts @@ -0,0 +1,16 @@ +import { Path, basename, dirname, normalize } from '@angular-devkit/core'; + +export interface Location { + name: string; + path: Path; +} + +export function parseName(path: string, name: string): Location { + const nameWithoutPath = basename(name as Path); + const namePath = dirname((path + '/' + name) as Path); + + return { + name: nameWithoutPath, + path: normalize('/' + namePath), + }; +} diff --git a/modules/component-store/schematics-core/utility/project.ts b/modules/component-store/schematics-core/utility/project.ts new file mode 100644 index 0000000000..43145d20fd --- /dev/null +++ b/modules/component-store/schematics-core/utility/project.ts @@ -0,0 +1,52 @@ +import { getWorkspace } from './config'; +import { Tree } from '@angular-devkit/schematics'; + +export interface WorkspaceProject { + root: string; + projectType: string; +} + +export function getProject( + host: Tree, + options: { project?: string | undefined; path?: string | undefined } +): WorkspaceProject { + const workspace = getWorkspace(host); + + if (!options.project) { + options.project = + workspace.defaultProject !== undefined + ? workspace.defaultProject + : Object.keys(workspace.projects)[0]; + } + + return workspace.projects[options.project]; +} + +export function getProjectPath( + host: Tree, + options: { project?: string | undefined; path?: string | undefined } +) { + const project = getProject(host, options); + + if (project.root.substr(-1) === '/') { + project.root = project.root.substr(0, project.root.length - 1); + } + + if (options.path === undefined) { + const projectDirName = + project.projectType === 'application' ? 'app' : 'lib'; + + return `${project.root ? `/${project.root}` : ''}/src/${projectDirName}`; + } + + return options.path; +} + +export function isLib( + host: Tree, + options: { project?: string | undefined; path?: string | undefined } +) { + const project = getProject(host, options); + + return project.projectType === 'library'; +} diff --git a/modules/component-store/schematics-core/utility/strings.ts b/modules/component-store/schematics-core/utility/strings.ts new file mode 100644 index 0000000000..361698f1db --- /dev/null +++ b/modules/component-store/schematics-core/utility/strings.ts @@ -0,0 +1,147 @@ +/** + * @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 + */ +const STRING_DASHERIZE_REGEXP = /[ _]/g; +const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; +const STRING_CAMELIZE_REGEXP = /(-|_|\.|\s)+(.)?/g; +const STRING_UNDERSCORE_REGEXP_1 = /([a-z\d])([A-Z]+)/g; +const STRING_UNDERSCORE_REGEXP_2 = /-|\s+/g; + +/** + * Converts a camelized string into all lower case separated by underscores. + * + ```javascript + decamelize('innerHTML'); // 'inner_html' + decamelize('action_name'); // 'action_name' + decamelize('css-class-name'); // 'css-class-name' + decamelize('my favorite items'); // 'my favorite items' + ``` + */ +export function decamelize(str: string): string { + return str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase(); +} + +/** + Replaces underscores, spaces, or camelCase with dashes. + + ```javascript + dasherize('innerHTML'); // 'inner-html' + dasherize('action_name'); // 'action-name' + dasherize('css-class-name'); // 'css-class-name' + dasherize('my favorite items'); // 'my-favorite-items' + ``` + */ +export function dasherize(str?: string): string { + return decamelize(str || '').replace(STRING_DASHERIZE_REGEXP, '-'); +} + +/** + Returns the lowerCamelCase form of a string. + + ```javascript + camelize('innerHTML'); // 'innerHTML' + camelize('action_name'); // 'actionName' + camelize('css-class-name'); // 'cssClassName' + camelize('my favorite items'); // 'myFavoriteItems' + camelize('My Favorite Items'); // 'myFavoriteItems' + ``` + */ +export function camelize(str: string): string { + return str + .replace( + STRING_CAMELIZE_REGEXP, + (_match: string, _separator: string, chr: string) => { + return chr ? chr.toUpperCase() : ''; + } + ) + .replace(/^([A-Z])/, (match: string) => match.toLowerCase()); +} + +/** + Returns the UpperCamelCase form of a string. + + ```javascript + 'innerHTML'.classify(); // 'InnerHTML' + 'action_name'.classify(); // 'ActionName' + 'css-class-name'.classify(); // 'CssClassName' + 'my favorite items'.classify(); // 'MyFavoriteItems' + ``` + */ +export function classify(str: string): string { + return str + .split('.') + .map((part) => capitalize(camelize(part))) + .join('.'); +} + +/** + More general than decamelize. Returns the lower\_case\_and\_underscored + form of a string. + + ```javascript + 'innerHTML'.underscore(); // 'inner_html' + 'action_name'.underscore(); // 'action_name' + 'css-class-name'.underscore(); // 'css_class_name' + 'my favorite items'.underscore(); // 'my_favorite_items' + ``` + */ +export function underscore(str: string): string { + return str + .replace(STRING_UNDERSCORE_REGEXP_1, '$1_$2') + .replace(STRING_UNDERSCORE_REGEXP_2, '_') + .toLowerCase(); +} + +/** + Returns the Capitalized form of a string + + ```javascript + 'innerHTML'.capitalize() // 'InnerHTML' + 'action_name'.capitalize() // 'Action_name' + 'css-class-name'.capitalize() // 'Css-class-name' + 'my favorite items'.capitalize() // 'My favorite items' + ``` + */ +export function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.substr(1); +} + +/** + Returns the plural form of a string + + ```javascript + 'innerHTML'.pluralize() // 'innerHTMLs' + 'action_name'.pluralize() // 'actionNames' + 'css-class-name'.pluralize() // 'cssClassNames' + 'regex'.pluralize() // 'regexes' + 'user'.pluralize() // 'users' + ``` + */ +export function pluralize(str: string): string { + return camelize( + [/([^aeiou])y$/, /()fe?$/, /([^aeiou]o|[sxz]|[cs]h)$/].map( + (c, i) => (str = str.replace(c, `$1${'iv'[i] || ''}e`)) + ) && str + 's' + ); +} + +export function group(name: string, group: string | undefined) { + return group ? `${group}/${name}` : name; +} + +export function featurePath( + group: boolean | undefined, + flat: boolean | undefined, + path: string, + name: string +) { + if (group && !flat) { + return `../../${path}/${name}/`; + } + + return group ? `../${path}/` : './'; +} diff --git a/modules/component-store/schematics-core/utility/update.ts b/modules/component-store/schematics-core/utility/update.ts new file mode 100644 index 0000000000..7e79cda781 --- /dev/null +++ b/modules/component-store/schematics-core/utility/update.ts @@ -0,0 +1,43 @@ +import { + Rule, + SchematicContext, + Tree, + SchematicsException, +} from '@angular-devkit/schematics'; + +export function updatePackage(name: string): Rule { + return (tree: Tree, context: SchematicContext) => { + const pkgPath = '/package.json'; + const buffer = tree.read(pkgPath); + if (buffer === null) { + throw new SchematicsException('Could not read package.json'); + } + const content = buffer.toString(); + const pkg = JSON.parse(content); + + if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) { + throw new SchematicsException('Error reading package.json'); + } + + const dependencyCategories = ['dependencies', 'devDependencies']; + + dependencyCategories.forEach((category) => { + const packageName = `@ngrx/${name}`; + + if (pkg[category] && pkg[category][packageName]) { + const firstChar = pkg[category][packageName][0]; + const suffix = match(firstChar, '^') || match(firstChar, '~'); + + pkg[category][packageName] = `${suffix}6.0.0`; + } + }); + + tree.overwrite(pkgPath, JSON.stringify(pkg, null, 2)); + + return tree; + }; +} + +function match(value: string, test: string) { + return value === test ? test : ''; +} diff --git a/modules/component-store/schematics-core/utility/visitors.ts b/modules/component-store/schematics-core/utility/visitors.ts new file mode 100644 index 0000000000..c4a6af6e83 --- /dev/null +++ b/modules/component-store/schematics-core/utility/visitors.ts @@ -0,0 +1,227 @@ +import * as ts from 'typescript'; +import { normalize, resolve } from '@angular-devkit/core'; +import { Tree, DirEntry } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles<Result = void>( + tree: Tree, + visitor: ( + sourceFile: ts.SourceFile, + tree: Tree, + result?: Result + ) => Result | undefined +): Result | undefined { + let result: Result | undefined = undefined; + for (const sourceFile of visit(tree.root)) { + result = visitor(sourceFile, tree, result); + } + + return result; +} + +export function visitTemplates( + tree: Tree, + visitor: ( + template: { + fileName: string; + content: string; + inline: boolean; + start: number; + }, + tree: Tree + ) => void +): void { + visitTSSourceFiles(tree, (source) => { + visitComponents(source, (_, decoratorExpressionNode) => { + ts.forEachChild(decoratorExpressionNode, function findTemplates(n) { + if (ts.isPropertyAssignment(n) && ts.isIdentifier(n.name)) { + if ( + n.name.text === 'template' && + ts.isStringLiteralLike(n.initializer) + ) { + // Need to add an offset of one to the start because the template quotes are + // not part of the template content. + const templateStartIdx = n.initializer.getStart() + 1; + visitor( + { + fileName: source.fileName, + content: n.initializer.text, + inline: true, + start: templateStartIdx, + }, + tree + ); + return; + } else if ( + n.name.text === 'templateUrl' && + ts.isStringLiteralLike(n.initializer) + ) { + const parts = normalize(source.fileName).split('/').slice(0, -1); + const templatePath = resolve( + normalize(parts.join('/')), + normalize(n.initializer.text) + ); + if (!tree.exists(templatePath)) { + return; + } + + const fileContent = tree.read(templatePath); + if (!fileContent) { + return; + } + + visitor( + { + fileName: templatePath, + content: fileContent.toString(), + inline: false, + start: 0, + }, + tree + ); + return; + } + } + + ts.forEachChild(n, findTemplates); + }); + }); + }); +} + +export function visitNgModuleImports( + sourceFile: ts.SourceFile, + callback: ( + importNode: ts.PropertyAssignment, + elementExpressions: ts.NodeArray<ts.Expression> + ) => void +) { + visitNgModuleProperty(sourceFile, callback, 'imports'); +} + +export function visitNgModuleExports( + sourceFile: ts.SourceFile, + callback: ( + exportNode: ts.PropertyAssignment, + elementExpressions: ts.NodeArray<ts.Expression> + ) => void +) { + visitNgModuleProperty(sourceFile, callback, 'exports'); +} + +function visitNgModuleProperty( + sourceFile: ts.SourceFile, + callback: ( + nodes: ts.PropertyAssignment, + elementExpressions: ts.NodeArray<ts.Expression> + ) => void, + property: string +) { + visitNgModules(sourceFile, (_, decoratorExpressionNode) => { + ts.forEachChild(decoratorExpressionNode, function findTemplates(n) { + if ( + ts.isPropertyAssignment(n) && + ts.isIdentifier(n.name) && + n.name.text === property && + ts.isArrayLiteralExpression(n.initializer) + ) { + callback(n, n.initializer.elements); + return; + } + + ts.forEachChild(n, findTemplates); + }); + }); +} +export function visitComponents( + sourceFile: ts.SourceFile, + callback: ( + classDeclarationNode: ts.ClassDeclaration, + decoratorExpressionNode: ts.ObjectLiteralExpression + ) => void +) { + visitDecorator(sourceFile, 'Component', callback); +} + +export function visitNgModules( + sourceFile: ts.SourceFile, + callback: ( + classDeclarationNode: ts.ClassDeclaration, + decoratorExpressionNode: ts.ObjectLiteralExpression + ) => void +) { + visitDecorator(sourceFile, 'NgModule', callback); +} + +export function visitDecorator( + sourceFile: ts.SourceFile, + decoratorName: string, + callback: ( + classDeclarationNode: ts.ClassDeclaration, + decoratorExpressionNode: ts.ObjectLiteralExpression + ) => void +) { + ts.forEachChild(sourceFile, function findClassDeclaration(node) { + if (!ts.isClassDeclaration(node)) { + ts.forEachChild(node, findClassDeclaration); + } + + const classDeclarationNode = node as ts.ClassDeclaration; + + if ( + !classDeclarationNode.decorators || + !classDeclarationNode.decorators.length + ) { + return; + } + + const componentDecorator = classDeclarationNode.decorators.find((d) => { + return ( + ts.isCallExpression(d.expression) && + ts.isIdentifier(d.expression.expression) && + d.expression.expression.text === decoratorName + ); + }); + + if (!componentDecorator) { + return; + } + + const { expression } = componentDecorator; + if (!ts.isCallExpression(expression)) { + return; + } + + const [arg] = expression.arguments; + if (!ts.isObjectLiteralExpression(arg)) { + return; + } + + callback(classDeclarationNode, arg); + }); +} + +function* visit(directory: DirEntry): IterableIterator<ts.SourceFile> { + for (const path of directory.subfiles) { + if (path.endsWith('.ts') && !path.endsWith('.d.ts')) { + const entry = directory.file(path); + if (entry) { + const content = entry.content; + const source = ts.createSourceFile( + entry.path, + content.toString().replace(/^\uFEFF/, ''), + ts.ScriptTarget.Latest, + true + ); + yield source; + } + } + } + + for (const path of directory.subdirs) { + if (path === 'node_modules') { + continue; + } + + yield* visit(directory.dir(path)); + } +} diff --git a/modules/component-store/schematics/BUILD b/modules/component-store/schematics/BUILD new file mode 100644 index 0000000000..a02f58955f --- /dev/null +++ b/modules/component-store/schematics/BUILD @@ -0,0 +1,36 @@ +load("//tools:defaults.bzl", "pkg_npm", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "schematics", + srcs = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/*.spec.ts", + "**/files/**/*", + ], + ), + module_name = "@ngrx/component-store/schematics", + deps = [ + "//modules/component-store/schematics-core", + "@npm//@angular-devkit/core", + "@npm//@angular-devkit/schematics", + "@npm//typescript", + ], +) + +pkg_npm( + name = "npm_package", + srcs = [ + ":collection.json", + ] + glob([ + "**/files/**/*", + "**/schema.json", + ]), + deps = [ + ":schematics", + ], +) diff --git a/modules/component-store/schematics/collection.json b/modules/component-store/schematics/collection.json new file mode 100644 index 0000000000..095e4b7271 --- /dev/null +++ b/modules/component-store/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "schematics": { + "ng-add": { + "aliases": ["init"], + "factory": "./ng-add", + "schema": "./ng-add/schema.json", + "description": "Add @ngrx/component-store to your application" + } + } +} diff --git a/modules/component-store/schematics/ng-add/index.spec.ts b/modules/component-store/schematics/ng-add/index.spec.ts new file mode 100644 index 0000000000..c3e5900c12 --- /dev/null +++ b/modules/component-store/schematics/ng-add/index.spec.ts @@ -0,0 +1,41 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { Schema as SchemaOptions } from './schema'; +import { createWorkspace } from '../../../schematics-core/testing'; + +describe('Component store ng-add Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/component-store', + path.join(__dirname, '../collection.json') + ); + const defaultOptions: SchemaOptions = { + skipPackageJson: false, + }; + + let appTree: UnitTestTree; + + beforeEach(async () => { + appTree = await createWorkspace(schematicRunner, appTree); + }); + + it('should update package.json', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(tree.readContent('/package.json')); + + expect(packageJson.dependencies['@ngrx/component-store']).toBeDefined(); + }); + + it('should skip package.json update', () => { + const options = { ...defaultOptions, skipPackageJson: true }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(tree.readContent('/package.json')); + + expect(packageJson.dependencies['@ngrx/component-store']).toBeUndefined(); + }); +}); diff --git a/modules/component-store/schematics/ng-add/index.ts b/modules/component-store/schematics/ng-add/index.ts new file mode 100644 index 0000000000..b346c43bca --- /dev/null +++ b/modules/component-store/schematics/ng-add/index.ts @@ -0,0 +1,34 @@ +import { + Rule, + SchematicContext, + Tree, + chain, + noop, +} from '@angular-devkit/schematics'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; +import { + addPackageToPackageJson, + platformVersion, +} from '@ngrx/component-store/schematics-core'; +import { Schema as SchemaOptions } from './schema'; + +function addModuleToPackageJson() { + return (host: Tree, context: SchematicContext) => { + addPackageToPackageJson( + host, + 'dependencies', + '@ngrx/component-store', + platformVersion + ); + context.addTask(new NodePackageInstallTask()); + return host; + }; +} + +export default function (options: SchemaOptions): Rule { + return (host: Tree, context: SchematicContext) => { + return chain([ + options && options.skipPackageJson ? noop() : addModuleToPackageJson(), + ])(host, context); + }; +} diff --git a/modules/component-store/schematics/ng-add/schema.json b/modules/component-store/schematics/ng-add/schema.json new file mode 100644 index 0000000000..06da1ff5a8 --- /dev/null +++ b/modules/component-store/schematics/ng-add/schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxComponentStore", + "title": "NgRx Component Store Schema", + "type": "object", + "properties": { + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": "Do not add @ngrx/component-store as dependency to package.json (e.g., --skipPackageJson)." + } + }, + "required": [] +} diff --git a/modules/component-store/schematics/ng-add/schema.ts b/modules/component-store/schematics/ng-add/schema.ts new file mode 100644 index 0000000000..c629d7fae1 --- /dev/null +++ b/modules/component-store/schematics/ng-add/schema.ts @@ -0,0 +1,3 @@ +export interface Schema { + skipPackageJson?: boolean; +} diff --git a/modules/data/package.json b/modules/data/package.json index a520a8b400..f7d9009564 100644 --- a/modules/data/package.json +++ b/modules/data/package.json @@ -36,7 +36,8 @@ "@ngrx/router-store", "@ngrx/data", "@ngrx/schematics", - "@ngrx/store-devtools" + "@ngrx/store-devtools", + "@ngrx/component-store" ], "migrations": "./migrations/migration.json" }, diff --git a/modules/effects/package.json b/modules/effects/package.json index 0d86c213f8..fae1d25add 100644 --- a/modules/effects/package.json +++ b/modules/effects/package.json @@ -34,7 +34,8 @@ "@ngrx/router-store", "@ngrx/data", "@ngrx/schematics", - "@ngrx/store-devtools" + "@ngrx/store-devtools", + "@ngrx/component-store" ], "migrations": "./migrations/migration.json" }, diff --git a/modules/entity/package.json b/modules/entity/package.json index c0bb1cd260..1859aa793f 100644 --- a/modules/entity/package.json +++ b/modules/entity/package.json @@ -33,7 +33,8 @@ "@ngrx/router-store", "@ngrx/data", "@ngrx/schematics", - "@ngrx/store-devtools" + "@ngrx/store-devtools", + "@ngrx/component-store" ], "migrations": "./migrations/migration.json" }, diff --git a/modules/router-store/package.json b/modules/router-store/package.json index e5267ea436..a052b5011b 100644 --- a/modules/router-store/package.json +++ b/modules/router-store/package.json @@ -35,7 +35,8 @@ "@ngrx/router-store", "@ngrx/data", "@ngrx/schematics", - "@ngrx/store-devtools" + "@ngrx/store-devtools", + "@ngrx/component-store" ], "migrations": "./migrations/migration.json" }, diff --git a/modules/schematics/package.json b/modules/schematics/package.json index 6bce48e81b..d303fd4660 100644 --- a/modules/schematics/package.json +++ b/modules/schematics/package.json @@ -29,7 +29,8 @@ "@ngrx/router-store", "@ngrx/data", "@ngrx/schematics", - "@ngrx/store-devtools" + "@ngrx/store-devtools", + "@ngrx/component-store" ], "migrations": "./migrations/migration.json" } diff --git a/modules/store-devtools/package.json b/modules/store-devtools/package.json index 9a3ce45198..e1b3cdc9af 100644 --- a/modules/store-devtools/package.json +++ b/modules/store-devtools/package.json @@ -32,7 +32,8 @@ "@ngrx/router-store", "@ngrx/data", "@ngrx/schematics", - "@ngrx/store-devtools" + "@ngrx/store-devtools", + "@ngrx/component-store" ], "migrations": "./migrations/migration.json" }, diff --git a/modules/store/package.json b/modules/store/package.json index a05b0dff6e..64d9ba966a 100644 --- a/modules/store/package.json +++ b/modules/store/package.json @@ -33,7 +33,8 @@ "@ngrx/router-store", "@ngrx/data", "@ngrx/schematics", - "@ngrx/store-devtools" + "@ngrx/store-devtools", + "@ngrx/component-store" ], "migrations": "./migrations/migration.json" }, diff --git a/tsconfig.json b/tsconfig.json index cc7a086ea6..7cf3218e5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,9 @@ "paths": { "@ngrx/component": ["modules/component"], "@ngrx/component-store": ["modules/component-store"], + "@ngrx/component-store/schematics-core": [ + "modules/component-store/schematics-core" + ], "@ngrx/data": ["./modules/data"], "@ngrx/data/schematics-core": ["./modules/data/schematics-core"], "@ngrx/effects": ["./modules/effects"], From cd012f05af5a4b83d9e5ab79c56b5ff9b8a97940 Mon Sep 17 00:00:00 2001 From: timdeschryver <28659384+timdeschryver@users.noreply.github.com> Date: Mon, 29 Jun 2020 17:17:45 +0200 Subject: [PATCH 2/3] build(component-store): add schematic commands --- angular.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/angular.json b/angular.json index 7a047fd70d..6da8bf3737 100644 --- a/angular.json +++ b/angular.json @@ -579,6 +579,9 @@ "commands": [ { "command": "ng run component-store:build-package" + }, + { + "command": "yarn tsc -p modules/component-store/tsconfig.schematics.json" } ] } From c89844a702626b03bd007279e3ffd926be706e9e Mon Sep 17 00:00:00 2001 From: timdeschryver <28659384+timdeschryver@users.noreply.github.com> Date: Thu, 2 Jul 2020 07:29:58 +0200 Subject: [PATCH 3/3] build(component-store): add schematics-core path --- modules/component-store/tsconfig.schematics.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/component-store/tsconfig.schematics.json b/modules/component-store/tsconfig.schematics.json index c18c7acf71..6ce954388f 100644 --- a/modules/component-store/tsconfig.schematics.json +++ b/modules/component-store/tsconfig.schematics.json @@ -7,7 +7,9 @@ "moduleResolution": "node", "downlevelIteration": true, "outDir": "../../dist/modules/component-store", - "paths": {}, + "paths": { + "@ngrx/component-store/schematics-core": ["./schematics-core"] + }, "sourceMap": true, "inlineSources": true, "lib": ["es2018", "dom"],