Skip to content

Commit fff8c76

Browse files
fix: validate project named passed as CLI option, allow and escape kebab-case project name (#327)
* transform kebab-case to PascalCase * simplify * improve dx * changeset * self review
1 parent e2afeba commit fff8c76

File tree

7 files changed

+89
-25
lines changed

7 files changed

+89
-25
lines changed

.changeset/healthy-grapes-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@rnef/create-app': patch
3+
---
4+
5+
fix: validate project named passed as CLI option, allow and escape kebab-case project name.

packages/create-app/src/lib/bin.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import {
2929
parsePackageInfo,
3030
parsePackageManagerFromUserAgent,
3131
} from './utils/parsers.js';
32+
import {
33+
normalizeProjectName,
34+
validateProjectName,
35+
} from './utils/project-name.js';
3236
import {
3337
confirmOverrideFiles,
3438
printByeMessage,
@@ -65,8 +69,12 @@ export async function run() {
6569

6670
printWelcomeMessage();
6771

68-
const projectName =
72+
let projectName =
6973
(options.dir || options.name) ?? (await promptProjectName());
74+
if (validateProjectName(projectName)) {
75+
projectName = await promptProjectName(projectName);
76+
}
77+
7078
const { targetDir } = parsePackageInfo(projectName);
7179
const absoluteTargetDir = resolveAbsolutePath(targetDir);
7280

@@ -124,7 +132,8 @@ export async function run() {
124132
}
125133

126134
renameCommonFiles(absoluteTargetDir);
127-
replacePlaceholder(absoluteTargetDir, projectName);
135+
replacePlaceholder(absoluteTargetDir, normalizeProjectName(projectName));
136+
// For package.json name we can use any valid name (kebab-case, PascalCase, etc).
128137
rewritePackageJson(absoluteTargetDir, projectName);
129138
createConfig(
130139
absoluteTargetDir,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { normalizeProjectName } from '../project-name.js';
3+
4+
describe('normalizeProjectName', () => {
5+
it('handles kebab-case', () => {
6+
expect(normalizeProjectName('hello')).toBe('Hello');
7+
expect(normalizeProjectName('hello-world')).toBe('HelloWorld');
8+
expect(normalizeProjectName('hello-world-long-name')).toBe(
9+
'HelloWorldLongName'
10+
);
11+
});
12+
13+
it('handles PascalCase', () => {
14+
expect(normalizeProjectName('Hello')).toBe('Hello');
15+
expect(normalizeProjectName('HelloWorld')).toBe('HelloWorld');
16+
expect(normalizeProjectName('HelloWorldLongName')).toBe(
17+
'HelloWorldLongName'
18+
);
19+
});
20+
21+
it('should handle edge cases', () => {
22+
expect(normalizeProjectName('')).toBe('');
23+
expect(normalizeProjectName('-')).toBe('');
24+
expect(normalizeProjectName('--')).toBe('');
25+
});
26+
});

packages/create-app/src/lib/utils/edit-template.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import * as path from 'node:path';
33
import { renameFile, walkDirectory } from './fs.js';
44

55
/**
6-
* Placeholder name used in template, that should be replaced with project name.
6+
* Placeholder name used in template, that should be replaced with normalized project name.
77
*/
8-
const DEFAULT_PLACEHOLDER_NAME = 'HelloWorld';
8+
const PLACEHOLDER_NAME = 'HelloWorld';
99

1010
/**
1111
* Rename common files that cannot be put into template literaly, e.g. .gitignore.
@@ -24,38 +24,41 @@ export function renameCommonFiles(projectPath: string) {
2424
* - Rename paths containing placeholder
2525
* - Replace placeholder in text files
2626
*/
27-
export function replacePlaceholder(projectPath: string, projectName: string) {
28-
if (projectName === DEFAULT_PLACEHOLDER_NAME) {
27+
export function replacePlaceholder(
28+
projectPath: string,
29+
normalizedName: string
30+
) {
31+
if (normalizedName === PLACEHOLDER_NAME) {
2932
return;
3033
}
3134

3235
for (const filePath of walkDirectory(projectPath).reverse()) {
3336
if (!fs.statSync(filePath).isDirectory()) {
34-
replacePlaceholderInTextFile(filePath, projectName);
37+
replacePlaceholderInTextFile(filePath, normalizedName);
3538
}
3639

37-
if (path.basename(filePath).includes(DEFAULT_PLACEHOLDER_NAME)) {
38-
renameFile(filePath, DEFAULT_PLACEHOLDER_NAME, projectName);
40+
if (path.basename(filePath).includes(PLACEHOLDER_NAME)) {
41+
renameFile(filePath, PLACEHOLDER_NAME, normalizedName);
3942
} else if (
40-
path.basename(filePath).includes(DEFAULT_PLACEHOLDER_NAME.toLowerCase())
43+
path.basename(filePath).includes(PLACEHOLDER_NAME.toLowerCase())
4144
) {
4245
renameFile(
4346
filePath,
44-
DEFAULT_PLACEHOLDER_NAME.toLowerCase(),
45-
projectName.toLowerCase()
47+
PLACEHOLDER_NAME.toLowerCase(),
48+
normalizedName.toLowerCase()
4649
);
4750
}
4851
}
4952
}
5053

51-
function replacePlaceholderInTextFile(filePath: string, projectName: string) {
54+
function replacePlaceholderInTextFile(
55+
filePath: string,
56+
normalizedName: string
57+
) {
5258
const fileContent = fs.readFileSync(filePath, 'utf8');
5359
const replacedFileContent = fileContent
54-
.replaceAll(DEFAULT_PLACEHOLDER_NAME, projectName)
55-
.replaceAll(
56-
DEFAULT_PLACEHOLDER_NAME.toLowerCase(),
57-
projectName.toLowerCase()
58-
);
60+
.replaceAll(PLACEHOLDER_NAME, normalizedName)
61+
.replaceAll(PLACEHOLDER_NAME.toLowerCase(), normalizedName.toLowerCase());
5962

6063
if (fileContent !== replacedFileContent) {
6164
fs.writeFileSync(filePath, replacedFileContent, 'utf8');

packages/create-app/src/lib/validate-project-name.ts renamed to packages/create-app/src/lib/utils/project-name.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
const NAME_REGEX = /^[$A-Z_][0-9A-Z_$]*$/i;
1+
/**
2+
* Allow for alphanumeric, hyphen (kebab-case), and underscore (_).
3+
* Has to start with a letter.
4+
*/
5+
const NAME_REGEX = /^[A-Z][0-9A-Z_-]*$/i;
26

37
// ref: https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html
48
const javaKeywords = [
@@ -62,13 +66,29 @@ export function validateProjectName(name: string) {
6266
}
6367

6468
if (!name.match(NAME_REGEX)) {
65-
return `Invalid project name: "${name}". Please use a valid identifier name (alphanumeric).`;
69+
return `Invalid project name: "${name}". Please use a valid identifier name (alphanumeric, hyphen, underscore).`;
6670
}
6771

6872
const lowerCaseName = name.toLowerCase();
6973
if (reservedNames.includes(lowerCaseName)) {
70-
return "Invalid project name: Can't use reserved name. Please use another name.";
74+
return `Invalid project name: "${name}". Can't use reserved name. Please use another name.`;
7175
}
7276

73-
return;
77+
return undefined;
78+
}
79+
80+
/**
81+
* Transform project name to PascalCase. The input name can be in either kebab-case or PascalCase.
82+
*
83+
* @param name - Project name
84+
* @returns PascalCase project name
85+
*/
86+
export function normalizeProjectName(name: string) {
87+
if (!name) return '';
88+
89+
return name
90+
.split('-')
91+
.filter(Boolean)
92+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
93+
.join('');
7494
}

packages/create-app/src/lib/utils/prompts.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { vice } from 'gradient-string';
1414
import path from 'path';
1515
import type { TemplateInfo } from '../templates.js';
16-
import { validateProjectName } from '../validate-project-name.js';
16+
import { validateProjectName } from './project-name.js';
1717
import { getRnefVersion } from './version.js';
1818

1919
export function printHelpMessage(
@@ -74,9 +74,10 @@ export function printByeMessage(
7474
outro('Success 🎉.');
7575
}
7676

77-
export function promptProjectName(): Promise<string> {
77+
export function promptProjectName(name?: string): Promise<string> {
7878
return promptText({
7979
message: 'What is your app named?',
80+
initialValue: name,
8081
validate: validateProjectName,
8182
});
8283
}

scripts/verdaccio-publish.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const ROOT_DIR = path.join(__dirname, '..');
99
const VERDACCIO_REGISTRY_URL = `http://localhost:4873`;
1010
const VERDACCIO_STORAGE_PATH = '/tmp/verdaccio-storage';
1111

12-
const loader = spinner();
12+
const loader = spinner({ indicator: 'timer' });
1313

1414
async function run() {
1515
intro('Verdaccio: publishing all packages');

0 commit comments

Comments
 (0)