diff --git a/@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.test.ts b/@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.test.ts index e28879f546..ddae52ae61 100644 --- a/@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.test.ts +++ b/@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.test.ts @@ -1,5 +1,5 @@ -import { describe, test, expect } from "vitest"; import { RuleConfigSeverity } from "@commitlint/types"; +import { describe, expect, test } from "vitest"; import { setPromptConfig } from "../store/prompts.js"; import { setRules } from "../store/rules.js"; @@ -253,6 +253,276 @@ describe("enum list", () => { expect(enumList?.[1]).toBe("lint"); expect(enumList?.[2]).toBe("cli"); }); + + test("should format enum items with emojis and descriptions, ensuring consistent spacing", () => { + const ENUM_RULE_LIST = ["feat", "fix", "chore"]; + setRules({ + "type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST], + } as any); + + setPromptConfig({ + questions: { + type: { + enum: { + feat: { + description: "Features", + emoji: "✨", + }, + fix: { + description: "Bug fixes", + emoji: "πŸ›", + }, + chore: { + description: "Chore", + emoji: "♻️", + }, + }, + }, + }, + }); + + const enumList = getRuleQuestionConfig("type")?.enumList; + expect(enumList).toEqual([ + { + name: "✨ feat: Features", + value: "feat", + short: "feat", + }, + { + name: "πŸ› fix: Bug fixes", + value: "fix", + short: "fix", + }, + { + name: "♻️ chore: Chore", + value: "chore", + short: "chore", + }, + ]); + }); + + test("should handle custom alignment for emoji and description with extra space", () => { + const ENUM_RULE_LIST = ["feat", "fix", "chore"]; + setRules({ + "type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST], + } as any); + + setPromptConfig({ + questions: { + type: { + enum: { + feat: { + description: "Features", + emoji: "✨ ", + }, + fix: { + description: "Bug fixes", + emoji: "πŸ› ", + }, + chore: { + description: "Chore", + emoji: "♻️ ", + }, + }, + }, + }, + }); + + const enumList = getRuleQuestionConfig("type")?.enumList; + expect(enumList).toEqual([ + { + name: "✨ feat: Features", + value: "feat", + short: "feat", + }, + { + name: "πŸ› fix: Bug fixes", + value: "fix", + short: "fix", + }, + { + name: "♻️ chore: Chore", + value: "chore", + short: "chore", + }, + ]); + }); + + test("should handle inconsistent emoji usage by using 4 blank spaces as a prefix", () => { + const ENUM_RULE_LIST = ["feat", "fix", "chore"]; + setRules({ + "type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST], + } as any); + + setPromptConfig({ + questions: { + type: { + enum: { + feat: { + description: "Features", + emoji: "✨", + }, + fix: { + description: "Bug fixes", + }, + chore: { + description: "Chore", + }, + }, + }, + }, + }); + + const enumList = getRuleQuestionConfig("type")?.enumList; + expect(enumList).toEqual([ + { + name: "✨ feat: Features", + value: "feat", + short: "feat", + }, + { + name: " fix: Bug fixes", + value: "fix", + short: "fix", + }, + { + name: " chore: Chore", + value: "chore", + short: "chore", + }, + ]); + }); + + test("should handle no enums having emojis correctly", () => { + const ENUM_RULE_LIST = ["feat", "fix", "chore"]; + setRules({ + "type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST], + } as any); + + setPromptConfig({ + questions: { + type: { + enum: { + feat: { + description: "Features", + }, + fix: { + description: "Bug fixes", + }, + chore: { + description: "Chore", + }, + }, + }, + }, + }); + + const enumList = getRuleQuestionConfig("type")?.enumList; + expect(enumList).toEqual([ + { + name: "feat: Features", + value: "feat", + short: "feat", + }, + { + name: "fix: Bug fixes", + value: "fix", + short: "fix", + }, + { + name: "chore: Chore", + value: "chore", + short: "chore", + }, + ]); + }); + + test("should include the emoji in the value when `emojiInHeader` is true", () => { + const ENUM_RULE_LIST = ["feat", "fix"]; + setRules({ + "type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST], + } as any); + + setPromptConfig({ + questions: { + type: { + emojiInHeader: true, + enum: { + feat: { + description: "Features", + emoji: "✨", + }, + fix: { + description: "Bug fixes", + emoji: "πŸ›", + }, + }, + }, + }, + }); + + const enumList = getRuleQuestionConfig("type")?.enumList; + expect(enumList).toEqual([ + { + name: "✨ feat: Features", + value: "✨ feat", + short: "feat", + }, + { + name: "πŸ› fix: Bug fixes", + value: "πŸ› fix", + short: "fix", + }, + ]); + }); + + test("should trim empty spaces from emoji in the answer", () => { + const ENUM_RULE_LIST = ["feat", "fix", "chore"]; + setRules({ + "type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST], + } as any); + + setPromptConfig({ + questions: { + type: { + emojiInHeader: true, + enum: { + feat: { + description: "Features", + emoji: "✨ ", + }, + fix: { + description: "Bug fixes", + emoji: "πŸ› ", + }, + chore: { + description: "Chore", + emoji: "♻️ ", + }, + }, + }, + }, + }); + + const enumList = getRuleQuestionConfig("type")?.enumList; + expect(enumList).toEqual([ + { + name: "✨ feat: Features", + value: "✨ feat", + short: "feat", + }, + { + name: "πŸ› fix: Bug fixes", + value: "πŸ› fix", + short: "fix", + }, + { + name: "♻️ chore: Chore", + value: "♻️ chore", + short: "chore", + }, + ]); + }); }); test("should return correct question config", () => { diff --git a/@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.ts b/@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.ts index 7cc85fe7fc..50ee8a1907 100644 --- a/@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.ts +++ b/@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.ts @@ -35,23 +35,41 @@ export default function (rulePrefix: RuleField): QuestionConfig | null { if (enumRuleList) { const enumDescriptions = questionSettings?.["enum"]; + const emojiInHeader = questionSettings?.emojiInHeader; if (enumDescriptions) { const enumNames = Object.keys(enumDescriptions); const longest = Math.max( ...enumRuleList.map((enumName) => enumName.length), ); - // TODO emoji + title + const firstHasEmoji = + (enumDescriptions[enumNames[0]]?.emoji?.length ?? 0)> 0; + const hasConsistentEmojiUsage = !enumRuleList.some( + (enumName) => + (enumDescriptions[enumName]?.emoji?.length ?? 0)> 0 !== + firstHasEmoji, + ); enumList = enumRuleList .sort((a, b) => enumNames.indexOf(a) - enumNames.indexOf(b)) .map((enumName) => { const enumDescription = enumDescriptions[enumName]?.description; if (enumDescription) { - return { - name: `${enumName}:`.padEnd(longest + 4) + enumDescription, - value: enumName, - short: enumName, - }; + const emoji = enumDescriptions[enumName]?.emoji; + + const emojiPrefix = emoji + ? `${emoji} ` + : hasConsistentEmojiUsage + ? "" + : " "; + + const paddedName = `${enumName}:`.padEnd(longest + 4); + + const name = `${emojiPrefix}${paddedName}${enumDescription}`; + + const value = + emojiInHeader && emoji ? `${emoji.trim()} ${enumName}` : enumName; + + return { name, value, short: enumName }; } else { return enumName; } diff --git a/@commitlint/types/src/prompt.ts b/@commitlint/types/src/prompt.ts index 92cada6bde..c426434d13 100644 --- a/@commitlint/types/src/prompt.ts +++ b/@commitlint/types/src/prompt.ts @@ -34,6 +34,7 @@ export type PromptConfig = { emoji?: string; }; }; + emojiInHeader?: boolean; } > >; diff --git a/docs/public/assets/cz-commitlint.png b/docs/public/assets/cz-commitlint.png index 35098c26f1..3c808656d7 100644 Binary files a/docs/public/assets/cz-commitlint.png and b/docs/public/assets/cz-commitlint.png differ diff --git a/docs/public/assets/vs-code-commit-msg.png b/docs/public/assets/vs-code-commit-msg.png new file mode 100644 index 0000000000..2c93243238 Binary files /dev/null and b/docs/public/assets/vs-code-commit-msg.png differ diff --git a/docs/public/assets/vs-code-emoji.png b/docs/public/assets/vs-code-emoji.png new file mode 100644 index 0000000000..fbea05cd9a Binary files /dev/null and b/docs/public/assets/vs-code-emoji.png differ diff --git a/docs/reference/examples.md b/docs/reference/examples.md index 773b4048ab..4d80227b88 100644 --- a/docs/reference/examples.md +++ b/docs/reference/examples.md @@ -22,3 +22,132 @@ These examples show common usages of how commitlint can be configured. // ... } ``` + +::: + +## Customizing Emojis and Alignment in VS Code + +Some terminals have trouble correctly calculating the width of Unicode emojis, which can cause a missing space after the emoji, leading to misaligned text in the commit prompt. + +![cz-commitlint questions](/assets/vs-code-emoji.png) + +To fix this issue in VS Code, you can specify an additional space after each emoji in your `commitlint.config.ts` file. + +::: code-group + +```ts [commitlint.config.ts] +import { type UserConfig } from "@commitlint/types"; + +export default { + // Use the conventional commit rules as a base. + extends: ["@commitlint/config-conventional"], + prompt: { + questions: { + type: { + enum: { + // Add a space to a few common types for better alignment. + build: { + emoji: "πŸ› οΈ ", // The extra space fixes the alignment. + }, + chore: { + emoji: "♻️ ", + }, + ci: { + emoji: "βš™οΈ ", + }, + revert: { + emoji: "πŸ—‘οΈ ", + }, + }, + }, + }, + }, +} satisfies UserConfig; +``` + +::: + +## Include Emojis in Commit Messages + +By default, emojis are only shown in the commit message prompt. To include them in the actual commit header, you need a custom parser and a setting to enable them. + +This configuration is based on the conventional commit rules and uses a _parser preset_ to validate commit headers that start with an emoji. + +::: code-group + +```ts [commitlint.config.ts] +import type { ParserPreset, UserConfig } from "@commitlint/types"; +import config from "@commitlint/config-conventional"; +import createPreset from "conventional-changelog-conventionalcommits"; +import { merge } from "lodash-es"; + +// A helper function to create the custom emoji parser preset. +async function createEmojiParser(): Promise { + // Generates the regex from the emojis defined in the conventional config. + const emojiRegexPart = Object.values(config.prompt.questions.type.enum) + .map((value) => value.emoji.trim()) + .join("|"); + + const parserOpts = { + // This regular expression validates commit headers with an emoji. + breakingHeaderPattern: new RegExp( + `^(?:${emojiRegexPart})\\s+(\\w*)(?:\\((.*)\\))?!:\\s+(.*)$`, + ), + headerPattern: new RegExp( + `^(?:${emojiRegexPart})\\s+(\\w*)(?:\\((.*)\\))?!?:\\s+(.*)$`, + ), + }; + + const emojiParser = merge({}, await createPreset(), { + conventionalChangelog: { parserOpts }, + parserOpts, + recommendedBumpOpts: { parserOpts }, + }); + + return emojiParser; +} + +const emojiParser = await createEmojiParser(); + +export default { + extends: ["@commitlint/config-conventional"], + parserPreset: emojiParser, + prompt: { + questions: { + type: { + enum: { + // Customize emojis and add the extra space for better alignment. + build: { emoji: "πŸ› οΈ " }, + chore: { emoji: "♻️ " }, + ci: { emoji: "βš™οΈ " }, + revert: { emoji: "πŸ—‘οΈ " }, + }, + // This setting includes the emoji in the final commit header. + headerWithEmoji: true, + }, + }, + }, +} satisfies UserConfig; +``` + +::: + +Although some emojis may appear without a trailing space in the terminal, the commit message itself is submitted with the correct formatting. + +![cz-commitlint questions](/assets/vs-code-commit-msg.png) + +You can verify this with `git log -4 --format=%B> commits.txt`. + +:::code-group + +```text [commits.txt] +βš™οΈ ci(scope): short + +πŸ›  build(scope): short + +πŸ› fix(scope): short + +✨ feat(scope): short +``` + +:::

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /