diff --git a/change/@react-native-windows-cli-db04fef2-0cc5-477b-ac9a-1aa9f4e4c2ee.json b/change/@react-native-windows-cli-db04fef2-0cc5-477b-ac9a-1aa9f4e4c2ee.json new file mode 100644 index 00000000000..dd2e9fc3e57 --- /dev/null +++ b/change/@react-native-windows-cli-db04fef2-0cc5-477b-ac9a-1aa9f4e4c2ee.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "[0.74] Improve new project name(space) validation and cleaning", + "packageName": "@react-native-windows/cli", + "email": "jthysell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@react-native-windows-telemetry-ca4bf582-eec5-4ee4-8830-19a6d937a87b.json b/change/@react-native-windows-telemetry-ca4bf582-eec5-4ee4-8830-19a6d937a87b.json new file mode 100644 index 00000000000..a05df72d763 --- /dev/null +++ b/change/@react-native-windows-telemetry-ca4bf582-eec5-4ee4-8830-19a6d937a87b.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "[0.74] Improve new project name(space) validation and cleaning", + "packageName": "@react-native-windows/telemetry", + "email": "jthysell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/@react-native-windows/cli/src/commands/initWindows/initWindows.ts b/packages/@react-native-windows/cli/src/commands/initWindows/initWindows.ts index 255768fcf5f..0c895b15ddf 100644 --- a/packages/@react-native-windows/cli/src/commands/initWindows/initWindows.ts +++ b/packages/@react-native-windows/cli/src/commands/initWindows/initWindows.ts @@ -29,6 +29,7 @@ import { endTelemetrySession, } from '../../utils/telemetryHelpers'; import {copyAndReplaceWithChangedCallback} from '../../generator-common'; +import * as nameHelpers from '../../utils/nameHelpers'; import {InitOptions, initOptions} from './initWindowsOptions'; export interface TemplateFileMapping { @@ -94,18 +95,6 @@ export class InitWindows { ); } - protected pascalCase(str: string): string { - const camelCase = _.camelCase(str); - return camelCase[0].toUpperCase() + camelCase.substr(1); - } - - protected isValidProjectName(name: string): boolean { - if (name.match(/^[a-z][a-z0-9]*$/gi)) { - return true; - } - return false; - } - protected getReactNativeProjectName(projectDir: string): string { this.verboseMessage('Looking for project name in package.json...'); const pkgJsonPath = path.join(projectDir, 'package.json'); @@ -152,21 +141,59 @@ export class InitWindows { } const templateConfig = this.templates.get(this.options.template)!; - if (this.options.name && !this.isValidProjectName(this.options.name)) { + // Check if there's a passed-in project name and if it's valid + if ( + this.options.name && + !nameHelpers.isValidProjectName(this.options.name) + ) { throw new CodedError( 'InvalidProjectName', - `The specified name is not a valid identifier`, + `The specified name '${this.options.name}' is not a valid identifier`, ); } + // If no project name is provided, calculate the name and clean if necessary if (!this.options.name) { const projectName = this.getReactNativeProjectName(this.config.root); - this.options.name = this.isValidProjectName(projectName) + this.options.name = nameHelpers.isValidProjectName(projectName) ? projectName - : this.pascalCase(projectName); + : nameHelpers.cleanName(projectName); + } + + // Final check that the project name is valid + if (!nameHelpers.isValidProjectName(this.options.name)) { + throw new CodedError( + 'InvalidProjectName', + `The name '${this.options.name}' is not a valid identifier`, + ); } - this.options.namespace ??= this.options.name; + // Check if there's a passed-in project namespace and if it's valid + if ( + this.options.namespace && + !nameHelpers.isValidProjectNamespace(this.options.namespace) + ) { + throw new CodedError( + 'InvalidProjectNamespace', + `The specified namespace '${this.options.namespace}' is not a valid identifier`, + ); + } + + // If no project namespace is provided, use the project name and clean if necessary + if (!this.options.namespace) { + const namespace = this.options.name; + this.options.namespace = nameHelpers.isValidProjectNamespace(namespace) + ? namespace + : nameHelpers.cleanNamespace(namespace); + } + + // Final check that the project namespace is valid + if (!nameHelpers.isValidProjectNamespace(this.options.namespace)) { + throw new CodedError( + 'InvalidProjectNamespace', + `The namespace '${this.options.namespace}' is not a valid identifier`, + ); + } if (templateConfig.preInstall) { spinner.info(`Running ${this.options.template} preInstall()...`); diff --git a/packages/@react-native-windows/cli/src/e2etest/initWindows.test.ts b/packages/@react-native-windows/cli/src/e2etest/initWindows.test.ts index 89d6cb7026b..bb9e012e698 100644 --- a/packages/@react-native-windows/cli/src/e2etest/initWindows.test.ts +++ b/packages/@react-native-windows/cli/src/e2etest/initWindows.test.ts @@ -11,6 +11,8 @@ import { InitOptions, } from '../commands/initWindows/initWindowsOptions'; +import * as nameHelpers from '../utils/nameHelpers'; + function validateOptionName( name: string, optionName: keyof InitOptions, @@ -57,3 +59,69 @@ test('initOptions - validate options', () => { ).toBe(true); } }); + +test('nameHelpers - cleanName', () => { + expect(nameHelpers.cleanName('@scope/package')).toBe('Package'); + expect(nameHelpers.cleanName('@scope/package-name')).toBe('PackageName'); + expect(nameHelpers.cleanName('package')).toBe('Package'); + expect(nameHelpers.cleanName('package-name')).toBe('PackageName'); +}); + +test('nameHelpers - isValidProjectName', () => { + expect(nameHelpers.isValidProjectName('package')).toBe(true); + expect(nameHelpers.isValidProjectName('package-name')).toBe(false); + expect(nameHelpers.isValidProjectName('Package')).toBe(true); + expect(nameHelpers.isValidProjectName('Package-name')).toBe(false); + expect(nameHelpers.isValidProjectName('Package-Name')).toBe(false); + expect(nameHelpers.isValidProjectName('@scope/package')).toBe(false); + expect(nameHelpers.isValidProjectName('@scope/package-name')).toBe(false); +}); + +test('nameHelpers - cleanNamespace', () => { + expect(nameHelpers.cleanNamespace('@scope/package')).toBe('Package'); + expect(nameHelpers.cleanNamespace('@scope/package-name')).toBe('PackageName'); + expect(nameHelpers.cleanNamespace('package')).toBe('Package'); + expect(nameHelpers.cleanNamespace('package-name')).toBe('PackageName'); + expect(nameHelpers.cleanNamespace('com.company.app')).toBe('Com.Company.App'); + expect(nameHelpers.cleanNamespace('com.company.app-name')).toBe( + 'Com.Company.AppName', + ); + expect(nameHelpers.cleanNamespace('com.company.app-name.other')).toBe( + 'Com.Company.AppName.Other', + ); + expect(nameHelpers.cleanNamespace('com::company::app')).toBe( + 'Com.Company.App', + ); + expect(nameHelpers.cleanNamespace('com::company::app-name')).toBe( + 'Com.Company.AppName', + ); + expect(nameHelpers.cleanNamespace('com::company::app-name::other')).toBe( + 'Com.Company.AppName.Other', + ); +}); + +test('nameHelpers - isValidProjectNamespace', () => { + expect(nameHelpers.isValidProjectNamespace('package')).toBe(true); + expect(nameHelpers.isValidProjectNamespace('package-name')).toBe(false); + expect(nameHelpers.isValidProjectNamespace('Package')).toBe(true); + expect(nameHelpers.isValidProjectNamespace('Package-name')).toBe(false); + expect(nameHelpers.isValidProjectNamespace('Package-Name')).toBe(false); + expect(nameHelpers.isValidProjectNamespace('@scope/package')).toBe(false); + expect(nameHelpers.isValidProjectNamespace('@scope/package-name')).toBe( + false, + ); + expect(nameHelpers.isValidProjectNamespace('com.company.app')).toBe(true); + expect(nameHelpers.isValidProjectNamespace('com.company.app-name')).toBe( + false, + ); + expect( + nameHelpers.isValidProjectNamespace('com.company.app-name.other'), + ).toBe(false); + expect(nameHelpers.isValidProjectNamespace('com::company::app')).toBe(false); + expect(nameHelpers.isValidProjectNamespace('com::company::app-name')).toBe( + false, + ); + expect( + nameHelpers.isValidProjectNamespace('com::company::app-name::other'), + ).toBe(false); +}); diff --git a/packages/@react-native-windows/cli/src/generator-windows/index.ts b/packages/@react-native-windows/cli/src/generator-windows/index.ts index 3c85f804b9f..a7f582c60ca 100644 --- a/packages/@react-native-windows/cli/src/generator-windows/index.ts +++ b/packages/@react-native-windows/cli/src/generator-windows/index.ts @@ -18,6 +18,7 @@ import { findPropertyValue, tryFindPropertyValueAsBoolean, } from '../commands/config/configUtils'; +import * as nameHelpers from '../utils/nameHelpers'; import { createDir, @@ -46,11 +47,6 @@ interface NugetPackage { privateAssets: boolean; } -function pascalCase(str: string) { - const camelCase = _.camelCase(str); - return camelCase[0].toUpperCase() + camelCase.substr(1); -} - function resolveRnwPath(subpath: string): string { return require.resolve(path.join('react-native-windows', subpath), { paths: [process.cwd()], @@ -90,15 +86,17 @@ export async function copyProjectTemplateAndReplace( const projectType = options.projectType; const language = options.language; - // React-native init only allows alphanumerics in project names, but other - // new project tools (like create-react-native-module) are less strict. - if (projectType === 'lib') { - newProjectName = pascalCase(newProjectName); + // @react-native-community/cli init only allows alphanumerics in project names, but other + // new project tools (like expo and create-react-native-module) are less strict. + // The default (legacy) behavior of this flow is to clean the name rather than throw an error. + if (!nameHelpers.isValidProjectName(newProjectName)) { + newProjectName = nameHelpers.cleanName(newProjectName); } // Similar to the above, but we want to retain namespace separators - if (projectType === 'lib') { - namespace = namespace.split(/[.:]+/).map(pascalCase).join('.'); + // The default (legacy) behavior of this flow is to clean the name rather than throw an error. + if (!nameHelpers.isValidProjectNamespace(namespace)) { + namespace = nameHelpers.cleanNamespace(namespace); } // Checking if we're overwriting an existing project and re-uses their projectGUID diff --git a/packages/@react-native-windows/cli/src/utils/nameHelpers.ts b/packages/@react-native-windows/cli/src/utils/nameHelpers.ts new file mode 100644 index 00000000000..de6d66e9024 --- /dev/null +++ b/packages/@react-native-windows/cli/src/utils/nameHelpers.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + */ + +import _ from 'lodash'; + +function pascalCase(str: string): string { + const camelCase = _.camelCase(str); + return camelCase[0].toUpperCase() + camelCase.substr(1); +} + +export function isValidProjectName(name: string): boolean { + if (name.match(/^[a-z][a-z0-9]*$/gi)) { + return true; + } + return false; +} + +export function cleanName(str: string): string { + str = str.replace('@', ''); // Remove '@' from package scope names + str = str.slice(str.lastIndexOf('/') + 1); // Remove package scope + str = pascalCase(str); // Convert to PascalCase + return str; +} + +export function isValidProjectNamespace(namespace: string): boolean { + if ( + namespace + .split(/[.]+/) + .map(isValidProjectName) + .every(x => x) + ) { + // Validate that every part of the namespace is a valid project name + return true; + } + return false; +} + +export function cleanNamespace(str: string): string { + return str.split(/[.:]+/).map(cleanName).join('.'); +} diff --git a/packages/@react-native-windows/telemetry/src/utils/errorUtils.ts b/packages/@react-native-windows/telemetry/src/utils/errorUtils.ts index 48b50a14274..1c3400df77e 100644 --- a/packages/@react-native-windows/telemetry/src/utils/errorUtils.ts +++ b/packages/@react-native-windows/telemetry/src/utils/errorUtils.ts @@ -78,6 +78,7 @@ export const CodedErrors = { InvalidTemplateName: 5002, NoProjectName: 5003, InvalidProjectName: 5004, + InvalidProjectNamespace: 5005, }; export type CodedErrorType = keyof typeof CodedErrors;