From c1f0352e84d79f684c4a230e2fc6fee67807111e Mon Sep 17 00:00:00 2001 From: IT-WIBRC Date: Sun, 5 Oct 2025 03:30:43 +0100 Subject: [PATCH] refactor(cli): implement dynamic help text generation --- .changeset/late-plums-march.md | 5 + packages/devkit/TODO.md | 4 +- .../commands/config/remove/index.spec.ts | 7 +- .../commands/config/update/index.spec.ts | 23 +- .../__tests__/units/commands/list.spec.ts | 206 ++++++++++-------- .../__tests__/units/commands/new.spec.ts | 30 ++- .../i18n/generate-dynamic-help-text.spec.ts | 123 +++++++++++ packages/devkit/locales/en.json | 18 +- packages/devkit/locales/fr.json | 20 +- packages/devkit/src/commands/config/add.ts | 18 +- .../src/commands/config/remove/index.ts | 8 +- .../src/commands/config/update/index.ts | 18 +- packages/devkit/src/commands/list.ts | 12 +- packages/devkit/src/commands/new.ts | 9 +- .../utils/i18n/generate-dynamic-help-text.ts | 35 +++ 15 files changed, 401 insertions(+), 135 deletions(-) create mode 100644 .changeset/late-plums-march.md create mode 100644 packages/devkit/__tests__/units/utils/i18n/generate-dynamic-help-text.spec.ts create mode 100644 packages/devkit/src/utils/i18n/generate-dynamic-help-text.ts diff --git a/.changeset/late-plums-march.md b/.changeset/late-plums-march.md new file mode 100644 index 0000000..ed2aebf --- /dev/null +++ b/.changeset/late-plums-march.md @@ -0,0 +1,5 @@ +--- +"scaffolder-toolkit": patch +--- + +refactor(cli): implement dynamic help text generation diff --git a/packages/devkit/TODO.md b/packages/devkit/TODO.md index 2de6105..357755e 100644 --- a/packages/devkit/TODO.md +++ b/packages/devkit/TODO.md @@ -68,7 +68,8 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [x] **Testing**: Stabilize the integration test of the `new` command - [x] Make sure to clean up if the `dk new` command fail - [x] Add a configuration validation step when updating the config file to ensure all required fields are present and correctly formatted. -- [ ] **Dynamic Help Text**: Programmatically generate help text for options with constrained values (e.g., `--cache-strategy`) to ensure it's always up to date. +- [x] **Dynamic Help Text**: Programmatically generate help text for options with constrained values (e.g., `--cache-strategy`) to ensure it's always up to date. +- [ ] **Skip Confirmation**: Add a global `-y`/`--yes` and `-n/--no` option to skip confirmation prompts in commands like `dk init`. #### Multi-Language Support @@ -82,7 +83,6 @@ This document tracks all planned and completed tasks for the Dev Kit project. ### Debating -- [ ] **Skip Confirmation**: Add a global `-y`/`--yes` and `-n/--no` option to skip confirmation prompts in commands like `dk init`. - [ ] For unsupported languages, we can let the configuration be added but with a warning that it's not supported yet. When using the `dk new` with an unsupported language, we can copy the template as is without any modifications but ignoring the `.git` folder and not installing dependencies. - [ ] **CLI Self-Update**: Implement a command to allow users to update the CLI itself. `dk upgrade` - [ ] **Color Configuration**: Add a feature to allow users to configure the colors for templates. diff --git a/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts b/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts index 43f20ca..cd6648c 100644 --- a/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/remove/index.spec.ts @@ -35,6 +35,10 @@ vi.mock("../../../../../src/commands/config/remove/logic.js", () => ({ saveConfig: mockSaveConfig, })); +vi.mock("#utils/i18n/generate-dynamic-help-text.js", () => ({ + generateDynamicHelpText: vi.fn((_, key) => `DYNAMIC_HELP_TEXT_FOR_${key}`), +})); + const CMD_DESCRIPTION_KEY = "commands.template.remove.command.description"; const STATUS_REMOVING_KEY = "messages.status.template_removing"; const SUCCESS_REMOVED_KEY = "messages.success.template_removed"; @@ -111,7 +115,7 @@ describe("setupRemoveCommand (Command Handler)", () => { ); expect(mockConfigCommand.alias).toHaveBeenCalledWith("rm"); expect(mockConfigCommand.description).toHaveBeenCalledWith( - mocktFn(CMD_DESCRIPTION_KEY), + `DYNAMIC_HELP_TEXT_FOR_${CMD_DESCRIPTION_KEY}`, ); }); @@ -152,7 +156,6 @@ describe("setupRemoveCommand (Command Handler)", () => { expect( savedConfig.templates[canonicalLang].templates[templateToRemove], ).toBeUndefined(); - expect(savedConfig.templates[aliasLang]).toBeUndefined(); }); describe("action handler", () => { diff --git a/packages/devkit/__tests__/units/commands/config/update/index.spec.ts b/packages/devkit/__tests__/units/commands/config/update/index.spec.ts index 6c89a09..721a1d7 100644 --- a/packages/devkit/__tests__/units/commands/config/update/index.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/update/index.spec.ts @@ -13,12 +13,16 @@ const { mockResolveTemplateNamesForUpdate, mockValidateProgrammingLanguage, mockMapLanguageAliasToCanonicalKey, + mockGenerateDynamicHelpText, } = vi.hoisted(() => ({ mockHandleErrorAndExit: vi.fn(), mockHandleNonInteractiveTemplateUpdate: vi.fn(), mockResolveTemplateNamesForUpdate: vi.fn(), mockValidateProgrammingLanguage: vi.fn(), mockMapLanguageAliasToCanonicalKey: vi.fn((lang) => lang), + mockGenerateDynamicHelpText: vi.fn( + (_, key) => `DYNAMIC_HELP_TEXT_FOR_${key}`, + ), })); let actionFn: (...options: unknown[]) => Promise; @@ -43,11 +47,17 @@ vi.mock("../../../../../src/commands/config/update/logic.js", () => ({ resolveTemplateNamesForUpdate: mockResolveTemplateNamesForUpdate, })); +vi.mock("#utils/i18n/generate-dynamic-help-text.js", () => ({ + generateDynamicHelpText: mockGenerateDynamicHelpText, +})); + const consoleLogSpy = mockLogger.log; const mockProcessExit = vi .spyOn(process, "exit") .mockImplementation((() => {}) as unknown as never); +const CMD_DESCRIPTION_KEY = + "commands.config.update_template.command.description"; const OPT_NEW_NAME_KEY = "commands.config.update_template.options.new_name"; const OPT_DESCRIPTION_KEY = "commands.config.update_template.options.description"; @@ -92,6 +102,10 @@ describe("setupUpdateCommand", () => { ); expect(mockConfigCommand.alias).toHaveBeenCalledWith("up"); + expect(mockConfigCommand.description).toHaveBeenCalledWith( + `DYNAMIC_HELP_TEXT_FOR_${CMD_DESCRIPTION_KEY}`, + ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( "-n, --new-name ", mocktFn(OPT_NEW_NAME_KEY), @@ -108,14 +122,17 @@ describe("setupUpdateCommand", () => { "-l, --location ", mocktFn(OPT_LOCATION_KEY), ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( "--cache-strategy ", - mocktFn(OPT_CACHE_STRATEGY_KEY), + `DYNAMIC_HELP_TEXT_FOR_${OPT_CACHE_STRATEGY_KEY}`, ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( "--package-manager ", - mocktFn(OPT_PACKAGE_MANAGER_KEY), + `DYNAMIC_HELP_TEXT_FOR_${OPT_PACKAGE_MANAGER_KEY}`, ); + expect(mockConfigCommand.option).toHaveBeenCalledWith( "-g, --global", mocktFn(OPT_GLOBAL_KEY), @@ -164,7 +181,7 @@ describe("setupUpdateCommand", () => { templateName, { ...defaultCmdOptions, - language: "ts", + language: aliasLang, }, false, ); diff --git a/packages/devkit/__tests__/units/commands/list.spec.ts b/packages/devkit/__tests__/units/commands/list.spec.ts index 7a34b1b..6ee15cd 100644 --- a/packages/devkit/__tests__/units/commands/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/list.spec.ts @@ -59,6 +59,7 @@ const { mockValidateDisplayMode, mockHandleErrorAndExit, mockMapLanguageAliasToCanonicalKey, + mockGenerateDynamicHelpText, } = vi.hoisted(() => { return { mockGetAnnotatedTemplates: vi.fn(), @@ -69,6 +70,9 @@ const { mockHandleErrorAndExit: vi.fn(), mockValidateDisplayMode: vi.fn(), mockMapLanguageAliasToCanonicalKey: vi.fn((lang) => lang), + mockGenerateDynamicHelpText: vi.fn( + (_, key) => `DYNAMIC_HELP_TEXT_FOR_${key}`, + ), }; }); @@ -111,6 +115,10 @@ vi.mock("#core/config/language.js", () => ({ mapLanguageAliasToCanonicalKey: mockMapLanguageAliasToCanonicalKey, })); +vi.mock("#utils/i18n/generate-dynamic-help-text.js", () => ({ + generateDynamicHelpText: mockGenerateDynamicHelpText, +})); + const CMD_DESCRIPTION_KEY = "commands.list.command.description"; const LANG_ARGUMENT_KEY = "commands.list.command.language.argument"; const GLOBAL_OPTION_KEY = "commands.list.options.global"; @@ -123,8 +131,12 @@ const HEADER_KEY = "commands.list.output.header"; const SETTINGS_HEADER_KEY = "commands.list.output.settings_header"; const MUTUALLY_EXCLUSIVE_KEY = "errors.command.mutually_exclusive_options"; const SUCCESS_CONFIG_LOADED_KEY = "messages.success.config_loaded"; -const WARNING_TEMPLATE_NOT_FOUND_KEY = +const WARNING_TEMPLATE_NOT_FOUND_FOR_LANGUAGE_KEY = "warnings.template.not_found_for_language"; +const WARNING_TEMPLATE_NOT_FOUND_IN_CONFIG_KEY = + "warnings.template.not_found_in_config"; +const INCLUDING_DEFAULTS_SUFFIX_KEY = + "messages.status.including_defaults_suffix"; describe("list command", () => { beforeEach(() => { @@ -134,7 +146,7 @@ describe("list command", () => { mockMapLanguageAliasToCanonicalKey.mockImplementation((lang) => lang); }); - it("should define the list command correctly with new options", () => { + it("should define the list command correctly with dynamic help text", () => { setupListCommand({ program: mockProgram }); expect(mockProgram.command).toHaveBeenCalledWith("list"); @@ -143,7 +155,7 @@ describe("list command", () => { expect(mockProgram.argument).toHaveBeenCalledWith( "[language]", - LANG_ARGUMENT_KEY, + `DYNAMIC_HELP_TEXT_FOR_${LANG_ARGUMENT_KEY}`, "", ); expect(mockProgram.option).toHaveBeenCalledWith( @@ -164,7 +176,7 @@ describe("list command", () => { ); expect(mockProgram.option).toHaveBeenCalledWith( "-m, --mode ", - MODE_OPTION_KEY, + `DYNAMIC_HELP_TEXT_FOR_${MODE_OPTION_KEY}`, "tree", ); expect(mockProgram.option).toHaveBeenCalledWith( @@ -193,99 +205,40 @@ describe("list command", () => { ); expect(mockSpinner.succeed).toHaveBeenCalledWith( - expect.stringContaining(SUCCESS_CONFIG_LOADED_KEY), + mocktFn(SUCCESS_CONFIG_LOADED_KEY), ); expect(mockLogger.log).toHaveBeenCalled(); expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining(`\n${HEADER_KEY}`), + mockLogger.colors.bold(`\n${HEADER_KEY}`), ); }); - it("should map language alias (e.g., 'ts') to canonical key and filter correctly", async () => { + it("should pass mergeAll: true to annotator with --all flag and include defaults suffix if applicable", async () => { setupListCommand({ program: mockProgram }); - const alias = "ts"; - const canonical = "typescript"; - - mockMapLanguageAliasToCanonicalKey.mockReturnValue(canonical); - - await actionFn(alias, { mode: "tree" }); - - expect(mockMapLanguageAliasToCanonicalKey).toHaveBeenCalledWith(alias); - expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(canonical); - - const expectedTemplates = MOCK_ANNOTATED_TEMPLATES.filter( - (t) => t._language === canonical, - ); - - expect(mockPrintTemplates).toHaveBeenCalledWith( - expectedTemplates, - [], - "tree", - ); - }); - - it("should pass mergeAll: true to annotator with --all flag", async () => { - setupListCommand({ program: mockProgram }); - await actionFn("", { all: true, mode: "table" }); + await actionFn("", { all: true, mode: "table", includeDefaults: true }); expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ forceGlobal: false, mergeAll: true, - includeDefaults: false, + includeDefaults: true, }); expect(mockPrintTemplates).toHaveBeenCalledWith( MOCK_ANNOTATED_TEMPLATES, [], "table", ); - }); - - it("should pass forceGlobal: true to annotator with --global flag", async () => { - setupListCommand({ program: mockProgram }); - await actionFn("", { global: true, mode: "tree" }); - - expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ - forceGlobal: true, - mergeAll: false, - includeDefaults: false, - }); - }); - - it("should filter templates by language argument", async () => { - setupListCommand({ program: mockProgram }); - await actionFn("javascript", { mode: "tree" }); - - expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith("javascript"); - - const expectedTemplates = MOCK_ANNOTATED_TEMPLATES.filter( - (t) => t._language === "javascript", - ); - - expect(mockPrintTemplates).toHaveBeenCalledWith( - expectedTemplates, - [], - "tree", - ); - }); - - it("should pass the 'where' clauses directly to printTemplates", async () => { - const whereClauses = ["pm:npm", "desc:project"]; - setupListCommand({ program: mockProgram }); - await actionFn("", { where: whereClauses, mode: "tree" }); - expect(mockPrintTemplates).toHaveBeenCalledWith( - MOCK_ANNOTATED_TEMPLATES, - whereClauses, - "tree", + const defaultsSuffix = mocktFn(INCLUDING_DEFAULTS_SUFFIX_KEY); + expect(mockLogger.log).toHaveBeenCalledWith( + mockLogger.colors.bold(`\n${HEADER_KEY}${defaultsSuffix}`), ); }); - it("should print settings when --settings is used (and call config merger)", async () => { + it("should print settings when --settings is used (and use the correct header)", async () => { setupListCommand({ program: mockProgram }); await actionFn("", { settings: true, mode: "tree" }); - expect(mockGetMergedConfig).toHaveBeenCalledOnce(); expect(mockGetMergedConfig).toHaveBeenCalledWith(false); expect(mockPrintSettings).toHaveBeenCalledWith( @@ -294,10 +247,15 @@ describe("list command", () => { ); expect(mockLogger.log).toHaveBeenCalledWith( - expect.stringContaining(`\n${SETTINGS_HEADER_KEY}`), + mockLogger.colors.bold(`\n${SETTINGS_HEADER_KEY}`), ); expect(mockPrintTemplates).toHaveBeenCalledTimes(1); + expect(mockPrintTemplates).toHaveBeenCalledWith( + MOCK_ANNOTATED_TEMPLATES, + [], + "tree", + ); }); it("should call getMergedConfig(true) when --settings and --all are used", async () => { @@ -307,17 +265,6 @@ describe("list command", () => { expect(mockGetMergedConfig).toHaveBeenCalledWith(true); }); - it("should pass includeDefaults: true to annotator with --include-defaults flag", async () => { - setupListCommand({ program: mockProgram }); - await actionFn("", { includeDefaults: true, mode: "tree" }); - - expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ - forceGlobal: false, - mergeAll: false, - includeDefaults: true, - }); - }); - it("should throw a DevkitError if both --global and --all flags are used", async () => { setupListCommand({ program: mockProgram }); @@ -336,9 +283,8 @@ describe("list command", () => { ); }); - it("should show a warning message if no templates are found after language filter", async () => { - mockGetAnnotatedTemplates.mockResolvedValue([]); - + it("should show WARNING_TEMPLATE_NOT_FOUND_FOR_LANGUAGE_KEY if no templates are found after language filter", async () => { + mockGetAnnotatedTemplates.mockResolvedValue(MOCK_ANNOTATED_TEMPLATES); setupListCommand({ program: mockProgram }); const language = "nonexistent"; @@ -347,8 +293,8 @@ describe("list command", () => { expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(language); expect(mockSpinner.warn).toHaveBeenCalledWith( - expect.stringContaining( - mocktFn(WARNING_TEMPLATE_NOT_FOUND_KEY, { language }), + mockLogger.colors.yellow( + mocktFn(WARNING_TEMPLATE_NOT_FOUND_FOR_LANGUAGE_KEY, { language }), ), ); @@ -358,6 +304,88 @@ describe("list command", () => { ); }); + it("should show WARNING_TEMPLATE_NOT_FOUND_IN_CONFIG_KEY if no templates are found without language filter", async () => { + mockGetAnnotatedTemplates.mockResolvedValue([]); + + setupListCommand({ program: mockProgram }); + + await actionFn("", { mode: "tree" }); + + expect(mockSpinner.warn).toHaveBeenCalledWith( + mockLogger.colors.yellow( + mocktFn(WARNING_TEMPLATE_NOT_FOUND_IN_CONFIG_KEY, { language: "" }), + ), + ); + + expect(mockPrintTemplates).not.toHaveBeenCalled(); + expect(mockLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining(`\n${HEADER_KEY}`), + ); + }); + + it("should map language alias (e.g., 'ts') to canonical key and filter correctly", async () => { + setupListCommand({ program: mockProgram }); + const alias = "ts"; + const canonical = "typescript"; + + mockMapLanguageAliasToCanonicalKey.mockReturnValue(canonical); + + await actionFn(alias, { mode: "tree" }); + + expect(mockMapLanguageAliasToCanonicalKey).toHaveBeenCalledWith(alias); + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(canonical); + + const expectedTemplates = MOCK_ANNOTATED_TEMPLATES.filter( + (t) => t._language === canonical, + ); + + expect(mockPrintTemplates).toHaveBeenCalledWith( + expectedTemplates, + [], + "tree", + ); + }); + + it("should pass forceGlobal: true to annotator with --global flag", async () => { + setupListCommand({ program: mockProgram }); + await actionFn("", { global: true, mode: "tree" }); + + expect(mockGetAnnotatedTemplates).toHaveBeenCalledWith({ + forceGlobal: true, + mergeAll: false, + includeDefaults: false, + }); + }); + + it("should filter templates by language argument", async () => { + setupListCommand({ program: mockProgram }); + await actionFn("javascript", { mode: "tree" }); + + expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith("javascript"); + + const expectedTemplates = MOCK_ANNOTATED_TEMPLATES.filter( + (t) => t._language === "javascript", + ); + + expect(mockPrintTemplates).toHaveBeenCalledWith( + expectedTemplates, + [], + "tree", + ); + }); + + it("should pass the 'where' clauses directly to printTemplates", async () => { + const whereClauses = ["pm:npm", "desc:project"]; + setupListCommand({ program: mockProgram }); + await actionFn("", { where: whereClauses, mode: "tree" }); + + expect(mockPrintTemplates).toHaveBeenCalledWith( + MOCK_ANNOTATED_TEMPLATES, + whereClauses, + "tree", + ); + }); + it("should call handleErrorAndExit for errors during processing (e.g., invalid mode)", async () => { const modeError = new DevkitError("Invalid mode"); mockValidateDisplayMode.mockImplementationOnce(() => { diff --git a/packages/devkit/__tests__/units/commands/new.spec.ts b/packages/devkit/__tests__/units/commands/new.spec.ts index 4a39b91..3b11556 100644 --- a/packages/devkit/__tests__/units/commands/new.spec.ts +++ b/packages/devkit/__tests__/units/commands/new.spec.ts @@ -40,12 +40,17 @@ vi.mock("#core/config/language.js", () => ({ mapLanguageAliasToCanonicalKey: mockMapLanguageAliasToCanonicalKey, })); +vi.mock("#utils/i18n/generate-dynamic-help-text.js", () => ({ + generateDynamicHelpText: vi.fn((_, key) => `DYNAMIC_HELP_TEXT_FOR_${key}`), +})); + const TEMPLATE_NOT_FOUND_KEY = "errors.template.not_found"; const NEW_PROJECT_SUCCESS_KEY = "messages.success.new_project"; const CMD_DESCRIPTION_KEY = "commands.new.command.description"; const LANG_ARGUMENT_KEY = "commands.new.project.language.argument"; const NAME_ARGUMENT_KEY = "commands.new.project.name.argument"; const TEMPLATE_OPTION_KEY = "commands.new.project.template.option.description"; +const LANG_NOT_FOUND_KEY = "errors.scaffolding.language_not_found"; describe("setupNewCommand", () => { let mockProgram: any; @@ -119,7 +124,7 @@ describe("setupNewCommand", () => { expect(mockProgram.description).toHaveBeenCalledWith(CMD_DESCRIPTION_KEY); expect(mockProgram.argument).toHaveBeenCalledWith( "", - LANG_ARGUMENT_KEY, + `DYNAMIC_HELP_TEXT_FOR_${LANG_ARGUMENT_KEY}`, ); expect(mockProgram.argument).toHaveBeenCalledWith( "", @@ -154,7 +159,7 @@ describe("setupNewCommand", () => { expect(mockSpinner.start).toHaveBeenCalledOnce(); expect(mockSpinner.stop).toHaveBeenCalled(); expect(mockSpinner.succeed).toHaveBeenCalledWith( - `${NEW_PROJECT_SUCCESS_KEY}- options projectName:react-project`, + mocktFn(NEW_PROJECT_SUCCESS_KEY, { projectName }), ); expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); @@ -207,28 +212,32 @@ describe("setupNewCommand", () => { }); expect(mockSpinner.succeed).toHaveBeenCalledWith( - `${NEW_PROJECT_SUCCESS_KEY}- options projectName:vue-project`, + mocktFn(NEW_PROJECT_SUCCESS_KEY, { projectName }), ); expect(mockHandleErrorAndExit).not.toHaveBeenCalled(); }); - it("should throw a DevkitError if the language is not valid (python)", async () => { + it("should throw a DevkitError if the language is not valid (python) before config lookup", async () => { setupNewCommand({ program: mockProgram }); const language = "python"; const projectName = "my-python-project"; - const cmdOptions = { template: "my-template" }; + const templateName = "my-template"; + const cmdOptions = { template: templateName }; - const expectedError = new DevkitError("Invalid language"); mockMapLanguageAliasToCanonicalKey.mockReturnValue(language); - - mockValidateProgrammingLanguage.mockImplementation(() => { - throw expectedError; + mockValidateProgrammingLanguage.mockImplementation(() => {}); + mockGetMergedConfig.mockResolvedValue({ + ...sampleConfig, + templates: {}, }); + const expectedErrorMessage = mocktFn(LANG_NOT_FOUND_KEY, { language }); + const expectedError = new DevkitError(expectedErrorMessage); + await actionFn(language, projectName, cmdOptions); expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(language); - expect(mockGetMergedConfig).not.toHaveBeenCalled(); + expect(mockGetMergedConfig).toHaveBeenCalledWith(true); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( expectedError, @@ -288,6 +297,7 @@ describe("setupNewCommand", () => { await actionFn(language, projectName, cmdOptions); + expect(mockMapLanguageAliasToCanonicalKey).toHaveBeenCalledWith(language); expect(mockValidateProgrammingLanguage).toHaveBeenCalledWith(language); expect(mockHandleErrorAndExit).toHaveBeenCalledWith( expectedError, diff --git a/packages/devkit/__tests__/units/utils/i18n/generate-dynamic-help-text.spec.ts b/packages/devkit/__tests__/units/utils/i18n/generate-dynamic-help-text.spec.ts new file mode 100644 index 0000000..c703333 --- /dev/null +++ b/packages/devkit/__tests__/units/utils/i18n/generate-dynamic-help-text.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { generateDynamicHelpText } from "../../../../src/utils/i18n/generate-dynamic-help-text.js"; + +const mockt = vi.fn(); +vi.mock("#utils/i18n/translator.js", () => ({ + t: (key: string, options: { options: string }) => mockt(key, options), +})); + +vi.mock("#utils/schema/schema.js", () => ({ + VALID_CACHE_STRATEGIES: ["always-refresh", "never-refresh", "daily"], + VALID_PACKAGE_MANAGERS: ["npm", "yarn", "pnpm"], + SUPPORTED_LANGUAGES: ["en", "fr"], + ProgrammingLanguage: { + Javascript: "Javascript", + Typescript: "Typescript", + Nodejs: "Nodejs", + }, + ProgrammingLanguageAlias: { + js: "javascript", + ts: "typescript", + node: "nodejs", + }, + DisplayModes: { + Tree: "Tree", + List: "List", + }, +})); + +const I18N_KEYS = { + CACHE: "help.option.cacheStrategy", + PM: "help.option.packageManager", + LANG: "help.option.language", + SUPPORTED_LANG: "help.option.supportedLanguage", +} as const; + +describe("generateDynamicHelpText", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockt.mockImplementation((i18nKey, options) => { + return `[i18n:${i18nKey}] - Options are: ${options.options}`; + }); + }); + + it("should generate help text for 'cacheStrategy' with correct formatting", () => { + const i18nKey = I18N_KEYS.CACHE; + const result = generateDynamicHelpText("cacheStrategy", i18nKey); + + const expectedOptions = "`always-refresh`, `never-refresh`, `daily`"; + + expect(mockt).toHaveBeenCalledWith(i18nKey, { options: expectedOptions }); + expect(result).toBe(`[i18n:${i18nKey}] - Options are: ${expectedOptions}`); + }); + + it("should generate help text for 'packageManager' with correct formatting", () => { + const i18nKey = I18N_KEYS.PM; + const result = generateDynamicHelpText("packageManager", i18nKey); + + const expectedOptions = "`npm`, `yarn`, `pnpm`"; + + expect(mockt).toHaveBeenCalledWith(i18nKey, { options: expectedOptions }); + expect(result).toBe(`[i18n:${i18nKey}] - Options are: ${expectedOptions}`); + }); + + it("should generate help text for 'language' with correct formatting", () => { + const i18nKey = I18N_KEYS.LANG; + const result = generateDynamicHelpText("language", i18nKey); + + const expectedOptions = "`en`, `fr`"; + + expect(mockt).toHaveBeenCalledWith(i18nKey, { options: expectedOptions }); + expect(result).toBe(`[i18n:${i18nKey}] - Options are: ${expectedOptions}`); + }); + + it("should generate help text for 'supportedLanguage' in lowercase and correct formatting", () => { + const i18nKey = I18N_KEYS.SUPPORTED_LANG; + const result = generateDynamicHelpText("supportedLanguage", i18nKey); + + const expectedOptions = + "`javascript`, `typescript`, `nodejs`, `js`, `ts`, `node`"; + + expect(mockt).toHaveBeenCalledWith(i18nKey, { options: expectedOptions }); + expect(result).toBe(`[i18n:${i18nKey}] - Options are: ${expectedOptions}`); + }); + + it("should correctly handle a scenario with no options (empty list)", async () => { + vi.resetModules(); + + vi.doMock("#utils/schema/schema.js", async () => ({ + VALID_CACHE_STRATEGIES: [], + VALID_PACKAGE_MANAGERS: ( + await vi.importActual("../../../../src/utils/schema/schema.js") + ).VALID_PACKAGE_MANAGERS, + SUPPORTED_LANGUAGES: ( + await vi.importActual("../../../../src/utils/schema/schema.js") + ).SUPPORTED_LANGUAGES, + ProgrammingLanguage: ( + await vi.importActual("../../../../src/utils/schema/schema.js") + ).ProgrammingLanguage, + ProgrammingLanguageAlias: ( + await vi.importActual("../../../../src/utils/schema/schema.js") + ).ProgrammingLanguageAlias, + DisplayModes: ( + await vi.importActual("../../../../src/utils/schema/schema.js") + ).DisplayModes, + })); + + const { generateDynamicHelpText: generateDynamicHelpTextEmpty } = + await import("../../../../src/utils/i18n/generate-dynamic-help-text.js"); + + const emptyI18nKey = "help.empty.test"; + const result = generateDynamicHelpTextEmpty("cacheStrategy", emptyI18nKey); + + expect(mockt).toHaveBeenCalledWith(emptyI18nKey, { options: "" }); + expect(result).toBe(`[i18n:${emptyI18nKey}] - Options are: `); + + vi.resetModules(); + vi.doMock("#utils/schema/schema.js", () => + vi.importActual("#utils/schema/schema.js"), + ); + await import("../../../../src/utils/i18n/generate-dynamic-help-text.js"); + }); +}); diff --git a/packages/devkit/locales/en.json b/packages/devkit/locales/en.json index c4c255c..467512e 100644 --- a/packages/devkit/locales/en.json +++ b/packages/devkit/locales/en.json @@ -26,7 +26,7 @@ }, "project": { "language": { - "argument": "The programming language of the template (e.g., 'react', 'node')" + "argument": "The programming language of the template (values: {options})" }, "name": { "argument": "The name of your new project" @@ -65,7 +65,7 @@ }, "config": { "command": { - "description": "Manage DevKit's global and local settings, including package managers, cache strategy, and templates." + "description": "Manage DevKit's global and local settings, including package managers(pm, packageManager) and caching strategy(cache, cacheStrategy)" }, "interactive": { "prompt_action": "What would you like to configure?", @@ -117,7 +117,7 @@ }, "update_template": { "command": { - "description": "Update properties of a template, such as its alias, description, or location." + "description": "Update properties of a template, such as its alias, description, or location from languages ({options})" }, "language": { "argument": "The programming language of the template to update." @@ -130,8 +130,8 @@ "description": "A new brief description for the template.", "alias": "A new alias for the template.", "location": "A new location for the template.", - "cache_strategy": "A new cache strategy for the template.", - "package_manager": "A new package manager for the template.", + "cache_strategy": "A new cache strategy for the template. Supported values: {options}.", + "package_manager": "A new package manager for the template. Supported values: {options}.", "global": "Update the template in the global configuration instead of the local one." } }, @@ -163,12 +163,12 @@ }, "template": { "add": { - "description": "Add a new template to the configuration", + "description": "Add a template to the configuration: Supported programming languages: {options}", "options": { "description": "A brief description of the template", "alias": "A short alias for the template", - "cache": "The cache strategy for the template", - "package_manager": "The package manager to use for the template" + "cache": "The cache strategy for the template: {options}", + "package_manager": "The package manager to use for the template: {options}" }, "prompts": { "language": "Template Language", @@ -182,7 +182,7 @@ }, "remove": { "command": { - "description": "Remove a template from the configuration." + "description": "Remove a template from the configuration. Template for supported languages: {options}" }, "language": { "argument": "The programming language of the template to remove." diff --git a/packages/devkit/locales/fr.json b/packages/devkit/locales/fr.json index aac625d..3393f40 100644 --- a/packages/devkit/locales/fr.json +++ b/packages/devkit/locales/fr.json @@ -26,7 +26,7 @@ }, "project": { "language": { - "argument": "Le langage de programmation du modèle (ex: 'react', 'node')" + "argument": "Le langage de programmation du modèle (values: {options})" }, "name": { "argument": "Le nom de votre nouveau projet" @@ -65,7 +65,7 @@ }, "config": { "command": { - "description": "Gérer les paramètres globaux et locaux de DevKit, y compris les gestionnaires de paquets, la stratégie de cache et les modèles (templates)." + "description": "Gérer les paramètres globaux et locaux de DevKit, y compris les gestionnaires de paquets(pm, packageManager) et la stratégie de cache(cache, cacheStrategy)." }, "interactive": { "prompt_action": "Que souhaitez-vous configurer ?", @@ -117,7 +117,7 @@ }, "update_template": { "command": { - "description": "Mettre à jour les propriétés d'un modèle, telles que son alias, sa description ou son emplacement." + "description": "Mettre à jour les propriétés d'un modèle, telles que son alias, sa description ou son emplacement à partir des langages ({options})" }, "language": { "argument": "Le langage de programmation du modèle à mettre à jour." @@ -130,8 +130,8 @@ "description": "Une nouvelle brève description pour le modèle.", "alias": "Un nouvel alias pour le modèle.", "location": "Un nouvel emplacement pour le modèle.", - "cache_strategy": "Une nouvelle stratégie de cache pour le modèle.", - "package_manager": "Un nouveau gestionnaire de paquets pour le modèle.", + "cache_strategy": "Une nouvelle stratégie de cache pour le modèle. Valeurs prises en charge : {options}.", + "package_manager": "Un nouveau gestionnaire de paquets pour le modèle. Valeurs prises en charge : {options}.", "global": "Mettre à jour le modèle dans la configuration globale au lieu de la locale." } }, @@ -163,12 +163,12 @@ }, "template": { "add": { - "description": "Ajouter un nouveau modèle à la configuration", + "description": "Ajouter un modèle à la configuration : Langages de programmation pris en charge : {options}", "options": { "description": "Une brève description du modèle", "alias": "Un alias court pour le modèle", - "cache": "La stratégie de cache pour le modèle", - "package_manager": "Le gestionnaire de paquets à utiliser pour le modèle" + "cache": "La stratégie de cache pour le modèle : {options}", + "package_manager": "Le gestionnaire de paquets à utiliser pour le modèle : {options}" }, "prompts": { "language": "Langage du modèle", @@ -182,7 +182,7 @@ }, "remove": { "command": { - "description": "Supprimer un ou plusieurs modèles de la configuration." + "description": "Supprimer un ou plusieurs modèles de la configuration. Modèle pour les langages pris en charge : {options}" }, "language": { "argument": "Le langage de programmation du modèle à supprimer." @@ -342,7 +342,7 @@ "missing_or_malformed": "La section requise '{field}' est manquante ou mal formée (doit être un objet).", "setting_invalid": "La valeur du paramètre '{setting}' est invalide ou manquante.", "setting_invalid_type": "Le paramètre '{setting}' doit être de type {type}.", - "invalid_language_key": "La clé de langage de modèle '{key}' est invalide. Utilisez des noms de langages officiels en minuscules (par exemple, 'javascript', 'typescript').", + "invalid_language_key": "La clé de langage de modèle '{key}' est invalide. Utilisez des noms de langages officiels en minuscules (par exemple, {options}).", "template_structure_malformed": "Le bloc de langage pour '{language}' est mal formé. Il doit contenir un objet 'templates'.", "template_malformed": "Le modèle '{template}' sous '{language}' est mal formé (doit être un objet).", "template_field_missing": "Le modèle '{template}' dans '{language}' n'a pas le champ requis : '{field}' ou la valeur est invalide.", diff --git a/packages/devkit/src/commands/config/add.ts b/packages/devkit/src/commands/config/add.ts index edf3e35..55acfe4 100644 --- a/packages/devkit/src/commands/config/add.ts +++ b/packages/devkit/src/commands/config/add.ts @@ -9,6 +9,7 @@ import { type AddCommandOptions, type AddTemplateSchema } from "./types.js"; import { validateProgrammingLanguage } from "#utils/validations/config.js"; import type { CliConfig } from "#utils/schema/schema.js"; import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; +import { generateDynamicHelpText } from "#utils/i18n/generate-dynamic-help-text.js"; async function getTargetConfigForModification( isGlobal: boolean, @@ -31,7 +32,12 @@ export function setupAddCommand(configCommand: Command): void { configCommand .command("add ") .alias("a") - .description(t("commands.template.add.description")) + .description( + generateDynamicHelpText( + "supportedLanguage", + "commands.template.add.description", + ), + ) .option( "-d, --description ", t("commands.template.add.options.description"), @@ -49,12 +55,18 @@ export function setupAddCommand(configCommand: Command): void { ) .option( "-c, --cache-strategy ", - t("commands.template.add.options.cache"), + generateDynamicHelpText( + "cacheStrategy", + "commands.template.add.options.cache", + ), "", ) .option( "-p, --package-manager ", - t("commands.template.add.options.package_manager"), + generateDynamicHelpText( + "packageManager", + "commands.template.add.options.package_manager", + ), "", ) .action( diff --git a/packages/devkit/src/commands/config/remove/index.ts b/packages/devkit/src/commands/config/remove/index.ts index 392b8cc..51f584a 100644 --- a/packages/devkit/src/commands/config/remove/index.ts +++ b/packages/devkit/src/commands/config/remove/index.ts @@ -6,12 +6,18 @@ import { type Command } from "commander"; import { type RemoveCommandOptions } from "../types.js"; import { getTemplateNamesToActOn, saveConfig } from "./logic.js"; import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; +import { generateDynamicHelpText } from "#utils/i18n/generate-dynamic-help-text.js"; export function setupRemoveCommand(configCommand: Command): void { configCommand .command("remove ") .alias("rm") - .description(t("commands.template.remove.command.description")) + .description( + generateDynamicHelpText( + "supportedLanguage", + "commands.template.remove.command.description", + ), + ) .action( async ( language: string, diff --git a/packages/devkit/src/commands/config/update/index.ts b/packages/devkit/src/commands/config/update/index.ts index 149ff2a..950cedb 100644 --- a/packages/devkit/src/commands/config/update/index.ts +++ b/packages/devkit/src/commands/config/update/index.ts @@ -8,12 +8,18 @@ import { resolveTemplateNamesForUpdate } from "./logic.js"; import { DevkitError } from "#utils/errors/base.js"; import { validateProgrammingLanguage } from "#utils/validations/config.js"; import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; +import { generateDynamicHelpText } from "#utils/i18n/generate-dynamic-help-text.js"; export function setupUpdateCommand(configCommand: Command): void { configCommand .command("update ") .alias("up") - .description(t("commands.config.update_template.command.description")) + .description( + generateDynamicHelpText( + "supportedLanguage", + "commands.config.update_template.command.description", + ), + ) .option( "-n, --new-name ", t("commands.config.update_template.options.new_name"), @@ -32,11 +38,17 @@ export function setupUpdateCommand(configCommand: Command): void { ) .option( "--cache-strategy ", - t("commands.config.update_template.options.cache_strategy"), + generateDynamicHelpText( + "cacheStrategy", + "commands.config.update_template.options.cache_strategy", + ), ) .option( "--package-manager ", - t("commands.config.update_template.options.package_manager"), + generateDynamicHelpText( + "packageManager", + "commands.config.update_template.options.package_manager", + ), ) .option( "-g, --global", diff --git a/packages/devkit/src/commands/list.ts b/packages/devkit/src/commands/list.ts index 13c4b4e..1d72abf 100644 --- a/packages/devkit/src/commands/list.ts +++ b/packages/devkit/src/commands/list.ts @@ -16,6 +16,7 @@ import { type AnnotatedTemplate, } from "#core/template/annotator.js"; import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; +import { generateDynamicHelpText } from "#utils/i18n/generate-dynamic-help-text.js"; type ListCommandOptions = { global?: boolean; @@ -33,14 +34,21 @@ export function setupListCommand(options: SetupCommandOptions): void { .command("list") .alias("ls") .description(t("commands.list.command.description")) - .argument("[language]", t("commands.list.command.language.argument"), "") + .argument( + "[language]", + generateDynamicHelpText( + "language", + "commands.list.command.language.argument", + ), + "", + ) .option("-g, --global", t("commands.list.options.global")) .option("-a, --all", t("commands.list.options.all")) .option("-s, --settings", t("commands.list.options.settings")) .option("-w, --where ", t("commands.list.command.where.option")) .option( "-m, --mode ", - t("commands.list.command.mode.option"), + generateDynamicHelpText("mode", "commands.list.command.mode.option"), "tree", ) .option( diff --git a/packages/devkit/src/commands/new.ts b/packages/devkit/src/commands/new.ts index 035d69d..8955f06 100644 --- a/packages/devkit/src/commands/new.ts +++ b/packages/devkit/src/commands/new.ts @@ -6,6 +6,7 @@ import { logger, type TSpinner } from "#utils/logger.js"; import { validateProgrammingLanguage } from "#utils/validations/config.js"; import { getMergedConfig } from "#core/config/merger.js"; import { mapLanguageAliasToCanonicalKey } from "#core/config/language.js"; +import { generateDynamicHelpText } from "#utils/i18n/generate-dynamic-help-text.js"; const getScaffolder = async (language: string) => { if (["javascript", "typescript", "nodejs"].includes(language)) { @@ -23,7 +24,13 @@ export function setupNewCommand(options: SetupCommandOptions) { .command("new") .alias("nw") .description(t("commands.new.command.description")) - .argument("", t("commands.new.project.language.argument")) + .argument( + "", + generateDynamicHelpText( + "language", + "commands.new.project.language.argument", + ), + ) .argument("", t("commands.new.project.name.argument")) .requiredOption( "-t, --template ", diff --git a/packages/devkit/src/utils/i18n/generate-dynamic-help-text.ts b/packages/devkit/src/utils/i18n/generate-dynamic-help-text.ts new file mode 100644 index 0000000..85bb3ac --- /dev/null +++ b/packages/devkit/src/utils/i18n/generate-dynamic-help-text.ts @@ -0,0 +1,35 @@ +import { t } from "#utils/i18n/translator.js"; +import { + VALID_CACHE_STRATEGIES, + VALID_PACKAGE_MANAGERS, + SUPPORTED_LANGUAGES, + ProgrammingLanguage, + ProgrammingLanguageAlias, + DisplayModes, +} from "#utils/schema/schema.js"; + +const CONSTRAINED_VALUES_MAP = { + cacheStrategy: VALID_CACHE_STRATEGIES, + packageManager: VALID_PACKAGE_MANAGERS, + language: SUPPORTED_LANGUAGES, + supportedLanguage: Object.values(ProgrammingLanguage) + .map((v) => v.toLowerCase()) + .concat(...Object.keys(ProgrammingLanguageAlias)), + mode: Object.values(DisplayModes).map((v) => v.toLowerCase()), +} as const; + +type ConstrainedKey = keyof typeof CONSTRAINED_VALUES_MAP; + +export function generateDynamicHelpText( + key: ConstrainedKey, + i18nKey: Parameters[0], +): string { + const values = CONSTRAINED_VALUES_MAP[key]; + + const valueList = values + .map((v) => (typeof v === "string" ? v : String(v))) + .map((v) => `\`${v}\``) + .join(", "); + + return t(i18nKey, { options: valueList }); +}