diff --git a/build/config.ts b/build/config.ts index b07020ae9b..62b6f05848 100644 --- a/build/config.ts +++ b/build/config.ts @@ -20,7 +20,8 @@ export const packages: PackageDescription[] = fs return false; } - if (path.includes('eslint-plugin')) { + // TODO: Remove signals when released as a stable package + if (path.includes('eslint-plugin') || path.includes('signals')) { return false; } diff --git a/build/copy-schematics-core.ts b/build/copy-schematics-core.ts index 3e1a052eb0..5242857568 100644 --- a/build/copy-schematics-core.ts +++ b/build/copy-schematics-core.ts @@ -8,7 +8,8 @@ const copySchematics = createBuilder([ copySchematics({ scope: '@ngrx', - packages, + // TODO: Remove signals when released as a stable package + packages: [...packages, { name: 'signals' }], }).catch((err) => { console.error(err); process.exit(1); diff --git a/modules/signals/.eslintrc.json b/modules/signals/.eslintrc.json new file mode 100644 index 0000000000..6df2465dda --- /dev/null +++ b/modules/signals/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "schematics-core"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "parserOptions": { + "project": ["modules/signals/tsconfig.*?.json"] + }, + "rules": { + "@angular-eslint/directive-selector": "off", + "@angular-eslint/component-selector": "off" + }, + "plugins": ["@typescript-eslint"] + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/modules/signals/CHANGELOG.md b/modules/signals/CHANGELOG.md new file mode 100644 index 0000000000..65272ec9fc --- /dev/null +++ b/modules/signals/CHANGELOG.md @@ -0,0 +1,3 @@ +# Change Log + +See [CHANGELOG.md](https://github.com/ngrx/platform/blob/main/CHANGELOG.md) diff --git a/modules/signals/README.md b/modules/signals/README.md new file mode 100644 index 0000000000..3468965599 --- /dev/null +++ b/modules/signals/README.md @@ -0,0 +1,3 @@ +# @ngrx/signals + +The sources for this package are in the main [NgRx](https://github.com/ngrx/platform) repo. Please file issues and pull requests against that repo. diff --git a/modules/signals/index.ts b/modules/signals/index.ts new file mode 100644 index 0000000000..637e1cf2bf --- /dev/null +++ b/modules/signals/index.ts @@ -0,0 +1,7 @@ +/** + * DO NOT EDIT + * + * This file is automatically generated at build + */ + +export * from './public_api'; diff --git a/modules/signals/jest.config.ts b/modules/signals/jest.config.ts new file mode 100644 index 0000000000..dcc5d616d6 --- /dev/null +++ b/modules/signals/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'Signals', + preset: '../../jest.preset.js', + coverageDirectory: '../../coverage/modules/signals', + setupFilesAfterEnv: ['/test-setup.ts'], + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/modules/signals/migrations/migration.json b/modules/signals/migrations/migration.json new file mode 100644 index 0000000000..00b46c7ac6 --- /dev/null +++ b/modules/signals/migrations/migration.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": {} +} diff --git a/modules/signals/ng-package.json b/modules/signals/ng-package.json new file mode 100644 index 0000000000..ba83d2550c --- /dev/null +++ b/modules/signals/ng-package.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/modules/signals", + "assets": ["migrations/**/*.json", "schematics/**/*.json", "**/files/**/*"], + "lib": { + "entryFile": "index.ts" + } +} diff --git a/modules/signals/package.json b/modules/signals/package.json new file mode 100644 index 0000000000..0eb2a8e515 --- /dev/null +++ b/modules/signals/package.json @@ -0,0 +1,35 @@ +{ + "name": "@ngrx/signals", + "version": "0.0.0", + "description": "Reactive State Management with Angular Signals", + "repository": { + "type": "git", + "url": "https://github.com/ngrx/platform.git" + }, + "keywords": [ + "Angular", + "NgRx", + "Signals", + "Signal Store", + "Signal State", + "State Management" + ], + "author": "NgRx", + "license": "MIT", + "bugs": { + "url": "https://github.com/ngrx/platform/issues" + }, + "homepage": "https://github.com/ngrx/platform#readme", + "peerDependencies": { + "@angular/core": "^16.1.0" + }, + "schematics": "./schematics/collection.json", + "sideEffects": false, + "ng-update": { + "packageGroup": ["@ngrx/signals"], + "migrations": "./migrations/migration.json" + }, + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/modules/signals/project.json b/modules/signals/project.json new file mode 100644 index 0000000000..fc096253e6 --- /dev/null +++ b/modules/signals/project.json @@ -0,0 +1,54 @@ +{ + "name": "signals", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "modules/signals/src", + "prefix": "ngrx", + "targets": { + "build-package": { + "executor": "@angular-devkit/build-angular:ng-packagr", + "options": { + "tsConfig": "modules/signals/tsconfig.build.json", + "project": "modules/signals/ng-package.json" + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "parallel": false, + "commands": [ + { + "command": "nx build-package signals" + }, + { + "command": "yarn tsc -p modules/signals/tsconfig.schematics.json" + }, + { + "command": "cpy LICENSE dist/modules/signals" + } + ] + }, + "outputs": ["{workspaceRoot}/dist/modules/signals"] + }, + "lint": { + "executor": "@nx/linter:eslint", + "options": { + "lintFilePatterns": [ + "modules/signals/*/**/*.ts", + "modules/signals/*/**/*.html" + ] + }, + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/jest:jest", + "options": { + "jestConfig": "modules/signals/jest.config.ts", + "runInBand": true + }, + "outputs": ["{workspaceRoot}/coverage/modules/signals"] + } + }, + "generators": {}, + "tags": [] +} diff --git a/modules/signals/public_api.ts b/modules/signals/public_api.ts new file mode 100644 index 0000000000..cba1843545 --- /dev/null +++ b/modules/signals/public_api.ts @@ -0,0 +1 @@ +export * from './src/index'; diff --git a/modules/signals/schematics-core/index.ts b/modules/signals/schematics-core/index.ts new file mode 100644 index 0000000000..bef4181cd1 --- /dev/null +++ b/modules/signals/schematics-core/index.ts @@ -0,0 +1,94 @@ +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 { + Host, + Change, + NoopChange, + InsertChange, + RemoveChange, + ReplaceChange, + createReplaceChange, + createChangeRecorder, + commitChanges, +} from './utility/change'; + +export { AppConfig, getWorkspace, getWorkspacePath } from './utility/config'; + +export { findComponentFromOptions } from './utility/find-component'; + +export { + findModule, + findModuleFromOptions, + buildRelativePath, + ModuleOptions, +} from './utility/find-module'; + +export { findPropertyInAstObject } from './utility/json-utilts'; + +export { + addReducerToState, + addReducerToStateInterface, + addReducerImportToNgModule, + addReducerToActionReducerMap, + omit, + getPrefix, +} from './utility/ngrx-utils'; + +export { getProjectPath, getProject, isLib } from './utility/project'; + +export const stringUtils = { + dasherize, + decamelize, + camelize, + classify, + underscore, + group, + capitalize, + featurePath, + pluralize, +}; + +export { updatePackage } from './utility/update'; + +export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; + +export { + visitTSSourceFiles, + visitNgModuleImports, + visitNgModuleExports, + visitComponents, + visitDecorator, + visitNgModules, + visitTemplates, +} from './utility/visitors'; diff --git a/modules/signals/schematics-core/jest.config.js b/modules/signals/schematics-core/jest.config.js new file mode 100644 index 0000000000..d27598e702 --- /dev/null +++ b/modules/signals/schematics-core/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + displayName: 'Schematics Core', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/test-setup.ts'], + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + astTransformers: { + before: [ + 'jest-preset-angular/build/InlineFilesTransformer', + 'jest-preset-angular/build/StripStylesTransformer', + ], + }, + }, + }, + coverageDirectory: '../../coverage/modules/schematics-core', +}; diff --git a/modules/signals/schematics-core/jest.config.ts b/modules/signals/schematics-core/jest.config.ts new file mode 100644 index 0000000000..ee892fd517 --- /dev/null +++ b/modules/signals/schematics-core/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'Schematics Core', + preset: '../../jest.preset.js', + coverageDirectory: '../../coverage/modules/schematics-core', + setupFilesAfterEnv: ['/test-setup.ts'], + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/modules/signals/schematics-core/test-setup.ts b/modules/signals/schematics-core/test-setup.ts new file mode 100644 index 0000000000..1100b3e8a6 --- /dev/null +++ b/modules/signals/schematics-core/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/modules/signals/schematics-core/tsconfig.json b/modules/signals/schematics-core/tsconfig.json new file mode 100644 index 0000000000..546e940e20 --- /dev/null +++ b/modules/signals/schematics-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/modules/signals/schematics-core/tsconfig.lib.json b/modules/signals/schematics-core/tsconfig.lib.json new file mode 100644 index 0000000000..c0f1097a2b --- /dev/null +++ b/modules/signals/schematics-core/tsconfig.lib.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "stripInternal": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "downlevelIteration": true, + "outDir": "../../dist/modules/schematics-score", + "sourceMap": true, + "inlineSources": true, + "lib": ["es2018", "dom"], + "skipLibCheck": true, + "strict": true + }, + "include": ["**/*.ts"], + "exclude": ["**/*.spec.ts", "test-setup.ts"], + "angularCompilerOptions": { + "skipMetadataEmit": true, + "enableSummariesForJit": false, + "enableIvy": false + } +} diff --git a/modules/signals/schematics-core/tsconfig.spec.json b/modules/signals/schematics-core/tsconfig.spec.json new file mode 100644 index 0000000000..dcb4681eef --- /dev/null +++ b/modules/signals/schematics-core/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"], + "target": "es2016" + }, + "files": ["test-setup.ts"], + "include": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] +} diff --git a/modules/signals/schematics-core/utility/ast-utils.ts b/modules/signals/schematics-core/utility/ast-utils.ts new file mode 100644 index 0000000000..5f7475c438 --- /dev/null +++ b/modules/signals/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/modules/signals/schematics-core/utility/change.ts b/modules/signals/schematics-core/utility/change.ts new file mode 100644 index 0000000000..3b8ed0cd2e --- /dev/null +++ b/modules/signals/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/modules/signals/schematics-core/utility/config.ts b/modules/signals/schematics-core/utility/config.ts new file mode 100644 index 0000000000..06daabcfe0 --- /dev/null +++ b/modules/signals/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/modules/signals/schematics-core/utility/find-component.ts b/modules/signals/schematics-core/utility/find-component.ts new file mode 100644 index 0000000000..bb59ec375b --- /dev/null +++ b/modules/signals/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/modules/signals/schematics-core/utility/find-module.ts b/modules/signals/schematics-core/utility/find-module.ts new file mode 100644 index 0000000000..134a59f0e6 --- /dev/null +++ b/modules/signals/schematics-core/utility/find-module.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { + Path, + join, + normalize, + relative, + strings, + basename, + extname, + dirname, +} from '@angular-devkit/core'; +import { DirEntry, Tree } from '@angular-devkit/schematics'; + +export interface ModuleOptions { + module?: string; + name: string; + flat?: boolean; + path?: string; + skipImport?: boolean; +} + +/** + * Find the module referred by a set of options passed to the schematics. + */ +export function findModuleFromOptions( + host: Tree, + options: ModuleOptions +): Path | undefined { + if (options.hasOwnProperty('skipImport') && options.skipImport) { + return undefined; + } + + if (!options.module) { + const pathToCheck = + (options.path || '') + + (options.flat ? '' : '/' + strings.dasherize(options.name)); + + return normalize(findModule(host, pathToCheck)); + } else { + const modulePath = normalize('/' + options.path + '/' + options.module); + const moduleBaseName = normalize(modulePath).split('/').pop(); + + if (host.exists(modulePath)) { + return normalize(modulePath); + } else if (host.exists(modulePath + '.ts')) { + return normalize(modulePath + '.ts'); + } else if (host.exists(modulePath + '.module.ts')) { + return normalize(modulePath + '.module.ts'); + } else if (host.exists(modulePath + '/' + moduleBaseName + '.module.ts')) { + return normalize(modulePath + '/' + moduleBaseName + '.module.ts'); + } else { + throw new Error(`Specified module path ${modulePath} does not exist`); + } + } +} + +/** + * Function to find the "closest" module to a generated file's path. + */ +export function findModule(host: Tree, generateDir: string): Path { + let dir: DirEntry | null = host.getDir('/' + generateDir); + + const moduleRe = /\.module\.ts$/; + const routingModuleRe = /-routing\.module\.ts/; + + while (dir) { + const matches = dir.subfiles.filter( + (p) => moduleRe.test(p) && !routingModuleRe.test(p) + ); + + if (matches.length == 1) { + return join(dir.path, matches[0]); + } else if (matches.length > 1) { + throw new Error( + 'More than one module matches. Use skip-import option to skip importing ' + + 'the component into the closest module.' + ); + } + + dir = dir.parent; + } + + throw new Error( + 'Could not find an NgModule. Use the skip-import ' + + 'option to skip importing in NgModule.' + ); +} + +/** + * Build a relative path from one file path to another file path. + */ +export function buildRelativePath(from: string, to: string): string { + const { + path: fromPath, + filename: fromFileName, + directory: fromDirectory, + } = parsePath(from); + const { + path: toPath, + filename: toFileName, + directory: toDirectory, + } = parsePath(to); + const relativePath = relative(fromDirectory, toDirectory); + const fixedRelativePath = relativePath.startsWith('.') + ? relativePath + : `./${relativePath}`; + + return !toFileName || toFileName === 'index.ts' + ? fixedRelativePath + : `${ + fixedRelativePath.endsWith('/') + ? fixedRelativePath + : fixedRelativePath + '/' + }${convertToTypeScriptFileName(toFileName)}`; +} + +function parsePath(path: string) { + const pathNormalized = normalize(path) as Path; + const filename = extname(pathNormalized) ? basename(pathNormalized) : ''; + const directory = filename ? dirname(pathNormalized) : pathNormalized; + return { + path: pathNormalized, + filename, + directory, + }; +} +/** + * Strips the typescript extension and clears index filenames + * foo.ts -> foo + * index.ts -> empty + */ +function convertToTypeScriptFileName(filename: string | undefined) { + return filename ? filename.replace(/(\.ts)|(index\.ts)$/, '') : ''; +} diff --git a/modules/signals/schematics-core/utility/json-utilts.ts b/modules/signals/schematics-core/utility/json-utilts.ts new file mode 100644 index 0000000000..be6b7b88ad --- /dev/null +++ b/modules/signals/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/modules/signals/schematics-core/utility/libs-version.ts b/modules/signals/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..3e611dd599 --- /dev/null +++ b/modules/signals/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^16.2.0'; diff --git a/modules/signals/schematics-core/utility/ngrx-utils.ts b/modules/signals/schematics-core/utility/ngrx-utils.ts new file mode 100644 index 0000000000..cf58124fce --- /dev/null +++ b/modules/signals/schematics-core/utility/ngrx-utils.ts @@ -0,0 +1,274 @@ +import * as ts from 'typescript'; +import * as stringUtils from './strings'; +import { InsertChange, Change, NoopChange } from './change'; +import { Tree, SchematicsException, Rule } from '@angular-devkit/schematics'; +import { normalize } from '@angular-devkit/core'; +import { buildRelativePath } from './find-module'; +import { addImportToModule, insertImport } from './ast-utils'; + +export function addReducerToState(options: any): Rule { + return (host: Tree) => { + if (!options.reducers) { + return host; + } + + const reducersPath = normalize(`/${options.path}/${options.reducers}`); + + if (!host.exists(reducersPath)) { + throw new Error(`Specified reducers path ${reducersPath} does not exist`); + } + + const text = host.read(reducersPath); + if (text === null) { + throw new SchematicsException(`File ${reducersPath} does not exist.`); + } + + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + reducersPath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const reducerPath = + `/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + (options.group ? 'reducers/' : '') + + stringUtils.dasherize(options.name) + + '.reducer'; + + const relativePath = buildRelativePath(reducersPath, reducerPath); + const reducerImport = insertImport( + source, + reducersPath, + `* as from${stringUtils.classify(options.name)}`, + relativePath, + true + ); + + const stateInterfaceInsert = addReducerToStateInterface( + source, + reducersPath, + options + ); + const reducerMapInsert = addReducerToActionReducerMap( + source, + reducersPath, + options + ); + + const changes = [reducerImport, stateInterfaceInsert, reducerMapInsert]; + const recorder = host.beginUpdate(reducersPath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +/** + * Insert the reducer into the first defined top level interface + */ +export function addReducerToStateInterface( + source: ts.SourceFile, + reducersPath: string, + options: { name: string; plural: boolean } +): Change { + const stateInterface = source.statements.find( + (stm) => stm.kind === ts.SyntaxKind.InterfaceDeclaration + ); + let node = stateInterface as ts.Statement; + + if (!node) { + return new NoopChange(); + } + + const state = options.plural + ? stringUtils.pluralize(options.name) + : stringUtils.camelize(options.name); + + const keyInsert = `[from${stringUtils.classify( + options.name + )}.${stringUtils.camelize(state)}FeatureKey]: from${stringUtils.classify( + options.name + )}.State;`; + const expr = node as any; + let position; + let toInsert; + + if (expr.members.length === 0) { + position = expr.getEnd() - 1; + toInsert = ` ${keyInsert}\n`; + } else { + node = expr.members[expr.members.length - 1]; + position = node.getEnd() + 1; + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n+(\s*)/); + + if (matches && matches.length > 0) { + toInsert = `${matches[1]}${keyInsert}\n`; + } else { + toInsert = `\n${keyInsert}`; + } + } + + return new InsertChange(reducersPath, position, toInsert); +} + +/** + * Insert the reducer into the ActionReducerMap + */ +export function addReducerToActionReducerMap( + source: ts.SourceFile, + reducersPath: string, + options: { name: string; plural: boolean } +): Change { + let initializer: any; + const actionReducerMap: any = source.statements + .filter((stm) => stm.kind === ts.SyntaxKind.VariableStatement) + .filter((stm: any) => !!stm.declarationList) + .map((stm: any) => { + const { + declarations, + }: { + declarations: ts.SyntaxKind.VariableDeclarationList[]; + } = stm.declarationList; + const variable: any = declarations.find( + (decl: any) => decl.kind === ts.SyntaxKind.VariableDeclaration + ); + const type = variable ? variable.type : {}; + + return { initializer: variable.initializer, type }; + }) + .filter((initWithType) => initWithType.type !== undefined) + .find(({ type }) => type.typeName.text === 'ActionReducerMap'); + + if (!actionReducerMap || !actionReducerMap.initializer) { + return new NoopChange(); + } + + let node = actionReducerMap.initializer; + + const state = options.plural + ? stringUtils.pluralize(options.name) + : stringUtils.camelize(options.name); + + const keyInsert = `[from${stringUtils.classify( + options.name + )}.${stringUtils.camelize(state)}FeatureKey]: from${stringUtils.classify( + options.name + )}.reducer,`; + const expr = node as any; + let position; + let toInsert; + + if (expr.properties.length === 0) { + position = expr.getEnd() - 1; + toInsert = ` ${keyInsert}\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd() + 1; + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n+(\s*)/); + + if (matches && matches.length > 0) { + toInsert = `\n${matches[1]}${keyInsert}`; + } else { + toInsert = `\n${keyInsert}`; + } + } + + return new InsertChange(reducersPath, position, toInsert); +} + +/** + * Add reducer feature to NgModule + */ +export function addReducerImportToNgModule(options: any): Rule { + return (host: Tree) => { + if (!options.module) { + return host; + } + + const modulePath = options.module; + if (!host.exists(options.module)) { + throw new Error(`Specified module path ${modulePath} does not exist`); + } + + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const commonImports = [ + insertImport(source, modulePath, 'StoreModule', '@ngrx/store'), + ]; + + const reducerPath = + `/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + (options.group ? 'reducers/' : '') + + stringUtils.dasherize(options.name) + + '.reducer'; + const relativePath = buildRelativePath(modulePath, reducerPath); + const reducerImport = insertImport( + source, + modulePath, + `* as from${stringUtils.classify(options.name)}`, + relativePath, + true + ); + const state = options.plural + ? stringUtils.pluralize(options.name) + : stringUtils.camelize(options.name); + const [storeNgModuleImport] = addImportToModule( + source, + modulePath, + `StoreModule.forFeature(from${stringUtils.classify( + options.name + )}.${state}FeatureKey, from${stringUtils.classify( + options.name + )}.reducer)`, + relativePath + ); + const changes = [...commonImports, reducerImport, storeNgModuleImport]; + const recorder = host.beginUpdate(modulePath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +export function omit( + object: T, + keyToRemove: keyof T +): Partial { + return Object.keys(object) + .filter((key) => key !== keyToRemove) + .reduce((result, key) => Object.assign(result, { [key]: object[key] }), {}); +} + +export function getPrefix(options: { prefix?: string }) { + return stringUtils.camelize(options.prefix || 'load'); +} diff --git a/modules/signals/schematics-core/utility/package.ts b/modules/signals/schematics-core/utility/package.ts new file mode 100644 index 0000000000..9ebbbed15f --- /dev/null +++ b/modules/signals/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')?.toString('utf-8') ?? '{}'; + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/signals/schematics-core/utility/parse-name.ts b/modules/signals/schematics-core/utility/parse-name.ts new file mode 100644 index 0000000000..a48f56b8ca --- /dev/null +++ b/modules/signals/schematics-core/utility/parse-name.ts @@ -0,0 +1,16 @@ +import { Path, basename, dirname, normalize } from '@angular-devkit/core'; + +export interface Location { + name: string; + path: Path; +} + +export function parseName(path: string, name: string): Location { + const nameWithoutPath = basename(name as Path); + const namePath = dirname((path + '/' + name) as Path); + + return { + name: nameWithoutPath, + path: normalize('/' + namePath), + }; +} diff --git a/modules/signals/schematics-core/utility/project.ts b/modules/signals/schematics-core/utility/project.ts new file mode 100644 index 0000000000..824c39aacd --- /dev/null +++ b/modules/signals/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) { + throw new SchematicsException(`Could not find the main file`); + } + + return projectOptions.main as string; +} diff --git a/modules/signals/schematics-core/utility/standalone.ts b/modules/signals/schematics-core/utility/standalone.ts new file mode 100644 index 0000000000..5262a80473 --- /dev/null +++ b/modules/signals/schematics-core/utility/standalone.ts @@ -0,0 +1,15 @@ +import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; +import { findBootstrapApplicationCall } from '@schematics/angular/private/standalone'; + +export function isStandaloneApp(host: Tree, mainPath: string): boolean { + const source = ts.createSourceFile( + mainPath, + host.readText(mainPath), + ts.ScriptTarget.Latest, + true + ); + const bootstrapCall = findBootstrapApplicationCall(source); + + return bootstrapCall !== null; +} diff --git a/modules/signals/schematics-core/utility/strings.ts b/modules/signals/schematics-core/utility/strings.ts new file mode 100644 index 0000000000..38e9924f15 --- /dev/null +++ b/modules/signals/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/modules/signals/schematics-core/utility/update.ts b/modules/signals/schematics-core/utility/update.ts new file mode 100644 index 0000000000..7e79cda781 --- /dev/null +++ b/modules/signals/schematics-core/utility/update.ts @@ -0,0 +1,43 @@ +import { + Rule, + SchematicContext, + Tree, + SchematicsException, +} from '@angular-devkit/schematics'; + +export function updatePackage(name: string): Rule { + return (tree: Tree, context: SchematicContext) => { + const pkgPath = '/package.json'; + const buffer = tree.read(pkgPath); + if (buffer === null) { + throw new SchematicsException('Could not read package.json'); + } + const content = buffer.toString(); + const pkg = JSON.parse(content); + + if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) { + throw new SchematicsException('Error reading package.json'); + } + + const dependencyCategories = ['dependencies', 'devDependencies']; + + dependencyCategories.forEach((category) => { + const packageName = `@ngrx/${name}`; + + if (pkg[category] && pkg[category][packageName]) { + const firstChar = pkg[category][packageName][0]; + const suffix = match(firstChar, '^') || match(firstChar, '~'); + + pkg[category][packageName] = `${suffix}6.0.0`; + } + }); + + tree.overwrite(pkgPath, JSON.stringify(pkg, null, 2)); + + return tree; + }; +} + +function match(value: string, test: string) { + return value === test ? test : ''; +} diff --git a/modules/signals/schematics-core/utility/visitors.ts b/modules/signals/schematics-core/utility/visitors.ts new file mode 100644 index 0000000000..fa4edd06af --- /dev/null +++ b/modules/signals/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/modules/signals/schematics/collection.json b/modules/signals/schematics/collection.json new file mode 100644 index 0000000000..5644cf0dcd --- /dev/null +++ b/modules/signals/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "schematics": { + "ng-add": { + "aliases": ["init"], + "factory": "./ng-add", + "schema": "./ng-add/schema.json", + "description": "Add @ngrx/signals to your application" + } + } +} diff --git a/modules/signals/schematics/ng-add/index.spec.ts b/modules/signals/schematics/ng-add/index.spec.ts new file mode 100644 index 0000000000..be0eb8003c --- /dev/null +++ b/modules/signals/schematics/ng-add/index.spec.ts @@ -0,0 +1,41 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { Schema as SchemaOptions } from './schema'; +import { createWorkspace } from '@ngrx/schematics-core/testing'; + +describe('Signals ng-add Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/signals', + path.join(__dirname, '../collection.json') + ); + const defaultOptions: SchemaOptions = { + skipPackageJson: false, + }; + + let appTree: UnitTestTree; + + beforeEach(async () => { + appTree = await createWorkspace(schematicRunner, appTree); + }); + + it('should update package.json', async () => { + const options = { ...defaultOptions }; + + const tree = await schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(tree.readContent('/package.json')); + + expect(packageJson.dependencies['@ngrx/signals']).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['@ngrx/signals']).toBeUndefined(); + }); +}); diff --git a/modules/signals/schematics/ng-add/index.ts b/modules/signals/schematics/ng-add/index.ts new file mode 100644 index 0000000000..ebb6cdae2f --- /dev/null +++ b/modules/signals/schematics/ng-add/index.ts @@ -0,0 +1,34 @@ +import { + Rule, + SchematicContext, + Tree, + chain, + noop, +} from '@angular-devkit/schematics'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; +import { + addPackageToPackageJson, + platformVersion, +} from '../../schematics-core'; +import { Schema as SchemaOptions } from './schema'; + +function addModuleToPackageJson() { + return (host: Tree, context: SchematicContext) => { + addPackageToPackageJson( + host, + 'dependencies', + '@ngrx/signals', + platformVersion + ); + context.addTask(new NodePackageInstallTask()); + return host; + }; +} + +export default function (options: SchemaOptions): Rule { + return (host: Tree, context: SchematicContext) => { + return chain([ + options && options.skipPackageJson ? noop() : addModuleToPackageJson(), + ])(host, context); + }; +} diff --git a/modules/signals/schematics/ng-add/schema.json b/modules/signals/schematics/ng-add/schema.json new file mode 100644 index 0000000000..3425e3142f --- /dev/null +++ b/modules/signals/schematics/ng-add/schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "SchematicsNgRxSignals", + "title": "NgRx Signals Schema", + "type": "object", + "properties": { + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": "Do not add @ngrx/signals as dependency to package.json (e.g., --skipPackageJson)." + } + }, + "required": [] +} diff --git a/modules/signals/schematics/ng-add/schema.ts b/modules/signals/schematics/ng-add/schema.ts new file mode 100644 index 0000000000..c629d7fae1 --- /dev/null +++ b/modules/signals/schematics/ng-add/schema.ts @@ -0,0 +1,3 @@ +export interface Schema { + skipPackageJson?: boolean; +} diff --git a/modules/signals/spec/index.spec.ts b/modules/signals/spec/index.spec.ts new file mode 100644 index 0000000000..0deeacca10 --- /dev/null +++ b/modules/signals/spec/index.spec.ts @@ -0,0 +1,5 @@ +describe('NgRx Signals', () => { + it('should work', () => { + expect(true).toBe(true); + }); +}); diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts new file mode 100644 index 0000000000..7f810d3f32 --- /dev/null +++ b/modules/signals/src/index.ts @@ -0,0 +1 @@ +export default 0; diff --git a/modules/signals/test-setup.ts b/modules/signals/test-setup.ts new file mode 100644 index 0000000000..1100b3e8a6 --- /dev/null +++ b/modules/signals/test-setup.ts @@ -0,0 +1 @@ +import 'jest-preset-angular/setup-jest'; diff --git a/modules/signals/tsconfig.build.json b/modules/signals/tsconfig.build.json new file mode 100644 index 0000000000..68760a9c13 --- /dev/null +++ b/modules/signals/tsconfig.build.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": "../../", + "declaration": true, + "stripInternal": true, + "experimentalDecorators": true, + "downlevelIteration": true, + "module": "ES2022", + "moduleResolution": "node", + "outDir": "../../dist/modules/signals", + "paths": {}, + "rootDir": ".", + "sourceMap": true, + "inlineSources": true, + "lib": ["ES2022", "dom"], + "target": "ES2022", + "skipLibCheck": true, + "strict": true + }, + "files": ["public_api.ts"], + "include": ["**/*.ts"], + "exclude": ["**/*.spec.ts"], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "compilationMode": "partial", + "flatModuleOutFile": "index.js", + "flatModuleId": "@ngrx/signals" + } +} diff --git a/modules/signals/tsconfig.schematics.json b/modules/signals/tsconfig.schematics.json new file mode 100644 index 0000000000..c5bb5a37e5 --- /dev/null +++ b/modules/signals/tsconfig.schematics.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "rootDir": ".", + "stripInternal": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "downlevelIteration": true, + "outDir": "../../dist/modules/signals", + "paths": { + "@ngrx/signals/schematics-core": ["./schematics-core"] + }, + "sourceMap": true, + "inlineSources": true, + "lib": ["es2018", "dom"], + "skipLibCheck": true, + "strict": true + }, + "include": ["migrations/**/*.ts", "schematics/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "angularCompilerOptions": { + "skipMetadataEmit": true, + "enableSummariesForJit": false, + "enableIvy": false + } +} diff --git a/modules/signals/tsconfig.spec.json b/modules/signals/tsconfig.spec.json new file mode 100644 index 0000000000..dcb4681eef --- /dev/null +++ b/modules/signals/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"], + "target": "es2016" + }, + "files": ["test-setup.ts"], + "include": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index ed4aa00643..32d0b6f1e3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,6 +42,8 @@ "@ngrx/schematics/schematics-core": [ "./modules/schematics/schematics-core" ], + "@ngrx/signals": ["./modules/signals"], + "@ngrx/signals/schematics-core": ["./modules/signals/schematics-core"], "@ngrx/store": ["./modules/store"], "@ngrx/store-devtools": ["./modules/store-devtools"], "@ngrx/store-devtools/schematics-core": [