From 8421152feb5977d3b8365a7246e0e00bc0fa190c Mon Sep 17 00:00:00 2001 From: David LJ Date: Mon, 28 Oct 2024 21:12:39 +0100 Subject: [PATCH] feat: add migration schematic for breaking changes --- jest.config.js | 7 +- .../schematics/external-utils/README.md | 19 ++ .../angular-devkit/core/src/utils/strings.ts | 69 ++++++ .../schematics/angular/utility/change.ts | 114 +++++++++ projects/ngx-meta/schematics/migrations.json | 10 + .../index.spec.ts | 228 ++++++++++++++++++ .../tree-shakeable-manager-providers/index.ts | 36 +++ ...-get-new-identifier-from-old-identifier.ts | 38 +++ .../testing/replacements.ts | 64 +++++ .../update-imports.ts | 67 +++++ .../update-usages.ts | 56 +++++ .../schematics/utils/apply-changes.ts | 30 +++ .../ngx-meta/schematics/utils/is-defined.ts | 2 + projects/ngx-meta/src/package.json | 5 +- 14 files changed, 743 insertions(+), 2 deletions(-) create mode 100644 projects/ngx-meta/schematics/external-utils/README.md create mode 100644 projects/ngx-meta/schematics/external-utils/angular-devkit/core/src/utils/strings.ts create mode 100644 projects/ngx-meta/schematics/external-utils/schematics/angular/utility/change.ts create mode 100644 projects/ngx-meta/schematics/migrations.json create mode 100644 projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/index.spec.ts create mode 100644 projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/index.ts create mode 100644 projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/maybe-get-new-identifier-from-old-identifier.ts create mode 100644 projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/testing/replacements.ts create mode 100644 projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/update-imports.ts create mode 100644 projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/update-usages.ts create mode 100644 projects/ngx-meta/schematics/utils/apply-changes.ts create mode 100644 projects/ngx-meta/schematics/utils/is-defined.ts diff --git a/jest.config.js b/jest.config.js index b64587413..9bbf93f17 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,5 +12,10 @@ module.exports = { coverageDirectory: '/coverage/jest', coverageReporters: ['json', 'text'], - collectCoverageFrom: ['/**/*.ts', '!**/testing/**'], + collectCoverageFrom: [ + '/**/*.ts', + '!**/testing/**', + '!**/external-utils/**', + '!**/utils/**', + ], } diff --git a/projects/ngx-meta/schematics/external-utils/README.md b/projects/ngx-meta/schematics/external-utils/README.md new file mode 100644 index 000000000..3de8e506f --- /dev/null +++ b/projects/ngx-meta/schematics/external-utils/README.md @@ -0,0 +1,19 @@ +# Why is this needed + +May schematic utilities are deep imports, hence not part of [Angular's public API surface](https://github.com/angular/angular/blob/main/contributing-docs/public-api-surface.md) + +> A deep import is an import deeper than one of the package's entry point. For instance `@angular/core` is a regular import. But `@angular/core/src/utils` is a deep import. + +For instance: + +- `@schematics/angular/utility` +- `@angular-devkit/core/src/utils` + +So the files needed from there are copy / pasted in here to avoid coupling to non-public APIs which can be dangerous (for instance breaking changes) + +Indeed, [some `@angular/core` schematic utils mysteriously disappeared in v15.1](https://stackoverflow.com/a/79123753/3263250) + +Existing `npm` libraries with exported utils aren't very popular or maintained at the moment of writing this. For instance: + +- [`schematics-utilities`](https://www.npmjs.com/package/schematics-utilities). Most popular one. Exports copy/pasted utils from Angular's schematics package and Angular Material package. [Latest release is from 2021 (3+ years ago)](https://github.com/nitayneeman/schematics-utilities/releases/tag/v2.0.3). [Depends on Angular v8 and Typescript v3](https://github.com/nitayneeman/schematics-utilities/blob/v2.0.3/package.json#L38-L41) +- [`@hug/ngx-schematics-utilities`](https://www.npmjs.com/package/@hug/ngx-schematics-utilities). Modern schematics with a builder-like pattern. It's updated: [latest release was last month](https://github.com/DSI-HUG/ngx-schematics-utilities/releases/tag/10.1.4). [Depends with peer dependencies (yay!) on Angular > v17](https://github.com/DSI-HUG/ngx-schematics-utilities/blob/10.1.4/projects/lib/package.json#L53-L58). Not very popular though. So prefer copy/pasting for now. diff --git a/projects/ngx-meta/schematics/external-utils/angular-devkit/core/src/utils/strings.ts b/projects/ngx-meta/schematics/external-utils/angular-devkit/core/src/utils/strings.ts new file mode 100644 index 000000000..57e37f80d --- /dev/null +++ b/projects/ngx-meta/schematics/external-utils/angular-devkit/core/src/utils/strings.ts @@ -0,0 +1,69 @@ +// Partial extraction from +// https://github.com/angular/angular-cli/blob/18.2.10/packages/angular_devkit/core/src/utils/strings.ts +const STRING_CAMELIZE_REGEXP = /(-|_|\.|\s)+(.)?/g + +/** + Returns the lowerCamelCase form of a string. + + ```javascript + camelize('innerHTML'); // 'innerHTML' + camelize('action_name'); // 'actionName' + camelize('css-class-name'); // 'cssClassName' + camelize('my favorite items'); // 'myFavoriteItems' + camelize('My Favorite Items'); // 'myFavoriteItems' + ``` + + @method camelize + @param {String} str The string to camelize. + @return {String} the camelized string. + */ +export function camelize(str: string): string { + return str + .replace( + STRING_CAMELIZE_REGEXP, + (_match: string, _separator: string, chr: string) => { + return chr ? chr.toUpperCase() : '' + }, + ) + .replace(/^([A-Z])/, (match: string) => match.toLowerCase()) +} + +/** + Returns the UpperCamelCase form of a string. + + @example + ```javascript + 'innerHTML'.classify(); // 'InnerHTML' + 'action_name'.classify(); // 'ActionName' + 'css-class-name'.classify(); // 'CssClassName' + 'my favorite items'.classify(); // 'MyFavoriteItems' + 'app.component'.classify(); // 'AppComponent' + ``` + @method classify + @param {String} str the string to classify + @return {String} the classified string + */ +export function classify(str: string): string { + return str + .split('.') + .map((part) => capitalize(camelize(part))) + .join('') +} + +/** + Returns the Capitalized form of a string + + ```javascript + 'innerHTML'.capitalize() // 'InnerHTML' + 'action_name'.capitalize() // 'Action_name' + 'css-class-name'.capitalize() // 'Css-class-name' + 'my favorite items'.capitalize() // 'My favorite items' + ``` + + @method capitalize + @param {String} str The string to capitalize. + @return {String} The capitalized string. + */ +export function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1) +} diff --git a/projects/ngx-meta/schematics/external-utils/schematics/angular/utility/change.ts b/projects/ngx-meta/schematics/external-utils/schematics/angular/utility/change.ts new file mode 100644 index 000000000..0da0de3b8 --- /dev/null +++ b/projects/ngx-meta/schematics/external-utils/schematics/angular/utility/change.ts @@ -0,0 +1,114 @@ +// Partial extraction from +// https://github.com/angular/angular-cli/blob/18.2.10/packages/schematics/angular/utility/change.ts +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { UpdateRecorder } from '@angular-devkit/schematics' + +export interface Change { + // The file this change should be applied to. Some changes might not apply to + // a file (maybe the config). + readonly path: string | null + + // The order this change should be applied. Normally the position inside the file. + // Changes are applied from the bottom of a file to the top. + readonly order: number + + // The description of this change. This will be outputted in a dry or verbose run. + readonly description: string +} + +/** + * An operation that does nothing. + */ +export class NoopChange implements Change { + description = 'No operation.' + order = Infinity + path = null +} + +/** + * Will add text to the source code. + */ +export class InsertChange implements Change { + order: number + description: string + + constructor( + public path: string, + public pos: number, + public toAdd: string, + ) { + if (pos < 0) { + throw new Error('Negative positions are invalid') + } + this.description = `Inserted ${toAdd} into position ${pos} of ${path}` + this.order = pos + } +} + +/** + * Will remove text from the source code. + */ +export class RemoveChange implements Change { + order: number + description: string + + constructor( + public path: string, + pos: number, + public toRemove: string, + ) { + if (pos < 0) { + throw new Error('Negative positions are invalid') + } + this.description = `Removed ${toRemove} into position ${pos} of ${path}` + this.order = pos + } +} + +/** + * Will replace text from the source code. + */ +export class ReplaceChange implements Change { + order: number + description: string + + constructor( + public path: string, + pos: number, + public oldText: string, + public newText: string, + ) { + if (pos < 0) { + throw new Error('Negative positions are invalid') + } + this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}` + this.order = pos + } +} + +export function applyToUpdateRecorder( + recorder: UpdateRecorder, + changes: Change[], +): void { + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd) + } else if (change instanceof RemoveChange) { + recorder.remove(change.order, change.toRemove.length) + } else if (change instanceof ReplaceChange) { + recorder.remove(change.order, change.oldText.length) + recorder.insertLeft(change.order, change.newText) + } else if (!(change instanceof NoopChange)) { + throw new Error( + 'Unknown Change type encountered when updating a recorder.', + ) + } + } +} diff --git a/projects/ngx-meta/schematics/migrations.json b/projects/ngx-meta/schematics/migrations.json new file mode 100644 index 000000000..13b86afb4 --- /dev/null +++ b/projects/ngx-meta/schematics/migrations.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "tree-shakeable-manager-providers": { + "version": "1.0.0-beta.31", + "description": "Updates non tree-shakeable built-in metadata manager providers into tree-shakeable ones", + "factory": "./migrations/tree-shakeable-manager-providers#migrate" + } + } +} diff --git a/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/index.spec.ts b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/index.spec.ts new file mode 100644 index 000000000..6047ab4e2 --- /dev/null +++ b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/index.spec.ts @@ -0,0 +1,228 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals' +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing' +import { join } from 'path' +import { logging } from '@angular-devkit/core' +import { LIB_NAME } from '../../testing/lib-name' +import { + JSON_LD_REPLACEMENTS, + OPEN_GRAPH_PROFILE_REPLACEMENTS, + OPEN_GRAPH_REPLACEMENTS, + STANDARD_REPLACEMENTS, + TWITTER_CARD_REPLACEMENTS, +} from './testing/replacements' +import { Tree } from '@angular-devkit/schematics' // https://github.com/angular/angular/blob/19.0.x/packages/core/schematics/test/explicit_standalone_flag_spec.ts + +// https://github.com/angular/angular/blob/19.0.x/packages/core/schematics/test/explicit_standalone_flag_spec.ts +// https://github.com/angular/angular/blob/19.0.x/packages/core/schematics/test/inject_migration_spec.ts +// https://github.com/angular/angular/blob/19.0.x/packages/core/schematics/test/provide_initializer_spec.ts +describe('Tree shakeable manager providers migration', () => { + let runner: SchematicTestRunner + let tree: UnitTestTree + let logWarnSpy: jest.Spied<(typeof logging.Logger.prototype)['warn']> + const [SAMPLE_OLD_IDENTIFIER, SAMPLE_NEW_IDENTIFIER, SAMPLE_ENTRYPOINT] = [ + Object.keys(STANDARD_REPLACEMENTS.identifierReplacements)[0], + Object.values(STANDARD_REPLACEMENTS.identifierReplacements)[0], + 'standard', + ] + const SAMPLE_TYPESCRIPT_FILE_PATH = '/index.ts' + + beforeEach(async () => { + runner = new SchematicTestRunner( + 'schematics', + join(__dirname, '..', '..', 'migrations.json'), + ) + tree = new UnitTestTree(Tree.empty()) + logWarnSpy = jest.spyOn(logging.Logger.prototype, 'warn').mockReturnValue() + }) + afterEach(() => { + jest.restoreAllMocks() + }) + + const runMigration = () => + runner.runSchematic('tree-shakeable-manager-providers', {}, tree) + + it('should not change non-Typescript files', async () => { + const INDEX_HTML_CONTENTS = ` + Some sample code that isn't in a Typescript file +
+        import { ${SAMPLE_OLD_IDENTIFIER} from '${LIB_NAME}/${SAMPLE_ENTRYPOINT}'
+
+        const providers = [ ${SAMPLE_OLD_IDENTIFIER} ];
+        
+ ` + tree.create('/index.html', INDEX_HTML_CONTENTS) + + await runMigration() + + expect(tree.readContent('/index.html')).toEqual(INDEX_HTML_CONTENTS) + }) + + it('should replace many old identifier imports and usages for new ones', async () => { + tree.create( + SAMPLE_TYPESCRIPT_FILE_PATH, + ` + import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core' + import { provideClientHydration } from '@angular/platform-browser' + import { + provideNgxMetaCore, + withNgxMetaBaseUrl, + withNgxMetaDefaults, + } from '@davidlj95/ngx-meta/core' + import { provideNgxMetaRouting } from '${LIB_NAME}/routing' + import { + ${JSON_LD_REPLACEMENTS.getOldIdentifiersLines()}, + provideNgxMetaJsonLd + } from '${LIB_NAME}/json-ld' + import { + ${OPEN_GRAPH_REPLACEMENTS.getOldIdentifiersLines()}, + provideNgxMetaOpenGraph + } from '${LIB_NAME}/open-graph' + import { + ${OPEN_GRAPH_PROFILE_REPLACEMENTS.getOldIdentifiersLines()}, + provideNgxMetaOpenGraphProfile + } from '${LIB_NAME}/open-graph' + import { + ${STANDARD_REPLACEMENTS.getOldIdentifiersLines()}, + provideNgxMetaStandard + } from '${LIB_NAME}/standard' + import { + ${TWITTER_CARD_REPLACEMENTS.getOldIdentifiersLines()}, + provideNgxMetaTwitterCard + } from '${LIB_NAME}/twitter-card' + + export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideClientHydration(), + provideNgxMetaCore( + withNgxMetaDefaults({ title: 'Default title' }), + withNgxMetaBaseUrl('https://example.com'), + ), + provideNgxMetaRouting(), + ${JSON_LD_REPLACEMENTS.getOldUsagesLines()}, + provideNgxMetaJsonLd(), + ${OPEN_GRAPH_REPLACEMENTS.getOldUsagesLines()}, + provideNgxMetaOpenGraph(), + ${OPEN_GRAPH_PROFILE_REPLACEMENTS.getOldUsagesLines()}, + provideNgxMetaOpenGraphProfile(), + ${STANDARD_REPLACEMENTS.getOldUsagesLines()}, + provideNgxMetaStandard(), + ${TWITTER_CARD_REPLACEMENTS.getOldUsagesLines()}, + provideNgxMetaTwitterCard(), + ], + }`, + ) + + await runMigration() + + const content = tree.readContent(SAMPLE_TYPESCRIPT_FILE_PATH) + expect(content).toEqual(` + import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core' + import { provideClientHydration } from '@angular/platform-browser' + import { + provideNgxMetaCore, + withNgxMetaBaseUrl, + withNgxMetaDefaults, + } from '@davidlj95/ngx-meta/core' + import { provideNgxMetaRouting } from '${LIB_NAME}/routing' + import { + ${JSON_LD_REPLACEMENTS.getNewIdentifiersLines()}, + provideNgxMetaJsonLd + } from '${LIB_NAME}/json-ld' + import { + ${OPEN_GRAPH_REPLACEMENTS.getNewIdentifiersLines()}, + provideNgxMetaOpenGraph + } from '${LIB_NAME}/open-graph' + import { + ${OPEN_GRAPH_PROFILE_REPLACEMENTS.getNewIdentifiersLines()}, + provideNgxMetaOpenGraphProfile + } from '${LIB_NAME}/open-graph' + import { + ${STANDARD_REPLACEMENTS.getNewIdentifiersLines()}, + provideNgxMetaStandard + } from '${LIB_NAME}/standard' + import { + ${TWITTER_CARD_REPLACEMENTS.getNewIdentifiersLines()}, + provideNgxMetaTwitterCard + } from '${LIB_NAME}/twitter-card' + + export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideClientHydration(), + provideNgxMetaCore( + withNgxMetaDefaults({ title: 'Default title' }), + withNgxMetaBaseUrl('https://example.com'), + ), + provideNgxMetaRouting(), + ${JSON_LD_REPLACEMENTS.getNewUsagesLines()}, + provideNgxMetaJsonLd(), + ${OPEN_GRAPH_REPLACEMENTS.getNewUsagesLines()}, + provideNgxMetaOpenGraph(), + ${OPEN_GRAPH_PROFILE_REPLACEMENTS.getNewUsagesLines()}, + provideNgxMetaOpenGraphProfile(), + ${STANDARD_REPLACEMENTS.getNewUsagesLines()}, + provideNgxMetaStandard(), + ${TWITTER_CARD_REPLACEMENTS.getNewUsagesLines()}, + provideNgxMetaTwitterCard(), + ], + }`) + }) + + describe('when namespace imports are used to import the library', () => { + const namespaceImport = `import ngxMetaMetadataModule from '${LIB_NAME}/${SAMPLE_ENTRYPOINT}'` + const oldUsage = `ngxMetaMetadataModule.${SAMPLE_OLD_IDENTIFIER}` + const newUsage = `ngxMetaMetadataModule.${SAMPLE_NEW_IDENTIFIER}()` + + it('should leave the import as is', async () => { + tree.create(SAMPLE_TYPESCRIPT_FILE_PATH, namespaceImport) + + await runMigration() + + expect(tree.readContent(SAMPLE_TYPESCRIPT_FILE_PATH)).toEqual( + namespaceImport, + ) + }) + + it('should warn about it', async () => { + tree.create(SAMPLE_TYPESCRIPT_FILE_PATH, namespaceImport) + + await runMigration() + + expect(logWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('non-named imports of the library detected'), + ) + }) + + it('should replace those usages too', async () => { + tree.create( + SAMPLE_TYPESCRIPT_FILE_PATH, + `${namespaceImport} + + const providers = [ + ${oldUsage}, + ]`, + ) + + await runMigration() + + expect(tree.readContent(SAMPLE_TYPESCRIPT_FILE_PATH)).toEqual( + `${namespaceImport} + + const providers = [ + ${newUsage}, + ]`, + ) + }) + }) +}) diff --git a/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/index.ts b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/index.ts new file mode 100644 index 000000000..63ba132f6 --- /dev/null +++ b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/index.ts @@ -0,0 +1,36 @@ +import { Rule } from '@angular-devkit/schematics' +import { createSourceFile, ScriptTarget } from 'typescript' +import { updateImports } from './update-imports' +import { updateUsages } from './update-usages' +import { applyChanges } from '../../utils/apply-changes' + +// Sources visited to create this function (and `migrateFile` one): +// https://github.com/angular/angular/blob/19.0.0-next.11/packages/core/schematics/migrations/provide-initializer/index.ts +// https://github.com/angular/angular/blob/19.0.0-next.11/packages/core/schematics/migrations/explicit-standalone-flag/index.ts +// https://github.com/angular/components/blob/19.0.0-next.10/src/google-maps/schematics/ng-update/index.ts +// https://github.com/ngrx/platform/blob/main/modules/store/migrations/18_0_0-beta/index.ts#L20 +// Eventually, generated most of it with ChatGPT though :P +// However, investigated first how folks are doing it around first to see what's the current way to do it +// noinspection JSUnusedGlobalSymbols +export function migrate(): Rule { + return (tree, context) => { + tree.visit((path) => { + if (!canMigrate(path)) { + return + } + const sourceFile = createSourceFile( + path, + tree.readText(path), + ScriptTarget.Latest, + true, + ) + applyChanges(tree, path, [ + ...updateImports(sourceFile, path, context.logger), + ...updateUsages(sourceFile, path), + ]) + }) + } +} + +const canMigrate = (path: string): boolean => + ['.ts', '.mts'].some((suffix) => path.endsWith(suffix)) diff --git a/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/maybe-get-new-identifier-from-old-identifier.ts b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/maybe-get-new-identifier-from-old-identifier.ts new file mode 100644 index 000000000..ef0e6bc3f --- /dev/null +++ b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/maybe-get-new-identifier-from-old-identifier.ts @@ -0,0 +1,38 @@ +import { classify } from '../../external-utils/angular-devkit/core/src/utils/strings' + +export const maybeGetNewIdentifierFromOldIdentifier = ( + maybeOldIdentifier: string, +): string | undefined => { + const maybeNewIdentifier = SPECIAL_IDENTIFIER_MAPPINGS.get(maybeOldIdentifier) + if (maybeNewIdentifier) { + return maybeNewIdentifier + } + + const prefix = OLD_IDENTIFIER_PREFIXES.find((prefix) => + maybeOldIdentifier.startsWith(prefix), + ) + if (!prefix || !maybeOldIdentifier.endsWith(OLD_IDENTIFIER_SUFFIX)) { + return + } + const oldIdentifierNoPrefixNorSuffix = maybeOldIdentifier + .slice(prefix.length) + .slice(0, -OLD_IDENTIFIER_SUFFIX.length) + const metadataModuleClassified = classify(prefix.toLowerCase()) + const metadataNameClassified = classify( + oldIdentifierNoPrefixNorSuffix.toLowerCase(), + ) + return `provide${metadataModuleClassified}${metadataNameClassified}` +} + +const SPECIAL_IDENTIFIER_MAPPINGS = new Map([ + ['JSON_LD_METADATA_PROVIDER', 'provideJsonLdInHead'], +]) + +const OLD_IDENTIFIER_PREFIXES = [ + 'JSON_LD_', + 'OPEN_GRAPH_PROFILE_', + 'OPEN_GRAPH_', + 'STANDARD_', + 'TWITTER_CARD_', +] +const OLD_IDENTIFIER_SUFFIX = '_METADATA_PROVIDER' diff --git a/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/testing/replacements.ts b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/testing/replacements.ts new file mode 100644 index 000000000..97dfd6a16 --- /dev/null +++ b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/testing/replacements.ts @@ -0,0 +1,64 @@ +export class ModuleReplacements { + constructor(public readonly identifierReplacements: Record) {} + + getOldIdentifiersLines() { + return Object.keys(this.identifierReplacements).join(',\n') + } + + getNewIdentifiersLines() { + return Object.values(this.identifierReplacements).join(',\n') + } + + getOldUsagesLines() { + return Object.keys(this.identifierReplacements).join(',\n') + } + + getNewUsagesLines() { + return Object.values(this.identifierReplacements) + .map((newIdentifier) => `${newIdentifier}()`) + .join(',\n') + } +} + +export const JSON_LD_REPLACEMENTS: ModuleReplacements = new ModuleReplacements({ + JSON_LD_METADATA_PROVIDER: 'provideJsonLdInHead', +}) + +export const OPEN_GRAPH_REPLACEMENTS = new ModuleReplacements({ + OPEN_GRAPH_TITLE_METADATA_PROVIDER: 'provideOpenGraphTitle', + OPEN_GRAPH_TYPE_METADATA_PROVIDER: 'provideOpenGraphType', + OPEN_GRAPH_IMAGE_METADATA_PROVIDER: 'provideOpenGraphImage', + OPEN_GRAPH_URL_METADATA_PROVIDER: 'provideOpenGraphUrl', + OPEN_GRAPH_DESCRIPTION_METADATA_PROVIDER: 'provideOpenGraphDescription', + OPEN_GRAPH_LOCALE_METADATA_PROVIDER: 'provideOpenGraphLocale', + OPEN_GRAPH_SITE_NAME_METADATA_PROVIDER: 'provideOpenGraphSiteName', +}) + +export const OPEN_GRAPH_PROFILE_REPLACEMENTS = new ModuleReplacements({ + OPEN_GRAPH_PROFILE_FIRST_NAME_METADATA_PROVIDER: + 'provideOpenGraphProfileFirstName', + OPEN_GRAPH_PROFILE_LAST_NAME_METADATA_PROVIDER: + 'provideOpenGraphProfileLastName', + OPEN_GRAPH_PROFILE_USERNAME_METADATA_PROVIDER: + 'provideOpenGraphProfileUsername', + OPEN_GRAPH_PROFILE_GENDER_METADATA_PROVIDER: 'provideOpenGraphProfileGender', +}) +export const STANDARD_REPLACEMENTS = new ModuleReplacements({ + STANDARD_TITLE_METADATA_PROVIDER: 'provideStandardTitle', + STANDARD_DESCRIPTION_METADATA_PROVIDER: 'provideStandardDescription', + STANDARD_AUTHOR_METADATA_PROVIDER: 'provideStandardAuthor', + STANDARD_KEYWORDS_METADATA_PROVIDER: 'provideStandardKeywords', + STANDARD_GENERATOR_METADATA_PROVIDER: 'provideStandardGenerator', + STANDARD_APPLICATION_NAME_METADATA_PROVIDER: 'provideStandardApplicationName', + STANDARD_CANONICAL_URL_METADATA_PROVIDER: 'provideStandardCanonicalUrl', + STANDARD_LOCALE_METADATA_PROVIDER: 'provideStandardLocale', + STANDARD_THEME_COLOR_METADATA_PROVIDER: 'provideStandardThemeColor', +}) +export const TWITTER_CARD_REPLACEMENTS = new ModuleReplacements({ + TWITTER_CARD_CARD_METADATA_PROVIDER: 'provideTwitterCardCard', + TWITTER_CARD_SITE_METADATA_PROVIDER: 'provideTwitterCardSite', + TWITTER_CARD_CREATOR_METADATA_PROVIDER: 'provideTwitterCardCreator', + TWITTER_CARD_DESCRIPTION_METADATA_PROVIDER: 'provideTwitterCardDescription', + TWITTER_CARD_TITLE_METADATA_PROVIDER: 'provideTwitterCardTitle', + TWITTER_CARD_IMAGE_METADATA_PROVIDER: 'provideTwitterCardImage', +}) diff --git a/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/update-imports.ts b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/update-imports.ts new file mode 100644 index 000000000..e0e517162 --- /dev/null +++ b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/update-imports.ts @@ -0,0 +1,67 @@ +import { + isImportDeclaration, + isNamedImports, + isStringLiteral, + SourceFile, +} from 'typescript' +import { SchematicContext } from '@angular-devkit/schematics' +import { + Change, + ReplaceChange, +} from '../../external-utils/schematics/angular/utility/change' +import { isDefined } from '../../utils/is-defined' +import { maybeGetNewIdentifierFromOldIdentifier } from './maybe-get-new-identifier-from-old-identifier' + +export const updateImports = ( + sourceFile: SourceFile, + filePath: string, + logger: SchematicContext['logger'], +): Change[] => { + const importStatements = sourceFile.statements.filter(isImportDeclaration) + const libraryImportStatements = importStatements.filter( + (importStatement) => + isStringLiteral(importStatement.moduleSpecifier) && + importStatement.moduleSpecifier.text.startsWith('@davidlj95/ngx-meta'), + ) + /* istanbul ignore next - perf opt */ + if (!libraryImportStatements.length) { + return [] + } + const libraryNamedImports = libraryImportStatements + .map((importStatement) => + /* istanbul ignore next - quite safe */ + importStatement.importClause?.namedBindings && + isNamedImports(importStatement.importClause.namedBindings) + ? importStatement.importClause.namedBindings + : undefined, + ) + .filter(isDefined) + if (libraryNamedImports.length != libraryImportStatements.length) { + logger.warn( + [ + `Some non-named imports of the library detected in ${filePath}`, + 'This is not the recommended usage. You should use named imports for tree-shaking purposes', + ].join('\n'), + ) + } + return libraryNamedImports + .map((libraryNamedImport) => + libraryNamedImport.elements + .map((element) => { + const identifier = element.name.text + const maybeNewIdentifier = + maybeGetNewIdentifierFromOldIdentifier(identifier) + if (!maybeNewIdentifier) { + return + } + return new ReplaceChange( + filePath, + element.getStart(), + identifier, + maybeNewIdentifier, + ) + }) + .filter(isDefined), + ) + .flatMap((x) => x) +} diff --git a/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/update-usages.ts b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/update-usages.ts new file mode 100644 index 000000000..d69205aa6 --- /dev/null +++ b/projects/ngx-meta/schematics/migrations/tree-shakeable-manager-providers/update-usages.ts @@ -0,0 +1,56 @@ +import { + forEachChild, + isIdentifier, + isImportDeclaration, + Node, + SourceFile, +} from 'typescript' +import { + Change, + ReplaceChange, +} from '../../external-utils/schematics/angular/utility/change' +import { maybeGetNewIdentifierFromOldIdentifier } from './maybe-get-new-identifier-from-old-identifier' + +export const updateUsages = ( + sourceFile: SourceFile, + filePath: string, +): Change[] => { + const changes: Change[] = [] + + const visitNode = (node: Node) => { + forEachChild(node, visitNode) + const change = maybeGetIdentifierReplaceChange(node, filePath) + if (change) { + changes.push(change) + } + } + + sourceFile.statements.forEach((statement) => { + if (isImportDeclaration(statement)) { + return + } + forEachChild(statement, visitNode) + }) + + return changes +} + +const maybeGetIdentifierReplaceChange = ( + node: Node, + filePath: string, +): Change | undefined => { + if (!isIdentifier(node)) { + return + } + const identifier = node.text + const maybeNewIdentifier = maybeGetNewIdentifierFromOldIdentifier(identifier) + if (!maybeNewIdentifier) { + return + } + return new ReplaceChange( + filePath, + node.getStart(), + identifier, + `${maybeGetNewIdentifierFromOldIdentifier(identifier)}()`, + ) +} diff --git a/projects/ngx-meta/schematics/utils/apply-changes.ts b/projects/ngx-meta/schematics/utils/apply-changes.ts new file mode 100644 index 000000000..3ac207a4e --- /dev/null +++ b/projects/ngx-meta/schematics/utils/apply-changes.ts @@ -0,0 +1,30 @@ +import { Tree } from '@angular-devkit/schematics' +import { + applyToUpdateRecorder, + Change, +} from '../external-utils/schematics/angular/utility/change' + +export const applyChanges = (tree: Tree, path: string, changes: Change[]) => { + const updateRecorder = tree.beginUpdate(path) + applyToUpdateRecorder( + updateRecorder, + sortChangesByDescendingPosition(changes), + ) + tree.commitUpdate(updateRecorder) +} + +/** + * Sorts changes to apply to a file by position in the file in descending order. + * So changes are applied from end to start of the file. + * + * This is important, as if applying changes in any order, it can happen that changes are messed up. + * For instance, if removing some characters first, and then applying a replacement later. + * If later replacement doesn't take into account that position has been updated due to the delete, + * Then the actual replacement performed won't be the expected one. + * + * See: + * + * - https://github.com/angular/angular/blob/18.2.9/packages/core/schematics/utils/change_tracker.ts#L187-L189 + */ +const sortChangesByDescendingPosition = (changes: Change[]): Change[] => + changes.sort((a, b) => a.order - b.order) diff --git a/projects/ngx-meta/schematics/utils/is-defined.ts b/projects/ngx-meta/schematics/utils/is-defined.ts new file mode 100644 index 000000000..90009144d --- /dev/null +++ b/projects/ngx-meta/schematics/utils/is-defined.ts @@ -0,0 +1,2 @@ +export const isDefined = (x: T | undefined | null): x is T => + x !== undefined && x !== null diff --git a/projects/ngx-meta/src/package.json b/projects/ngx-meta/src/package.json index 7c52f258d..43d7eb0d3 100644 --- a/projects/ngx-meta/src/package.json +++ b/projects/ngx-meta/src/package.json @@ -50,5 +50,8 @@ "provenance": true }, "sideEffects": false, - "schematics": "./schematics/collection.json" + "schematics": "./schematics/collection.json", + "ng-update": { + "migrations": "./schematics/migrations.json" + } }