From 1e863eee13cb258ccb8722cf4f71d33b50ac1a4a Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 10 Sep 2024 21:33:25 -0700 Subject: [PATCH 1/5] feat: initial work to provide schematics This work is heavily based off of NgRx's schematics. Thank you to their contributors for their amazing work. --- package.json | 6 + projects/angular-redux/package.json | 7 +- .../angular-redux/schematics-core/README.md | 4 + .../angular-redux/schematics-core/index.ts | 86 ++ .../testing/create-app-module.ts | 60 ++ .../schematics-core/testing/create-package.ts | 26 + .../testing/create-reducers.ts | 34 + .../testing/create-workspace.ts | 69 ++ .../schematics-core/testing/index.ts | 3 + .../schematics-core/testing/update.ts | 2 + .../schematics-core/utility/ast-utils.ts | 920 ++++++++++++++++++ .../schematics-core/utility/change.ts | 187 ++++ .../schematics-core/utility/config.ts | 147 +++ .../schematics-core/utility/find-component.ts | 145 +++ .../schematics-core/utility/find-module.ts | 140 +++ .../schematics-core/utility/json-utilts.ts | 14 + .../schematics-core/utility/package.ts | 27 + .../schematics-core/utility/parse-name.ts | 16 + .../schematics-core/utility/project.ts | 75 ++ .../schematics-core/utility/standalone.ts | 470 +++++++++ .../schematics-core/utility/strings.ts | 147 +++ .../schematics-core/utility/update.ts | 0 .../schematics-core/utility/visitors.ts | 225 +++++ .../angular-redux/schematics/collection.json | 10 + .../__statePath__/counter-slice.ts.template | 30 + .../files/__statePath__/index.ts.template | 11 + .../schematics/ng-add/index.spec.ts | 113 +++ .../angular-redux/schematics/ng-add/index.ts | 227 +++++ .../schematics/ng-add/schema.json | 39 + .../angular-redux/schematics/ng-add/schema.ts | 7 + .../angular-redux/tsconfig.schematics.json | 23 + yarn.lock | 18 +- 32 files changed, 3286 insertions(+), 2 deletions(-) create mode 100644 projects/angular-redux/schematics-core/README.md create mode 100644 projects/angular-redux/schematics-core/index.ts create mode 100644 projects/angular-redux/schematics-core/testing/create-app-module.ts create mode 100644 projects/angular-redux/schematics-core/testing/create-package.ts create mode 100644 projects/angular-redux/schematics-core/testing/create-reducers.ts create mode 100644 projects/angular-redux/schematics-core/testing/create-workspace.ts create mode 100644 projects/angular-redux/schematics-core/testing/index.ts create mode 100644 projects/angular-redux/schematics-core/testing/update.ts create mode 100644 projects/angular-redux/schematics-core/utility/ast-utils.ts create mode 100644 projects/angular-redux/schematics-core/utility/change.ts create mode 100644 projects/angular-redux/schematics-core/utility/config.ts create mode 100644 projects/angular-redux/schematics-core/utility/find-component.ts create mode 100644 projects/angular-redux/schematics-core/utility/find-module.ts create mode 100644 projects/angular-redux/schematics-core/utility/json-utilts.ts create mode 100644 projects/angular-redux/schematics-core/utility/package.ts create mode 100644 projects/angular-redux/schematics-core/utility/parse-name.ts create mode 100644 projects/angular-redux/schematics-core/utility/project.ts create mode 100644 projects/angular-redux/schematics-core/utility/standalone.ts create mode 100644 projects/angular-redux/schematics-core/utility/strings.ts create mode 100644 projects/angular-redux/schematics-core/utility/update.ts create mode 100644 projects/angular-redux/schematics-core/utility/visitors.ts create mode 100644 projects/angular-redux/schematics/collection.json create mode 100644 projects/angular-redux/schematics/ng-add/files/__statePath__/counter-slice.ts.template create mode 100644 projects/angular-redux/schematics/ng-add/files/__statePath__/index.ts.template create mode 100644 projects/angular-redux/schematics/ng-add/index.spec.ts create mode 100644 projects/angular-redux/schematics/ng-add/index.ts create mode 100644 projects/angular-redux/schematics/ng-add/schema.json create mode 100644 projects/angular-redux/schematics/ng-add/schema.ts create mode 100644 projects/angular-redux/tsconfig.schematics.json diff --git a/package.json b/package.json index 2eb0e12..e2fb70e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,11 @@ "watch": "ng build --watch --configuration development", "test": "ng test" }, + "workspaces": { + "packages": [ + "projects/*" + ] + }, "private": true, "dependencies": { "@angular/animations": "^18.2.0", @@ -34,6 +39,7 @@ "@testing-library/user-event": "^14.5.2", "@types/jasmine": "~5.1.0", "@types/jest": "^29.5.12", + "@types/node": "^22.5.4", "jasmine-core": "~5.2.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/projects/angular-redux/package.json b/projects/angular-redux/package.json index bdee58d..7f5b4b0 100644 --- a/projects/angular-redux/package.json +++ b/projects/angular-redux/package.json @@ -13,13 +13,18 @@ "peerDependencies": { "@angular/common": ">=17.3.0", "@angular/core": ">=17.3.0", - "redux": "^5.0.0" + "redux": "^5.0.0", + "@reduxjs/toolkit": "^2.2.7" }, "peerDependenciesMeta": { "redux": { "optional": true + }, + "@reduxjs/toolkit": { + "optional": true } }, + "schematics": "./schematics/collection.json", "dependencies": { "tslib": "^2.3.0" }, diff --git a/projects/angular-redux/schematics-core/README.md b/projects/angular-redux/schematics-core/README.md new file mode 100644 index 0000000..ac0e438 --- /dev/null +++ b/projects/angular-redux/schematics-core/README.md @@ -0,0 +1,4 @@ +This code is originally from NgRx: + +https://github.com/ngrx/platform/tree/main/modules/schematics-core +https://github.com/ngrx/platform/tree/main/modules/store/schematics-core diff --git a/projects/angular-redux/schematics-core/index.ts b/projects/angular-redux/schematics-core/index.ts new file mode 100644 index 0000000..155cc31 --- /dev/null +++ b/projects/angular-redux/schematics-core/index.ts @@ -0,0 +1,86 @@ +import { + dasherize, + decamelize, + camelize, + classify, + underscore, + group, + capitalize, + featurePath, + pluralize, +} from './utility/strings'; + +export { + findNodes, + getSourceNodes, + getDecoratorMetadata, + getContentOfKeyLiteral, + insertAfterLastOccurrence, + insertImport, + addBootstrapToModule, + addDeclarationToModule, + addExportToModule, + addImportToModule, + addProviderToComponent, + addProviderToModule, + replaceImport, + containsProperty, +} from './utility/ast-utils'; + +export { + NoopChange, + InsertChange, + RemoveChange, + ReplaceChange, + createReplaceChange, + createChangeRecorder, + commitChanges +} from './utility/change'; +export type { + Host, + Change +} from './utility/change'; + +export {getWorkspace, getWorkspacePath} from './utility/config'; +export type { AppConfig } from './utility/config'; + +export { findComponentFromOptions } from './utility/find-component'; + +export { + findModule, + findModuleFromOptions, + buildRelativePath +} from './utility/find-module'; +export type { ModuleOptions } from './utility/find-module'; + +export { findPropertyInAstObject } from './utility/json-utilts'; + +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 { + visitTSSourceFiles, + visitNgModuleImports, + visitNgModuleExports, + visitComponents, + visitDecorator, + visitNgModules, + visitTemplates, +} from './utility/visitors'; diff --git a/projects/angular-redux/schematics-core/testing/create-app-module.ts b/projects/angular-redux/schematics-core/testing/create-app-module.ts new file mode 100644 index 0000000..ebf3b82 --- /dev/null +++ b/projects/angular-redux/schematics-core/testing/create-app-module.ts @@ -0,0 +1,60 @@ +import { UnitTestTree } from '@angular-devkit/schematics/testing'; + +export function createAppModule( + tree: UnitTestTree, + path?: string +): UnitTestTree { + tree.create( + path || '/src/app/app.module.ts', + ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + ` + ); + + return tree; +} + +export function createAppModuleWithEffects( + tree: UnitTestTree, + path: string, + effects?: string +): UnitTestTree { + tree.create( + path || '/src/app/app.module.ts', + ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + import { EffectsModule } from '@ngrx/effects'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + ${effects} + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + ` + ); + + return tree; +} diff --git a/projects/angular-redux/schematics-core/testing/create-package.ts b/projects/angular-redux/schematics-core/testing/create-package.ts new file mode 100644 index 0000000..23a5d50 --- /dev/null +++ b/projects/angular-redux/schematics-core/testing/create-package.ts @@ -0,0 +1,26 @@ +import { Tree } from '@angular-devkit/schematics'; +import { + UnitTestTree, + SchematicTestRunner, +} from '@angular-devkit/schematics/testing'; + +export const packagePath = '/package.json'; + +export function createPackageJson( + prefix: string, + pkg: string, + tree: UnitTestTree, + version = '5.2.0', + packagePath = '/package.json' +) { + tree.create( + packagePath, + `{ + "dependencies": { + "@ngrx/${pkg}": "${prefix}${version}" + } + }` + ); + + return tree; +} diff --git a/projects/angular-redux/schematics-core/testing/create-reducers.ts b/projects/angular-redux/schematics-core/testing/create-reducers.ts new file mode 100644 index 0000000..2ed0625 --- /dev/null +++ b/projects/angular-redux/schematics-core/testing/create-reducers.ts @@ -0,0 +1,34 @@ +import { UnitTestTree } from '@angular-devkit/schematics/testing'; + +export function createReducers( + tree: UnitTestTree, + path?: string, + project = 'bar' +) { + tree.create( + path || `/projects/${project}/src/app/reducers/index.ts`, + ` + import { isDevMode } from '@angular/core'; + import { + ActionReducer, + ActionReducerMap, + createFeatureSelector, + createSelector, + MetaReducer + } from '@ngrx/${'store'}'; + + export interface State { + + } + + export const reducers: ActionReducerMap = { + + }; + + + export const metaReducers: MetaReducer[] = isDevMode() ? [] : []; + ` + ); + + return tree; +} diff --git a/projects/angular-redux/schematics-core/testing/create-workspace.ts b/projects/angular-redux/schematics-core/testing/create-workspace.ts new file mode 100644 index 0000000..c5526c2 --- /dev/null +++ b/projects/angular-redux/schematics-core/testing/create-workspace.ts @@ -0,0 +1,69 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; + +export const defaultWorkspaceOptions = { + name: 'workspace', + newProjectRoot: 'projects', + version: '6.0.0', +}; + +export const defaultAppOptions = { + name: 'bar', + inlineStyle: false, + inlineTemplate: false, + viewEncapsulation: 'Emulated', + routing: false, + style: 'css', + skipTests: false, + standalone: false, +}; + +const defaultLibOptions = { + name: 'baz', +}; + +export function getTestProjectPath( + workspaceOptions: any = defaultWorkspaceOptions, + appOptions: any = defaultAppOptions +) { + return `/${workspaceOptions.newProjectRoot}/${appOptions.name}`; +} + +export async function createWorkspace( + schematicRunner: SchematicTestRunner, + appTree: UnitTestTree, + workspaceOptions = defaultWorkspaceOptions, + appOptions = defaultAppOptions, + libOptions = defaultLibOptions +) { + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'workspace', + workspaceOptions + ); + + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'application', + appOptions, + appTree + ); + + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'application', + { ...appOptions, name: 'bar-standalone', standalone: true }, + appTree + ); + + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'library', + libOptions, + appTree + ); + + return appTree; +} diff --git a/projects/angular-redux/schematics-core/testing/index.ts b/projects/angular-redux/schematics-core/testing/index.ts new file mode 100644 index 0000000..fd1a5cb --- /dev/null +++ b/projects/angular-redux/schematics-core/testing/index.ts @@ -0,0 +1,3 @@ +export * from './create-app-module'; +export * from './create-reducers'; +export * from './create-workspace'; diff --git a/projects/angular-redux/schematics-core/testing/update.ts b/projects/angular-redux/schematics-core/testing/update.ts new file mode 100644 index 0000000..0910a57 --- /dev/null +++ b/projects/angular-redux/schematics-core/testing/update.ts @@ -0,0 +1,2 @@ +export const upgradeVersion = '6.0.0'; +export const versionPrefixes = ['~', '^', '']; diff --git a/projects/angular-redux/schematics-core/utility/ast-utils.ts b/projects/angular-redux/schematics-core/utility/ast-utils.ts new file mode 100644 index 0000000..5f7475c --- /dev/null +++ b/projects/angular-redux/schematics-core/utility/ast-utils.ts @@ -0,0 +1,920 @@ +/* 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} 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]; // eslint-disable-line @typescript-eslint/no-explicit-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; + 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] = (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]; +} + +function _addSymbolToComponentMetadata( + source: ts.SourceFile, + componentPath: string, + metadataField: string, + symbolName: string, + importPath: string +): Change[] { + const nodes = getDecoratorMetadata(source, 'Component', '@angular/core'); + let node: any = nodes[0]; // eslint-disable-line @typescript-eslint/no-explicit-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( + componentPath, + position, + toInsert + ); + const newMetadataImport = insertImport( + source, + componentPath, + 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 component 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 = ` ${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(componentPath, position, toInsert); + const importInsert: Change = insertImport( + source, + componentPath, + 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 a provider into Component. It also imports it. + */ +export function addProviderToComponent( + source: ts.SourceFile, + componentPath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToComponentMetadata( + source, + componentPath, + '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 namedImports = p?.importClause?.namedBindings as ts.NamedImports; + if (!namedImports) { + return []; + } + + const importSpecifiers = 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/projects/angular-redux/schematics-core/utility/change.ts b/projects/angular-redux/schematics-core/utility/change.ts new file mode 100644 index 0000000..3b8ed0c --- /dev/null +++ b/projects/angular-redux/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; + 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, 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 { + 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 { + 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/projects/angular-redux/schematics-core/utility/config.ts b/projects/angular-redux/schematics-core/utility/config.ts new file mode 100644 index 0000000..06daabc --- /dev/null +++ b/projects/angular-redux/schematics-core/utility/config.ts @@ -0,0 +1,147 @@ +import { SchematicsException, Tree } from '@angular-devkit/schematics'; + +// 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; // eslint-disable-line @typescript-eslint/no-explicit-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; // eslint-disable-line @typescript-eslint/no-explicit-any + } + )[]; + /** + * Source file for environment config. + */ + environmentSource?: string; + /** + * Name and corresponding file for environment config. + */ + environments?: { + [name: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any + }; + appShell?: { + app: string; + route: string; + }; +} + +export function getWorkspacePath(host: Tree): string { + const possibleFiles = ['/angular.json', '/.angular.json', '/workspace.json']; + const path = possibleFiles.filter((path) => host.exists(path))[0]; + + return path; +} + +export function getWorkspace(host: Tree) { + 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/projects/angular-redux/schematics-core/utility/find-component.ts b/projects/angular-redux/schematics-core/utility/find-component.ts new file mode 100644 index 0000000..bb59ec3 --- /dev/null +++ b/projects/angular-redux/schematics-core/utility/find-component.ts @@ -0,0 +1,145 @@ +/** + * @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 ComponentOptions { + component?: string; + name: string; + flat?: boolean; + path?: string; + skipImport?: boolean; +} + +/** + * Find the component referred by a set of options passed to the schematics. + */ +export function findComponentFromOptions( + host: Tree, + options: ComponentOptions +): Path | undefined { + if (options.hasOwnProperty('skipImport') && options.skipImport) { + return undefined; + } + + if (!options.component) { + const pathToCheck = + (options.path || '') + + (options.flat ? '' : '/' + strings.dasherize(options.name)); + + return normalize(findComponent(host, pathToCheck)); + } else { + const componentPath = normalize( + '/' + options.path + '/' + options.component + ); + const componentBaseName = normalize(componentPath).split('/').pop(); + + if (host.exists(componentPath)) { + return normalize(componentPath); + } else if (host.exists(componentPath + '.ts')) { + return normalize(componentPath + '.ts'); + } else if (host.exists(componentPath + '.component.ts')) { + return normalize(componentPath + '.component.ts'); + } else if ( + host.exists(componentPath + '/' + componentBaseName + '.component.ts') + ) { + return normalize( + componentPath + '/' + componentBaseName + '.component.ts' + ); + } else { + throw new Error( + `Specified component path ${componentPath} does not exist` + ); + } + } +} + +/** + * Function to find the "closest" component to a generated file's path. + */ +export function findComponent(host: Tree, generateDir: string): Path { + let dir: DirEntry | null = host.getDir('/' + generateDir); + + const componentRe = /\.component\.ts$/; + + while (dir) { + const matches = dir.subfiles.filter((p) => componentRe.test(p)); + + if (matches.length == 1) { + return join(dir.path, matches[0]); + } else if (matches.length > 1) { + throw new Error( + 'More than one component matches. Use skip-import option to skip importing ' + + 'the component store into the closest component.' + ); + } + + dir = dir.parent; + } + + throw new Error( + 'Could not find an Component. Use the skip-import ' + + 'option to skip importing in Component.' + ); +} + +/** + * 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/projects/angular-redux/schematics-core/utility/find-module.ts b/projects/angular-redux/schematics-core/utility/find-module.ts new file mode 100644 index 0000000..134a59f --- /dev/null +++ b/projects/angular-redux/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/projects/angular-redux/schematics-core/utility/json-utilts.ts b/projects/angular-redux/schematics-core/utility/json-utilts.ts new file mode 100644 index 0000000..be6b7b8 --- /dev/null +++ b/projects/angular-redux/schematics-core/utility/json-utilts.ts @@ -0,0 +1,14 @@ +// https://github.com/angular/angular-cli/blob/master/packages/schematics/angular/utility/json-utils.ts +export function findPropertyInAstObject( + node: any, + propertyName: string +): any | null { + let maybeNode: any | null = null; + for (const property of node.properties) { + if (property.key.value == propertyName) { + maybeNode = property.value; + } + } + + return maybeNode; +} diff --git a/projects/angular-redux/schematics-core/utility/package.ts b/projects/angular-redux/schematics-core/utility/package.ts new file mode 100644 index 0000000..9ebbbed --- /dev/null +++ b/projects/angular-redux/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/projects/angular-redux/schematics-core/utility/parse-name.ts b/projects/angular-redux/schematics-core/utility/parse-name.ts new file mode 100644 index 0000000..a48f56b --- /dev/null +++ b/projects/angular-redux/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/projects/angular-redux/schematics-core/utility/project.ts b/projects/angular-redux/schematics-core/utility/project.ts new file mode 100644 index 0000000..0e3e580 --- /dev/null +++ b/projects/angular-redux/schematics-core/utility/project.ts @@ -0,0 +1,75 @@ +import { TargetDefinition } from '@angular-devkit/core/src/workspace'; +import { getWorkspace } from './config'; +import { SchematicsException, Tree } from '@angular-devkit/schematics'; + +export interface WorkspaceProject { + root: string; + projectType: string; + architect: { + [key: string]: TargetDefinition; + }; +} + +export function getProject( + host: Tree, + options: { project?: string | undefined; path?: string | undefined } +): WorkspaceProject { + const workspace = getWorkspace(host); + + if (!options.project) { + const defaultProject = (workspace as { defaultProject?: string }) + .defaultProject; + options.project = + defaultProject !== undefined + ? 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.slice(-1) === '/') { + project.root = project.root.substring(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'; +} + +export function getProjectMainFile( + host: Tree, + options: { project?: string | undefined; path?: string | undefined } +) { + if (isLib(host, options)) { + throw new SchematicsException(`Invalid project type`); + } + const project = getProject(host, options); + const projectOptions = project.architect['build'].options; + + if (!projectOptions?.main && !projectOptions?.browser) { + throw new SchematicsException(`Could not find the main file ${project}`); + } + + return (projectOptions.browser || projectOptions.main) as string; +} diff --git a/projects/angular-redux/schematics-core/utility/standalone.ts b/projects/angular-redux/schematics-core/utility/standalone.ts new file mode 100644 index 0000000..f0e172c --- /dev/null +++ b/projects/angular-redux/schematics-core/utility/standalone.ts @@ -0,0 +1,470 @@ +// copied from https://github.com/angular/angular-cli/blob/17.3.x/packages/schematics/angular/private/standalone.ts +import { + SchematicsException, + Tree, + UpdateRecorder, +} from '@angular-devkit/schematics'; +import { dirname, join } from 'path'; +import { insertImport } from './ast-utils'; +import { InsertChange } from './change'; +import * as ts from 'typescript'; + +/** App config that was resolved to its source node. */ +interface ResolvedAppConfig { + /** Tree-relative path of the file containing the app config. */ + filePath: string; + + /** Node defining the app config. */ + node: ts.ObjectLiteralExpression; +} + +/** + * Checks whether a providers function is being called in a `bootstrapApplication` call. + * @param tree File tree of the project. + * @param filePath Path of the file in which to check. + * @param functionName Name of the function to search for. + * @deprecated Private utility that will be removed. Use `addRootImport` or `addRootProvider` from + * `@schematics/angular/utility` instead. + */ +export function callsProvidersFunction( + tree: Tree, + filePath: string, + functionName: string +): boolean { + const sourceFile = createSourceFile(tree, filePath); + const bootstrapCall = findBootstrapApplicationCall(sourceFile); + const appConfig = bootstrapCall + ? findAppConfig(bootstrapCall, tree, filePath) + : null; + const providersLiteral = appConfig + ? findProvidersLiteral(appConfig.node) + : null; + + return !!providersLiteral?.elements.some( + (el) => + ts.isCallExpression(el) && + ts.isIdentifier(el.expression) && + el.expression.text === functionName + ); +} + +/** + * Adds a providers function call to the `bootstrapApplication` call. + * @param tree File tree of the project. + * @param filePath Path to the file that should be updated. + * @param functionName Name of the function that should be called. + * @param importPath Path from which to import the function. + * @param args Arguments to use when calling the function. + * @return The file path that the provider was added to. + * @deprecated Private utility that will be removed. Use `addRootImport` or `addRootProvider` from + * `@schematics/angular/utility` instead. + */ +export function addFunctionalProvidersToStandaloneBootstrap( + tree: Tree, + filePath: string, + functionName: string, + importPath: string, + args: ts.Expression[] = [] +): string { + const sourceFile = createSourceFile(tree, filePath); + const bootstrapCall = findBootstrapApplicationCall(sourceFile); + const addImports = (file: ts.SourceFile, recorder: UpdateRecorder) => { + const change = insertImport(file, file.getText(), functionName, importPath); + + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + }; + + if (!bootstrapCall) { + throw new SchematicsException( + `Could not find bootstrapApplication call in ${filePath}` + ); + } + + const providersCall = ts.factory.createCallExpression( + ts.factory.createIdentifier(functionName), + undefined, + args + ); + + // If there's only one argument, we have to create a new object literal. + if (bootstrapCall.arguments.length === 1) { + const recorder = tree.beginUpdate(filePath); + addNewAppConfigToCall(bootstrapCall, providersCall, recorder); + addImports(sourceFile, recorder); + tree.commitUpdate(recorder); + + return filePath; + } + + // If the config is a `mergeApplicationProviders` call, add another config to it. + if (isMergeAppConfigCall(bootstrapCall.arguments[1])) { + const recorder = tree.beginUpdate(filePath); + addNewAppConfigToCall(bootstrapCall.arguments[1], providersCall, recorder); + addImports(sourceFile, recorder); + tree.commitUpdate(recorder); + + return filePath; + } + + // Otherwise attempt to merge into the current config. + const appConfig = findAppConfig(bootstrapCall, tree, filePath); + + if (!appConfig) { + throw new SchematicsException( + `Could not statically analyze config in bootstrapApplication call in ${filePath}` + ); + } + + const { filePath: configFilePath, node: config } = appConfig; + const recorder = tree.beginUpdate(configFilePath); + const providersLiteral = findProvidersLiteral(config); + + addImports(config.getSourceFile(), recorder); + + if (providersLiteral) { + // If there's a `providers` array, add the import to it. + addElementToArray(providersLiteral, providersCall, recorder); + } else { + // Otherwise add a `providers` array to the existing object literal. + addProvidersToObjectLiteral(config, providersCall, recorder); + } + + tree.commitUpdate(recorder); + + return configFilePath; +} + +/** + * Finds the call to `bootstrapApplication` within a file. + * @deprecated Private utility that will be removed. Use `addRootImport` or `addRootProvider` from + * `@schematics/angular/utility` instead. + */ +export function findBootstrapApplicationCall( + sourceFile: ts.SourceFile +): ts.CallExpression | null { + const localName = findImportLocalName( + sourceFile, + 'bootstrapApplication', + '@angular/platform-browser' + ); + + if (!localName) { + return null; + } + + let result: ts.CallExpression | null = null; + + sourceFile.forEachChild(function walk(node) { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === localName + ) { + result = node; + } + + if (!result) { + node.forEachChild(walk); + } + }); + + return result; +} + +/** Finds the `providers` array literal within an application config. */ +function findProvidersLiteral( + config: ts.ObjectLiteralExpression +): ts.ArrayLiteralExpression | null { + for (const prop of config.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'providers' && + ts.isArrayLiteralExpression(prop.initializer) + ) { + return prop.initializer; + } + } + + return null; +} + +/** + * Resolves the node that defines the app config from a bootstrap call. + * @param bootstrapCall Call for which to resolve the config. + * @param tree File tree of the project. + * @param filePath File path of the bootstrap call. + */ +function findAppConfig( + bootstrapCall: ts.CallExpression, + tree: Tree, + filePath: string +): ResolvedAppConfig | null { + if (bootstrapCall.arguments.length > 1) { + const config = bootstrapCall.arguments[1]; + + if (ts.isObjectLiteralExpression(config)) { + return { filePath, node: config }; + } + + if (ts.isIdentifier(config)) { + return resolveAppConfigFromIdentifier(config, tree, filePath); + } + } + + return null; +} + +/** + * Resolves the app config from an identifier referring to it. + * @param identifier Identifier referring to the app config. + * @param tree File tree of the project. + * @param bootstapFilePath Path of the bootstrap call. + */ +function resolveAppConfigFromIdentifier( + identifier: ts.Identifier, + tree: Tree, + bootstapFilePath: string +): ResolvedAppConfig | null { + const sourceFile = identifier.getSourceFile(); + + for (const node of sourceFile.statements) { + // Only look at relative imports. This will break if the app uses a path + // mapping to refer to the import, but in order to resolve those, we would + // need knowledge about the entire program. + if ( + !ts.isImportDeclaration(node) || + !node.importClause?.namedBindings || + !ts.isNamedImports(node.importClause.namedBindings) || + !ts.isStringLiteralLike(node.moduleSpecifier) || + !node.moduleSpecifier.text.startsWith('.') + ) { + continue; + } + + for (const specifier of node.importClause.namedBindings.elements) { + if (specifier.name.text !== identifier.text) { + continue; + } + + // Look for a variable with the imported name in the file. Note that ideally we would use + // the type checker to resolve this, but we can't because these utilities are set up to + // operate on individual files, not the entire program. + const filePath = join( + dirname(bootstapFilePath), + node.moduleSpecifier.text + '.ts' + ); + const importedSourceFile = createSourceFile(tree, filePath); + const resolvedVariable = findAppConfigFromVariableName( + importedSourceFile, + (specifier.propertyName || specifier.name).text + ); + + if (resolvedVariable) { + return { filePath, node: resolvedVariable }; + } + } + } + + const variableInSameFile = findAppConfigFromVariableName( + sourceFile, + identifier.text + ); + + return variableInSameFile + ? { filePath: bootstapFilePath, node: variableInSameFile } + : null; +} + +/** + * Finds an app config within the top-level variables of a file. + * @param sourceFile File in which to search for the config. + * @param variableName Name of the variable containing the config. + */ +function findAppConfigFromVariableName( + sourceFile: ts.SourceFile, + variableName: string +): ts.ObjectLiteralExpression | null { + for (const node of sourceFile.statements) { + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if ( + ts.isIdentifier(decl.name) && + decl.name.text === variableName && + decl.initializer && + ts.isObjectLiteralExpression(decl.initializer) + ) { + return decl.initializer; + } + } + } + } + + return null; +} + +/** + * Finds the local name of an imported symbol. Could be the symbol name itself or its alias. + * @param sourceFile File within which to search for the import. + * @param name Actual name of the import, not its local alias. + * @param moduleName Name of the module from which the symbol is imported. + */ +function findImportLocalName( + sourceFile: ts.SourceFile, + name: string, + moduleName: string +): string | null { + for (const node of sourceFile.statements) { + // Only look for top-level imports. + if ( + !ts.isImportDeclaration(node) || + !ts.isStringLiteral(node.moduleSpecifier) || + node.moduleSpecifier.text !== moduleName + ) { + continue; + } + + // Filter out imports that don't have the right shape. + if ( + !node.importClause || + !node.importClause.namedBindings || + !ts.isNamedImports(node.importClause.namedBindings) + ) { + continue; + } + + // Look through the elements of the declaration for the specific import. + for (const element of node.importClause.namedBindings.elements) { + if ((element.propertyName || element.name).text === name) { + // The local name is always in `name`. + return element.name.text; + } + } + } + + return null; +} + +/** Creates a source file from a file path within a project. */ +function createSourceFile(tree: Tree, filePath: string): ts.SourceFile { + return ts.createSourceFile( + filePath, + tree.readText(filePath), + ts.ScriptTarget.Latest, + true + ); +} + +/** + * Creates a new app config object literal and adds it to a call expression as an argument. + * @param call Call to which to add the config. + * @param expression Expression that should inserted into the new config. + * @param recorder Recorder to which to log the change. + */ +function addNewAppConfigToCall( + call: ts.CallExpression, + expression: ts.Expression, + recorder: UpdateRecorder +): void { + const newCall = ts.factory.updateCallExpression( + call, + call.expression, + call.typeArguments, + [ + ...call.arguments, + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + 'providers', + ts.factory.createArrayLiteralExpression([expression]) + ), + ], + true + ), + ] + ); + + recorder.remove(call.getStart(), call.getWidth()); + recorder.insertRight( + call.getStart(), + ts + .createPrinter() + .printNode(ts.EmitHint.Unspecified, newCall, call.getSourceFile()) + ); +} + +/** + * Adds an element to an array literal expression. + * @param node Array to which to add the element. + * @param element Element to be added. + * @param recorder Recorder to which to log the change. + */ +function addElementToArray( + node: ts.ArrayLiteralExpression, + element: ts.Expression, + recorder: UpdateRecorder +): void { + const newLiteral = ts.factory.updateArrayLiteralExpression(node, [ + ...node.elements, + element, + ]); + recorder.remove(node.getStart(), node.getWidth()); + recorder.insertRight( + node.getStart(), + ts + .createPrinter() + .printNode(ts.EmitHint.Unspecified, newLiteral, node.getSourceFile()) + ); +} + +/** + * Adds a `providers` property to an object literal. + * @param node Literal to which to add the `providers`. + * @param expression Provider that should be part of the generated `providers` array. + * @param recorder Recorder to which to log the change. + */ +function addProvidersToObjectLiteral( + node: ts.ObjectLiteralExpression, + expression: ts.Expression, + recorder: UpdateRecorder +) { + const newOptionsLiteral = ts.factory.updateObjectLiteralExpression(node, [ + ...node.properties, + ts.factory.createPropertyAssignment( + 'providers', + ts.factory.createArrayLiteralExpression([expression]) + ), + ]); + recorder.remove(node.getStart(), node.getWidth()); + recorder.insertRight( + node.getStart(), + ts + .createPrinter() + .printNode( + ts.EmitHint.Unspecified, + newOptionsLiteral, + node.getSourceFile() + ) + ); +} + +/** Checks whether a node is a call to `mergeApplicationConfig`. */ +function isMergeAppConfigCall(node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node)) { + return false; + } + + const localName = findImportLocalName( + node.getSourceFile(), + 'mergeApplicationConfig', + '@angular/core' + ); + + return ( + !!localName && + ts.isIdentifier(node.expression) && + node.expression.text === localName + ); +} diff --git a/projects/angular-redux/schematics-core/utility/strings.ts b/projects/angular-redux/schematics-core/utility/strings.ts new file mode 100644 index 0000000..38e9924 --- /dev/null +++ b/projects/angular-redux/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.substring(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/projects/angular-redux/schematics-core/utility/update.ts b/projects/angular-redux/schematics-core/utility/update.ts new file mode 100644 index 0000000..e69de29 diff --git a/projects/angular-redux/schematics-core/utility/visitors.ts b/projects/angular-redux/schematics-core/utility/visitors.ts new file mode 100644 index 0000000..fa4edd0 --- /dev/null +++ b/projects/angular-redux/schematics-core/utility/visitors.ts @@ -0,0 +1,225 @@ +import * as ts from 'typescript'; +import { normalize, resolve } from '@angular-devkit/core'; +import { Tree, DirEntry } from '@angular-devkit/schematics'; + +export function visitTSSourceFiles( + 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 + ) => void +) { + visitNgModuleProperty(sourceFile, callback, 'imports'); +} + +export function visitNgModuleExports( + sourceFile: ts.SourceFile, + callback: ( + exportNode: ts.PropertyAssignment, + elementExpressions: ts.NodeArray + ) => void +) { + visitNgModuleProperty(sourceFile, callback, 'exports'); +} + +function visitNgModuleProperty( + sourceFile: ts.SourceFile, + callback: ( + nodes: ts.PropertyAssignment, + elementExpressions: ts.NodeArray + ) => 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; + const decorators = ts.getDecorators(classDeclarationNode); + + if (!decorators || !decorators.length) { + return; + } + + const componentDecorator = 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 (!arg || !ts.isObjectLiteralExpression(arg)) { + return; + } + + callback(classDeclarationNode, arg); + }); +} + +function* visit(directory: DirEntry): IterableIterator { + 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/projects/angular-redux/schematics/collection.json b/projects/angular-redux/schematics/collection.json new file mode 100644 index 0000000..a94b72b --- /dev/null +++ b/projects/angular-redux/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "schematics": { + "ng-add": { + "aliases": ["init"], + "factory": "./ng-add", + "schema": "./ng-add/schema.json", + "description": "Adds initial setup for state managment" + } + } +} diff --git a/projects/angular-redux/schematics/ng-add/files/__statePath__/counter-slice.ts.template b/projects/angular-redux/schematics/ng-add/files/__statePath__/counter-slice.ts.template new file mode 100644 index 0000000..e2a3c95 --- /dev/null +++ b/projects/angular-redux/schematics/ng-add/files/__statePath__/counter-slice.ts.template @@ -0,0 +1,30 @@ +import { createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' + +export interface CounterState { + value: number +} + +const initialState: CounterState = { + value: 0, +} + +export const counterSlice = createSlice({ + name: 'counter', + initialState, + reducers: { + increment: (state) => { + state.value += 1 + }, + decrement: (state) => { + state.value -= 1 + }, + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload + }, + }, +}) + +export const { increment, decrement, incrementByAmount } = counterSlice.actions + +export default counterSlice.reducer diff --git a/projects/angular-redux/schematics/ng-add/files/__statePath__/index.ts.template b/projects/angular-redux/schematics/ng-add/files/__statePath__/index.ts.template new file mode 100644 index 0000000..00d8944 --- /dev/null +++ b/projects/angular-redux/schematics/ng-add/files/__statePath__/index.ts.template @@ -0,0 +1,11 @@ +import { configureStore } from '@reduxjs/toolkit' +import counterReducer from './counter-slice' + +export const store = configureStore({ + reducer: { + counter: counterReducer, + }, +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch diff --git a/projects/angular-redux/schematics/ng-add/index.spec.ts b/projects/angular-redux/schematics/ng-add/index.spec.ts new file mode 100644 index 0000000..76c7245 --- /dev/null +++ b/projects/angular-redux/schematics/ng-add/index.spec.ts @@ -0,0 +1,113 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { Schema as AngularReduxOptions } from './schema'; +import { + getTestProjectPath, + createWorkspace, +} from '@reduxjs/angular-redux/schematics-core/testing'; + +describe('Store ng-add Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@reduxjs/angular-redux', + path.join(__dirname, '../collection.json') + ); + const defaultOptions: AngularReduxOptions = { + skipPackageJson: false, + project: 'bar', + module: 'app' + }; + + const projectPath = getTestProjectPath(); + let appTree: UnitTestTree; + + beforeEach(async () => { + appTree = await createWorkspace(schematicRunner, appTree); + }); + + it('should update package.json', async () => { + const options = { ...defaultOptions }; + + const tree = await schematicRunner.runSchematic('ng-add', options, appTree); + + const packageJson = JSON.parse(tree.readContent('/package.json')); + + expect(packageJson.dependencies['@reduxjs/angular-redux']).toBeDefined(); + expect(packageJson.dependencies['redux']).toBeDefined(); + expect(packageJson.dependencies['@reduxjs/toolkit']).toBeDefined(); + }); + + it('should skip package.json update', async () => { + const options = { ...defaultOptions, skipPackageJson: true }; + + const tree = await schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(tree.readContent('/package.json')); + + expect(packageJson.dependencies['@reduxjs/angular-redux']).toBeUndefined(); + expect(packageJson.dependencies['redux']).toBeUndefined(); + expect(packageJson.dependencies['@reduxjs/toolkit']).toBeUndefined(); + }); + + it('should create the initial store setup', async () => { + const options = { ...defaultOptions }; + + const tree = await schematicRunner.runSchematic('ng-add', options, appTree); + const files = tree.files; + expect( + files.indexOf(`${projectPath}/src/app/store/index.ts`) + ).toBeGreaterThanOrEqual(0); + }); + + it('should import into a specified module', async () => { + const options = { ...defaultOptions }; + + const tree = await schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + expect(content).toMatch( + /import { store } from '\.\/store';/ + ); + }); + + it('should fail if specified module does not exist', async () => { + const options = { ...defaultOptions, module: '/src/app/app.moduleXXX.ts' }; + let thrownError: Error | null = null; + try { + await schematicRunner.runSchematic('ng-add', options, appTree); + } catch (err: any) { + thrownError = err; + } + expect(thrownError).toBeDefined(); + }); + + describe('Store ng-add Schematic for standalone application', () => { + const projectPath = getTestProjectPath(undefined, { + name: 'bar-standalone', + }); + const standaloneDefaultOptions = { + ...defaultOptions, + project: 'bar-standalone', + }; + + it('provides minimal store setup', async () => { + const options = { ...standaloneDefaultOptions, minimal: true }; + const tree = await schematicRunner.runSchematic( + 'ng-add', + options, + appTree + ); + + const content = tree.readContent(`${projectPath}/src/app/app.config.ts`); + const files = tree.files; + + expect(content).toMatch(/provideStore\(\)/); + expect(content).not.toMatch( + /import { store } from '\.\/store';/ + ); + expect(files.indexOf(`${projectPath}/src/app/store/index.ts`)).toBe( + -1 + ); + }); + }); +}); diff --git a/projects/angular-redux/schematics/ng-add/index.ts b/projects/angular-redux/schematics/ng-add/index.ts new file mode 100644 index 0000000..d82d96e --- /dev/null +++ b/projects/angular-redux/schematics/ng-add/index.ts @@ -0,0 +1,227 @@ +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from 'url'; +import * as ts from 'typescript'; +import { + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + applyTemplates, + branchAndMerge, + chain, + mergeWith, + url, + noop, + move, + filter, +} from '@angular-devkit/schematics'; +import { + NodePackageInstallTask, + RunSchematicTask, +} from '@angular-devkit/schematics/tasks'; +import { + InsertChange, + addImportToModule, + buildRelativePath, + findModuleFromOptions, + getProjectPath, + insertImport, + stringUtils, + addPackageToPackageJson, + parseName, +} from '../../schematics-core'; +import { Schema as AngularReduxOptions } from './schema'; +import { getProjectMainFile } from '../../schematics-core/utility/project'; +import { + addFunctionalProvidersToStandaloneBootstrap, + callsProvidersFunction, +} from '../../schematics-core/utility/standalone'; +import { isStandaloneApp } from '@schematics/angular/utility/ng-ast-utils'; + +function addImportToNgModule(options: AngularReduxOptions): Rule { + return (host: Tree) => { + const modulePath = options.module; + + if (!modulePath) { + return host; + } + + if (!host.exists(modulePath)) { + throw new Error('Specified module 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 storeModuleSetup = `provideRedux({store: store})`; + + const statePath = `/${options.path}/${options.storePath}`; + const relativePath = buildRelativePath(modulePath, statePath); + const [storeNgModuleImport] = addImportToModule( + source, + modulePath, + storeModuleSetup, + relativePath + ); + + let changes = [ + insertImport(source, modulePath, 'provideRedux', '@reduxjs/angular-redux'), + insertImport(source, modulePath, 'store', relativePath), + 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; + }; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const angularReduxPackageMeta = fs.readFile(path.resolve(__dirname, '../../package.json')) as { + version: string; + peerDependencies: { + [key: string]: string; + } +}; + +function addReduxDepsToPackageJson() { + return (host: Tree, context: SchematicContext) => { + addPackageToPackageJson( + host, + 'dependencies', + '@reduxjs/toolkit', + angularReduxPackageMeta.peerDependencies['@reduxjs/toolkit'] + ); + addPackageToPackageJson( + host, + 'dependencies', + 'redux', + angularReduxPackageMeta.peerDependencies['redux'] + ); + addPackageToPackageJson( + host, + 'dependencies', + '@reduxjs/angular-redux', + angularReduxPackageMeta.version + ); + context.addTask(new NodePackageInstallTask()); + return host; + }; +} + +function addStandaloneConfig(options: AngularReduxOptions): Rule { + return (host: Tree) => { + const mainFile = getProjectMainFile(host, options); + + if (host.exists(mainFile)) { + const storeProviderFn = 'provideRedux'; + + if (callsProvidersFunction(host, mainFile, storeProviderFn)) { + // exit because the store config is already provided + return host; + } + const storeProviderOptions = [ + ts.factory.createIdentifier('{ store }'), + ]; + const patchedConfigFile = addFunctionalProvidersToStandaloneBootstrap( + host, + mainFile, + storeProviderFn, + '@reduxjs/angular-redux', + storeProviderOptions + ); + + // insert reducers import into the patched file + const configFileContent = host.read(patchedConfigFile); + const source = ts.createSourceFile( + patchedConfigFile, + configFileContent?.toString('utf-8') || '', + ts.ScriptTarget.Latest, + true + ); + const statePath = `/${options.path}/${options.storePath}`; + const relativePath = buildRelativePath( + `/${patchedConfigFile}`, + statePath + ); + + const recorder = host.beginUpdate(patchedConfigFile); + + const change = insertImport( + source, + patchedConfigFile, + 'store', + relativePath + ); + + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + + host.commitUpdate(recorder); + + return host; + } + throw new SchematicsException( + `Main file not found for a project ${options.project}` + ); + }; +} + +export default function (options: AngularReduxOptions): Rule { + return (host: Tree, context: SchematicContext) => { + const mainFile = getProjectMainFile(host, options); + const isStandalone = isStandaloneApp(host, mainFile); + + options.path = getProjectPath(host, options); + + const parsedPath = parseName(options.path, ''); + options.path = parsedPath.path; + + if (options.module && !isStandalone) { + options.module = findModuleFromOptions(host, { + name: '', + module: options.module, + path: options.path, + }); + } + + const templateSource = apply(url('./files'), [ + applyTemplates({ + ...stringUtils, + ...options, + }), + move(parsedPath.path), + ]); + + const configOrModuleUpdate = isStandalone + ? addStandaloneConfig(options) + : addImportToNgModule(options); + + return chain([ + branchAndMerge(chain([configOrModuleUpdate, mergeWith(templateSource)])), + options && options.skipPackageJson ? noop() : addReduxDepsToPackageJson(), + ])(host, context); + }; +} diff --git a/projects/angular-redux/schematics/ng-add/schema.json b/projects/angular-redux/schematics/ng-add/schema.json new file mode 100644 index 0000000..7c2c0c6 --- /dev/null +++ b/projects/angular-redux/schematics/ng-add/schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "SchematicsAngularRedux", + "title": "Angular Redux Options Schema", + "type": "object", + "properties": { + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": "Do not add Redux packages as dependencies to package.json (e.g., --skipPackageJson)." + }, + "path": { + "type": "string", + "format": "path", + "description": "The path to create the state.", + "visible": false, + "$default": { + "$source": "workingDirectory" + } + }, + "project": { + "type": "string", + "description": "The name of the project.", + "aliases": ["p"] + }, + "module": { + "type": "string", + "default": "app", + "description": "Allows specification of the declaring module.", + "alias": "m", + "subtype": "filepath" + }, + "storePath": { + "type": "string", + "default": "store" + } + }, + "required": [] +} diff --git a/projects/angular-redux/schematics/ng-add/schema.ts b/projects/angular-redux/schematics/ng-add/schema.ts new file mode 100644 index 0000000..f9cc044 --- /dev/null +++ b/projects/angular-redux/schematics/ng-add/schema.ts @@ -0,0 +1,7 @@ +export interface Schema { + skipPackageJson?: boolean; + path?: string; + project?: string; + module?: string; + storePath?: string; +} diff --git a/projects/angular-redux/tsconfig.schematics.json b/projects/angular-redux/tsconfig.schematics.json new file mode 100644 index 0000000..d1f463a --- /dev/null +++ b/projects/angular-redux/tsconfig.schematics.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "rootDir": ".", + "stripInternal": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "downlevelIteration": true, + "outDir": "../../dist/modules/angular-redux", + "sourceMap": true, + "inlineSources": true, + "lib": ["es2018", "dom"], + "skipLibCheck": true, + "strict": true + }, + "include": ["migrations/**/*.ts", "schematics/**/*.ts", "schematics-core/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "angularCompilerOptions": { + "skipMetadataEmit": true, + "enableSummariesForJit": false, + "enableIvy": false + } +} diff --git a/yarn.lock b/yarn.lock index a113618..16d35f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3155,6 +3155,7 @@ __metadata: "@testing-library/user-event": "npm:^14.5.2" "@types/jasmine": "npm:~5.1.0" "@types/jest": "npm:^29.5.12" + "@types/node": "npm:^22.5.4" jasmine-core: "npm:~5.2.0" jest: "npm:^29.7.0" jest-environment-jsdom: "npm:^29.7.0" @@ -3167,6 +3168,21 @@ __metadata: languageName: unknown linkType: soft +"@reduxjs/angular-redux@workspace:projects/angular-redux": + version: 0.0.0-use.local + resolution: "@reduxjs/angular-redux@workspace:projects/angular-redux" + dependencies: + tslib: "npm:^2.3.0" + peerDependencies: + "@angular/common": ">=17.3.0" + "@angular/core": ">=17.3.0" + redux: ^5.0.0 + peerDependenciesMeta: + redux: + optional: true + languageName: unknown + linkType: soft + "@reduxjs/toolkit@npm:^2.2.7": version: 2.2.7 resolution: "@reduxjs/toolkit@npm:2.2.7" @@ -3887,7 +3903,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:^22.5.2": +"@types/node@npm:*, @types/node@npm:^22.5.2, @types/node@npm:^22.5.4": version: 22.5.4 resolution: "@types/node@npm:22.5.4" dependencies: From 243cbde40518dfee4100e586f42e57e19c82d4bb Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 10 Sep 2024 21:59:05 -0700 Subject: [PATCH 2/5] chore: fix up test configs --- angular.json | 3 + package.json | 5 +- projects/angular-redux/package.json | 8 +- .../angular-redux/schematics-core/index.ts | 2 - .../schematics-core/utility/project.ts | 5 +- .../angular-redux/schematics/jest.config.mjs | 13 ++ .../counter-slice.ts.template | 0 .../index.ts.template | 0 .../angular-redux/schematics/ng-add/index.ts | 6 +- .../schematics/tsconfig.spec.json | 15 +++ projects/angular-redux/tsconfig.spec.json | 4 + yarn.lock | 124 +++++++++++++++++- 12 files changed, 166 insertions(+), 19 deletions(-) create mode 100644 projects/angular-redux/schematics/jest.config.mjs rename projects/angular-redux/schematics/ng-add/files/{__statePath__ => __storePath__}/counter-slice.ts.template (100%) rename projects/angular-redux/schematics/ng-add/files/{__statePath__ => __storePath__}/index.ts.template (100%) create mode 100644 projects/angular-redux/schematics/tsconfig.spec.json diff --git a/angular.json b/angular.json index 473e9db..80f0bfb 100644 --- a/angular.json +++ b/angular.json @@ -27,6 +27,9 @@ "test": { "builder": "@angular-devkit/build-angular:jest", "options": { + "exclude": [ + "**/schematics/ng-add/*.spec.ts" + ], "tsConfig": "projects/angular-redux/tsconfig.spec.json", "polyfills": [ "zone.js", diff --git a/package.json b/package.json index e2fb70e..85c008c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test" + "test": "yarn test:ng && yarn test:schematics", + "test:ng": "ng test", + "test:schematics": "cd projects/angular-redux/schematics && jest" }, "workspaces": { "packages": [ @@ -44,6 +46,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "ng-packagr": "^18.2.0", + "ts-jest": "^29.2.5", "typescript": "~5.5.2" }, "packageManager": "yarn@4.1.0" diff --git a/projects/angular-redux/package.json b/projects/angular-redux/package.json index 7f5b4b0..f49196f 100644 --- a/projects/angular-redux/package.json +++ b/projects/angular-redux/package.json @@ -13,14 +13,14 @@ "peerDependencies": { "@angular/common": ">=17.3.0", "@angular/core": ">=17.3.0", - "redux": "^5.0.0", - "@reduxjs/toolkit": "^2.2.7" + "@reduxjs/toolkit": "^2.2.7", + "redux": "^5.0.0" }, "peerDependenciesMeta": { - "redux": { + "@reduxjs/toolkit": { "optional": true }, - "@reduxjs/toolkit": { + "redux": { "optional": true } }, diff --git a/projects/angular-redux/schematics-core/index.ts b/projects/angular-redux/schematics-core/index.ts index 155cc31..caf02f9 100644 --- a/projects/angular-redux/schematics-core/index.ts +++ b/projects/angular-redux/schematics-core/index.ts @@ -69,8 +69,6 @@ export const stringUtils = { pluralize, }; -export { updatePackage } from './utility/update'; - export { parseName } from './utility/parse-name'; export { addPackageToPackageJson } from './utility/package'; diff --git a/projects/angular-redux/schematics-core/utility/project.ts b/projects/angular-redux/schematics-core/utility/project.ts index 0e3e580..36b546f 100644 --- a/projects/angular-redux/schematics-core/utility/project.ts +++ b/projects/angular-redux/schematics-core/utility/project.ts @@ -1,4 +1,4 @@ -import { TargetDefinition } from '@angular-devkit/core/src/workspace'; +// import { TargetDefinition } from '@angular-devkit/core/src/workspace'; import { getWorkspace } from './config'; import { SchematicsException, Tree } from '@angular-devkit/schematics'; @@ -6,7 +6,8 @@ export interface WorkspaceProject { root: string; projectType: string; architect: { - [key: string]: TargetDefinition; + // [key: string]: TargetDefinition; + [key: string]: any; }; } diff --git a/projects/angular-redux/schematics/jest.config.mjs b/projects/angular-redux/schematics/jest.config.mjs new file mode 100644 index 0000000..991c5fc --- /dev/null +++ b/projects/angular-redux/schematics/jest.config.mjs @@ -0,0 +1,13 @@ +export default { + displayName: 'Schematics', + coverageDirectory: '../../coverage/modules/schematics', + transform: { + '^.+\\.(ts|mjs|js)$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json' + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'] +}; diff --git a/projects/angular-redux/schematics/ng-add/files/__statePath__/counter-slice.ts.template b/projects/angular-redux/schematics/ng-add/files/__storePath__/counter-slice.ts.template similarity index 100% rename from projects/angular-redux/schematics/ng-add/files/__statePath__/counter-slice.ts.template rename to projects/angular-redux/schematics/ng-add/files/__storePath__/counter-slice.ts.template diff --git a/projects/angular-redux/schematics/ng-add/files/__statePath__/index.ts.template b/projects/angular-redux/schematics/ng-add/files/__storePath__/index.ts.template similarity index 100% rename from projects/angular-redux/schematics/ng-add/files/__statePath__/index.ts.template rename to projects/angular-redux/schematics/ng-add/files/__storePath__/index.ts.template diff --git a/projects/angular-redux/schematics/ng-add/index.ts b/projects/angular-redux/schematics/ng-add/index.ts index d82d96e..0ad8d95 100644 --- a/projects/angular-redux/schematics/ng-add/index.ts +++ b/projects/angular-redux/schematics/ng-add/index.ts @@ -1,6 +1,5 @@ import * as fs from "fs"; import * as path from "path"; -import { fileURLToPath } from 'url'; import * as ts from 'typescript'; import { Rule, @@ -95,10 +94,7 @@ function addImportToNgModule(options: AngularReduxOptions): Rule { }; } -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const angularReduxPackageMeta = fs.readFile(path.resolve(__dirname, '../../package.json')) as { +const angularReduxPackageMeta = fs.readFileSync(path.resolve(__dirname, '../../package.json')) as unknown as { version: string; peerDependencies: { [key: string]: string; diff --git a/projects/angular-redux/schematics/tsconfig.spec.json b/projects/angular-redux/schematics/tsconfig.spec.json new file mode 100644 index 0000000..445ce27 --- /dev/null +++ b/projects/angular-redux/schematics/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "emitDecoratorMetadata": true, + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/projects/angular-redux/tsconfig.spec.json b/projects/angular-redux/tsconfig.spec.json index fe9e389..d120faa 100644 --- a/projects/angular-redux/tsconfig.spec.json +++ b/projects/angular-redux/tsconfig.spec.json @@ -12,5 +12,9 @@ "include": [ "**/*.spec.ts", "**/*.d.ts" + ], + "exclude": [ + "./schematics/**/*.ts", + "./schematics-core/**/*.ts" ] } diff --git a/yarn.lock b/yarn.lock index 16d35f9..7161173 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3162,6 +3162,7 @@ __metadata: ng-packagr: "npm:^18.2.0" redux: "npm:^5.0.1" rxjs: "npm:~7.8.0" + ts-jest: "npm:^29.2.5" tslib: "npm:^2.3.0" typescript: "npm:~5.5.2" zone.js: "npm:~0.14.10" @@ -3176,8 +3177,11 @@ __metadata: peerDependencies: "@angular/common": ">=17.3.0" "@angular/core": ">=17.3.0" + "@reduxjs/toolkit": ^2.2.7 redux: ^5.0.0 peerDependenciesMeta: + "@reduxjs/toolkit": + optional: true redux: optional: true languageName: unknown @@ -4499,6 +4503,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.3": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10/cb6e0561a3c01c4b56a799cc8bab6ea5fef45f069ab32500b6e19508db270ef2dffa55e5aed5865c5526e9907b1f8be61b27530823b411ffafb5e1538c86c368 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -4777,6 +4788,15 @@ __metadata: languageName: node linkType: hard +"bs-logger@npm:^0.2.6": + version: 0.2.6 + resolution: "bs-logger@npm:0.2.6" + dependencies: + fast-json-stable-stringify: "npm:2.x" + checksum: 10/e6d3ff82698bb3f20ce64fb85355c5716a3cf267f3977abe93bf9c32a2e46186b253f48a028ae5b96ab42bacd2c826766d9ae8cf6892f9b944656be9113cf212 + languageName: node + linkType: hard + "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -4915,7 +4935,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0, chalk@npm:^4.1.0": +"chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -5685,6 +5705,17 @@ __metadata: languageName: node linkType: hard +"ejs@npm:^3.1.10": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: "npm:^10.8.5" + bin: + ejs: bin/cli.js + checksum: 10/a9cb7d7cd13b7b1cd0be5c4788e44dd10d92f7285d2f65b942f33e127230c054f99a42db4d99f766d8dbc6c57e94799593ee66a14efd7c8dd70c4812bf6aa384 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.4": version: 1.5.18 resolution: "electron-to-chromium@npm:1.5.18" @@ -6324,7 +6355,7 @@ __metadata: languageName: node linkType: hard -"fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": +"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" checksum: 10/2c20055c1fa43c922428f16ca8bb29f2807de63e5c851f665f7ac9790176c01c3b40335257736b299764a8d383388dabc73c8083b8e1bc3d99f0a941444ec60e @@ -6365,6 +6396,15 @@ __metadata: languageName: node linkType: hard +"filelist@npm:^1.0.4": + version: 1.0.4 + resolution: "filelist@npm:1.0.4" + dependencies: + minimatch: "npm:^5.0.1" + checksum: 10/4b436fa944b1508b95cffdfc8176ae6947b92825483639ef1b9a89b27d82f3f8aa22b21eed471993f92709b431670d4e015b39c087d435a61e1bb04564cf51de + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -7402,6 +7442,20 @@ __metadata: languageName: node linkType: hard +"jake@npm:^10.8.5": + version: 10.9.2 + resolution: "jake@npm:10.9.2" + dependencies: + async: "npm:^3.2.3" + chalk: "npm:^4.0.2" + filelist: "npm:^1.0.4" + minimatch: "npm:^3.1.2" + bin: + jake: bin/cli.js + checksum: 10/3be324708f99f031e0aec49ef8fd872eb4583cbe8a29a0c875f554f6ac638ee4ea5aa759bb63723fd54f77ca6d7db851eaa78353301734ed3700db9cb109a0cd + languageName: node + linkType: hard + "jasmine-core@npm:~5.2.0": version: 5.2.0 resolution: "jasmine-core@npm:5.2.0" @@ -7794,7 +7848,7 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:^29.7.0": +"jest-util@npm:^29.0.0, jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" dependencies: @@ -8245,6 +8299,13 @@ __metadata: languageName: node linkType: hard +"lodash.memoize@npm:^4.1.2": + version: 4.1.2 + resolution: "lodash.memoize@npm:4.1.2" + checksum: 10/192b2168f310c86f303580b53acf81ab029761b9bd9caa9506a019ffea5f3363ea98d7e39e7e11e6b9917066c9d36a09a11f6fe16f812326390d8f3a54a1a6da + languageName: node + linkType: hard + "lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" @@ -8337,6 +8398,13 @@ __metadata: languageName: node linkType: hard +"make-error@npm:^1.3.6": + version: 1.3.6 + resolution: "make-error@npm:1.3.6" + checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 + languageName: node + linkType: hard + "make-fetch-happen@npm:^13.0.0, make-fetch-happen@npm:^13.0.1": version: 13.0.1 resolution: "make-fetch-happen@npm:13.0.1" @@ -8495,7 +8563,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1": +"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" dependencies: @@ -8504,6 +8572,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^5.0.1": + version: 5.1.6 + resolution: "minimatch@npm:5.1.6" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10/126b36485b821daf96d33b5c821dac600cc1ab36c87e7a532594f9b1652b1fa89a1eebcaad4dff17c764dce1a7ac1531327f190fed5f97d8f6e5f889c116c429 + languageName: node + linkType: hard + "minimatch@npm:^9.0.0, minimatch@npm:^9.0.4": version: 9.0.5 resolution: "minimatch@npm:9.0.5" @@ -10242,7 +10319,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.6.3, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4": +"semver@npm:7.6.3, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3": version: 7.6.3 resolution: "semver@npm:7.6.3" bin: @@ -10985,6 +11062,43 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:^29.2.5": + version: 29.2.5 + resolution: "ts-jest@npm:29.2.5" + dependencies: + bs-logger: "npm:^0.2.6" + ejs: "npm:^3.1.10" + fast-json-stable-stringify: "npm:^2.1.0" + jest-util: "npm:^29.0.0" + json5: "npm:^2.2.3" + lodash.memoize: "npm:^4.1.2" + make-error: "npm:^1.3.6" + semver: "npm:^7.6.3" + yargs-parser: "npm:^21.1.1" + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 + "@jest/types": ^29.0.0 + babel-jest: ^29.0.0 + jest: ^29.0.0 + typescript: ">=4.3 <6" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + bin: + ts-jest: cli.js + checksum: 10/f89e562816861ec4510840a6b439be6145f688b999679328de8080dc8e66481325fc5879519b662163e33b7578f35243071c38beb761af34e5fe58e3e326a958 + languageName: node + linkType: hard + "tslib@npm:2.6.3": version: 2.6.3 resolution: "tslib@npm:2.6.3" From d7881b3cb819a3869edcb5ac3d1ae14e3753900a Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 10 Sep 2024 22:02:11 -0700 Subject: [PATCH 3/5] chore: fix tests --- projects/angular-redux/schematics/ng-add/index.spec.ts | 6 +++--- projects/angular-redux/schematics/ng-add/index.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/angular-redux/schematics/ng-add/index.spec.ts b/projects/angular-redux/schematics/ng-add/index.spec.ts index 76c7245..24e0312 100644 --- a/projects/angular-redux/schematics/ng-add/index.spec.ts +++ b/projects/angular-redux/schematics/ng-add/index.spec.ts @@ -101,11 +101,11 @@ describe('Store ng-add Schematic', () => { const content = tree.readContent(`${projectPath}/src/app/app.config.ts`); const files = tree.files; - expect(content).toMatch(/provideStore\(\)/); - expect(content).not.toMatch( + expect(content).toMatch(/provideRedux\(\{ store \}\)/); + expect(content).toMatch( /import { store } from '\.\/store';/ ); - expect(files.indexOf(`${projectPath}/src/app/store/index.ts`)).toBe( + expect(files.indexOf(`${projectPath}/src/app/store/index.ts`)).not.toBe( -1 ); }); diff --git a/projects/angular-redux/schematics/ng-add/index.ts b/projects/angular-redux/schematics/ng-add/index.ts index 0ad8d95..c17cda2 100644 --- a/projects/angular-redux/schematics/ng-add/index.ts +++ b/projects/angular-redux/schematics/ng-add/index.ts @@ -94,7 +94,7 @@ function addImportToNgModule(options: AngularReduxOptions): Rule { }; } -const angularReduxPackageMeta = fs.readFileSync(path.resolve(__dirname, '../../package.json')) as unknown as { +const angularReduxPackageMeta = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../package.json'), "utf8")) as unknown as { version: string; peerDependencies: { [key: string]: string; From 62deaa450695a6a026242810c1e54fe4433e4026 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 10 Sep 2024 22:27:06 -0700 Subject: [PATCH 4/5] chore: fix output files for schematics --- package.json | 6 +- .../angular-redux/tsconfig.schematics.json | 2 +- yarn.lock | 130 +++++++++++++++++- 3 files changed, 131 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 85c008c..bc7d0ea 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "scripts": { "ng": "ng", "start": "ng serve", - "build": "ng build", + "build": "ng build angular-redux && tsc -p projects/angular-redux/tsconfig.schematics.json && yarn build:copy", + "build:copy": "cd ./projects/angular-redux/schematics && copyfiles \"**/*.json\" ../../../dist/angular-redux/schematics", + "build:ng": "ng build", "watch": "ng build --watch --configuration development", "test": "yarn test:ng && yarn test:schematics", "test:ng": "ng test", @@ -39,9 +41,11 @@ "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/user-event": "^14.5.2", + "@types/copyfiles": "^2", "@types/jasmine": "~5.1.0", "@types/jest": "^29.5.12", "@types/node": "^22.5.4", + "copyfiles": "^2.4.1", "jasmine-core": "~5.2.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/projects/angular-redux/tsconfig.schematics.json b/projects/angular-redux/tsconfig.schematics.json index d1f463a..65c5169 100644 --- a/projects/angular-redux/tsconfig.schematics.json +++ b/projects/angular-redux/tsconfig.schematics.json @@ -6,7 +6,7 @@ "experimentalDecorators": true, "moduleResolution": "node", "downlevelIteration": true, - "outDir": "../../dist/modules/angular-redux", + "outDir": "../../dist/angular-redux", "sourceMap": true, "inlineSources": true, "lib": ["es2018", "dom"], diff --git a/yarn.lock b/yarn.lock index 7161173..c3e1db4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3153,9 +3153,11 @@ __metadata: "@testing-library/dom": "npm:^10.0.0" "@testing-library/jest-dom": "npm:^6.4.8" "@testing-library/user-event": "npm:^14.5.2" + "@types/copyfiles": "npm:^2" "@types/jasmine": "npm:~5.1.0" "@types/jest": "npm:^29.5.12" "@types/node": "npm:^22.5.4" + copyfiles: "npm:^2.4.1" jasmine-core: "npm:~5.2.0" jest: "npm:^29.7.0" jest-environment-jsdom: "npm:^29.7.0" @@ -3766,6 +3768,13 @@ __metadata: languageName: node linkType: hard +"@types/copyfiles@npm:^2": + version: 2.4.4 + resolution: "@types/copyfiles@npm:2.4.4" + checksum: 10/0513199240828feda5f6ed04c69d6a642c47e6ab66b81214716807f948ed3e865e9b3d2b69f75cbcc6fbe2154630755c47ca473b3913f0a831179366c709a8cc + languageName: node + linkType: hard + "@types/estree@npm:1.0.5, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.5": version: 1.0.5 resolution: "@types/estree@npm:1.0.5" @@ -5055,6 +5064,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^7.0.2": + version: 7.0.4 + resolution: "cliui@npm:7.0.4" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.0" + wrap-ansi: "npm:^7.0.0" + checksum: 10/db858c49af9d59a32d603987e6fddaca2ce716cd4602ba5a2bb3a5af1351eebe82aba8dff3ef3e1b331f7fa9d40ca66e67bdf8e7c327ce0ea959747ead65c0ef + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -5281,6 +5301,24 @@ __metadata: languageName: node linkType: hard +"copyfiles@npm:^2.4.1": + version: 2.4.1 + resolution: "copyfiles@npm:2.4.1" + dependencies: + glob: "npm:^7.0.5" + minimatch: "npm:^3.0.3" + mkdirp: "npm:^1.0.4" + noms: "npm:0.0.0" + through2: "npm:^2.0.1" + untildify: "npm:^4.0.0" + yargs: "npm:^16.1.0" + bin: + copyfiles: copyfiles + copyup: copyfiles + checksum: 10/17070f88cbeaf62a9355341cb2521bacd48069e1ac8e7f95a3f69c848c53646f16ff0f94807a789e0f3eedc11407ec8d3980a13ab62e2add6ef81d0a5900fd85 + languageName: node + linkType: hard + "core-js-compat@npm:^3.37.1, core-js-compat@npm:^3.38.0": version: 3.38.1 resolution: "core-js-compat@npm:3.38.1" @@ -6671,7 +6709,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.3, glob@npm:^7.1.4": +"glob@npm:^7.0.5, glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -7094,7 +7132,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.1, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 @@ -7336,6 +7374,13 @@ __metadata: languageName: node linkType: hard +"isarray@npm:0.0.1": + version: 0.0.1 + resolution: "isarray@npm:0.0.1" + checksum: 10/49191f1425681df4a18c2f0f93db3adb85573bcdd6a4482539d98eac9e705d8961317b01175627e860516a2fc45f8f9302db26e5a380a97a520e272e2a40a8d4 + languageName: node + linkType: hard + "isarray@npm:~1.0.0": version: 1.0.0 resolution: "isarray@npm:1.0.0" @@ -8563,7 +8608,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": +"minimatch@npm:^3.0.3, minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" dependencies: @@ -8674,7 +8719,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^1.0.3": +"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" bin: @@ -8946,6 +8991,16 @@ __metadata: languageName: node linkType: hard +"noms@npm:0.0.0": + version: 0.0.0 + resolution: "noms@npm:0.0.0" + dependencies: + inherits: "npm:^2.0.1" + readable-stream: "npm:~1.0.31" + checksum: 10/a05f056dabf764c86472b6b5aad10455f3adcb6971f366cdf36a72b559b29310a940e316bca30802f2804fdd41707941366224f4cba80c4f53071512245bf200 + languageName: node + linkType: hard + "nopt@npm:^7.0.0": version: 7.2.1 resolution: "nopt@npm:7.2.1" @@ -9751,7 +9806,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.1": +"readable-stream@npm:^2.0.1, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -9777,6 +9832,18 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:~1.0.31": + version: 1.0.34 + resolution: "readable-stream@npm:1.0.34" + dependencies: + core-util-is: "npm:~1.0.0" + inherits: "npm:~2.0.1" + isarray: "npm:0.0.1" + string_decoder: "npm:~0.10.x" + checksum: 10/20537fca5a8ffd4af0f483be1cce0e981ed8cbb1087e0c762e2e92ae77f1005627272cebed8422f28047b465056aa1961fefd24baf532ca6a3616afea6811ae0 + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -10795,6 +10862,13 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:~0.10.x": + version: 0.10.31 + resolution: "string_decoder@npm:0.10.31" + checksum: 10/cc43e6b1340d4c7843da0e37d4c87a4084c2342fc99dcf6563c3ec273bb082f0cbd4ebf25d5da19b04fb16400d393885fda830be5128e1c416c73b5a6165f175 + languageName: node + linkType: hard + "string_decoder@npm:~1.1.1": version: 1.1.1 resolution: "string_decoder@npm:1.1.1" @@ -10977,6 +11051,16 @@ __metadata: languageName: node linkType: hard +"through2@npm:^2.0.1": + version: 2.0.5 + resolution: "through2@npm:2.0.5" + dependencies: + readable-stream: "npm:~2.3.6" + xtend: "npm:~4.0.1" + checksum: 10/cd71f7dcdc7a8204fea003a14a433ef99384b7d4e31f5497e1f9f622b3cf3be3691f908455f98723bdc80922a53af7fa10c3b7abbe51c6fd3d536dbc7850e2c4 + languageName: node + linkType: hard + "thunky@npm:^1.0.2": version: 1.1.0 resolution: "thunky@npm:1.1.0" @@ -11252,6 +11336,13 @@ __metadata: languageName: node linkType: hard +"untildify@npm:^4.0.0": + version: 4.0.0 + resolution: "untildify@npm:4.0.0" + checksum: 10/39ced9c418a74f73f0a56e1ba4634b4d959422dff61f4c72a8e39f60b99380c1b45ed776fbaa0a4101b157e4310d873ad7d114e8534ca02609b4916bb4187fb9 + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.1.0": version: 1.1.0 resolution: "update-browserslist-db@npm:1.1.0" @@ -11754,6 +11845,13 @@ __metadata: languageName: node linkType: hard +"xtend@npm:~4.0.1": + version: 4.0.2 + resolution: "xtend@npm:4.0.2" + checksum: 10/ac5dfa738b21f6e7f0dd6e65e1b3155036d68104e67e5d5d1bde74892e327d7e5636a076f625599dc394330a731861e87343ff184b0047fef1360a7ec0a5a36a + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -11775,6 +11873,13 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^20.2.2": + version: 20.2.9 + resolution: "yargs-parser@npm:20.2.9" + checksum: 10/0188f430a0f496551d09df6719a9132a3469e47fe2747208b1dd0ab2bb0c512a95d0b081628bbca5400fb20dbf2fabe63d22badb346cecadffdd948b049f3fcc + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -11797,6 +11902,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^16.1.0": + version: 16.2.0 + resolution: "yargs@npm:16.2.0" + dependencies: + cliui: "npm:^7.0.2" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.0" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^20.2.2" + checksum: 10/807fa21211d2117135d557f95fcd3c3d390530cda2eca0c840f1d95f0f40209dcfeb5ec18c785a1f3425896e623e3b2681e8bb7b6600060eda1c3f4804e7957e + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" From 49aacb339dc8a2eda260e3bb831a50da0eb51792 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Tue, 10 Sep 2024 22:33:29 -0700 Subject: [PATCH 5/5] docs: update README with `ng add` support --- projects/angular-redux/README.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/projects/angular-redux/README.md b/projects/angular-redux/README.md index 015dfb8..731d2a5 100644 --- a/projects/angular-redux/README.md +++ b/projects/angular-redux/README.md @@ -3,7 +3,6 @@ Official Angular bindings for [Redux](https://github.com/reduxjs/redux). Performant and flexible. - ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/reduxjs/angular-redux/test.yml?style=flat-square) [![npm version](https://img.shields.io/npm/v/@reduxjs/angular-redux.svg?style=flat-square)](https://www.npmjs.com/package/@reduxjs/angular-redux) [![npm downloads](https://img.shields.io/npm/dm/@reduxjs/angular-redux.svg?style=flat-square)](https://www.npmjs.com/package/@reduxjs/angular-redux) @@ -14,6 +13,32 @@ Performant and flexible. Angular Redux requires **Angular 17.3 or later**. +### Installing with `ng add` + +You can install the Store to your project with the following `ng add` command (details here): + +```sh +ng add @reduxjs/angular-redux@latest +``` + +#### Optional `ng add` flags + +| flag | description | value type | default value | +|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------|--------------- +| `--path` | Path to the module that you wish to add the import for the StoreModule to. | `string` | +| `--project` | Name of the project defined in your `angular.json` to help locating the module to add the `provideRedux` to. | `string` | +| `--module` | Name of file containing the module that you wish to add the import for the `provideRedux` to. Can also include the relative path to the file. For example, `src/app/app.module.ts`. | `string` | `app` +| `--storePath` | The file path to create the state in. | `string` | `store` | + +This command will automate the following steps: + +1. Update `package.json` > `dependencies` with Redux, Redux Toolkit, and Angular Redux +2. Run `npm install` to install those dependencies. +3. Update your `src/app/app.module.ts` > `imports` array with `provideRedux({store})` +4. If the project is using a `standalone bootstrap`, it adds `provideRedux({store})` into the application config. + +## Installing with `npm` or `yarn` + To use React Redux with your Angular app, install it as a dependency: ```bash @@ -35,7 +60,7 @@ modules](https://webpack.js.org/api/module-methods/#commonjs). The following Angular component works as-expected: -```angular-ts +```typescript import { Component } from '@angular/core' import { injectSelector, injectDispatch } from "@reduxjs/angular-redux"; import { decrement, increment } from './store/counter-slice'