Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[0.74] Improve new project name(space) validation and cleaning #13616

Merged
merged 3 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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()...`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
InitOptions,
} from '../commands/initWindows/initWindowsOptions';

import * as nameHelpers from '../utils/nameHelpers';

function validateOptionName(
name: string,
optionName: keyof InitOptions,
Expand Down Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
findPropertyValue,
tryFindPropertyValueAsBoolean,
} from '../commands/config/configUtils';
import * as nameHelpers from '../utils/nameHelpers';

import {
createDir,
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions packages/@react-native-windows/cli/src/utils/nameHelpers.ts
Original file line number Diff line number Diff line change
@@ -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('.');
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const CodedErrors = {
InvalidTemplateName: 5002,
NoProjectName: 5003,
InvalidProjectName: 5004,
InvalidProjectNamespace: 5005,
};

export type CodedErrorType = keyof typeof CodedErrors;
Expand Down