From 7aea872bb2cdc1ed8179c3af06d1556dcc83b7cc Mon Sep 17 00:00:00 2001 From: Parker Duckworth Date: Thu, 2 Apr 2026 16:16:37 -0400 Subject: [PATCH] default title case app name --- packages/app/src/cli/commands/app/init.ts | 2 +- packages/app/src/cli/prompts/dev.test.ts | 25 +++++++++++++ packages/app/src/cli/prompts/dev.ts | 5 +++ .../cli-kit/src/public/common/string.test.ts | 22 ++++++++++++ packages/cli-kit/src/public/common/string.ts | 10 ++++++ packages/cli-kit/src/public/node/fs.test.ts | 36 +++++++++++++++++++ packages/cli-kit/src/public/node/fs.ts | 13 +++++-- 7 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/commands/app/init.ts b/packages/app/src/cli/commands/app/init.ts index 09548496475..72b1619f94f 100644 --- a/packages/app/src/cli/commands/app/init.ts +++ b/packages/app/src/cli/commands/app/init.ts @@ -158,7 +158,7 @@ export default class Init extends AppLinkedCommand { async function getAppName(directory: string): Promise { for (let i = 0; i < 3; i++) { // eslint-disable-next-line no-await-in-loop - const name = await generateRandomNameForSubdirectory({suffix: 'app', directory}) + const name = await generateRandomNameForSubdirectory({suffix: 'app', directory, titleCase: true}) if (isValidName(name)) return name } return '' diff --git a/packages/app/src/cli/prompts/dev.test.ts b/packages/app/src/cli/prompts/dev.test.ts index c20a1b36181..e05fc324c4a 100644 --- a/packages/app/src/cli/prompts/dev.test.ts +++ b/packages/app/src/cli/prompts/dev.test.ts @@ -311,9 +311,34 @@ describe('appName', () => { expect(renderTextPrompt).toHaveBeenCalledWith({ message: 'App name', defaultValue: 'suggested-name', + preview: expect.any(Function), validate: expect.any(Function), }) }) + + test('preview shows hyphenated folder name for Title Case input', async () => { + // Given + vi.mocked(renderTextPrompt).mockResolvedValue('My Cool App') + + // When + await appNamePrompt('Default Name') + + // Then + const call = vi.mocked(renderTextPrompt).mock.calls[0]![0] as {preview: (value: string) => unknown} + expect(call.preview('My Cool App')).toEqual(['Folder name:', {userInput: 'my-cool-app'}]) + }) + + test('preview falls back to defaultValue when input is empty', async () => { + // Given + vi.mocked(renderTextPrompt).mockResolvedValue('Default Name') + + // When + await appNamePrompt('Default Name') + + // Then + const call = vi.mocked(renderTextPrompt).mock.calls[0]![0] as {preview: (value: string) => unknown} + expect(call.preview('')).toEqual(['Folder name:', {userInput: 'default-name'}]) + }) }) describe('reloadStoreList', () => { diff --git a/packages/app/src/cli/prompts/dev.ts b/packages/app/src/cli/prompts/dev.ts index 8071191364c..acf53278d3c 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -11,6 +11,7 @@ import { renderTextPrompt, } from '@shopify/cli-kit/node/ui' import {outputCompleted} from '@shopify/cli-kit/node/output' +import {hyphenate} from '@shopify/cli-kit/common/string' export async function selectOrganizationPrompt(organizations: Organization[]): Promise { if (organizations.length === 1) { @@ -143,6 +144,10 @@ export async function appNamePrompt(currentName: string): Promise { return renderTextPrompt({ message: 'App name', defaultValue: currentName, + preview: (value) => { + const folderName = hyphenate(value || currentName) + return ['Folder name:', {userInput: folderName}] + }, validate: (value) => { if (value.length === 0) { return "App name can't be empty" diff --git a/packages/cli-kit/src/public/common/string.test.ts b/packages/cli-kit/src/public/common/string.test.ts index 4ec1868592e..18758fbb7b3 100644 --- a/packages/cli-kit/src/public/common/string.test.ts +++ b/packages/cli-kit/src/public/common/string.test.ts @@ -2,6 +2,7 @@ import { formatDate, formatLocalDate, getRandomName, + getRandomTitleCaseName, joinWithAnd, linesToColumns, normalizeDelimitedString, @@ -21,6 +22,27 @@ describe('getRandomName', () => { }) }) +describe('getRandomTitleCaseName', () => { + test('generates a Title Case name with spaces', () => { + const got = getRandomTitleCaseName() + expect(got.length).not.toBe(0) + // Should not contain hyphens, should have spaces and capitalized words + expect(got).not.toContain('-') + expect(got).toContain(' ') + // Each word should start with an uppercase letter + got.split(' ').forEach((word) => { + expect(word[0]).toBe(word[0]!.toUpperCase()) + }) + }) + + test('works with creative family', () => { + const got = getRandomTitleCaseName('creative') + expect(got.length).not.toBe(0) + expect(got).not.toContain('-') + expect(got).toContain(' ') + }) +}) + describe('tryParseInt', () => { test('converts a string to an int', () => { expect(tryParseInt(' 999 ')).toEqual(999) diff --git a/packages/cli-kit/src/public/common/string.ts b/packages/cli-kit/src/public/common/string.ts index 0e3e295df4d..7c00a24c6c7 100644 --- a/packages/cli-kit/src/public/common/string.ts +++ b/packages/cli-kit/src/public/common/string.ts @@ -181,6 +181,16 @@ export function getRandomName(family: RandomNameFamily = 'business'): string { return `${takeRandomFromArray(mapping[family].adjectives)}-${takeRandomFromArray(mapping[family].nouns)}` } +/** + * Generates a random Title Case name by combining an adjective and noun. + * + * @param family - Theme to use for the random name (business or creative). + * @returns A Title Case random name (e.g. "Adaptive Vertical"). + */ +export function getRandomTitleCaseName(family: RandomNameFamily = 'business'): string { + return capitalizeWords(getRandomName(family)) +} + /** * Given a string, it returns it with the first letter capitalized. * diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index 60a3a973541..c34b9a5d4f1 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -224,6 +224,42 @@ describe('makeDirectoryWithRandomName', () => { expect(takeRandomFromArray).toHaveBeenCalledTimes(4) }) }) + + test('returns a Title Case name when titleCase option is true', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(takeRandomFromArray).mockReturnValueOnce('adaptive') + vi.mocked(takeRandomFromArray).mockReturnValueOnce('vertical') + + // When + const got = await generateRandomNameForSubdirectory({suffix: 'app', directory: tmpDir, titleCase: true}) + + // Then + expect(got).toEqual('Adaptive Vertical App') + }) + }) + + test('checks hyphenated directory name for collisions when titleCase is true', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - create a directory with the hyphenated version of the first attempt + vi.mocked(takeRandomFromArray).mockReturnValueOnce('taken') + vi.mocked(takeRandomFromArray).mockReturnValueOnce('name') + vi.mocked(takeRandomFromArray).mockReturnValueOnce('free') + vi.mocked(takeRandomFromArray).mockReturnValueOnce('name') + + const content = 'test' + // The hyphenated form of "Taken Name App" is "taken-name-app" + const filePath = joinPath(tmpDir, 'taken-name-app') + await writeFile(filePath, content) + + // When + const got = await generateRandomNameForSubdirectory({suffix: 'app', directory: tmpDir, titleCase: true}) + + // Then - should have rerolled and returned the second attempt + expect(got).toEqual('Free Name App') + expect(takeRandomFromArray).toHaveBeenCalledTimes(4) + }) + }) }) describe('readFileSync', () => { diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index a20f0df445b..06ccd03d390 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -1,7 +1,7 @@ import {outputContent, outputToken, outputDebug} from './output.js' import {joinPath, normalizePath} from './path.js' import {OverloadParameters} from '../../private/common/ts/overloaded-parameters.js' -import {getRandomName, RandomNameFamily} from '../common/string.js' +import {capitalize, getRandomName, getRandomTitleCaseName, hyphenate, RandomNameFamily} from '../common/string.js' import { copy as fsCopy, ensureFile as fsEnsureFile, @@ -537,6 +537,9 @@ interface GenerateRandomDirectoryOptions { /** Type of word to use for random name. */ family?: RandomNameFamily + + /** If true, return a Title Case name instead of kebab-case. */ + titleCase?: boolean } /** @@ -547,8 +550,12 @@ interface GenerateRandomDirectoryOptions { * @returns It returns the name of the directory. */ export async function generateRandomNameForSubdirectory(options: GenerateRandomDirectoryOptions): Promise { - const generated = `${getRandomName(options.family ?? 'business')}-${options.suffix}` - const randomDirectoryPath = joinPath(options.directory, generated) + const family = options.family ?? 'business' + const baseName = options.titleCase ? getRandomTitleCaseName(family) : getRandomName(family) + const suffix = options.titleCase ? capitalize(options.suffix) : options.suffix + const generated = options.titleCase ? `${baseName} ${suffix}` : `${baseName}-${suffix}` + const directoryName = options.titleCase ? hyphenate(generated) : generated + const randomDirectoryPath = joinPath(options.directory, directoryName) const isAppDirectoryTaken = await fileExists(randomDirectoryPath) if (isAppDirectoryTaken) {