diff --git a/.changeset/honest-deers-work.md b/.changeset/honest-deers-work.md new file mode 100644 index 000000000000..625bfd74dc94 --- /dev/null +++ b/.changeset/honest-deers-work.md @@ -0,0 +1,5 @@ +--- +"create-cloudflare": patch +--- + +style(create-cloudflare): guiding user template selection with description for each options diff --git a/packages/cli/interactive.ts b/packages/cli/interactive.ts index 1780b1de825e..7495b5b56eb0 100644 --- a/packages/cli/interactive.ts +++ b/packages/cli/interactive.ts @@ -9,7 +9,16 @@ import { createLogUpdate } from "log-update"; import { blue, bold, brandColor, dim, gray, white } from "./colors"; import SelectRefreshablePrompt from "./select-list"; import { stdout } from "./streams"; -import { cancel, crash, logRaw, newline, shapes, space, status } from "./index"; +import { + cancel, + crash, + logRaw, + newline, + shapes, + space, + status, + stripAnsi, +} from "./index"; import type { OptionWithDetails } from "./select-list"; import type { Prompt } from "@clack/core"; @@ -23,6 +32,7 @@ export const leftT = gray(shapes.leftT); export type Option = { label: string; // user-visible string sublabel?: string; // user-visible string + description?: string; value: string; // underlying key hidden?: boolean; }; @@ -303,8 +313,7 @@ const getSelectRenderers = ( const helpText = _helpText ?? ""; const maxItemsPerPage = config.maxItemsPerPage ?? 32; - const defaultRenderer: Renderer = ({ cursor, value }) => { - cursor = cursor ?? 0; + const defaultRenderer: Renderer = ({ cursor = 0, value }) => { const renderOption = (opt: Option, i: number) => { const { label: optionLabel, value: optionValue } = opt; const active = i === cursor; @@ -327,7 +336,6 @@ const getSelectRenderers = ( return true; } - cursor = cursor ?? 0; if (i < cursor) { return options.length - i <= maxItemsPerPage; } @@ -335,14 +343,15 @@ const getSelectRenderers = ( return cursor + maxItemsPerPage > i; }; - return [ + const visibleOptions = options.filter((o) => !o.hidden); + const activeOption = visibleOptions.at(cursor); + const lines = [ `${blCorner} ${bold(question)} ${dim(helpText)}`, `${ cursor > 0 && options.length > maxItemsPerPage ? `${space(2)}${dim("...")}\n` : "" - }${options - .filter((o) => !o.hidden) + }${visibleOptions .map(renderOption) .filter(renderOptionCondition) .join(`\n`)}${ @@ -353,6 +362,48 @@ const getSelectRenderers = ( }`, ``, // extra line for readability ]; + + if (activeOption?.description) { + // To wrap the text by words instead of characters + const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" }); + const padding = space(2); + const availableWidth = + process.stdout.columns - stripAnsi(padding).length * 2; + + // The description cannot have any ANSI code + // As the segmenter will split the code to several segments + const description = stripAnsi(activeOption.description); + const descriptionLines: string[] = []; + let descriptionLineNumber = 0; + + for (const data of wordSegmenter.segment(description)) { + let line = descriptionLines[descriptionLineNumber] ?? ""; + + const currentLineWidth = line.length; + const segmentSize = data.segment.length; + + if (currentLineWidth + segmentSize > availableWidth) { + descriptionLineNumber++; + line = ""; + + // To avoid starting a new line with a space + if (data.segment.match(/^\s+$/)) { + continue; + } + } + + descriptionLines[descriptionLineNumber] = line + data.segment; + } + + lines.push( + dim( + descriptionLines.map((line) => padding + line + padding).join("\n") + ), + `` + ); + } + + return lines; }; return { diff --git a/packages/create-cloudflare/e2e-tests/cli.test.ts b/packages/create-cloudflare/e2e-tests/cli.test.ts index 08f1522d8eb9..4c2b3c217052 100644 --- a/packages/create-cloudflare/e2e-tests/cli.test.ts +++ b/packages/create-cloudflare/e2e-tests/cli.test.ts @@ -143,7 +143,7 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( ); expect(projectPath).toExist(); - expect(output).toContain(`type Example router & proxy Worker`); + expect(output).toContain(`type Scheduled Worker (Cron Trigger)`); expect(output).toContain(`lang JavaScript`); expect(output).toContain(`no git`); expect(output).toContain(`no deploy`); @@ -225,5 +225,38 @@ describe.skipIf(frameworkToTest || isQuarantineMode())( expect(output).toContain(`lang Python`); }, ); + + test.skipIf(process.platform === "win32")( + "Selecting template by description", + async () => { + const { output } = await runC3( + [projectPath, "--no-deploy", "--git=false"], + [ + { + matcher: /What would you like to start with\?/, + input: { + type: "select", + searchBy: "description", + target: + "Select from a range of starter applications using various Cloudflare products", + }, + }, + { + matcher: /Which template would you like to use\?/, + input: { + type: "select", + searchBy: "description", + target: "Get started building a basic API on Workers", + }, + }, + ], + logStream, + ); + + expect(projectPath).toExist(); + expect(output).toContain(`category Demo application`); + expect(output).toContain(`type API starter (OpenAPI compliant)`); + }, + ); }, ); diff --git a/packages/create-cloudflare/e2e-tests/helpers.ts b/packages/create-cloudflare/e2e-tests/helpers.ts index 42cc13b3d231..4b2a2936c3e7 100644 --- a/packages/create-cloudflare/e2e-tests/helpers.ts +++ b/packages/create-cloudflare/e2e-tests/helpers.ts @@ -48,7 +48,13 @@ const testEnv = { export type PromptHandler = { matcher: RegExp; - input: string[] | { type: "select"; target: RegExp | string }; + input: + | string[] + | { + type: "select"; + target: RegExp | string; + searchBy?: "label" | "description"; + }; }; export type RunnerConfig = { @@ -113,10 +119,17 @@ export const runC3 = async ( return; } + const { target, searchBy } = currentDialog.input; + const searchText = + searchBy === "description" + ? lines + .filter((line) => !line.startsWith("●") && !line.startsWith("○")) + .join(" ") + : currentSelection; const matchesSelectionTarget = - typeof currentDialog.input.target === "string" - ? currentSelection.includes(currentDialog.input.target) - : currentDialog.input.target.test(currentSelection); + typeof target === "string" + ? searchText.includes(target) + : target.test(searchText); if (matchesSelectionTarget) { // matches selection, so hit enter diff --git a/packages/create-cloudflare/src/templates.ts b/packages/create-cloudflare/src/templates.ts index 36ad0a2fea34..c11d17eb448b 100644 --- a/packages/create-cloudflare/src/templates.ts +++ b/packages/create-cloudflare/src/templates.ts @@ -31,6 +31,8 @@ export type TemplateConfig = { id: string; /** A string that controls how the template is presented to the user in the selection menu*/ displayName: string; + /** A string that explains what is inside the template, including any resources and how those will be used*/ + description?: string; /** The deployment platform for this template */ platform: "workers" | "pages"; /** When set to true, hides this template from the selection menu */ @@ -203,10 +205,28 @@ export const selectTemplate = async (args: Partial) => { question: "What would you like to start with?", label: "category", options: [ - { label: "Hello World example", value: "hello-world" }, - { label: "Framework Starter", value: "web-framework" }, - { label: "Demo application", value: "demo" }, - { label: "Template from a Github repo", value: "remote-template" }, + { + label: "Hello World example", + value: "hello-world", + description: + "Select from barebones examples to get started with Workers", + }, + { + label: "Framework Starter", + value: "web-framework", + description: "Select from the most popular full-stack web frameworks", + }, + { + label: "Demo application", + value: "demo", + description: + "Select from a range of starter applications using various Cloudflare products", + }, + { + label: "Template from a Github repo", + value: "remote-template", + description: "Start from an existing GitHub repo link", + }, // This is used only if the type is `pre-existing` { label: "Others", value: "others", hidden: true }, ], @@ -223,7 +243,7 @@ export const selectTemplate = async (args: Partial) => { const templateMap = await getTemplateMap(); const templateOptions = Object.entries(templateMap).map( - ([value, { displayName, hidden }]) => { + ([value, { displayName, description, hidden }]) => { const isHelloWorldExample = value.startsWith("hello-world"); const isCategoryMatched = category === "hello-world" ? isHelloWorldExample : !isHelloWorldExample; @@ -231,6 +251,7 @@ export const selectTemplate = async (args: Partial) => { return { value, label: displayName, + description, hidden: hidden || !isCategoryMatched, }; }, diff --git a/packages/create-cloudflare/templates/common/c3.ts b/packages/create-cloudflare/templates/common/c3.ts index 14716ef15ac4..621e836143e3 100644 --- a/packages/create-cloudflare/templates/common/c3.ts +++ b/packages/create-cloudflare/templates/common/c3.ts @@ -2,7 +2,10 @@ export default { configVersion: 1, id: "common", displayName: "Example router & proxy Worker", + description: + "Create a Worker to route and forward requests to other services", platform: "workers", + hidden: true, copyFiles: { variants: { js: { diff --git a/packages/create-cloudflare/templates/hello-world-durable-object/c3.ts b/packages/create-cloudflare/templates/hello-world-durable-object/c3.ts index 7b91e707aa27..9d2712209afd 100644 --- a/packages/create-cloudflare/templates/hello-world-durable-object/c3.ts +++ b/packages/create-cloudflare/templates/hello-world-durable-object/c3.ts @@ -2,6 +2,8 @@ export default { configVersion: 1, id: "hello-world-durable-object", displayName: "Hello World Worker Using Durable Objects", + description: + "Get started with a basic stateful app to build projects like real-time chats, collaborative apps, and multiplayer games", platform: "workers", copyFiles: { variants: { diff --git a/packages/create-cloudflare/templates/hello-world/c3.ts b/packages/create-cloudflare/templates/hello-world/c3.ts index 1352c15cd41b..9edaa7c0c78e 100644 --- a/packages/create-cloudflare/templates/hello-world/c3.ts +++ b/packages/create-cloudflare/templates/hello-world/c3.ts @@ -2,6 +2,7 @@ export default { configVersion: 1, id: "hello-world", displayName: "Hello World Worker", + description: "Get started with a basic Worker in the language of your choice", platform: "workers", copyFiles: { variants: { diff --git a/packages/create-cloudflare/templates/openapi/c3.ts b/packages/create-cloudflare/templates/openapi/c3.ts index 134640fafb7b..c4644f7472e8 100644 --- a/packages/create-cloudflare/templates/openapi/c3.ts +++ b/packages/create-cloudflare/templates/openapi/c3.ts @@ -2,6 +2,7 @@ export default { configVersion: 1, id: "openapi", displayName: "API starter (OpenAPI compliant)", + description: "Get started building a basic API on Workers", platform: "workers", copyFiles: { path: "./ts", diff --git a/packages/create-cloudflare/templates/queues/c3.ts b/packages/create-cloudflare/templates/queues/c3.ts index c6c9cca72013..b4e9f3fc61c6 100644 --- a/packages/create-cloudflare/templates/queues/c3.ts +++ b/packages/create-cloudflare/templates/queues/c3.ts @@ -2,6 +2,8 @@ export default { configVersion: 1, id: "queues", displayName: "Queue consumer & producer Worker", + description: + "Get started with a Worker that processes background tasks and message batches with Cloudflare Queues", platform: "workers", copyFiles: { variants: { diff --git a/packages/create-cloudflare/templates/scheduled/c3.ts b/packages/create-cloudflare/templates/scheduled/c3.ts index f151251883b1..d5845e134ae5 100644 --- a/packages/create-cloudflare/templates/scheduled/c3.ts +++ b/packages/create-cloudflare/templates/scheduled/c3.ts @@ -2,6 +2,8 @@ export default { configVersion: 1, id: "scheduled", displayName: "Scheduled Worker (Cron Trigger)", + description: + "Create a Worker to be executed on a schedule for periodic (cron) jobs", platform: "workers", copyFiles: { variants: {