diff --git a/CHANGELOG.md b/CHANGELOG.md index 5055b7a..316fa7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-10-18 + +### Added + +- Custom themes via `"theme": "custom"` and a `customTheme` block in the user + or project `.merlinrc.json` + + `customTheme` takes `extends` (`"wizard"` or `"standard"`) and a + `messages` object. Only the listed messages are overridden; everything + else falls back to the base theme, which also decides decoration such as + emoji columns and menu icons. The three template messages accept + `{field}`, `{maxCharacters}` and `{fileName}` placeholders, filled by + plain string substitution and never evaluated. + +- Validation and sanitization for custom theme messages + + Overrides are checked against the built-in theme shape, so unknown keys + are dropped. C0 and C1 control characters and bidirectional override + characters are stripped, and each message is capped at 200 code points. + +- `custom` option in the `merlin config` theme picker, and the active + `customTheme` in `merlin config --show` output + +- Custom theme documentation: a Custom Themes section and a full message key + reference in `docs/usage/themes.md`, plus `customTheme` entries in + `docs/reference/config-schema.md` and `docs/reference/configuration.md` + +### Changed + +- `DEFAULT_CONFIG` now includes an empty `customTheme` block + + The next time `merlin config` saves `~/.merlinrc.json`, it writes a + starter `"customTheme": { "extends": "wizard", "messages": {} }` entry. + The block has no effect unless `theme` is set to `"custom"`. A + hand-written `customTheme` is kept exactly as written when saving from + `merlin config`. + ## [1.0.2] - 2026-10-14 ### Changed diff --git a/docs/reference/config-schema.md b/docs/reference/config-schema.md index f232d70..a1dfa8c 100644 --- a/docs/reference/config-schema.md +++ b/docs/reference/config-schema.md @@ -13,6 +13,7 @@ Both config files are JSON objects at the top level. Every field is optional - o ```json { "theme": "wizard", + "customTheme": { "extends": "wizard", "messages": {} }, "maxSubjectLength": 72, "maxScopeLength": 20, "editor": "vim", @@ -32,7 +33,7 @@ Invalid values are silently ignored and the built-in default is used instead. Me ### `theme` -**Type:** `"wizard"` | `"standard"` +**Type:** `"wizard"` | `"standard"` | `"custom"` **Default:** `"wizard"` Controls the UI theme used throughout the prompt sequence. @@ -41,6 +42,7 @@ Controls the UI theme used throughout the prompt sequence. | ------------ | --------------------------------------------------------------------------------------- | | `"wizard"` | Magical themed prompts with emoji, color, and thematic vocabulary. | | `"standard"` | Minimal prompts with plain text. Better for low-color terminals or accessibility needs. | +| `"custom"` | Your messages from [`customTheme`](#customtheme), layered over its base theme. | **Example:** @@ -54,6 +56,41 @@ See [Themes](../usage/themes.md) for a full comparison including exact prompt te --- +### `customTheme` + +**Type:** `{ extends: "wizard" | "standard"; messages: object }` +**Default:** `{ "extends": "wizard", "messages": {} }` + +Messages used when `theme` is `"custom"`. Only the overridden messages need to be listed - every other message comes from the `extends` theme, which also decides decoration (emoji columns and menu icons). + +| Field | Type | Required | Description | +| ---------- | -------------------------- | -------- | ----------------------------------------------------------------- | +| `extends` | `"wizard"` \| `"standard"` | Yes | Base theme for fallback messages and decoration | +| `messages` | `object` | No | Nested message overrides. Keys mirror the built-in theme messages | + +Template messages (`config.projectFieldAction`, `errors.commit.tooLong`, `init.overwrite`) are written with `{field}`, `{maxCharacters}` and `{fileName}` placeholders. + +**Example:** + +```json +{ + "theme": "custom", + "customTheme": { + "extends": "standard", + "messages": { + "commit": { "intro": "Ready to ship" }, + "errors": { "commit": { "tooLong": "Max {maxCharacters} characters" } } + } + } +} +``` + +Like every field, `customTheme` is replaced as a whole by a higher-priority tier: a project `customTheme` replaces the user one rather than merging with it. + +See [Custom Themes](../usage/themes.md#custom-themes) for placeholders, limits and the full key reference. + +--- + ### `maxSubjectLength` **Type:** `number` @@ -268,15 +305,16 @@ To extend the built-ins, include all 11 default types in your array plus your ad ## Validation Rules -| Field | Validation | On invalid value | -| ---------------------- | ---------------------------------------- | --------------------------------------------------------------- | -| `theme` | Must be `"wizard"` or `"standard"` | Ignored, falls back to default | -| `maxSubjectLength` | Must be a number greater than 0 | Ignored, falls back to default | -| `maxScopeLength` | Must be a number greater than 0 | Ignored, falls back to default | -| `editor` | Must be a non-empty string | Ignored, falls back to default | -| `autoAdd` | Must be a boolean | Ignored, falls back to default | -| `showCharacterCounter` | Must be a boolean | Ignored, falls back to default | -| `types` | Array, each entry filtered by type shape | Invalid entries filtered out; empty array falls back to default | +| Field | Validation | On invalid value | +| ---------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `theme` | Must be `"wizard"`, `"standard"` or `"custom"` | Ignored, falls back to default | +| `customTheme` | Object with `extends` of `"wizard"` or `"standard"`; messages must be strings at known keys | Whole object ignored if `extends` is invalid; unknown keys and non-strings dropped; control and bidi-override characters stripped; messages cut to 200 characters | +| `maxSubjectLength` | Must be a number greater than 0 | Ignored, falls back to default | +| `maxScopeLength` | Must be a number greater than 0 | Ignored, falls back to default | +| `editor` | Must be a non-empty string | Ignored, falls back to default | +| `autoAdd` | Must be a boolean | Ignored, falls back to default | +| `showCharacterCounter` | Must be a boolean | Ignored, falls back to default | +| `types` | Array, each entry filtered by type shape | Invalid entries filtered out; empty array falls back to default | Unknown fields are silently ignored. @@ -286,9 +324,15 @@ Unknown fields are silently ignored. ```json { - // UI theme: "wizard" (default) or "standard" + // UI theme: "wizard" (default), "standard" or "custom" "theme": "wizard", + // Messages for theme "custom", layered over the "extends" theme + "customTheme": { + "extends": "wizard", + "messages": { "commit": { "intro": "🚀 Ready to launch your commit!" } } + }, + // Subject line character limit (10-200, default 72) "maxSubjectLength": 72, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f96116e..05cfae5 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -130,6 +130,25 @@ Or edit `~/.merlinrc.json`: See [Themes](../usage/themes.md) for a comparison of both themes. +### Create a custom theme + +Set `theme` to `"custom"` and list only the messages you want to change. Everything else comes from the theme named in `extends`: + +```json +{ + "theme": "custom", + "customTheme": { + "extends": "wizard", + "messages": { + "commit": { "intro": "🚀 Ready to launch your commit!" }, + "prompts": { "type": "🎯 Select mission type:" } + } + } +} +``` + +Put it in the project `.merlinrc.json` to share it with your team. See [Custom Themes](../usage/themes.md#custom-themes) for placeholders, limits and every message key. + ### Enable auto-add When `autoAdd` is true, running `merlin` when no files are staged opens an interactive file picker instead of exiting with an error. diff --git a/docs/usage/themes.md b/docs/usage/themes.md index a11b37e..4b8411e 100644 --- a/docs/usage/themes.md +++ b/docs/usage/themes.md @@ -1,6 +1,6 @@ # Themes -Merlin Commit ships with two UI themes. Both produce identical commit messages - the theme only affects the prompt text and visual decoration. +Merlin Commit ships with two UI themes, and you can build a custom theme on top of either one. Every theme produces identical commit messages - the theme only affects the prompt text and visual decoration. --- @@ -124,6 +124,153 @@ Edit `~/.merlinrc.json` (user, applies everywhere) or `.merlinrc.json` (project, The theme takes effect immediately - no restart needed. +--- + +## Custom Themes + +A custom theme takes one of the built-in themes as its base and replaces only the messages you list. Everything you leave out comes from the base theme, so new messages added in later Merlin versions never break your theme. + +```json +{ + "theme": "custom", + "customTheme": { + "extends": "wizard", + "messages": { + "commit": { "intro": "🚀 Ready to launch your commit!" }, + "prompts": { + "type": "🎯 Select mission type:", + "confirm": "🛰️ Launch this commit?" + }, + "errors": { + "commit": { + "tooLong": "Payload too heavy - max {maxCharacters} characters" + } + } + } + } +} +``` + +| Field | Description | +| ---------------------- | ---------------------------------------------------------------------------------------------------------- | +| `theme` | Set to `"custom"` to use `customTheme`. Other values ignore `customTheme`. | +| `customTheme.extends` | `"wizard"` or `"standard"`. Supplies every message you don't override, and decides decoration (see below). | +| `customTheme.messages` | Nested object of messages to replace. Keys mirror the reference table below. | + +You can also pick the theme from the menu: `merlin config` → Theme → `custom`. The messages themselves are edited in `.merlinrc.json`. + +### Decoration follows the base + +`extends` decides more than the fallback text. With `"wizard"` as the base, the type picker shows emoji columns and the config menu shows emoji icons. With `"standard"`, both use plain labels. Your own messages can contain any emoji either way. + +### Template messages + +Three messages take a value at runtime. Write them as strings with `{placeholder}` tokens: + +| Key | Placeholder | Example | +| --------------------------- | ----------------- | ------------------------------------------- | +| `config.projectFieldAction` | `{field}` | `"What should happen to {field}?"` | +| `errors.commit.tooLong` | `{maxCharacters}` | `"Too long - max {maxCharacters}"` | +| `init.overwrite` | `{fileName}` | `"Already exists. Overwrite? ({fileName})"` | + +- `init.overwrite` is sometimes shown without a file name. The placeholder then renders as empty, and parentheses left empty are removed, so the example above becomes `Already exists. Overwrite?`. +- A placeholder Merlin doesn't know (for example a typo like `{maxChars}`) is shown exactly as typed, so the mistake is easy to spot. +- Placeholders are plain text substitution. Nothing in a theme is ever run as code. + +### Validation and limits + +A custom theme is checked when the config loads. Invalid parts are dropped silently, the same way as other config fields: + +- Keys that don't exist in the reference table are ignored. +- Values must be strings. Numbers, arrays and objects in place of a message are ignored. +- Control characters (including the escape characters used for terminal colour and cursor codes) and right-to-left override characters are stripped, so a shared config can't send commands to your terminal or disguise prompt text. +- Messages are cut to 200 characters (counted so an emoji is never split in half). +- If the `customTheme` in a file is invalid (for example `extends` isn't `"wizard"` or `"standard"`), that file's `customTheme` is ignored. Merlin then uses the `customTheme` from the next config level down - project, then user - or, if none is valid, the plain wizard theme. +- Saving settings from `merlin config` never rewrites your `customTheme` block, so a typo in it is left for you to fix rather than silently dropped from the file. + +### Sharing a theme with your team + +Put `theme` and `customTheme` in the project `.merlinrc.json` and commit it. Project config replaces user config field by field, so a project `customTheme` replaces your personal one as a whole - the two are not merged message by message. + +### Message key reference + +Every key a custom theme can override, with the standard theme's text. Template placeholders are shown in braces. + +| Key | Standard text | +| --------------------------------- | ------------------------------------------------------------ | +| `commit.intro` | Ready to create a commit | +| `commit.exit` | Commit complete | +| `commit.dryRunExit` | Dry run complete - no commit was created. | +| `commit.previewHeader` | Commit Preview: | +| `config.intro` | Configuration settings | +| `config.exit` | Configuration closed | +| `config.scopeSelector` | Edit configuration scope: | +| `config.scopeUser` | User config (applies to all repos - ~/.merlinrc.json) | +| `config.scopeProject` | Project config (applies to this repo - .merlinrc.json) | +| `config.noProjectConfig` | No project config found. | +| `config.createProjectConfig` | Create .merlinrc.json for this repo? | +| `config.projectConfigCreated` | Created .merlinrc.json | +| `config.projectFieldAction` | Action for {field}: | +| `config.back` | Returning to scope selection... | +| `checking.repo` | Checking git repository | +| `checking.staged` | Checking staged changes | +| `checking.unstaged` | Checking unstaged files | +| `checking.autoAdd` | Staging selected files... | +| `prompts.type` | Select the type of change: | +| `prompts.scope` | Scope (optional): | +| `prompts.subject` | Short description: | +| `prompts.body` | Add a detailed description? | +| `prompts.breaking` | Are there any breaking changes? | +| `prompts.issues` | Reference any issues? (e.g., fixes #123) | +| `prompts.confirm` | Create this commit? | +| `prompts.selectFiles` | Select files to stage: | +| `success.commit.created` | Commit created successfully | +| `success.commit.amended` | Commit amended successfully | +| `success.commit.dryRun` | Preview mode - no commit created | +| `success.config.saved` | Configuration updated | +| `success.init.completed` | Setup complete | +| `errors.notRepo` | Not a git repository | +| `errors.commit.noStaged` | No staged changes found | +| `errors.commit.required` | This field is required | +| `errors.commit.tooLong` | Text exceeds maximum length (max {maxCharacters} characters) | +| `errors.commit.malformed` | Invalid format | +| `errors.commit.commitFailed` | Failed to create commit | +| `errors.commit.editorFailed` | Editor exited with error | +| `errors.init.noPackageJson` | No package.json found | +| `errors.init.installFailed` | Failed to install dependencies | +| `errors.init.huskyFailed` | Failed to initialize husky | +| `errors.init.hookFailed` | Failed to create commit-msg hook | +| `errors.init.configFailed` | Failed to create commitlint config | +| `errors.init.aliasFailed` | Failed to create git alias | +| `warnings.cancel` | Commit cancelled | +| `warnings.commit.noVerify` | Skipping git hooks (--no-verify) | +| `warnings.config.resetConfig` | This will reset all configuration to defaults | +| `warnings.config.noProjectConfig` | No git repository detected - project config is not available | +| `warnings.init.existingSetup` | Existing setup detected: | +| `warnings.init.aliasExists` | Git alias 'merlin' already exists | +| `tips.commit.gitAdd` | Use "git add " to stage changes | +| `tips.init.runGitInit` | Run "git init" to initialize a repository | +| `tips.init.runNpmInit` | Run "npm init" to create a package.json | +| `tips.init.manualInstall` | Install manually: npm install -D | +| `tips.init.nextSteps` | Setup complete! Next steps: | +| `init.intro` | Setting up conventional commits for your repository | +| `init.checkingPackageJson` | Checking for package.json | +| `init.installDeps` | Install husky and commitlint dependencies? | +| `init.installingDeps` | Installing dependencies... | +| `init.initializingHusky` | Initializing husky... | +| `init.creatingHook` | Creating commit-msg hook... | +| `init.creatingCommitlint` | Creating commitlint config... | +| `init.setupAlias` | Setup 'git merlin' alias? | +| `init.aliasScope` | Select alias scope: | +| `init.creatingAlias` | Creating git alias... | +| `init.skipExisting` | Skipping existing file: | +| `init.overwrite` | File already exists. Overwrite? ({fileName}) | +| `init.aliasScopeGlobal` | Global (all repositories) | +| `init.aliasScopeLocal` | Local (this repository only) | +| `init.createProjectConfig` | Create project config (.merlinrc.json) for team sharing? | +| `init.creatingProjectConfig` | Creating project config... | +| `init.exit` | Repository setup complete. | +

switching themes

@@ -179,5 +326,6 @@ Color output requires a terminal that supports ANSI escape codes. Colors are not ## Related - [Glossary](../GLOSSARY.md) - Complete wizard-theme vocabulary reference +- [Config Schema](../reference/config-schema.md#customtheme) - `customTheme` field reference - [Configuration](../reference/configuration.md) - Setting a default theme and other options - [CLI Reference](../reference/cli-reference.md) - The `merlin config` command diff --git a/package-lock.json b/package-lock.json index 314a893..dbb9ac9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "merlin-commit", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "merlin-commit", - "version": "1.0.2", + "version": "1.1.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.6.0", diff --git a/package.json b/package.json index aa055ca..ca0edb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "merlin-commit", - "version": "1.0.2", + "version": "1.1.0", "description": "🧙‍♂️ Magical, interactive CLI for creating conventional commits", "type": "module", "main": "./dist/cli.js", diff --git a/src/commands/config.ts b/src/commands/config.ts index ffe2525..164dd97 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -27,6 +27,7 @@ import type { ThemeMessages, } from "../types/index.js"; import { colors, DEFAULT_CONFIG } from "../utils/constants.js"; +import { resolveBaseTheme } from "../utils/theme.js"; import { createNonEmptyValidator, createRangeValidator } from "../utils/validators.js"; type SaveFn = (config: Partial) => void; @@ -81,6 +82,7 @@ const CONFIGURE_PROMPTS = { theme: "Select theme:", themeWizardLabel: "wizard - Magical experience with themed messages", themeStandardLabel: "standard - Minimalist, professional interface", + themeCustomLabel: "custom - Your messages from customTheme in .merlinrc.json", maxSubjectLength: "Maximum subject line length (10-200):", maxScopeLength: "Maximum scope length (5-50):", editor: "External editor command:", @@ -177,7 +179,7 @@ function formatFieldChoice( currentValue: string | number | boolean, config: Required ): MenuChoice { - const isWizardTheme = config.theme === "wizard"; + const isWizardTheme = resolveBaseTheme(config) === "wizard"; const prefix = isWizardTheme ? FIELD_EMOJI_PREFIXES.wizard[field] : FIELD_EMOJI_PREFIXES.standard[field]; @@ -198,7 +200,7 @@ function formatFieldChoiceNoValue( field: ConfigurableField, config: Required ): MenuChoice { - const isWizardTheme = config.theme === "wizard"; + const isWizardTheme = resolveBaseTheme(config) === "wizard"; const prefix = isWizardTheme ? FIELD_EMOJI_PREFIXES.wizard[field] : FIELD_EMOJI_PREFIXES.standard[field]; @@ -218,7 +220,7 @@ function formatActionChoice( action: "show" | "reset" | "syncCommitlint" | "back" | "exit", config: Required ): MenuChoice { - const isWizardTheme = config.theme === "wizard"; + const isWizardTheme = resolveBaseTheme(config) === "wizard"; const prefixMap = isWizardTheme ? FIELD_EMOJI_PREFIXES.wizard : FIELD_EMOJI_PREFIXES.standard; @@ -275,7 +277,7 @@ function buildUserMenuChoices( } /** - * Configure the UI theme (wizard or standard). + * Configure the UI theme (wizard, standard, or custom). */ async function configureTheme( config: Required, @@ -294,6 +296,11 @@ async function configureTheme( name: CONFIGURE_PROMPTS.themeStandardLabel, short: "standard", }, + { + value: "custom" as const, + name: CONFIGURE_PROMPTS.themeCustomLabel, + short: "custom", + }, ], default: config.theme, }); @@ -400,6 +407,7 @@ async function showConfig(): Promise { const displayConfig = { theme: config.theme, + ...(config.theme === "custom" && { customTheme: config.customTheme }), maxSubjectLength: config.maxSubjectLength, maxScopeLength: config.maxScopeLength, editor: config.editor, diff --git a/src/lib/config-loader.ts b/src/lib/config-loader.ts index 1938698..2e7fcfe 100644 --- a/src/lib/config-loader.ts +++ b/src/lib/config-loader.ts @@ -9,6 +9,7 @@ import { STANDARD_MESSAGES, WIZARD_MESSAGES, } from "../utils/constants.js"; +import { mergeThemeMessages, resolveBaseTheme } from "../utils/theme.js"; import { normalizeEmojiSpacing } from "./terminal.js"; const USER_CONFIG_PATH = join(homedir(), ".merlinrc.json"); @@ -101,10 +102,54 @@ export function loadConfig(repoRoot?: string | null): Required { */ export function saveConfig(config: Partial): void { const currentConfig = loadUserConfig(); - const newConfig = { ...DEFAULT_CONFIG, ...currentConfig, ...config }; + const newConfig = preserveRawCustomTheme( + { ...DEFAULT_CONFIG, ...currentConfig, ...config }, + config, + USER_CONFIG_PATH + ); writeFileSync(USER_CONFIG_PATH, `${JSON.stringify(newConfig, null, 4)}\n`); } +/** + * Reads the unvalidated `customTheme` value from a config file. + * Returns undefined when the file is missing, unparseable, or has no customTheme. + * Parse errors are already reported by loadUserConfig/loadProjectConfig. + */ +function readRawCustomTheme(configPath: string): unknown { + if (!existsSync(configPath)) { + return undefined; + } + try { + const rawConfig: unknown = JSON.parse(readFileSync(configPath, "utf-8")); + return typeof rawConfig === "object" && rawConfig !== null + ? (rawConfig as Record).customTheme + : undefined; + } catch { + return undefined; + } +} + +/** + * Keeps the hand-written `customTheme` from disk when a save does not change it. + * + * Saves write back the validated config, which would replace a custom theme with + * its sanitized form - dropping a whole block over one typo. Other fields are + * single values, so only customTheme needs this. + */ +function preserveRawCustomTheme( + newConfig: Partial, + changes: Partial, + configPath: string +): Record { + if (changes.customTheme !== undefined) { + return newConfig; + } + const rawCustomTheme = readRawCustomTheme(configPath); + return rawCustomTheme === undefined + ? newConfig + : { ...newConfig, customTheme: rawCustomTheme }; +} + /** * Resets user configuration to default settings. * @@ -129,7 +174,11 @@ export function resetConfig(): void { export function saveProjectConfig(config: Partial, repoRoot: string): void { const projectConfigPath = join(repoRoot, ".merlinrc.json"); const currentProjectConfig = loadProjectConfig(repoRoot); - const newConfig = { ...currentProjectConfig, ...config }; + const newConfig = preserveRawCustomTheme( + { ...currentProjectConfig, ...config }, + config, + projectConfigPath + ); writeFileSync(projectConfigPath, `${JSON.stringify(newConfig, null, 4)}\n`); } @@ -149,10 +198,11 @@ export function removeProjectConfigField( const currentProjectConfig = loadProjectConfig(repoRoot); delete (currentProjectConfig as Record)[field]; - writeFileSync( - projectConfigPath, - `${JSON.stringify(currentProjectConfig, null, 4)}\n` - ); + const newConfig = + field === "customTheme" + ? currentProjectConfig + : preserveRawCustomTheme(currentProjectConfig, {}, projectConfigPath); + writeFileSync(projectConfigPath, `${JSON.stringify(newConfig, null, 4)}\n`); } /** @@ -200,13 +250,24 @@ function normalizeThemeStrings(object: T): T { * * For the wizard theme, VS16 emoji spacing is normalized at runtime based on * terminal detection. Standard theme has no VS16 emojis and skips normalization. + * A custom theme is layered over its base and always normalized, since user + * strings may contain VS16 emojis. `customTheme` merges like every other field, + * so a project-level custom theme replaces the user-level one as a whole. * * @param repoRoot - Repository root path for project-level config, or null/undefined to skip * @returns Message object containing all UI text for prompts, errors, and tips */ export function getMessages(repoRoot?: string | null): ThemeMessages { const config = loadConfig(repoRoot); - if (config.theme !== "wizard") { + const baseTheme = resolveBaseTheme(config); + const baseMessages = baseTheme === "wizard" ? WIZARD_MESSAGES : STANDARD_MESSAGES; + + if (config.theme === "custom") { + return normalizeThemeStrings( + mergeThemeMessages(baseMessages, config.customTheme.messages) + ); + } + if (baseTheme !== "wizard") { return STANDARD_MESSAGES; } return normalizeThemeStrings(WIZARD_MESSAGES); diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts index c462053..eb7a590 100644 --- a/src/lib/prompt.ts +++ b/src/lib/prompt.ts @@ -8,6 +8,7 @@ import { VALUE_COLUMN_WIDTH, VARIATION_SELECTOR, } from "../utils/constants.js"; +import { resolveBaseTheme } from "../utils/theme.js"; import type { CommitEditorContext } from "./editor-wrapper.js"; import type { StagedFile, StagingCandidate } from "./git.js"; import { isWideEmojiTerminal } from "./terminal.js"; @@ -61,7 +62,7 @@ export async function promptUser( }; // Prompt for commit type - const isWizardTheme = config.theme === "wizard"; + const isWizardTheme = resolveBaseTheme(config) === "wizard"; answers.type = await select({ message: messages.prompts.type, choices: config.types.map((type) => { diff --git a/src/types/config.ts b/src/types/config.ts index fdc8b22..ccc9de9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,4 +1,5 @@ import type { CommitType } from "./commit.js"; +import type { CustomThemeConfig, ThemeName } from "./theme.js"; /** * Configuration options for customizing Merlin's behavior. @@ -45,9 +46,19 @@ export type MerlinConfig = { * Visual theme for the commit interface. * - "wizard": Guided experience with themed messages and tips * - "standard": Minimalist interface with simple prompts + * - "custom": Messages from `customTheme` layered over its base theme * @default "wizard" */ - theme?: "wizard" | "standard"; + theme?: ThemeName; + + /** + * User-defined messages used when `theme` is "custom". + * Only the overridden messages need to be listed - the rest come from `extends`. + * @default { extends: "wizard", messages: {} } + * @example { extends: "wizard", messages: { commit: { intro: "🚀 Ready to launch" } } } + */ + customTheme?: CustomThemeConfig; + /** * Show real-time character counter on subject and scope inputs. * @default true @@ -87,7 +98,8 @@ export type ConfigMenuAction = /** * Configurable fields that can appear in the project config menu. - * Excludes 'types' since custom commit types are not yet user-configurable. + * Excludes 'types' since custom commit types are not yet user-configurable, + * and 'customTheme' since theme messages are edited in .merlinrc.json directly. */ export type ConfigurableField = | "theme" diff --git a/src/types/index.ts b/src/types/index.ts index d392b60..1f9faee 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,4 +7,9 @@ export type { MerlinConfig, } from "./config.js"; export type { InitOptions } from "./init.js"; -export type { ThemeMessages } from "./theme.js"; +export type { + BaseThemeName, + CustomThemeConfig, + ThemeMessageOverrides, + ThemeMessages, +} from "./theme.js"; diff --git a/src/types/theme.ts b/src/types/theme.ts index 6086a26..eba65bd 100644 --- a/src/types/theme.ts +++ b/src/types/theme.ts @@ -352,3 +352,40 @@ export type ThemeMessages = { creatingProjectConfig: string; }; }; + +/** + * Built-in themes that a custom theme can extend. + * The base also decides decoration (emoji layout in pickers and menus). + */ +export type BaseThemeName = "wizard" | "standard"; + +/** + * Every value accepted by the `theme` config field. + */ +export type ThemeName = BaseThemeName | "custom"; + +/** + * Partial, JSON-friendly version of ThemeMessages used by custom themes. + * Every key is optional, and template functions become strings with + * `{placeholder}` tokens (e.g. "Too long - max {maxCharacters}"). + */ +export type ThemeMessageOverrides = { + [Key in keyof ThemeMessages]?: MessageOverrideNode; +}; + +type MessageOverrideNode = Node extends string + ? string + : Node extends (...parameters: never[]) => string + ? string + : { [Key in keyof Node]?: MessageOverrideNode }; + +/** + * User-defined theme: a base theme plus the messages that replace its text. + */ +export type CustomThemeConfig = { + /** Built-in theme that supplies every message not overridden. */ + extends: BaseThemeName; + + /** Messages that replace the base theme's text. */ + messages: ThemeMessageOverrides; +}; diff --git a/src/utils/config.ts b/src/utils/config.ts index 513a8dd..2e71504 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,4 +1,29 @@ -import type { CommitType, MerlinConfig } from "../types/index.js"; +import type { + BaseThemeName, + CommitType, + CustomThemeConfig, + MerlinConfig, +} from "../types/index.js"; +import { STANDARD_MESSAGES } from "./constants.js"; +import { validateThemeOverrides } from "./theme.js"; + +function isBaseThemeName(value: unknown): value is BaseThemeName { + return value === "wizard" || value === "standard"; +} + +/** + * Validates a custom theme object. Requires a valid base theme in `extends`; + * messages are filtered down to known, sanitized strings. + */ +function validateCustomTheme(customTheme: unknown): CustomThemeConfig | undefined { + if (typeof customTheme !== "object" || customTheme === null) return undefined; + const rawTheme = customTheme as Record; + if (!isBaseThemeName(rawTheme.extends)) return undefined; + return { + extends: rawTheme.extends, + messages: validateThemeOverrides(rawTheme.messages, STANDARD_MESSAGES), + }; +} /** * Validates a single commit type object. @@ -60,9 +85,15 @@ export function validateConfig(userConfig: unknown): Partial { } // Validate theme - if (config.theme === "wizard" || config.theme === "standard") { + if (isBaseThemeName(config.theme) || config.theme === "custom") { validated.theme = config.theme; } + // Validate customTheme + const customTheme = validateCustomTheme(config.customTheme); + if (customTheme) { + validated.customTheme = customTheme; + } + return validated; } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 9f241f1..e5aa8f2 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -338,4 +338,22 @@ export const DEFAULT_CONFIG: Required = { autoAdd: false, showCharacterCounter: true, theme: "wizard", + customTheme: { extends: "wizard", messages: {} }, }; + +/** + * Parameter names for every template message in ThemeMessages, keyed by dotted path. + * Custom themes write these messages as strings with `{name}` placeholders, + * and the names listed here map each positional argument to its placeholder. + */ +export const TEMPLATE_PARAMETER_NAMES: Readonly> = { + "config.projectFieldAction": ["field"], + "errors.commit.tooLong": ["maxCharacters"], + "init.overwrite": ["fileName"], +}; + +/** + * Longest custom theme message accepted from config, in characters. + * Longer strings are truncated so a shared config cannot flood the terminal. + */ +export const MAX_CUSTOM_MESSAGE_LENGTH_CHARACTERS = 200; diff --git a/src/utils/theme.ts b/src/utils/theme.ts new file mode 100644 index 0000000..489a707 --- /dev/null +++ b/src/utils/theme.ts @@ -0,0 +1,205 @@ +import type { + BaseThemeName, + MerlinConfig, + ThemeMessageOverrides, + ThemeMessages, +} from "../types/index.js"; +import { + MAX_CUSTOM_MESSAGE_LENGTH_CHARACTERS, + TEMPLATE_PARAMETER_NAMES, +} from "./constants.js"; + +/** + * A compiled custom template message, called the same way as the built-in + * template functions (e.g. `tooLong(72)`). + */ +export type MessageTemplate = (...templateArguments: unknown[]) => string; + +type MessageTree = { [key: string]: unknown }; + +/** + * Characters stripped from custom messages so a shared .merlinrc.json cannot + * drive or spoof the terminal: C0 controls (including ESC, which starts ANSI + * escape sequences), DEL, C1 controls (including the 8-bit CSI U+009B), and + * bidirectional embedding/override/isolate controls. + */ +// eslint-disable-next-line no-control-regex +const UNSAFE_CHARACTER_REGEX = /[\u0000-\u001F\u007F-\u009F‪-‮⁦-⁩]/g; + +/** + * Matches a `{name}` placeholder, together with parentheses wrapping it directly + * (and the whitespace before them), so a missing argument can drop them too. + */ +const PLACEHOLDER_REGEX = /(\s*\()?\{(\w+)\}(\))?/g; + +function isMessageTree(value: unknown): value is MessageTree { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function joinPath(pathPrefix: string, key: string): string { + return pathPrefix ? `${pathPrefix}.${key}` : key; +} + +/** + * Removes unsafe characters and truncates a custom message to the allowed length. + * Truncation counts code points, so an emoji is never cut into a broken half. + */ +function sanitizeMessage(message: string): string { + const safeCharacters = Array.from(message.replace(UNSAFE_CHARACTER_REGEX, "")); + return safeCharacters.slice(0, MAX_CUSTOM_MESSAGE_LENGTH_CHARACTERS).join(""); +} + +/** + * Resolves the built-in theme that supplies messages and decoration. + * A custom theme resolves to the theme it extends. + * + * @param config - Config holding the theme selection + * @returns "wizard" or "standard" + */ +export function resolveBaseTheme( + config: Pick, "theme" | "customTheme"> +): BaseThemeName { + return config.theme === "custom" ? config.customTheme.extends : config.theme; +} + +function validateOverrideNode(rawNode: unknown, referenceNode: MessageTree): MessageTree { + const validated: MessageTree = {}; + if (!isMessageTree(rawNode)) { + return validated; + } + + for (const [key, referenceValue] of Object.entries(referenceNode)) { + const rawValue = rawNode[key]; + const isMessageLeaf = + typeof referenceValue === "string" || typeof referenceValue === "function"; + + if (isMessageLeaf) { + if (typeof rawValue === "string") { + validated[key] = sanitizeMessage(rawValue); + } + continue; + } + + const validatedChild = validateOverrideNode( + rawValue, + referenceValue as MessageTree + ); + if (Object.keys(validatedChild).length > 0) { + validated[key] = validatedChild; + } + } + return validated; +} + +/** + * Validates custom theme messages against the shape of a built-in theme. + * + * Keeps only string values at paths that exist in the reference (including + * template paths, which accept `{placeholder}` strings). Unknown keys and + * non-string values are dropped. Kept strings are sanitized. + * + * @param rawOverrides - Untrusted `customTheme.messages` value from config + * @param reference - Built-in theme whose shape defines the valid paths + * @returns Overrides containing only valid, sanitized messages + */ +export function validateThemeOverrides( + rawOverrides: unknown, + reference: ThemeMessages +): ThemeMessageOverrides { + return validateOverrideNode(rawOverrides, reference); +} + +/** + * Compiles a custom template string into a message function. + * Placeholders are replaced with plain string substitution - no code is evaluated. + * + * - A placeholder whose argument is missing renders as an empty string. When the + * placeholder is wrapped in its own parentheses, those (and the space before them) + * are removed too, so "Overwrite? ({fileName})" becomes "Overwrite?". + * - A placeholder not in `parameterNames` is left as typed, so typos stay visible. + * + * @param template - Message with `{name}` placeholders, e.g. "Too long - max {maxCharacters}" + * @param parameterNames - Placeholder name for each positional argument, in order + * @returns Function that renders the template with the given arguments + * @example + * ```typescript + * const tooLong = compileMessageTemplate("Max {maxCharacters}", ["maxCharacters"]); + * tooLong(72); // "Max 72" + * ``` + */ +export function compileMessageTemplate( + template: string, + parameterNames: readonly string[] +): MessageTemplate { + return (...templateArguments: unknown[]) => + template.replace( + PLACEHOLDER_REGEX, + ( + placeholder: string, + openingParenthesis: string | undefined, + name: string, + closingParenthesis: string | undefined + ) => { + const argumentIndex = parameterNames.indexOf(name); + if (argumentIndex === -1) { + return placeholder; + } + const templateArgument = templateArguments[argumentIndex]; + const isMissing = + templateArgument === undefined || templateArgument === null; + if (isMissing && openingParenthesis && closingParenthesis) { + return ""; + } + const value = isMissing ? "" : String(templateArgument); + return `${openingParenthesis ?? ""}${value}${closingParenthesis ?? ""}`; + } + ); +} + +function mergeMessageNode( + baseNode: MessageTree, + overrideNode: unknown, + pathPrefix: string +): MessageTree { + const overrides = isMessageTree(overrideNode) ? overrideNode : {}; + const merged: MessageTree = {}; + + for (const [key, baseValue] of Object.entries(baseNode)) { + const overrideValue = overrides[key]; + const path = joinPath(pathPrefix, key); + + if (typeof baseValue === "function") { + merged[key] = + typeof overrideValue === "string" + ? compileMessageTemplate( + overrideValue, + TEMPLATE_PARAMETER_NAMES[path] ?? [] + ) + : baseValue; + } else if (typeof baseValue === "string") { + merged[key] = typeof overrideValue === "string" ? overrideValue : baseValue; + } else { + // ThemeMessages leaves are strings or functions, so anything else is a group + merged[key] = mergeMessageNode(baseValue as MessageTree, overrideValue, path); + } + } + return merged; +} + +/** + * Layers custom messages over a built-in theme. + * + * Every message missing from the overrides falls back to the base, so the + * result is always a complete ThemeMessages. Override strings at template + * paths are compiled into functions. Keys not in the base are ignored. + * + * @param base - Built-in theme supplying every message not overridden + * @param overrides - Validated custom messages + * @returns Complete message set + */ +export function mergeThemeMessages( + base: ThemeMessages, + overrides: ThemeMessageOverrides +): ThemeMessages { + return mergeMessageNode(base, overrides, "") as ThemeMessages; +} diff --git a/tests/unit/config-command.test.ts b/tests/unit/config-command.test.ts index 65937af..4b94649 100644 --- a/tests/unit/config-command.test.ts +++ b/tests/unit/config-command.test.ts @@ -341,6 +341,44 @@ describe("configCommand", () => { expect(choices).toContainEqual( expect.objectContaining({ value: "standard" }) ); + expect(choices).toContainEqual(expect.objectContaining({ value: "custom" })); + }); + + it("keeps emoji menu prefixes for a custom theme on the wizard base", async () => { + vi.mocked(loadConfig).mockReturnValue({ + ...DEFAULT_CONFIG, + theme: "custom", + customTheme: { extends: "wizard", messages: {} }, + }); + vi.mocked(select).mockResolvedValueOnce("user").mockResolvedValueOnce("exit"); + + await configCommand({}); + + // calls[0] = scope, calls[1] = menu + const menuChoices = vi.mocked(select).mock.calls[1][0].choices as Array<{ + value?: string; + name?: string; + }>; + const themeChoice = menuChoices.find((choice) => choice.value === "theme"); + expect(themeChoice?.name).toContain("🎨"); + }); + + it("uses plain menu prefixes for a custom theme on the standard base", async () => { + vi.mocked(loadConfig).mockReturnValue({ + ...DEFAULT_CONFIG, + theme: "custom", + customTheme: { extends: "standard", messages: {} }, + }); + vi.mocked(select).mockResolvedValueOnce("user").mockResolvedValueOnce("exit"); + + await configCommand({}); + + const menuChoices = vi.mocked(select).mock.calls[1][0].choices as Array<{ + value?: string; + name?: string; + }>; + const themeChoice = menuChoices.find((choice) => choice.value === "theme"); + expect(themeChoice?.name?.startsWith("•")).toBe(true); }); }); diff --git a/tests/unit/config-loader.test.ts b/tests/unit/config-loader.test.ts index 9e617c3..866933a 100644 --- a/tests/unit/config-loader.test.ts +++ b/tests/unit/config-loader.test.ts @@ -115,6 +115,35 @@ describe("config-loader", () => { expect(parsed.theme).toBe("standard"); expect(parsed.maxSubjectLength).toBe(50); }); + + it("keeps a hand-written customTheme that fails validation", () => { + const rawCustomTheme = { + extends: "Wizard", + messages: { commit: { intro: "Launch", typo: "kept" } }, + }; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ theme: "standard", customTheme: rawCustomTheme }) + ); + + saveConfig({ theme: "custom" }); + + const writtenContent = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(JSON.parse(writtenContent).customTheme).toEqual(rawCustomTheme); + }); + + it("writes customTheme when the save changes it", () => { + const newCustomTheme = { extends: "standard" as const, messages: {} }; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ customTheme: { extends: "wizard", messages: {} } }) + ); + + saveConfig({ customTheme: newCustomTheme }); + + const writtenContent = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(JSON.parse(writtenContent).customTheme).toEqual(newCustomTheme); + }); }); describe("resetConfig", () => { @@ -362,6 +391,19 @@ describe("config-loader", () => { expect(parsed.maxSubjectLength).toBe(50); }); + it("keeps the raw project customTheme when saving another field", () => { + const rawCustomTheme = { extends: "standard", messages: { bogus: "kept" } }; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ customTheme: rawCustomTheme }) + ); + + saveProjectConfig({ maxSubjectLength: 50 }, repoRoot); + + const writtenContent = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(JSON.parse(writtenContent).customTheme).toEqual(rawCustomTheme); + }); + it("writes to the correct path with trailing newline", () => { vi.mocked(existsSync).mockReturnValue(false); @@ -412,6 +454,34 @@ describe("config-loader", () => { expect(parsed.maxSubjectLength).toBeUndefined(); }); + it("keeps the raw customTheme when removing another field", () => { + const rawCustomTheme = { extends: "Wizard", messages: {} }; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ theme: "custom", customTheme: rawCustomTheme }) + ); + + removeProjectConfigField("theme", repoRoot); + + const writtenContent = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(JSON.parse(writtenContent)).toEqual({ customTheme: rawCustomTheme }); + }); + + it("removes customTheme when it is the field being removed", () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + theme: "custom", + customTheme: { extends: "wizard", messages: {} }, + }) + ); + + removeProjectConfigField("customTheme", repoRoot); + + const writtenContent = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(JSON.parse(writtenContent)).toEqual({ theme: "custom" }); + }); + it("writes to the correct path with trailing newline", () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ theme: "wizard" })); @@ -517,5 +587,87 @@ describe("config-loader", () => { expect(messages).toBe(STANDARD_MESSAGES); }); + + describe("when theme is custom", () => { + it.each([ + ["wizard", WIZARD_MESSAGES], + ["standard", STANDARD_MESSAGES], + ] as const)( + "layers overrides over the %s base", + (baseTheme, baseMessages) => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + theme: "custom", + customTheme: { + extends: baseTheme, + messages: { commit: { intro: "Launch" } }, + }, + }) + ); + + const messages = getMessages(); + + expect(messages.commit.intro).toBe("Launch"); + expect(messages.commit.exit).toBe(baseMessages.commit.exit); + expect(messages.prompts).toEqual(baseMessages.prompts); + } + ); + + it("renders custom template messages", () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + theme: "custom", + customTheme: { + extends: "standard", + messages: { + errors: { commit: { tooLong: "Max {maxCharacters}" } }, + }, + }, + }) + ); + + const messages = getMessages(); + + expect(messages.errors.commit.tooLong(50)).toBe("Max 50"); + }); + + it("falls back to the wizard base when customTheme is missing", () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ theme: "custom" }) + ); + + const messages = getMessages(); + + expect(messages).toEqual(WIZARD_MESSAGES); + }); + + it("uses the project customTheme instead of the user one", () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockImplementation((filePath) => + filePath === USER_CONFIG_PATH + ? JSON.stringify({ + theme: "custom", + customTheme: { + extends: "wizard", + messages: { commit: { intro: "User", exit: "Bye" } }, + }, + }) + : JSON.stringify({ + customTheme: { + extends: "standard", + messages: { commit: { intro: "Project" } }, + }, + }) + ); + + const messages = getMessages("/mock/repo"); + + expect(messages.commit.intro).toBe("Project"); + expect(messages.commit.exit).toBe(STANDARD_MESSAGES.commit.exit); + }); + }); }); }); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 9d577af..c02d098 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -39,6 +39,50 @@ describe("validateConfig", () => { expect(result.theme).toBeUndefined(); }); + it("accepts the custom theme value", () => { + expect(validateConfig({ theme: "custom" }).theme).toBe("custom"); + }); + + describe("customTheme", () => { + it("keeps a valid custom theme with validated messages", () => { + const result = validateConfig({ + customTheme: { + extends: "standard", + messages: { commit: { intro: "Launch", unknown: "x" } }, + }, + }); + + expect(result.customTheme).toEqual({ + extends: "standard", + messages: { commit: { intro: "Launch" } }, + }); + }); + + it("defaults messages to empty overrides when missing", () => { + const result = validateConfig({ customTheme: { extends: "wizard" } }); + + expect(result.customTheme).toEqual({ extends: "wizard", messages: {} }); + }); + + it.each([["dark"], ["custom"], [undefined], [42]])( + "rejects a custom theme extending %s", + (baseTheme) => { + const result = validateConfig({ + customTheme: { extends: baseTheme, messages: {} }, + }); + + expect(result.customTheme).toBeUndefined(); + } + ); + + it.each([["string"], [null], [42]])( + "rejects a non-object custom theme (%s)", + (customTheme) => { + expect(validateConfig({ customTheme }).customTheme).toBeUndefined(); + } + ); + }); + it("rejects zero and negative maxSubjectLength", () => { expect(validateConfig({ maxSubjectLength: 0 })).toEqual({}); expect(validateConfig({ maxSubjectLength: -5 })).toEqual({}); diff --git a/tests/unit/constants.test.ts b/tests/unit/constants.test.ts index 286a217..7c67f2a 100644 --- a/tests/unit/constants.test.ts +++ b/tests/unit/constants.test.ts @@ -6,6 +6,7 @@ import { COMMIT_TYPES, DEFAULT_CONFIG, STANDARD_MESSAGES, + TEMPLATE_PARAMETER_NAMES, WIZARD_MESSAGES, } from "../../src/utils/constants.js"; @@ -181,5 +182,28 @@ describe("DEFAULT_CONFIG", () => { expect(DEFAULT_CONFIG.autoAdd).toBe(false); expect(DEFAULT_CONFIG.theme).toBe("wizard"); expect(DEFAULT_CONFIG.types).toBe(COMMIT_TYPES); + expect(DEFAULT_CONFIG.customTheme).toEqual({ extends: "wizard", messages: {} }); + }); +}); + +describe("TEMPLATE_PARAMETER_NAMES", () => { + function collectFunctionPaths(messageTree: object, pathPrefix = ""): string[] { + return Object.entries(messageTree).flatMap(([key, value]) => { + const path = pathPrefix ? `${pathPrefix}.${key}` : key; + if (typeof value === "function") return [path]; + if (typeof value === "object" && value !== null) { + return collectFunctionPaths(value, path); + } + return []; + }); + } + + it.each([ + ["WIZARD_MESSAGES", WIZARD_MESSAGES], + ["STANDARD_MESSAGES", STANDARD_MESSAGES], + ])("lists exactly the template paths in %s", (_, messages) => { + const templatePaths = collectFunctionPaths(messages).sort(); + + expect(Object.keys(TEMPLATE_PARAMETER_NAMES).sort()).toEqual(templatePaths); }); }); diff --git a/tests/unit/prompt.test.ts b/tests/unit/prompt.test.ts index 1d66773..24f35bf 100644 --- a/tests/unit/prompt.test.ts +++ b/tests/unit/prompt.test.ts @@ -91,6 +91,44 @@ describe("promptUser", () => { ); }); + it("keeps emoji decoration for a custom theme on the wizard base", async () => { + setupMinimalFlow(); + const dependencies = createMockDependencies({ + config: { + ...DEFAULT_CONFIG, + theme: "custom", + customTheme: { extends: "wizard", messages: {} }, + }, + }); + + await promptUser(dependencies); + + const choices = vi.mocked(select).mock.calls[0][0].choices as Array<{ + name: string; + }>; + expect(choices[0].name.startsWith(DEFAULT_CONFIG.types[0].emoji)).toBe(true); + }); + + it("uses plain labels for a custom theme on the standard base", async () => { + setupMinimalFlow(); + const dependencies = createMockDependencies({ + config: { + ...DEFAULT_CONFIG, + theme: "custom", + customTheme: { extends: "standard", messages: {} }, + }, + }); + + await promptUser(dependencies); + + const choices = vi.mocked(select).mock.calls[0][0].choices as Array<{ + name: string; + }>; + expect(choices[0].name.startsWith(`${DEFAULT_CONFIG.types[0].value}:`)).toBe( + true + ); + }); + it("applies character counter transformer to scope", async () => { setupMinimalFlow(); const dependencies = createMockDependencies(); diff --git a/tests/unit/theme.test.ts b/tests/unit/theme.test.ts new file mode 100644 index 0000000..5b69b9f --- /dev/null +++ b/tests/unit/theme.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_CONFIG, + MAX_CUSTOM_MESSAGE_LENGTH_CHARACTERS, + STANDARD_MESSAGES, + WIZARD_MESSAGES, +} from "../../src/utils/constants.js"; +import { + compileMessageTemplate, + mergeThemeMessages, + resolveBaseTheme, + validateThemeOverrides, +} from "../../src/utils/theme.js"; + +describe("resolveBaseTheme", () => { + it.each([ + ["wizard", "wizard"], + ["standard", "standard"], + ] as const)("returns %s for the built-in %s theme", (theme, expectedBase) => { + const config = { theme, customTheme: DEFAULT_CONFIG.customTheme }; + + expect(resolveBaseTheme(config)).toBe(expectedBase); + }); + + it("returns the extended theme for a custom theme", () => { + const config = { + theme: "custom" as const, + customTheme: { extends: "standard" as const, messages: {} }, + }; + + expect(resolveBaseTheme(config)).toBe("standard"); + }); +}); + +describe("validateThemeOverrides", () => { + describe("when input is not an object", () => { + it.each([null, undefined, "intro", 42, ["commit"]])( + "returns empty overrides for %s", + (rawOverrides) => { + expect(validateThemeOverrides(rawOverrides, STANDARD_MESSAGES)).toEqual( + {} + ); + } + ); + }); + + describe("when input matches the reference shape", () => { + it("keeps nested string messages", () => { + const rawOverrides = { + commit: { intro: "Launch" }, + errors: { commit: { noStaged: "Nothing staged" } }, + }; + + const overrides = validateThemeOverrides(rawOverrides, STANDARD_MESSAGES); + + expect(overrides).toEqual(rawOverrides); + }); + + it("keeps strings at template function paths", () => { + const rawOverrides = { + errors: { commit: { tooLong: "Max {maxCharacters}" } }, + }; + + const overrides = validateThemeOverrides(rawOverrides, STANDARD_MESSAGES); + + expect(overrides).toEqual(rawOverrides); + }); + }); + + describe("when input contains invalid entries", () => { + it("drops keys that are not in the reference", () => { + const rawOverrides = { commit: { intro: "Launch", unknown: "x" }, extra: {} }; + + const overrides = validateThemeOverrides(rawOverrides, STANDARD_MESSAGES); + + expect(overrides).toEqual({ commit: { intro: "Launch" } }); + }); + + it("drops non-string message values", () => { + const rawOverrides = { commit: { intro: 42, exit: null, dryRunExit: ["a"] } }; + + const overrides = validateThemeOverrides(rawOverrides, STANDARD_MESSAGES); + + expect(overrides).toEqual({}); + }); + + it("drops a string where the reference expects a group", () => { + const overrides = validateThemeOverrides( + { commit: "Launch" }, + STANDARD_MESSAGES + ); + + expect(overrides).toEqual({}); + }); + + it("drops a group where the reference expects a message", () => { + const overrides = validateThemeOverrides( + { commit: { intro: { text: "Launch" } } }, + STANDARD_MESSAGES + ); + + expect(overrides).toEqual({}); + }); + }); + + describe("when strings need sanitizing", () => { + it("strips ANSI escape and other control characters", () => { + const rawOverrides = { commit: { intro: "\u001b[2JLaunch\u0007\n\u007f!" } }; + + const overrides = validateThemeOverrides(rawOverrides, STANDARD_MESSAGES); + + expect(overrides.commit?.intro).toBe("[2JLaunch!"); + }); + + it("strips C1 controls and bidirectional overrides", () => { + const rawOverrides = { commit: { intro: "\u009b2JLaunch‮!⁦" } }; + + const overrides = validateThemeOverrides(rawOverrides, STANDARD_MESSAGES); + + expect(overrides.commit?.intro).toBe("2JLaunch!"); + }); + + it("keeps emoji variation selectors and joiners", () => { + const emojiMessage = "⚠️ \u{1f9d9}‍♂️ Launch"; + + const overrides = validateThemeOverrides( + { commit: { intro: emojiMessage } }, + STANDARD_MESSAGES + ); + + expect(overrides.commit?.intro).toBe(emojiMessage); + }); + + it("does not split an emoji at the length limit", () => { + const message = `${"a".repeat(MAX_CUSTOM_MESSAGE_LENGTH_CHARACTERS - 1)}\u{1f680}\u{1f680}`; + + const overrides = validateThemeOverrides( + { commit: { intro: message } }, + STANDARD_MESSAGES + ); + + expect(overrides.commit?.intro?.endsWith("a\u{1f680}")).toBe(true); + }); + + it("truncates messages longer than the maximum length", () => { + const longMessage = "a".repeat(MAX_CUSTOM_MESSAGE_LENGTH_CHARACTERS + 50); + + const overrides = validateThemeOverrides( + { commit: { intro: longMessage } }, + STANDARD_MESSAGES + ); + + expect(overrides.commit?.intro).toHaveLength( + MAX_CUSTOM_MESSAGE_LENGTH_CHARACTERS + ); + }); + }); +}); + +describe("compileMessageTemplate", () => { + it("replaces a placeholder with its argument", () => { + const tooLong = compileMessageTemplate("Max {maxCharacters}", ["maxCharacters"]); + + expect(tooLong(72)).toBe("Max 72"); + }); + + it("keeps parentheses around a present argument", () => { + const overwrite = compileMessageTemplate("Overwrite ({fileName})", ["fileName"]); + + expect(overwrite("a.json")).toBe("Overwrite (a.json)"); + }); + + it("replaces repeated placeholders", () => { + const render = compileMessageTemplate("{field} - set {field}", ["field"]); + + expect(render("theme")).toBe("theme - set theme"); + }); + + it("maps multiple placeholders by argument position", () => { + const render = compileMessageTemplate("{second} then {first}", [ + "first", + "second", + ]); + + expect(render("a", "b")).toBe("b then a"); + }); + + it("returns the template unchanged when it has no placeholders", () => { + const render = compileMessageTemplate("Plain text", ["field"]); + + expect(render("ignored")).toBe("Plain text"); + }); + + it("leaves unknown placeholders as typed", () => { + const render = compileMessageTemplate("Max {maxChars}", ["maxCharacters"]); + + expect(render(72)).toBe("Max {maxChars}"); + }); + + describe("when an argument is missing", () => { + it("removes parentheses left empty", () => { + const overwrite = compileMessageTemplate("Overwrite? ({fileName})", [ + "fileName", + ]); + + expect(overwrite()).toBe("Overwrite?"); + expect(overwrite(".husky/")).toBe("Overwrite? (.husky/)"); + }); + + it("keeps parentheses that do not wrap the placeholder", () => { + const overwrite = compileMessageTemplate("Overwrite? (keep ()) {fileName}", [ + "fileName", + ]); + + expect(overwrite()).toBe("Overwrite? (keep ()) "); + }); + + it("renders the placeholder as an empty string", () => { + const overwrite = compileMessageTemplate("Overwrite {fileName}?", [ + "fileName", + ]); + + expect(overwrite(undefined)).toBe("Overwrite ?"); + }); + }); + + it("does not evaluate placeholder content as code", () => { + const render = compileMessageTemplate("{field}", ["field"]); + + expect(render("${process.exit()}")).toBe("${process.exit()}"); + }); +}); + +describe("mergeThemeMessages", () => { + it("returns messages equal to the base when there are no overrides", () => { + const merged = mergeThemeMessages(WIZARD_MESSAGES, {}); + + expect(merged).toEqual(WIZARD_MESSAGES); + }); + + it("replaces only the overridden messages", () => { + const merged = mergeThemeMessages(STANDARD_MESSAGES, { + commit: { intro: "Launch" }, + prompts: { type: "Mission type:" }, + }); + + expect(merged.commit.intro).toBe("Launch"); + expect(merged.prompts.type).toBe("Mission type:"); + expect(merged.commit.exit).toBe(STANDARD_MESSAGES.commit.exit); + expect(merged.errors).toEqual(STANDARD_MESSAGES.errors); + }); + + it("keeps base template functions that are not overridden", () => { + const merged = mergeThemeMessages(STANDARD_MESSAGES, {}); + + expect(merged.errors.commit.tooLong).toBe( + STANDARD_MESSAGES.errors.commit.tooLong + ); + }); + + it("compiles overridden templates into functions", () => { + const merged = mergeThemeMessages(STANDARD_MESSAGES, { + errors: { commit: { tooLong: "Max {maxCharacters}" } }, + }); + + expect(merged.errors.commit.tooLong(50)).toBe("Max 50"); + }); + + it("compiles the projectFieldAction template with its field parameter", () => { + const merged = mergeThemeMessages(STANDARD_MESSAGES, { + config: { projectFieldAction: "Edit {field}" }, + }); + + expect(merged.config.projectFieldAction("theme")).toBe("Edit theme"); + }); + + it("compiles the overwrite template with its fileName parameter", () => { + const merged = mergeThemeMessages(STANDARD_MESSAGES, { + init: { overwrite: "Replace {fileName}?" }, + }); + + expect(merged.init.overwrite("a.json")).toBe("Replace a.json?"); + }); + + it("does not mutate the base theme", () => { + const originalIntro = STANDARD_MESSAGES.commit.intro; + + mergeThemeMessages(STANDARD_MESSAGES, { commit: { intro: "Launch" } }); + + expect(STANDARD_MESSAGES.commit.intro).toBe(originalIntro); + }); +});