diff --git a/projects/schematics/src/collection.json b/projects/schematics/src/collection.json index a68f1a19f8d..1525c799bd2 100644 --- a/projects/schematics/src/collection.json +++ b/projects/schematics/src/collection.json @@ -31,6 +31,14 @@ "description": "Generate a feature wrapper module", "factory": "./wrapper-module/index#generateWrapperModule", "aliases": ["wrapper"] + }, + "modernize-app-migrated-from-6_8-to-2211_19": { + "description": "Modernize Angular application migrated from 6.8 to 2211.19", + "factory": "./modernize-app-migrated-from-6_8-to-2211_19/index#migrate" + }, + "modernize-app-migrated-from-2211_32-to-2211_35": { + "description": "Modernize Angular application migrated from 2211.32 to 2211.35", + "factory": "./modernize-app-migrated-from-2211_32-to-2211_35/index#migrate" } } } diff --git a/projects/schematics/src/migrations/2211_19/ssr/ssr.ts b/projects/schematics/src/migrations/2211_19/ssr/ssr.ts index 88c3254af45..7a6968881f7 100644 --- a/projects/schematics/src/migrations/2211_19/ssr/ssr.ts +++ b/projects/schematics/src/migrations/2211_19/ssr/ssr.ts @@ -5,11 +5,11 @@ */ import { noop, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; -import { checkIfSSRIsUsed } from '../../../shared/utils/package-utils'; +import { isUsingLegacyServerBuilder as isOldSsrUsed } from '../../../shared/utils/package-utils'; import { updateServerFiles } from '../update-ssr/update-ssr-files'; export function migrate(): Rule { return (tree: Tree, _context: SchematicContext) => { - return checkIfSSRIsUsed(tree) ? updateServerFiles() : noop(); + return isOldSsrUsed(tree) ? updateServerFiles() : noop(); }; } diff --git a/projects/schematics/src/migrations/2211_35/ssr/ssr.ts b/projects/schematics/src/migrations/2211_35/ssr/ssr.ts index d0c4b3a943e..e10de304c63 100644 --- a/projects/schematics/src/migrations/2211_35/ssr/ssr.ts +++ b/projects/schematics/src/migrations/2211_35/ssr/ssr.ts @@ -5,13 +5,11 @@ */ import { noop, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; -import { checkIfSSRIsUsedWithApplicationBuilder } from '../../../shared/utils/package-utils'; +import { isSsrUsed } from '../../../shared/utils/package-utils'; import { updateServerFile } from './update-ssr/update-server-files'; export function migrate(): Rule { return (tree: Tree, _context: SchematicContext) => { - return checkIfSSRIsUsedWithApplicationBuilder(tree) - ? updateServerFile() - : noop(); + return isSsrUsed(tree) ? updateServerFile() : noop(); }; } diff --git a/projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts b/projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts index fbe85601c50..416c31fd52e 100644 --- a/projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts +++ b/projects/schematics/src/migrations/2211_35/ssr/ssr_spec.ts @@ -5,7 +5,7 @@ import * as shared from '../../../shared/utils/package-utils'; jest.mock('../../../shared/utils/package-utils', () => ({ ...jest.requireActual('../../../shared/utils/package-utils'), - checkIfSSRIsUsedWithApplicationBuilder: jest.fn(), + isSsrUsed: jest.fn(), })); const collectionPath = join(__dirname, '../../migrations.json'); @@ -68,9 +68,7 @@ describe('Update SSR Migration', () => { it.each(['/server.ts', '/src/server.ts'])( 'should update %s when using application builder and SSR is used', async (filePath) => { - ( - shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock - ).mockReturnValue(true); + (shared.isSsrUsed as jest.Mock).mockReturnValue(true); tree.create(filePath, serverFileContent); const newTree = await runner.runSchematic( @@ -83,16 +81,12 @@ describe('Update SSR Migration', () => { expect(content).toContain('export function app()'); expect(content).toContain("join(serverDistFolder, 'index.server.html')"); expect(content).not.toContain("join(browserDistFolder, 'index.html')"); - expect( - shared.checkIfSSRIsUsedWithApplicationBuilder - ).toHaveBeenCalledWith(tree); + expect(shared.isSsrUsed).toHaveBeenCalledWith(tree); } ); it('should not update when SSR is not used', async () => { - ( - shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock - ).mockReturnValue(false); + (shared.isSsrUsed as jest.Mock).mockReturnValue(false); tree.create('/server.ts', serverFileContent); const newTree = await runner.runSchematic(MIGRATION_SCRIPT_NAME, {}, tree); @@ -102,29 +96,21 @@ describe('Update SSR Migration', () => { expect(content).not.toContain( "join(serverDistFolder, 'index.server.html')" ); - expect(shared.checkIfSSRIsUsedWithApplicationBuilder).toHaveBeenCalledWith( - tree - ); + expect(shared.isSsrUsed).toHaveBeenCalledWith(tree); }); it('should handle missing server.ts file', async () => { - ( - shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock - ).mockReturnValue(true); + (shared.isSsrUsed as jest.Mock).mockReturnValue(true); const newTree = await runner.runSchematic(MIGRATION_SCRIPT_NAME, {}, tree); expect(newTree.exists('/server.ts')).toBe(false); expect(newTree.exists('/src/server.ts')).toBe(false); - expect(shared.checkIfSSRIsUsedWithApplicationBuilder).toHaveBeenCalledWith( - tree - ); + expect(shared.isSsrUsed).toHaveBeenCalledWith(tree); }); it('should preserve other join statements when SSR is used', async () => { - ( - shared.checkIfSSRIsUsedWithApplicationBuilder as jest.Mock - ).mockReturnValue(true); + (shared.isSsrUsed as jest.Mock).mockReturnValue(true); const contentWithMultipleJoins = ` const otherFile = join(process.cwd(), 'other.html'); @@ -140,8 +126,6 @@ describe('Update SSR Migration', () => { expect(content).toContain("join(process.cwd(), 'other.html')"); expect(content).toContain('join(serverDistFolder, "index.server.html")'); expect(content).toContain("join(process.cwd(), 'another.html')"); - expect(shared.checkIfSSRIsUsedWithApplicationBuilder).toHaveBeenCalledWith( - tree - ); + expect(shared.isSsrUsed).toHaveBeenCalledWith(tree); }); }); diff --git a/projects/schematics/src/migrations/3_0/ssr/ssr.ts b/projects/schematics/src/migrations/3_0/ssr/ssr.ts index a3e8a17e64b..cfcd2004e0c 100644 --- a/projects/schematics/src/migrations/3_0/ssr/ssr.ts +++ b/projects/schematics/src/migrations/3_0/ssr/ssr.ts @@ -34,7 +34,7 @@ import { } from '../../../shared/utils/lib-utils'; import { createImportChange } from '../../../shared/utils/module-file-utils'; import { - checkIfSSRIsUsed, + isUsingLegacyServerBuilder as isOldSsrUsed, getSpartacusSchematicsVersion, readPackageJson, } from '../../../shared/utils/package-utils'; @@ -43,7 +43,7 @@ export function migrate(): Rule { return (tree: Tree, _context: SchematicContext) => { const packageJson = readPackageJson(tree); - return checkIfSSRIsUsed(tree) + return isOldSsrUsed(tree) ? chain([ updateImport(), addSetupPackageJsonDependencies(packageJson), diff --git a/projects/schematics/src/migrations/6_0/ssr/ssr.ts b/projects/schematics/src/migrations/6_0/ssr/ssr.ts index 3043a094a92..48785352747 100644 --- a/projects/schematics/src/migrations/6_0/ssr/ssr.ts +++ b/projects/schematics/src/migrations/6_0/ssr/ssr.ts @@ -6,10 +6,10 @@ import { noop, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { modifyAppServerModuleFile } from '../../../add-ssr/index'; -import { checkIfSSRIsUsed } from '../../../shared/utils/package-utils'; +import { isUsingLegacyServerBuilder as isOldSsrUsed } from '../../../shared/utils/package-utils'; export function migrate(): Rule { return (tree: Tree, _context: SchematicContext) => { - return checkIfSSRIsUsed(tree) ? modifyAppServerModuleFile() : noop(); + return isOldSsrUsed(tree) ? modifyAppServerModuleFile() : noop(); }; } diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/move-assets-to-public.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/move-assets-to-public.ts new file mode 100644 index 00000000000..b8e2dd5aa3d --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/move-assets-to-public.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_2211_32_To_2211_35 as printErrorWithDocs } from '../fallback-advice-to-follow-docs'; + +/** + * Moves the `src/assets/` folder to the root and renames it to `public/`, + * to adapt to the new Angular v19 standards. + */ +export function moveAssetsToPublic(): Rule { + return (tree: Tree, context: SchematicContext) => { + enum AssetsDirs { + OLD = 'src/assets', + NEW = 'public', + } + + context.logger.info( + `\n⏳ Moving assets folder from "${AssetsDirs.OLD}/" to "${AssetsDirs.NEW}/"...` + ); + + const sourceDir = tree.getDir(AssetsDirs.OLD); + if (!sourceDir.subfiles.length && !sourceDir.subdirs.length) { + printErrorWithDocs( + `Assets folder not found or empty at ${AssetsDirs.OLD}`, + context + ); + return; + } + + try { + tree.getDir(AssetsDirs.OLD).visit((filePath) => { + const relativeFilePath = filePath.replace(`${AssetsDirs.OLD}/`, ''); + context.logger.info(` ↳ Moving file "${filePath}"`); + + const content = tree.read(filePath); + if (content) { + tree.create(`${AssetsDirs.NEW}/${relativeFilePath}`, content); + } else { + printErrorWithDocs(`Failed to read ${filePath} file`, context); + } + }); + } catch (error) { + printErrorWithDocs( + `Error moving assets file from "${AssetsDirs.OLD}" to "${AssetsDirs.NEW}". Error: ${error}`, + context + ); + } + + context.logger.info(` ↳ Deleting old "${AssetsDirs.OLD}/" directory`); + try { + tree.delete(AssetsDirs.OLD); + } catch (error) { + printErrorWithDocs( + `Error deleting old assets directory "${AssetsDirs.OLD}". Error: ${error}`, + context + ); + } + + context.logger.info( + `✅ Moved assets folder from "${AssetsDirs.OLD}/" to "${AssetsDirs.NEW}/"` + ); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/move-favicon-to-public.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/move-favicon-to-public.ts new file mode 100644 index 00000000000..cbd02707a3c --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/move-favicon-to-public.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_2211_32_To_2211_35 as printErrorWithDocs } from '../fallback-advice-to-follow-docs'; + +/** + * Moves the `favicon.ico` file from the `src/` folder to the `public/` folder, + * to adapt to the new Angular v19 standards. + */ +export function moveFaviconToPublic(): Rule { + return (tree: Tree, context: SchematicContext) => { + const fileName = 'favicon.ico'; + + const oldDir = 'src'; + const oldPath = `${oldDir}/${fileName}`; + + const newDir = 'public'; + const newPath = `${newDir}/${fileName}`; + + context.logger.info( + `\n⏳ Moving ${fileName} from "${oldDir}/" to "${newDir}/"...` + ); + + if (!tree.exists(oldPath)) { + printErrorWithDocs(`Favicon not found at ${oldPath}`, context); + return; + } + + const content = tree.read(oldPath); + if (content) { + tree.create(newPath, content); + tree.delete(oldPath); + } else { + printErrorWithDocs(`Failed to read ${oldPath} file`, context); + return; + } + + context.logger.info( + `✅ Moved ${fileName} from "${oldDir}/" to "${newDir}/"` + ); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-angular-json.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-angular-json.ts new file mode 100644 index 00000000000..3223c7f3e4c --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-angular-json.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { getWorkspace } from '../../shared/utils/workspace-utils'; +import { printErrorWithDocsForMigrated_2211_32_To_2211_35 as printErrorWithDocs } from '../fallback-advice-to-follow-docs'; +import { + BrowserBuilderBaseOptions, + BrowserBuilderTarget, +} from '@schematics/angular/utility/workspace-models'; + +/** + * Updates the Angular configuration file to new Angular v19 standards. + * + * It updates the "assets" property for the "build" and "test" targets, + * to use the new path with the `public/` folder, + * instead of `src/assets` and `src/favicon.ico`. + */ +export function updateAngularJson(): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.info('\n⏳ Updating angular.json assets configuration...'); + + const { workspace, path } = getWorkspace(tree); + const project = workspace.projects[Object.keys(workspace.projects)[0]]; + + if (!project) { + printErrorWithDocs('No project found in workspace', context); + return; + } + + const buildTarget = project.architect?.build as BrowserBuilderTarget; + const testTarget = project.architect?.test; + + if (!buildTarget) { + printErrorWithDocs( + 'Could not find "build" target in project configuration', + context + ); + return; + } + + const oldAssets: BrowserBuilderBaseOptions['assets'] = [ + 'src/favicon.ico', + 'src/assets', + ]; + const newAssets: BrowserBuilderBaseOptions['assets'] = [ + { glob: '**/*', input: 'public' }, + ]; + + context.logger.info( + ` ↳ Removing "assets" configuration for ${oldAssets + .map((x) => `"${x}"`) + .join(', ')}` + ); + context.logger.info( + ' ↳ Adding "assets" configuration: `{ glob: "**/*", input: "public" }`' + ); + + if (Array.isArray(buildTarget.options?.assets)) { + buildTarget.options.assets = buildTarget.options.assets.filter( + (asset: string | object) => !oldAssets.includes(asset) + ); + + // Add the new public assets config + buildTarget.options.assets = [ + ...newAssets, + ...buildTarget.options.assets, + ]; + } else { + printErrorWithDocs( + 'Could not find "assets" array in "build" target configuration', + context + ); + } + + // Update config for "test" target + if (Array.isArray(testTarget?.options?.assets)) { + testTarget.options.assets = testTarget.options.assets.filter( + (asset: string | object) => !oldAssets.includes(asset) + ); + + // Add the new public assets config + testTarget.options.assets = [...newAssets, ...testTarget.options.assets]; + } else { + printErrorWithDocs( + 'Could not find "assets" array in "test" target configuration', + context + ); + } + + const JSON_INDENT = 2; + tree.overwrite(path, JSON.stringify(workspace, null, JSON_INDENT)); + context.logger.info('✅ Updated angular.json assets configuration'); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-i18n-lazy-loading-config.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-i18n-lazy-loading-config.ts new file mode 100644 index 00000000000..bcefebf6d8f --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-i18n-lazy-loading-config.ts @@ -0,0 +1,220 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_2211_32_To_2211_35 as printErrorWithDocs } from '../fallback-advice-to-follow-docs'; +import * as ts from 'typescript'; + +/** + * Updates the i18n lazy loading config to use the new path with the `public/` folder, + * instead of `../../assets`, because of moving the assets folder with respect to the new Angular v19 standards. + * + * It checks whether the config for i18n lazy loading is present. + * If it is, it updates the path from `../../assets` to `../../../public`. + * Otherwise, no changes are made. + */ +export function updateI18nLazyLoadingConfig(): Rule { + return (tree: Tree, context: SchematicContext) => { + const configurationModulePath = + 'src/app/spartacus/spartacus-configuration.module.ts'; + context.logger.info( + `\n⏳ Updating config for i18n lazy loading in "${configurationModulePath}", if needed...` + ); + + if (!tree.exists(configurationModulePath)) { + printErrorWithDocs(`${configurationModulePath} file not found`, context); + return; + } + + const content = tree.read(configurationModulePath); + if (!content) { + printErrorWithDocs( + `Failed to read ${configurationModulePath} file`, + context + ); + return; + } + + let updatedContent = content.toString(); + + context.logger.info( + ' ↳ Checking if app has a config for i18n lazy loading...' + ); + if (!hasConfigForLazyLoadingI18n(updatedContent)) { + context.logger.info(' ↳ No.'); + context.logger.info( + `✅ Updating config for i18n lazy loading in "${configurationModulePath}" was not needed` + ); + return; + } + context.logger.info( + ' ↳ Updating the path from "../../assets" to "../../../public"' + ); + + updatedContent = updateDynamicImportPath(updatedContent); + + tree.overwrite(configurationModulePath, updatedContent); + context.logger.info('✅ Updated config for i18n lazy loading'); + }; +} + +/** + * Checks if the content has a config for lazy loaded i18n. + * + * It looks for an object literal with the following structure: + * ``` + * { + * i18n: { + * backend: { + * loader: ... + * } + * } + * } + */ +function hasConfigForLazyLoadingI18n(content: string): boolean { + const sourceFile = ts.createSourceFile( + '', + content, + ts.ScriptTarget.Latest, + true + ); + + const objectLiterals = findObjectLiterals(sourceFile); + return objectLiterals.some(isConfigForLazyLoadingI18n); +} + +/** + * Checks if the given node is a config for lazy loaded i18n. + * + * It looks for an object literal with the following structure: + * ``` + * { + * i18n: { + * backend: { + * loader: ... + * } + * } + * } + * ``` + */ +function isConfigForLazyLoadingI18n(node: ts.ObjectLiteralExpression): boolean { + // Expecting syntax: `i18n: { ... }` + const i18nProp = node.properties.find( + (prop): prop is ts.PropertyAssignment => + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'i18n' + ); + if (!i18nProp || !ts.isObjectLiteralExpression(i18nProp.initializer)) { + return false; + } + + // Expecting syntax: `backend: { ... }` + const backendProp = i18nProp.initializer.properties.find( + (prop): prop is ts.PropertyAssignment => + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'backend' + ); + if (!backendProp || !ts.isObjectLiteralExpression(backendProp.initializer)) { + return false; + } + + // Expecting one of the following syntaxes: + // - `loader: (lang: string, chunkName: string) => { ... }` + // - `loader(lang: string, chunkName: string) { ... }` + return backendProp.initializer.properties.some((prop) => { + if (!ts.isPropertyAssignment(prop) && !ts.isMethodDeclaration(prop)) { + return false; + } + const name = prop.name; + return ( + (ts.isIdentifier(name) || ts.isStringLiteral(name)) && + name.text === 'loader' + ); + }); +} + +/** + * Returns all object literals in the given node. + */ +function findObjectLiterals(node: ts.Node): ts.ObjectLiteralExpression[] { + const objects: ts.ObjectLiteralExpression[] = []; + + if (ts.isObjectLiteralExpression(node)) { + objects.push(node); + } + + ts.forEachChild(node, (child) => { + objects.push(...findObjectLiterals(child)); + }); + + return objects; +} + +/** + * Updates the path of dynamic imports by replacing `../../assets` with `../../../public`. + */ +function updateDynamicImportPath(content: string): string { + const sourceFile = ts.createSourceFile( + '', + content, + ts.ScriptTarget.Latest, + true + ); + + enum ImportPathPrefixes { + OLD = '../../assets/', + NEW = '../../../public/', + } + + function visitor(node: ts.Node): void { + // Expect syntax: import( ) + if (!isDynamicImport(node)) { + ts.forEachChild(node, visitor); + return; + } + + // Expect syntax: import( ` ... ` ) + const argument = node.arguments[0]; + if (!ts.isTemplateLiteral(argument)) { + ts.forEachChild(node, visitor); + return; + } + + // Expect syntax: import( `... ...` ) + const importPath = content.substring( + argument.getStart(), + argument.getEnd() + ); + if (!importPath.includes(ImportPathPrefixes.OLD)) { + ts.forEachChild(node, visitor); + return; + } + + // Update the path + const updatedPath = importPath.replace( + ImportPathPrefixes.OLD, + ImportPathPrefixes.NEW + ); + content = + content.slice(0, argument.getStart()) + + updatedPath + + content.slice(argument.getEnd()); + + ts.forEachChild(node, visitor); + } + + visitor(sourceFile); + return content; +} + +function isDynamicImport(node: ts.Node): node is ts.CallExpression { + return ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword + ); +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-main-ts.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-main-ts.ts new file mode 100644 index 00000000000..ad0d21a44fb --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-main-ts.ts @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_2211_32_To_2211_35 as printErrorWithDocs } from '../fallback-advice-to-follow-docs'; +import * as ts from 'typescript'; + +/** + * Updates `src/main.ts` to include a config object with `ngZoneEventCoalescing: true` in the `bootstrapModule()` call, + * to adapt to the new Angular v19 standards. + */ +export function updateMainTs(): Rule { + return (tree: Tree, context: SchematicContext) => { + const mainTsPath = 'src/main.ts'; + context.logger.info(`\n⏳ Updating ${mainTsPath}...`); + + if (!tree.exists(mainTsPath)) { + printErrorWithDocs(`${mainTsPath} file not found`, context); + return; + } + + const content = tree.read(mainTsPath); + if (!content) { + printErrorWithDocs(`Failed to read ${mainTsPath} file`, context); + return; + } + const originalContent = content.toString(); + + context.logger.info( + ' ↳ Updating `bootstrapModule()` call to include `ngZoneEventCoalescing: true`' + ); + const updatedContent = updateBootstrapCall(originalContent); + if (updatedContent === originalContent) { + printErrorWithDocs( + 'Could not update `bootstrapModule()` call to include `ngZoneEventCoalescing: true`', + context + ); + return; + } + + tree.overwrite(mainTsPath, updatedContent); + context.logger.info(`✅ Updated ${mainTsPath}`); + }; +} + +/** + * Updates the `bootstrapModule()` call to include a config object with `ngZoneEventCoalescing: true`. + */ +function updateBootstrapCall(content: string): string { + const sourceFile = ts.createSourceFile( + '', + content, + ts.ScriptTarget.Latest, + true + ); + + /** + * Visits the node and checks if it's a method call to `bootstrapModule()`. + * If it is, it updates the options to include `ngZoneEventCoalescing: true`. + * + * @returns { updated: boolean } - tells whether the update was performed + */ + function visit(node: ts.Node): { updated: boolean } { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'bootstrapModule' + ) { + const options = node.arguments[1]; + const ngZoneOption = `{ ngZoneEventCoalescing: true }`; + + if (!options) { + // No options - insert new ones + const start = node.arguments[0].getEnd(); + content = `${content.slice(0, start)}, ${ngZoneOption}${content.slice(start)}`; + return { updated: true }; + } + + if (ts.isObjectLiteralExpression(options)) { + // Find ngZoneEventCoalescing property if it exists + const existingProperty = options.properties.find( + (prop): prop is ts.PropertyAssignment => + ts.isPropertyAssignment(prop) && + prop.name.getText() === 'ngZoneEventCoalescing' + ); + + if (!existingProperty) { + // Add to other existing options + const start = options.getStart() + 1; // After { + content = `${content.slice(0, start)}ngZoneEventCoalescing: true,${content.slice(start)}`; + } else { + // Update existing property + const valueStart = existingProperty.initializer.getStart(); + const valueEnd = existingProperty.initializer.getEnd(); + content = `${content.slice(0, valueStart)}true${content.slice(valueEnd)}`; + } + return { updated: true }; + } + } + + for (const child of node.getChildren()) { + // short-circuit and early return if the update has been already performed in one of the children nodes + if (visit(child).updated) { + return { updated: true }; + } + } + return { updated: false }; + } + + visit(sourceFile); + return content; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-ts-config.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-ts-config.ts new file mode 100644 index 00000000000..3a2dec8c452 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/csr-and-ssr/update-ts-config.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { parse } from 'jsonc-parser'; +import { printErrorWithDocsForMigrated_2211_32_To_2211_35 as printErrorWithDocs } from '../fallback-advice-to-follow-docs'; + +/** + * It updates the "compilerOptions" in the `tsconfig.json` file, + * to adapt to the new Angular v19 standards. + * + * 1. Adds `"isolatedModules": true` + * 2. Removes `"sourceMap"`, `"declaration"`, `"useDefineForClassFields"`, `"lib"`, `"moduleResolution"` + */ +export function updateTsConfig(): Rule { + return (tree: Tree, context: SchematicContext) => { + const tsconfigPath = 'tsconfig.json'; + context.logger.info(`\n⏳ Updating ${tsconfigPath}...`); + + if (!tree.exists(tsconfigPath)) { + printErrorWithDocs(`${tsconfigPath} file not found`, context); + return; + } + + const tsConfigContent = tree.read(tsconfigPath); + if (!tsConfigContent) { + printErrorWithDocs(`Failed to read ${tsconfigPath} file`, context); + return; + } + + const tsConfig = parse(tsConfigContent.toString()); + + if (!tsConfig.compilerOptions) { + printErrorWithDocs( + `No compilerOptions found in ${tsconfigPath}`, + context + ); + return; + } + + context.logger.info( + ' ↳ Adding `"isolatedModules": true` to "compilerOptions"' + ); + tsConfig.compilerOptions.isolatedModules = true; + + context.logger.info( + ' ↳ Removing "sourceMap", "declaration", "useDefineForClassFields", "lib", "moduleResolution" from "compilerOptions"' + ); + delete tsConfig.compilerOptions.sourceMap; + delete tsConfig.compilerOptions.declaration; + delete tsConfig.compilerOptions.useDefineForClassFields; + delete tsConfig.compilerOptions.lib; + delete tsConfig.compilerOptions.moduleResolution; + + context.logger.info( + ' ↳ Updating "moduleResolution" to "bundler" in "compilerOptions"' + ); + tsConfig.compilerOptions.moduleResolution = 'bundler'; + + const JSON_INDENT = 2; + tree.overwrite(tsconfigPath, JSON.stringify(tsConfig, null, JSON_INDENT)); + context.logger.info(`✅ Updated ${tsconfigPath}`); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/fallback-advice-to-follow-docs.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/fallback-advice-to-follow-docs.ts new file mode 100644 index 00000000000..1f02c0a6173 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/fallback-advice-to-follow-docs.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; + +/** + * URL where to find the manual migration steps. + */ +export const DOCS_URL_FOR_MODERNIZING_APPS_MIGRATED_FROM_2211_32_TO_2211_35 = + 'https://help.sap.com/docs/SAP_COMMERCE_COMPOSABLE_STOREFRONT/10a8bc7f635b4e3db6f6bb7880e58a7d/9bb7dd061da14cfc9c5e3f36388c77e4.html'; + +const FALLBACK_ADVICE_TO_FOLLOW_DOCS = `Could not complete this step automatically. To complete the migration, please follow the manual steps: ${DOCS_URL_FOR_MODERNIZING_APPS_MIGRATED_FROM_2211_32_TO_2211_35}`; + +/** + * Prints an error message and a link to the manual migration steps. + */ +export function printErrorWithDocsForMigrated_2211_32_To_2211_35( + message: string, + context: SchematicContext +) { + context.logger.error(`⚠️ ${message}`); + context.logger.error(FALLBACK_ADVICE_TO_FOLLOW_DOCS); +} + +/** + * If the wrapped Rule throws an error, it logs the error and prints a link to manual migration docs. + */ +export function withFallbackDocsForMigrated_2211_32_To_2211_35( + rule: Rule +): Rule { + return (tree: Tree, context: SchematicContext) => { + try { + return rule(tree, context); + } catch (error) { + printErrorWithDocsForMigrated_2211_32_To_2211_35( + error instanceof Error ? error.message : 'Unknown error', + context + ); + return tree; + } + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/index.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/index.ts new file mode 100644 index 00000000000..2597b4bac6c --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/index.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Rule, + SchematicContext, + Tree, + chain, +} from '@angular-devkit/schematics'; +import { updateAngularJson } from './csr-and-ssr/update-angular-json'; +import { updateTsConfig } from './csr-and-ssr/update-ts-config'; +import { moveAssetsToPublic } from './csr-and-ssr/move-assets-to-public'; +import { moveFaviconToPublic } from './csr-and-ssr/move-favicon-to-public'; +import { updateMainTs } from './csr-and-ssr/update-main-ts'; +import { updateServerTs } from './ssr/update-server-ts'; +import { updateAngularJsonForSsr } from './ssr/update-angular-json-for-ssr'; +import { updateTsConfigApp } from './ssr/update-ts-config-app'; +import { withFallbackDocsForMigrated_2211_32_To_2211_35 as withFallbackDocs } from './fallback-advice-to-follow-docs'; +import { updateI18nLazyLoadingConfig } from './csr-and-ssr/update-i18n-lazy-loading-config'; +import { isSsrUsed } from '../shared/utils/package-utils'; + +/** + * Modernizes an application migrated from Angular v2211.32 to v2211.35 + * to align with new Angular 19 standards. + */ +export function migrate(): Rule { + return (tree: Tree, _context: SchematicContext) => { + return chain([ + withFallbackDocs(updateAngularJson()), + withFallbackDocs(updateTsConfig()), + withFallbackDocs(moveAssetsToPublic()), + withFallbackDocs(moveFaviconToPublic()), + withFallbackDocs(updateMainTs()), + withFallbackDocs(updateI18nLazyLoadingConfig()), + + ...(isSsrUsed(tree) + ? [ + withFallbackDocs(updateServerTs()), + withFallbackDocs(updateAngularJsonForSsr()), + withFallbackDocs(updateTsConfigApp()), + ] + : []), + ]); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/ssr/update-angular-json-for-ssr.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/ssr/update-angular-json-for-ssr.ts new file mode 100644 index 00000000000..13ce611c52c --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/ssr/update-angular-json-for-ssr.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { getWorkspace } from '../../shared/utils/workspace-utils'; +import { printErrorWithDocsForMigrated_2211_32_To_2211_35 as printErrorWithDocs } from '../fallback-advice-to-follow-docs'; + +/** + * Updates the Angular configuration related to SSR for new Angular v19 standards. + * + * It updates the "ssr.entry" path in the "build" target, + * to use the new path with the `src/server.ts` file, + * instead of `server.ts`. + */ +export function updateAngularJsonForSsr(): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.info('\n⏳ Updating angular.json for SSR...'); + + const { workspace, path } = getWorkspace(tree); + const project = workspace.projects[Object.keys(workspace.projects)[0]]; + + if (!project) { + printErrorWithDocs('No project found in workspace', context); + return; + } + + const buildTarget = project.architect?.build as any; + if (!buildTarget) { + printErrorWithDocs( + 'No build target found in project configuration', + context + ); + return; + } + + const oldEntryPath = buildTarget.options.ssr.entry; + const newEntryPath = 'src/server.ts'; + context.logger.info( + ` ↳ Updating "ssr.entry" path in build target from "${oldEntryPath}" to "${newEntryPath}"` + ); + if (buildTarget.options?.ssr?.entry) { + buildTarget.options.ssr.entry = newEntryPath; + } else { + printErrorWithDocs('No "ssr.entry" path found in build target', context); + return; + } + + const JSON_INDENT = 2; + tree.overwrite(path, JSON.stringify(workspace, null, JSON_INDENT)); + context.logger.info('✅ Updated angular.json for SSR'); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/ssr/update-server-ts.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/ssr/update-server-ts.ts new file mode 100644 index 00000000000..55271d475fe --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/ssr/update-server-ts.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_2211_32_To_2211_35 as printErrorWithDocs } from '../fallback-advice-to-follow-docs'; + +/** + * Moves the `server.ts` file to the `src/` folder and updates the implementation, + * to adapt to the new Angular v19 standards. + * + * 1. Moves the `server.ts` file to the `src/` folder. + * 2. Updates the import path from `./src/main.server` to `./main.server`. + */ +export function updateServerTs(): Rule { + return (tree: Tree, context: SchematicContext) => { + enum ServerTsPaths { + OLD = 'server.ts', + NEW = 'src/server.ts', + } + + context.logger.info( + `\n⏳ Moving "${ServerTsPaths.OLD}" to "${ServerTsPaths.NEW}" and relative import paths...` + ); + + if (!tree.exists(ServerTsPaths.OLD)) { + printErrorWithDocs(`${ServerTsPaths.OLD} file not found`, context); + return; + } + + const content = tree.read(ServerTsPaths.OLD); + if (!content) { + printErrorWithDocs(`Failed to read ${ServerTsPaths.OLD} file`, context); + return; + } + + let serverTs = content.toString(); + + context.logger.info( + ' ↳ Updating relative import path from "./src/main.server" to "./main.server"' + ); + serverTs = serverTs.replace( + /from ['"]\.\/src\/main\.server['"];/, + "from './main.server';" + ); + + context.logger.info( + ` ↳ Moving the file "${ServerTsPaths.OLD}" to "${ServerTsPaths.NEW}" with the updated content` + ); + tree.create(ServerTsPaths.NEW, serverTs); + tree.delete(ServerTsPaths.OLD); + + context.logger.info( + `✅ Moved and updated ${ServerTsPaths.OLD} to ${ServerTsPaths.NEW}` + ); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/ssr/update-ts-config-app.ts b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/ssr/update-ts-config-app.ts new file mode 100644 index 00000000000..93ac7898e10 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-2211_32-to-2211_35/ssr/update-ts-config-app.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { parse } from 'jsonc-parser'; +import { printErrorWithDocsForMigrated_2211_32_To_2211_35 as printErrorWithDocs } from '../fallback-advice-to-follow-docs'; + +/** + * Updates the `tsconfig.app.json` to adapt to the new Angular v19 standards. + * + * It updates the path in the "files" array from `server.ts` to `src/server.ts`. + */ +export function updateTsConfigApp(): Rule { + return (tree: Tree, context: SchematicContext) => { + const tsconfigAppPath = 'tsconfig.app.json'; + context.logger.info(`\n⏳ Updating ${tsconfigAppPath}...`); + + if (!tree.exists(tsconfigAppPath)) { + printErrorWithDocs(`${tsconfigAppPath} file not found`, context); + return; + } + + const tsConfigContent = tree.read(tsconfigAppPath); + if (!tsConfigContent) { + printErrorWithDocs(`Failed to read ${tsconfigAppPath} file`, context); + return; + } + + const tsConfig = parse(tsConfigContent.toString()); + + const oldPath = 'server.ts'; + const newPath = 'src/server.ts'; + context.logger.info( + ` ↳ Updating path in "files" array from "${oldPath}" to "${newPath}"` + ); + if (Array.isArray(tsConfig.files)) { + const serverTsIndex = tsConfig.files.indexOf(oldPath); + if (serverTsIndex !== -1) { + tsConfig.files[serverTsIndex] = newPath; + } else { + printErrorWithDocs( + `Path "${oldPath}" not found in "files" array`, + context + ); + return; + } + } else { + printErrorWithDocs( + `"files" array not found in ${tsconfigAppPath}`, + context + ); + return; + } + + const JSON_INDENT = 2; + tree.overwrite( + tsconfigAppPath, + JSON.stringify(tsConfig, null, JSON_INDENT) + ); + context.logger.info(`✅ Updated ${tsconfigAppPath}`); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/csr-and-ssr/update-angular-json-for-application-builder.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/csr-and-ssr/update-angular-json-for-application-builder.ts new file mode 100644 index 00000000000..74887532b61 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/csr-and-ssr/update-angular-json-for-application-builder.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { getWorkspace } from '../../shared/utils/workspace-utils'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Updates the Angular configuration file to new Angular v17 standards. + * + * Configures the new Angular application builder: + * '@angular-devkit/build-angular:application', + * updates output paths, and removes obsolete build options. + */ +export function updateAngularJsonForApplicationBuilder(): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.info( + '\n⏳ Updating angular.json for application builder...' + ); + + const { workspace, path } = getWorkspace(tree); + const project = workspace.projects[Object.keys(workspace.projects)[0]]; + + if (!project) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'No project found in workspace', + context + ); + return; + } + + const buildTarget = project.architect?.build; + if (!buildTarget) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'No build target found in project configuration', + context + ); + return; + } + + const newBuilder = '@angular-devkit/build-angular:application'; + context.logger.info( + ` ↳ Updating builder to "${newBuilder}" from "${buildTarget.builder}"` + ); + buildTarget.builder = newBuilder as any; + + context.logger.info(' ↳ Renaming "main" to "browser" in build options'); + const options = buildTarget.options as any; + if (options?.main) { + options.browser = options.main; + delete options.main; + } else { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'Could not rename "main" to "browser" in angular.json', + context + ); + return; + } + + context.logger.info( + ' ↳ Removing obsolete build options from "development" configuration' + ); + const devConfig = (buildTarget as any).configurations?.development; + if (devConfig) { + delete devConfig.buildOptimizer; + delete devConfig.vendorChunk; + delete devConfig.namedChunks; + } else { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'Could not update "development" configuration in angular.json', + context + ); + return; + } + + context.logger.info('✅ Updated angular.json for application builder'); + const JSON_INDENT = 2; + tree.overwrite(path, JSON.stringify(workspace, null, JSON_INDENT)); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/csr-and-ssr/update-app-module.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/csr-and-ssr/update-app-module.ts new file mode 100644 index 00000000000..d9e9c86f38d --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/csr-and-ssr/update-app-module.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { removeImportFromContent } from '../../shared'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Updates `app.module.ts` file for new Angular v17 standards. + * + * Removes `HttpClientModule` in favor of `provideHttpClient()`, + * with using `withFetch` and `withInterceptorsFromDi()`, + * and removes the obsolete method `BrowserModule.withServerTransition()`. + */ +export function updateAppModule(): Rule { + return (tree: Tree, context: SchematicContext) => { + const appModulePath = 'src/app/app.module.ts'; + context.logger.info(`\n⏳ Updating ${appModulePath}...`); + + if (!tree.exists(appModulePath)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `No ${appModulePath} found`, + context + ); + return; + } + + const content = tree.read(appModulePath); + if (!content) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Could not read content of ${appModulePath}`, + context + ); + return; + } + + let updatedContent = content.toString(); + + context.logger.info( + ' ↳ Removing import of HttpClientModule from @angular/common/http' + ); + updatedContent = removeImportFromContent(updatedContent, { + symbolName: 'HttpClientModule', + importPath: '@angular/common/http', + }); + + context.logger.info(' ↳ Removing usage of HttpClientModule'); + updatedContent = updatedContent.replace(/HttpClientModule,?\s*/g, ''); + + context.logger.info(' ↳ Adding provideHttpClient to NgModule providers'); + if (!updatedContent.includes('provideHttpClient')) { + updatedContent = updatedContent.replace( + /providers:\s*\[/, + 'providers: [\n provideHttpClient(withFetch(), withInterceptorsFromDi()),' + ); + + context.logger.info( + ' ↳ Adding imports of provideHttpClient, withFetch, withInterceptorsFromDi from @angular/common/http' + ); + const httpImport = + "import { provideHttpClient, withFetch, withInterceptorsFromDi } from '@angular/common/http';\n"; + if (updatedContent.includes('import {')) { + updatedContent = updatedContent.replace( + /import {/, + `${httpImport}import {` + ); + } + } + + tree.overwrite(appModulePath, updatedContent); + + context.logger.info(`✅ Updated ${appModulePath}`); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/csr-and-ssr/update-ts-config.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/csr-and-ssr/update-ts-config.ts new file mode 100644 index 00000000000..b7fe5612b21 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/csr-and-ssr/update-ts-config.ts @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { parse } from 'jsonc-parser'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Updates the `tsconfig.json` file for new Angular v17 standards. + * + * 1. Removes the properties `"baseUrl"`, `"forceConsistentCasingInFileNames"`, `"downlevelIteration"`, + * 2. Adds `"skipLibCheck": true`, `"esModuleInterop": true` + */ +export function updateTsConfig(): Rule { + return (tree: Tree, context: SchematicContext) => { + const tsconfigPath = 'tsconfig.json'; + context.logger.info(`\n⏳ Updating ${tsconfigPath}...`); + + if (!tree.exists(tsconfigPath)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `${tsconfigPath} file not found`, + context + ); + return; + } + + const tsConfigContent = tree.read(tsconfigPath); + if (!tsConfigContent) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Failed to read ${tsconfigPath} file`, + context + ); + return; + } + + const tsConfig = parse(tsConfigContent.toString()); + + if (!tsConfig.compilerOptions) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `No compilerOptions found in ${tsconfigPath}`, + context + ); + return; + } + + context.logger.info(' ↳ Removing "baseUrl" from compilerOptions'); + delete tsConfig.compilerOptions.baseUrl; + + context.logger.info( + ' ↳ Removing "forceConsistentCasingInFileNames" from compilerOptions' + ); + delete tsConfig.compilerOptions.forceConsistentCasingInFileNames; + + context.logger.info( + ' ↳ Removing "downlevelIteration" from compilerOptions' + ); + delete tsConfig.compilerOptions.downlevelIteration; + + context.logger.info(' ↳ Adding "skipLibCheck": true to compilerOptions'); + tsConfig.compilerOptions.skipLibCheck = true; + + context.logger.info( + ' ↳ Adding "esModuleInterop": true to compilerOptions' + ); + tsConfig.compilerOptions.esModuleInterop = true; + delete tsConfig.compilerOptions.baseUrl; + delete tsConfig.compilerOptions.forceConsistentCasingInFileNames; + delete tsConfig.compilerOptions.downlevelIteration; + + tsConfig.compilerOptions.skipLibCheck = true; + tsConfig.compilerOptions.esModuleInterop = true; + + const JSON_INDENT = 2; + tree.overwrite(tsconfigPath, JSON.stringify(tsConfig, null, JSON_INDENT)); + + context.logger.info(`✅ Updated ${tsconfigPath}`); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/fallback-advice-to-follow-docs.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/fallback-advice-to-follow-docs.ts new file mode 100644 index 00000000000..f98666ba8c5 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/fallback-advice-to-follow-docs.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; + +/** + * URL where to find the manual migration steps. + */ +export const DOCS_URL_FOR_MODERNIZING_APPS_MIGRATED_FROM_6_8_TO_2211_19 = + 'https://help.sap.com/docs/SAP_COMMERCE_COMPOSABLE_STOREFRONT/10a8bc7f635b4e3db6f6bb7880e58a7d/525dd86b812544249e78d73f67f3e6ed.html'; + +const FALLBACK_ADVICE_TO_FOLLOW_DOCS = `Could not complete this step automatically. To complete the migration, please follow the manual steps: ${DOCS_URL_FOR_MODERNIZING_APPS_MIGRATED_FROM_6_8_TO_2211_19}`; + +/** + * Prints an error message and a link to the manual migration steps. + */ +export function printErrorWithDocsForMigrated_6_8_To_2211_19( + message: string, + context: SchematicContext +) { + context.logger.error(`⚠️ ${message}`); + context.logger.error(FALLBACK_ADVICE_TO_FOLLOW_DOCS); +} + +/** + * If the wrapped Rule throws an error, it logs the error and prints a link to manual migration docs. + */ +export function withFallbackDocsForMigrated_6_8_To_2211_19(rule: Rule): Rule { + return (tree: Tree, context: SchematicContext) => { + try { + return rule(tree, context); + } catch (error) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + error instanceof Error ? error.message : 'Unknown error', + context + ); + return tree; + } + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/index.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/index.ts new file mode 100644 index 00000000000..67cd0fbb921 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/index.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, Tree, chain } from '@angular-devkit/schematics'; + +import { updateAngularJsonForApplicationBuilder } from './csr-and-ssr/update-angular-json-for-application-builder'; +import { updateTsConfig } from './csr-and-ssr/update-ts-config'; +import { updateAngularJsonForSsr } from './ssr/update-angular-json-for-ssr'; +import { updateTsConfigApp } from './ssr/update-ts-config-app'; +import { renameAppServerModule } from './ssr/rename-app-server-module'; +import { updateMainServerTs } from './ssr/update-main-server-ts'; +import { updateServerTs } from './ssr/update-server-ts'; +import { updateAppModule } from './csr-and-ssr/update-app-module'; +import { updatePackageJsonServerScripts } from './ssr/update-package-json-server-scripts'; +import { removeTsConfigServer } from './ssr/remove-ts-config-server'; +import { withFallbackDocsForMigrated_6_8_To_2211_19 as withFallbackDocs } from './fallback-advice-to-follow-docs'; +import { updateAppModuleForSsr } from './ssr/update-app-module-for-ssr'; +import { isUsingLegacyServerBuilder } from '../shared/utils/package-utils'; + +/** + * Modernizes an application to use new Angular v17 standards. + */ +export function migrate(): Rule { + return (tree: Tree) => { + return chain([ + withFallbackDocs(updateAngularJsonForApplicationBuilder()), + withFallbackDocs(updateTsConfig()), + withFallbackDocs(updateAppModule()), + + // Only for SSR with legacy configuration (not using Application Builder): + ...(isUsingLegacyServerBuilder(tree) + ? [ + withFallbackDocs(updateAngularJsonForSsr()), + withFallbackDocs(updatePackageJsonServerScripts()), + withFallbackDocs(updateTsConfigApp()), + withFallbackDocs(removeTsConfigServer()), + withFallbackDocs(renameAppServerModule()), + withFallbackDocs(updateMainServerTs()), + withFallbackDocs(updateServerTs()), + withFallbackDocs(updateAppModuleForSsr()), + ] + : []), + ]); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/remove-ts-config-server.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/remove-ts-config-server.ts new file mode 100644 index 00000000000..bd0d5d8240e --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/remove-ts-config-server.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Removes the `tsconfig.server.json` file from the project, + * to align with the new Angular v17 standards. + */ +export function removeTsConfigServer(): Rule { + return (tree: Tree, context: SchematicContext) => { + const tsconfigServerPath = 'tsconfig.server.json'; + + context.logger.info(`\n⏳ Removing ${tsconfigServerPath}...`); + + if (!tree.exists(tsconfigServerPath)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `${tsconfigServerPath} file not found`, + context + ); + return; + } + + tree.delete(tsconfigServerPath); + + context.logger.info(`✅ Removed ${tsconfigServerPath}`); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/rename-app-server-module.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/rename-app-server-module.ts new file mode 100644 index 00000000000..e4f432f9ced --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/rename-app-server-module.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Renames `app.server.module.ts` to `app.module.server.ts` + * to align with new Angular v17 standards. + */ +export function renameAppServerModule(): Rule { + return (tree: Tree, context: SchematicContext) => { + enum AppServerModulePaths { + old = 'src/app/app.server.module.ts', + new = 'src/app/app.module.server.ts', + } + context.logger.info( + `\n⏳ Renaming ${AppServerModulePaths.old} to ${AppServerModulePaths.new}...` + ); + + if (!tree.exists(AppServerModulePaths.old)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `${AppServerModulePaths.old} file not found`, + context + ); + return; + } + + const content = tree.read(AppServerModulePaths.old); + if (!content) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Failed to read ${AppServerModulePaths.old} file`, + context + ); + return; + } + + tree.create(AppServerModulePaths.new, content.toString()); + tree.delete(AppServerModulePaths.old); + + context.logger.info( + `✅ Renamed ${AppServerModulePaths.old} to ${AppServerModulePaths.new}` + ); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-angular-json-for-ssr.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-angular-json-for-ssr.ts new file mode 100644 index 00000000000..3eac3757790 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-angular-json-for-ssr.ts @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { getWorkspace } from '../../shared/utils/workspace-utils'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Updates the Angular configuration related to SSR for new Angular v17 standards. + * + * 1. In the section `architect > build > options` it adds 3 new options with values: + * `"server": "src/main.server.ts"`, + * `"prerender": false`, + * `"ssr": { "entry": "server.ts" }` + * + * 2. In the section `architect > build > configurations` it adds a new property with object value + * `"noSsr": { "ssr": false, "prerender": false }` + * + * 3. In the section `architect > serve > configurations` it adds the ending `,noSsr` (with the preceding comma) + * at the end of the string values in subsections + * `... > production > buildTarget` and + * `... > development > buildTarget` + * + * 4. It removes the whole 3 sections `architect > server`, `architect > serve-ssr` and `architect > prerender` + * (because their responsibilities are now handled just by the single new Angular `application` builder) + */ +export function updateAngularJsonForSsr(): Rule { + return (tree: Tree, context: SchematicContext) => { + context.logger.info('\n⏳ Updating angular.json for SSR...'); + + const { workspace, path } = getWorkspace(tree); + const [firstProjectKey] = Object.keys(workspace.projects); + const project = workspace.projects[firstProjectKey]; + + if (!project) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'No project found for SSR migration', + context + ); + return; + } + + if (!project.architect?.build) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'No build target found in project configuration', + context + ); + return; + } + + context.logger.info( + ' ↳ Updating build target with SSR options: "server: "src/main.server.ts", "prerender": false, "ssr": { "entry": "server.ts" }' + ); + project.architect.build = { + ...project.architect.build, + options: { + ...project.architect.build.options, + server: 'src/main.server.ts', + prerender: false, + ssr: { entry: 'server.ts' }, + } as any, + configurations: { + ...project.architect.build.configurations, + noSsr: { ssr: false, prerender: false }, + } as any, + }; + + context.logger.info(' ↳ Updating "outputPath" if it ends with /browser'); + const buildOptions = project.architect?.build?.options as any; + if ( + typeof buildOptions.outputPath === 'string' && + buildOptions.outputPath.endsWith('/browser') + ) { + buildOptions.outputPath = buildOptions.outputPath.replace( + /\/browser$/, + '' + ); + } else { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'Could not update "outputPath" in angular.json', + context + ); + return; + } + + context.logger.info(' ↳ Updating "serve" configurations with `,noSsr`'); + const serveConfigs = project.architect?.serve?.configurations; + if (serveConfigs) { + project.architect.serve = { + ...project.architect.serve, + configurations: { + ...serveConfigs, + production: { + ...serveConfigs.production, + buildTarget: `${serveConfigs.production?.buildTarget},noSsr`, + }, + development: { + ...serveConfigs.development, + buildTarget: `${serveConfigs.development?.buildTarget},noSsr`, + }, + }, + } as any; + } else { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'Could not update "serve" configurations in angular.json', + context + ); + return; + } + + context.logger.info( + ' ↳ Removing obsolete "architect" targets: "server", "serve-ssr", "prerender"' + ); + delete project.architect?.server; + delete project.architect?.['serve-ssr']; + delete project.architect?.prerender; + + const JSON_INDENT = 2; + tree.overwrite(path, JSON.stringify(workspace, null, JSON_INDENT)); + context.logger.info('✅ Updated angular.json for SSR'); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-app-module-for-ssr.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-app-module-for-ssr.ts new file mode 100644 index 00000000000..ab7dd0ec971 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-app-module-for-ssr.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; +export function updateAppModuleForSsr(): Rule { + return (tree, context) => { + const appModulePath = 'src/app/app.module.ts'; + context.logger.info(`\n⏳ Updating ${appModulePath} for SSR...`); + + if (!tree.exists(appModulePath)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Could not find ${appModulePath}`, + context + ); + return; + } + + const content = tree.read(appModulePath); + if (!content) { + return; + } + + let updatedContent = content.toString(); + + context.logger.info( + ' ↳ Removing "withServerTransition()" call from the "BrowserModule" import' + ); + updatedContent = updatedContent.replace( + /BrowserModule\.withServerTransition\(\s*{\s*appId:\s*['"]serverApp['"]\s*}\s*\)/, + 'BrowserModule' + ); + + tree.overwrite(appModulePath, updatedContent); + + context.logger.info(`✅ Updated ${appModulePath} for SSR`); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-main-server-ts.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-main-server-ts.ts new file mode 100644 index 00000000000..5a171a295c9 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-main-server-ts.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Updates `main.server.ts` file for new Angular v17 standards. + * + * 1. Changes the the export path of the `AppServerModule` from `./app/app.server.module'` to `./app/app.module.server'`. + * 2. Exports `AppServerModule` using `as default`. + */ +export function updateMainServerTs(): Rule { + return (tree: Tree, context: SchematicContext) => { + const mainServerPath = 'src/main.server.ts'; + context.logger.info(`\n⏳ Updating ${mainServerPath}...`); + + if (!tree.exists(mainServerPath)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `${mainServerPath} file not found`, + context + ); + return; + } + + const mainServerContent = tree.read(mainServerPath); + if (!mainServerContent) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Failed to read ${mainServerPath} file`, + context + ); + return; + } + + context.logger.info(' ↳ Updating export path of "AppServerModule"'); + const expectedPattern = + /export \{ AppServerModule \} from ['"]\.\/app\/app\.server\.module['"];/; + const newPattern = `export { AppServerModule as default } from './app/app.module.server';`; + + let updatedContent = mainServerContent.toString(); + if (!expectedPattern.test(updatedContent)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `${mainServerPath} does not contain the expected export`, + context + ); + return; + } + + updatedContent = updatedContent.replace(expectedPattern, newPattern); + tree.overwrite(mainServerPath, updatedContent); + + context.logger.info(`✅ Updated ${mainServerPath}`); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-package-json-server-scripts.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-package-json-server-scripts.ts new file mode 100644 index 00000000000..98ad48915cc --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-package-json-server-scripts.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { getWorkspace } from '../../shared/utils/workspace-utils'; +import { parse } from 'jsonc-parser'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Updates `package.json` scripts related to SSR to align with new Angular v17 standards. + * + * 1. Removes properties `"dev:ssr"` and `"prerender"` + * 2. Changes value of the property `"build:ssr"` to `"ng build"` + * 3. Renames the property `"serve:ssr"` to `"serve:ssr:YOUR-APP-NAME"` and change its value to `"node dist/YOUR-APP-NAME/server/server.mjs"` + * ``` + */ +export function updatePackageJsonServerScripts(): Rule { + return (tree: Tree, context: SchematicContext) => { + const packageJsonPath = 'package.json'; + + context.logger.info(`\n⏳ Updating ${packageJsonPath} scripts...`); + + if (!tree.exists(packageJsonPath)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `${packageJsonPath} file not found`, + context + ); + return; + } + + // Get app name from workspace + const { workspace } = getWorkspace(tree); + const projectName = Object.keys(workspace.projects)[0]; + if (!projectName) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'No project found in workspace', + context + ); + return; + } + + const content = tree.read(packageJsonPath); + if (!content) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Failed to read ${packageJsonPath} file`, + context + ); + return; + } + + const packageJson = parse(content.toString()); + + if (!packageJson.scripts) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `No scripts section found in ${packageJsonPath}`, + context + ); + return; + } + + context.logger.info(' ↳ Removing "dev:ssr" script'); + delete packageJson.scripts['dev:ssr']; + + context.logger.info(' ↳ Removing "prerender" script'); + delete packageJson.scripts['prerender']; + + context.logger.info(' ↳ Removing "serve:ssr" script'); + delete packageJson.scripts['serve:ssr']; + + context.logger.info(' ↳ Updating "build:ssr" script'); + packageJson.scripts['build:ssr'] = 'ng build'; + + context.logger.info(` ↳ Adding "serve:ssr:${projectName}" script`); + packageJson.scripts[`serve:ssr:${projectName}`] = + `node dist/${projectName}/server/server.mjs`; + + const JSON_INDENT = 2; + tree.overwrite( + packageJsonPath, + JSON.stringify(packageJson, null, JSON_INDENT) + ); + + context.logger.info(`✅ Updated ${packageJsonPath} scripts`); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts.ts new file mode 100644 index 00000000000..34ca1b1c5a4 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { addImportsToServerTs } from './update-server-ts/add-imports-to-server-ts'; +import { removeImportsFromServerTs } from './update-server-ts/remove-imports-from-server-ts'; +import { removeReexportFromServerTs } from './update-server-ts/remove-reexport-from-server-ts'; +import { removeWebpackFromServerTs } from './update-server-ts/remove-webpack-from-server-ts'; +import { updateVariablesInServerTs } from './update-server-ts/update-variables-in-server-ts'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Updates `server.ts` file for new Angular v17 standards. + * + * Modernizes imports, configures new paths for `index.html` file, + * removes Webpack-specific code, and removes obsolete export. + */ +export function updateServerTs(): Rule { + return (tree: Tree, context: SchematicContext) => { + const serverTsPath = 'server.ts'; + + context.logger.info(`\n⏳ Updating ${serverTsPath} implementation...`); + + if (!tree.exists(serverTsPath)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `${serverTsPath} file not found`, + context + ); + return; + } + + const content = tree.read(serverTsPath); + if (!content) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Failed to read ${serverTsPath} file`, + context + ); + return; + } + + let updatedContent = content.toString(); + updatedContent = removeImportsFromServerTs(updatedContent, context); + updatedContent = addImportsToServerTs(updatedContent, context); + updatedContent = updateVariablesInServerTs(updatedContent, context); + updatedContent = removeWebpackFromServerTs(updatedContent, context); + updatedContent = removeReexportFromServerTs(updatedContent, context); + + tree.overwrite(serverTsPath, updatedContent); + + context.logger.info(`✅ Updated ${serverTsPath} implementation`); + }; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/add-imports-to-server-ts.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/add-imports-to-server-ts.ts new file mode 100644 index 00000000000..8daeaee6e5f --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/add-imports-to-server-ts.ts @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { insertImport } from '@schematics/angular/utility/ast-utils'; +import { Change, InsertChange } from '@schematics/angular/utility/change'; +import { parseTsFileContent } from '../../../shared/utils/file-utils'; +import { SchematicContext } from '@angular-devkit/schematics'; + +/** + * Adds new imports to server.ts file, to align with Angular v17 standards. + * + * ```diff + * + import { APP_BASE_HREF } from '@angular/common'; + * + import { + * + NgExpressEngineDecorator, + * + ngExpressEngine as engine, + * + } from '@spartacus/setup/ssr'; + * + import express from 'express'; + * + import { dirname, join, resolve } from 'node:path'; + * + import { fileURLToPath } from 'node:url'; + * + import AppServerModule from './src/main.server'; +``` + */ +export function addImportsToServerTs( + updatedContent: string, + context: SchematicContext +): string { + const sourceFile = parseTsFileContent(updatedContent); + + // List of new imports to add + const importsToAdd: { + importPath: string; + symbolName: string; + isDefault?: boolean; + asName?: string; + }[] = [ + { + importPath: 'express', + symbolName: 'express', + isDefault: true, + }, + { + importPath: 'node:path', + symbolName: 'dirname', + }, + { + importPath: 'node:path', + symbolName: 'join', + }, + { + importPath: 'node:path', + symbolName: 'resolve', + }, + { + importPath: 'node:url', + symbolName: 'fileURLToPath', + }, + { + importPath: './src/main.server', + symbolName: 'AppServerModule', + isDefault: true, + }, + ]; + + context.logger.info(' ↳ Adding new imports'); + const importAdditionChanges: Change[] = importsToAdd.map((imp) => { + context.logger.info( + ` ↳ Importing "${ + imp.symbolName ? imp.symbolName + ' from ' : '' + }${imp.importPath}"` + ); + return insertImport( + sourceFile, + '', + imp.symbolName, + imp.importPath, + imp.isDefault, + imp.asName + ); + }); + + importAdditionChanges.forEach((change) => { + if (change instanceof InsertChange) { + const start = change.pos; + updatedContent = + updatedContent.slice(0, start) + + change.toAdd + + updatedContent.slice(start); + } + }); + + return updatedContent; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/remove-imports-from-server-ts.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/remove-imports-from-server-ts.ts new file mode 100644 index 00000000000..1f037932b80 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/remove-imports-from-server-ts.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SchematicContext } from '@angular-devkit/schematics'; +import { removeImportFromContent } from '../../../shared/utils/file-utils'; + +/** + * Removes imports from server.ts file, to align with Angular v17 standards. + * + * ```diff + * - import 'zone.js/node'; + * + * - import { ngExpressEngine as engine } from - '@spartacus/setup/ssr'; + * - import { NgExpressEngineDecorator } from - '@spartacus/setup/ssr'; + * - import * as express from 'express'; + * - import { join } from 'path'; + * + * - import { AppServerModule } from './src/main.- server'; + * - import { APP_BASE_HREF } from '@angular/common'; + * - import { existsSync } from 'fs'; + * ``` + */ +export function removeImportsFromServerTs( + updatedContent: string, + context: SchematicContext +): string { + const importsToRemove: { symbolName?: string; importPath: string }[] = [ + { importPath: 'zone.js/node' }, + { symbolName: 'express', importPath: 'express' }, + { symbolName: 'join', importPath: 'path' }, + { symbolName: 'AppServerModule', importPath: './src/main.server' }, + { symbolName: 'existsSync', importPath: 'fs' }, + ]; + + context.logger.info(' ↳ Removing old imports'); + importsToRemove.forEach((importToRemove) => { + context.logger.info( + ` ↳ Removing import of "${ + importToRemove.symbolName ? importToRemove.symbolName + ' from ' : '' + }${importToRemove.importPath}"` + ); + updatedContent = removeImportFromContent(updatedContent, importToRemove); + }); + + return updatedContent; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/remove-reexport-from-server-ts.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/remove-reexport-from-server-ts.ts new file mode 100644 index 00000000000..81c439b86c3 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/remove-reexport-from-server-ts.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { findNodes } from '@schematics/angular/utility/ast-utils'; +import { parseTsFileContent } from '../../../shared/utils/file-utils'; +import ts from 'typescript'; +import { SchematicContext } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../../fallback-advice-to-follow-docs'; + +/** + * Removes the re-export of the path `./src/main.server`. + * + * ```diff + * - export * from './src/main.server'; + * ``` + */ +export function removeReexportFromServerTs( + fileContent: string, + context: SchematicContext +): string { + const sourceFile = parseTsFileContent(fileContent); + const nodes = findNodes(sourceFile, ts.SyntaxKind.ExportDeclaration); + + const reexportPath = './src/main.server'; + + context.logger.info( + ` ↳ Removing the re-export of the path "${reexportPath}"` + ); + + const exportNode = nodes.find((node) => { + if (!ts.isExportDeclaration(node)) { + return false; + } + const moduleSpecifier = node.moduleSpecifier; + if (!moduleSpecifier) { + return false; + } + return ( + ts.isStringLiteral(moduleSpecifier) && + moduleSpecifier.text === reexportPath + ); + }); + + if (!exportNode) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Could not remove the re-export of the path ${reexportPath}`, + context + ); + return fileContent; + } + + const start = exportNode.getFullStart(); + const end = exportNode.getEnd(); + return fileContent.slice(0, start) + fileContent.slice(end); +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/remove-webpack-from-server-ts.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/remove-webpack-from-server-ts.ts new file mode 100644 index 00000000000..5995efa2db2 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/remove-webpack-from-server-ts.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { findNodes } from '@schematics/angular/utility/ast-utils'; +import { parseTsFileContent } from '../../../shared/utils/file-utils'; +import { removeVariableDeclaration } from '../../../shared/utils/variable-utils'; +import ts from 'typescript'; +import { SchematicContext } from '@angular-devkit/schematics'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../../fallback-advice-to-follow-docs'; + +/** + * Removes the Webpack-specific comments. + * + * ```diff + * - // Webpack will replace 'require' with '__webpack_require__' + * - // '__non_webpack_require__' is a proxy to Node 'require' + * - // The below code is to ensure that the server is run only when not requiring the bundle. + */ +function removeWebpackSpecificComments( + fileContent: string, + context: SchematicContext +): string { + context.logger.info(' ↳ Removing Webpack-specific code comments //'); + return fileContent + .replace( + /\/\/ Webpack will replace 'require' with '__webpack_require__'\n/, + '' + ) + .replace( + /\/\/ '__non_webpack_require__' is a proxy to Node 'require'\n/, + '' + ) + .replace( + /\/\/ The below code is to ensure that the server is run only when not requiring the bundle\.\n/, + '' + ); +} + +/** + * Removes the Webpack-specific code and replaces with a simple `run()` call. + * + * ```diff + * - declare const __non_webpack_require__: NodeRequire; + * - const mainModule = __non_webpack_require__.main; + * - const moduleFilename = (mainModule && mainModule.- filename) || ''; + * - if (moduleFilename === __filename || moduleFilename.- includes('iisnode')) { + * - run(); + * - } + * + run(); + * ``` + */ +function removeWebpackSpecificCode( + fileContent: string, + context: SchematicContext +): string { + let updatedContent = fileContent; + + context.logger.info(' ↳ Removing Webpack-specific code'); + + context.logger.info(' ↳ Removing const "__non_webpack_require__"'); + updatedContent = removeVariableDeclaration({ + fileContent: updatedContent, + variableName: '__non_webpack_require__', + }); + + context.logger.info(' ↳ Removing const "mainModule"'); + updatedContent = removeVariableDeclaration({ + fileContent: updatedContent, + variableName: 'mainModule', + }); + + context.logger.info(' ↳ Removing const "moduleFilename"'); + updatedContent = removeVariableDeclaration({ + fileContent: updatedContent, + variableName: 'moduleFilename', + }); + + context.logger.info( + ' ↳ Removing "if" statement wrapping the "run()" call' + ); + const sourceFile = parseTsFileContent(updatedContent); + const ifNodes = findNodes(sourceFile, ts.SyntaxKind.IfStatement); + const ifNode = ifNodes.find((node) => { + if (!ts.isIfStatement(node)) { + return false; + } + const condition = node.expression; + return ( + condition.getText().includes('moduleFilename === __filename') && + condition.getText().includes("moduleFilename.includes('iisnode')") + ); + }); + + if (ifNode) { + const start = ifNode.getFullStart(); + const end = ifNode.getEnd(); + updatedContent = updatedContent.slice(0, start) + updatedContent.slice(end); + } else { + printErrorWithDocsForMigrated_6_8_To_2211_19( + 'Could not remove the Webpack-specific `if` block', + context + ); + return updatedContent; + } + + context.logger.info(' ↳ Adding standalone "run()" call'); + return ( + updatedContent + + ` +run(); +` + ); +} + +/** + * Removes the Webpack-specific code and comments from the server.ts file. + */ +export function removeWebpackFromServerTs( + updatedContent: string, + context: SchematicContext +): string { + updatedContent = removeWebpackSpecificComments(updatedContent, context); + updatedContent = removeWebpackSpecificCode(updatedContent, context); + return updatedContent; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/update-variables-in-server-ts.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/update-variables-in-server-ts.ts new file mode 100644 index 00000000000..38f6d14da05 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-server-ts/update-variables-in-server-ts.ts @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SchematicContext } from '@angular-devkit/schematics'; +import { replaceMethodCallArgument } from '../../../shared/utils/method-call-utils'; +import { replaceVariableDeclaration } from '../../../shared/utils/variable-utils'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../../fallback-advice-to-follow-docs'; + +/** + * Updates variables and method calls in server.ts file. + */ +export function updateVariablesInServerTs( + updatedContent: string, + context: SchematicContext +): string { + /* + * Removes `distFolder` variable declaration and replaces with 2 new variables: + * `serverDistFolder` and `browserDistFolder` + * + * ```diff + * - const distFolder = join(process.cwd(), 'dist//browser'); + * + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + * + const browserDistFolder = resolve(serverDistFolder, '../browser'); + * ``` + */ + try { + context.logger.info( + ' ↳ Replacing "distFolder" variable declaration with "serverDistFolder" and "browserDistFolder"' + ); + updatedContent = replaceVariableDeclaration({ + fileContent: updatedContent, + variableName: 'distFolder', + newText: `const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const browserDistFolder = resolve(serverDistFolder, '../browser');`, + throwErrorIfNotFound: true, + }); + } catch (error) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Could not replace "distFolder" variable declaration`, + context + ); + } + + /* + * Replace `indexHtml` variable declaration to use `browserDistFolder` + * ```diff + * - const indexHtml = existsSync(join(distFolder, 'index.original.html')) + * - ? 'index.original.html' + * - : 'index'; + * + const indexHtml = join(browserDistFolder, 'index.html'); + * ``` + */ + try { + context.logger.info( + ' ↳ Changing "indexHtml" variable declaration to use "browserDistFolder"' + ); + updatedContent = replaceVariableDeclaration({ + fileContent: updatedContent, + variableName: 'indexHtml', + newText: `const indexHtml = join(browserDistFolder, 'index.html');`, + throwErrorIfNotFound: true, + }); + } catch (error) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Could not replace "indexHtml" variable declaration`, + context + ); + } + + /* + * Change `server.set(_, distFolder)` to `server.set(_, browserDistFolder)` + * + * ```diff + * - server.set('views', distFolder); + * + server.set('views', browserDistFolder); + * ``` + */ + try { + context.logger.info( + ' ↳ Replacing argument "distFolder" with "browserDistFolder" in "server.set()" method call' + ); + updatedContent = replaceMethodCallArgument({ + fileContent: updatedContent, + objectName: 'server', + methodName: 'set', + argument: { + position: 1, + oldText: 'distFolder', + newText: 'browserDistFolder', + }, + throwErrorIfNotFound: true, + }); + } catch (error) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Could not replace argument "distFolder" in "server.set()" method call`, + context + ); + } + + /* + * Change `express.static(distFolder, { ... })` to `express.static(browserDistFolder, { ... })` + * + * ```diff + * server.get( + * '*.*', + * - express.static(distFolder, { + * + express.static(browserDistFolder, { + * ``` + */ + try { + context.logger.info( + ' ↳ Replacing argument "distFolder" with "browserDistFolder" in "express.static()" method call' + ); + updatedContent = replaceMethodCallArgument({ + fileContent: updatedContent, + objectName: 'express', + methodName: 'static', + argument: { + position: 0, + oldText: 'distFolder', + newText: 'browserDistFolder', + }, + throwErrorIfNotFound: true, + }); + } catch (error) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Could not replace argument "distFolder" in "express.static()" method call`, + context + ); + } + + return updatedContent; +} diff --git a/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-ts-config-app.ts b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-ts-config-app.ts new file mode 100644 index 00000000000..d190b31f123 --- /dev/null +++ b/projects/schematics/src/modernize-app-migrated-from-6_8-to-2211_19/ssr/update-ts-config-app.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { parse } from 'jsonc-parser'; +import { mergeArraysWithoutDuplicates } from '../../shared/utils/array-utils'; +import { printErrorWithDocsForMigrated_6_8_To_2211_19 } from '../fallback-advice-to-follow-docs'; + +/** + * Updates `tsconfig.app.json` to align with new Angular v17 standards. + * + * 1. Adds 1 new item to the array in the property `"types"`: `"node"` + * 2. Adds 2 new items to the in the `"files"` array: `"src/main.server.ts"` , `"server.ts"` + */ +export function updateTsConfigApp(): Rule { + return (tree: Tree, context: SchematicContext) => { + const tsconfigAppPath = 'tsconfig.app.json'; + + context.logger.info(`\n⏳ Updating ${tsconfigAppPath} configuration...`); + + if (!tree.exists(tsconfigAppPath)) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `${tsconfigAppPath} file not found`, + context + ); + return; + } + + const tsConfigAppContent = tree.read(tsconfigAppPath); + if (!tsConfigAppContent) { + printErrorWithDocsForMigrated_6_8_To_2211_19( + `Failed to read ${tsconfigAppPath} file`, + context + ); + return; + } + + const tsConfigApp = parse(tsConfigAppContent.toString()); + + context.logger.info(' ↳ Adding "node" to "types" array'); + tsConfigApp.compilerOptions = { + ...tsConfigApp.compilerOptions, + types: mergeArraysWithoutDuplicates(tsConfigApp.compilerOptions?.types, [ + 'node', + ]), + }; + + const serverFiles = ['src/main.server.ts', 'server.ts']; + context.logger.info( + ` ↳ Adding ${serverFiles + .map((x) => `"${x}"`) + .join(', ')} to "files" array` + ); + tsConfigApp.files = mergeArraysWithoutDuplicates( + tsConfigApp.files, + serverFiles + ); + + const JSON_INDENT = 2; + tree.overwrite( + tsconfigAppPath, + JSON.stringify(tsConfigApp, null, JSON_INDENT) + ); + + context.logger.info(`✅ Updated ${tsconfigAppPath} configuration`); + }; +} diff --git a/projects/schematics/src/shared/utils/array-utils.ts b/projects/schematics/src/shared/utils/array-utils.ts new file mode 100644 index 00000000000..c185da45d52 --- /dev/null +++ b/projects/schematics/src/shared/utils/array-utils.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Returns a new array with all unique items from the two arrays. + * + * The order of the items is not guaranteed. + */ +export function mergeArraysWithoutDuplicates(array1: T[], array2: T[]): T[] { + return Array.from(new Set([...(array1 ?? []), ...(array2 ?? [])])); +} diff --git a/projects/schematics/src/shared/utils/file-utils.ts b/projects/schematics/src/shared/utils/file-utils.ts index 87f063c48e2..690f57bf9e0 100644 --- a/projects/schematics/src/shared/utils/file-utils.ts +++ b/projects/schematics/src/shared/utils/file-utils.ts @@ -622,7 +622,10 @@ export function removeConstructorParam( const changes: Change[] = []; if (shouldRemoveImportAndParam(source, paramToRemove)) { - const importRemovalChange = removeImport(source, paramToRemove); + const importRemovalChange = removeImport(source, { + className: paramToRemove.className, + importPath: paramToRemove.importPath || '', + }); const injectImportRemovalChange = removeInjectImports( source, constructorNode, @@ -794,7 +797,7 @@ export function removeInjectImports( export function removeImport( source: ts.SourceFile, - importToRemove: ClassType + importToRemove: { className?: string; importPath: string } ): Change { const importDeclarationNode = getImportDeclarationNode( source, @@ -804,8 +807,42 @@ export function removeImport( return new NoopChange(); } + // Handle cases where we want to remove the whole import, + // not only a specific item (e.g. `import 'zone.js/node'`). + if (!importToRemove.className) { + return new RemoveChange( + source.fileName, + importDeclarationNode.getStart(), + importDeclarationNode.getText() + ); + } + let position: number; - let toRemove = importToRemove.className; + const className = importToRemove.className; + let toRemove = className; + + // Handle cases where we want to remove a namespace import (e.g. `import * as 'express'`). + const namespaceImports = findNodes( + importDeclarationNode, + ts.SyntaxKind.NamespaceImport + ); + if (namespaceImports.length > 0) { + const namespaceImport = namespaceImports[0]; + const nameNode = findNode( + namespaceImport, + ts.SyntaxKind.Identifier, + className + ); + if (nameNode) { + // If we found a matching namespace import, remove the whole import declaration + position = importDeclarationNode.getStart(); + toRemove = importDeclarationNode.getText(); + return new RemoveChange(source.fileName, position, toRemove); + } + } + + // Handle cases where we want to remove a named import + // (e.g. `import { join } from 'path'`). const importSpecifierNodes = findNodes( importDeclarationNode, ts.SyntaxKind.ImportSpecifier @@ -818,11 +855,7 @@ export function removeImport( // delete only the specified import, and leave the rest const importSpecifier = importSpecifierNodes .map((node, i) => { - const importNode = findNode( - node, - ts.SyntaxKind.Identifier, - importToRemove.className - ); + const importNode = findNode(node, ts.SyntaxKind.Identifier, className); return { importNode, i, @@ -844,9 +877,39 @@ export function removeImport( return new RemoveChange(source.fileName, position, toRemove); } +export function removeImportFromContent( + updatedContent: string, + importToRemove: { symbolName?: string; importPath: string } +): string { + try { + const sourceFile = parseTsFileContent(updatedContent); + + const change = removeImport(sourceFile, { + className: importToRemove.symbolName, + importPath: importToRemove.importPath, + }); + + if (change instanceof RemoveChange) { + const searchText = change.toRemove; + const searchIndex = updatedContent.indexOf(searchText); + if (searchIndex !== -1) { + updatedContent = + updatedContent.slice(0, searchIndex) + + updatedContent.slice(searchIndex + searchText.length); + } + } + } catch (error) { + throw new Error( + `Could not remove the import declaration for ${importToRemove.symbolName} from ${importToRemove.importPath} - ERROR: ${error}` + ); + } + + return updatedContent; +} + function getImportDeclarationNode( source: ts.SourceFile, - importToCheck: ClassType + importToCheck: { className?: string; importPath: string } ): ts.Node | undefined { if (!importToCheck.importPath) { return undefined; @@ -1306,3 +1369,11 @@ export function getServerTsPath(host: Tree): string | undefined { return angularJson.projects[projectName].architect?.server?.options?.main; } + +/** + * Takes a string of content and returns a ts.SourceFile object. + * This is useful for creating a temporary source file for AST operations. + */ +export function parseTsFileContent(fileContent: string): ts.SourceFile { + return ts.createSourceFile('', fileContent, ts.ScriptTarget.Latest, true); +} diff --git a/projects/schematics/src/shared/utils/method-call-utils.ts b/projects/schematics/src/shared/utils/method-call-utils.ts new file mode 100644 index 00000000000..8066925d1e2 --- /dev/null +++ b/projects/schematics/src/shared/utils/method-call-utils.ts @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { findNodes } from '@schematics/angular/utility/ast-utils'; +import ts from 'typescript'; +import { parseTsFileContent } from './file-utils'; + +interface ReplaceMethodCallArgumentParams { + fileContent: string; + objectName: string; + methodName: string; + argument: { + position: number; + oldText: string; + newText: string; + }; + throwErrorIfNotFound?: boolean; +} + +interface FindMethodCallsParams { + fileContent: string; + objectName: string; + methodName: string; +} + +/** + * Finds all method calls in the given file content. + * + * Returns the method calls. + */ +export function findMethodCalls({ + fileContent, + objectName, + methodName, +}: FindMethodCallsParams): ts.CallExpression[] { + const sourceFile = parseTsFileContent(fileContent); + + // Get all method calls + const nodes = findNodes( + sourceFile, + ts.SyntaxKind.CallExpression, + undefined, + true + ); + + return nodes.filter((node): node is ts.CallExpression => { + if (!ts.isCallExpression(node)) { + return false; + } + const expression = node.expression; + if (!ts.isPropertyAccessExpression(expression)) { + return false; + } + + const object = expression.expression; + const method = expression.name; + + if (!ts.isIdentifier(object) || !ts.isIdentifier(method)) { + return false; + } + + return object.text === objectName && method.text === methodName; + }); +} + +/** + * Replaces an argument in a method call in the given file content. + * Works for multiple occurrences of the method call. + * + * It matches the call by the object name, method name, position of the argument, + * and the argument text. + * + * Returns the updated file content. + */ +export function replaceMethodCallArgument({ + fileContent, + objectName, + methodName, + argument, + throwErrorIfNotFound = false, +}: ReplaceMethodCallArgumentParams): string { + const targetNodes = findMethodCalls({ + fileContent, + objectName, + methodName, + }).filter((node) => { + // filter out method calls with less arguments than the expected argument position + if (node.arguments.length <= argument.position) { + return false; + } + + // filter out method calls with the wrong argument text + if (node.arguments[argument.position].getText() !== argument.oldText) { + return false; + } + + return true; + }); + + if (!targetNodes.length) { + if (throwErrorIfNotFound) { + throw new Error( + `Could not replace ${objectName}.${methodName}() method call argument on position ${argument.position}` + ); + } + return fileContent; + } + + // Replace all occurrences of the method call + let updatedContent = fileContent; + + // Replace occurrences from last to first - to not interfere with + // the already calculated start and end positions of the other occurrences + const targetNodesReversed = [...targetNodes].reverse(); + targetNodesReversed.forEach((methodCallNode) => { + const argumentToReplace = methodCallNode.arguments[argument.position]; + updatedContent = + updatedContent.slice(0, argumentToReplace.getStart()) + + argument.newText + + updatedContent.slice(argumentToReplace.getEnd()); + }); + + return updatedContent; +} diff --git a/projects/schematics/src/shared/utils/package-utils.ts b/projects/schematics/src/shared/utils/package-utils.ts index dd04b6909c1..3bf110d68af 100644 --- a/projects/schematics/src/shared/utils/package-utils.ts +++ b/projects/schematics/src/shared/utils/package-utils.ts @@ -148,7 +148,11 @@ export function getSpartacusCurrentFeatureLevel(): string { return version.split('.').slice(0, 2).join('.'); } -export function checkIfSSRIsUsed(tree: Tree): boolean { +/** + * Tells whether the app is using SSR with legacy configuration + * (not using Application Builder, but the old Server Builder) + */ +export function isUsingLegacyServerBuilder(tree: Tree): boolean { const projectName = getDefaultProjectNameFromWorkspace(tree); const { workspace: angularJson } = getWorkspace(tree); const isServerConfiguration = @@ -186,7 +190,7 @@ interface ApplicationBuilderWorkspaceArchitect { * @param tree - The file tree to check for SSR configuration * @returns true if SSR is configured and the server file exists, false otherwise */ -export function checkIfSSRIsUsedWithApplicationBuilder(tree: Tree): boolean { +export function isSsrUsed(tree: Tree): boolean { const projectName = getDefaultProjectNameFromWorkspace(tree); const { workspace: angularJson } = getWorkspace(tree); const architect = angularJson.projects[projectName] diff --git a/projects/schematics/src/shared/utils/package-utils_spec.ts b/projects/schematics/src/shared/utils/package-utils_spec.ts index 5a8eb3d20ed..9b54609b470 100644 --- a/projects/schematics/src/shared/utils/package-utils_spec.ts +++ b/projects/schematics/src/shared/utils/package-utils_spec.ts @@ -12,7 +12,7 @@ import * as path from 'path'; import { UTF_8 } from '../constants'; import { SPARTACUS_SCHEMATICS } from '../libs-constants'; import { - checkIfSSRIsUsedWithApplicationBuilder, + isSsrUsed, getMajorVersionNumber, getServerTsPathForApplicationBuilder, getSpartacusCurrentFeatureLevel, @@ -138,15 +138,15 @@ describe('Package utils', () => { ssrTree = new UnitTestTree(Tree.empty()); }); - describe('checkIfSSRIsUsedWithApplicationBuilder', () => { + describe('isSsrUsed', () => { it('should return false when builder is not application builder', () => { createWorkspace('@angular-devkit/build-angular:browser'); - expect(checkIfSSRIsUsedWithApplicationBuilder(ssrTree)).toBeFalsy(); + expect(isSsrUsed(ssrTree)).toBeFalsy(); }); it('should return false when SSR config is missing', () => { createWorkspace('@angular-devkit/build-angular:application'); - expect(checkIfSSRIsUsedWithApplicationBuilder(ssrTree)).toBeFalsy(); + expect(isSsrUsed(ssrTree)).toBeFalsy(); }); it('should return false when server file does not exist', () => { @@ -155,7 +155,7 @@ describe('Package utils', () => { 'src/main.server.ts', 'server.ts' ); - expect(checkIfSSRIsUsedWithApplicationBuilder(ssrTree)).toBeFalsy(); + expect(isSsrUsed(ssrTree)).toBeFalsy(); }); it('should return true when SSR is properly configured and server file exists', () => { @@ -165,7 +165,7 @@ describe('Package utils', () => { 'server.ts' ); ssrTree.create('server.ts', 'export const server = {};'); - expect(checkIfSSRIsUsedWithApplicationBuilder(ssrTree)).toBeTruthy(); + expect(isSsrUsed(ssrTree)).toBeTruthy(); }); }); diff --git a/projects/schematics/src/shared/utils/variable-utils.ts b/projects/schematics/src/shared/utils/variable-utils.ts new file mode 100644 index 00000000000..df76c8d24c2 --- /dev/null +++ b/projects/schematics/src/shared/utils/variable-utils.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { findNodes } from '@schematics/angular/utility/ast-utils'; +import { parseTsFileContent } from './file-utils'; +import ts from 'typescript'; + +interface ReplaceVariableDeclarationParams { + fileContent: string; + variableName: string; + newText: string; + throwErrorIfNotFound?: boolean; +} + +interface RemoveVariableDeclarationParams { + fileContent: string; + variableName: string; +} + +/** + * Replaces a variable declaration in the given file content. + * + * Returns the updated file content. + */ +export function replaceVariableDeclaration({ + fileContent, + variableName, + newText, + throwErrorIfNotFound = false, +}: ReplaceVariableDeclarationParams): string { + const sourceFile = parseTsFileContent(fileContent); + + // Get all variables + const nodes = findNodes(sourceFile, ts.SyntaxKind.VariableDeclaration); + + // Find variable by name + const targetNode = nodes.find((node) => { + if (!ts.isVariableDeclaration(node)) { + return false; + } + const name = node.name; + return ts.isIdentifier(name) && name.text === variableName; + }); + + if (!targetNode) { + if (throwErrorIfNotFound) { + throw new Error(`Could not replace ${variableName} variable`); + } + return fileContent; + } + + // Get the parent VariableStatement - to include the `const` keyword + const statement = targetNode.parent.parent; + const start = statement.getStart(); + const end = statement.getEnd(); + + // Replace the whole statement + return fileContent.slice(0, start) + newText + fileContent.slice(end); +} + +/** + * Removes a variable declaration in the given file content. + * + * Returns the updated file content. + */ +export function removeVariableDeclaration({ + fileContent, + variableName, +}: RemoveVariableDeclarationParams): string { + return replaceVariableDeclaration({ + fileContent, + variableName, + newText: '', + }); +}