Skip to content

Commit 9e3e2d3

Browse files
mrt181Martin T.
andauthored
fix(cz-commitlint): add VS16 to single character emojis (conventional-changelog#4666)
Single character emojis like πŸ›  or πŸ—‘ do not align with 2 character emojis in the rendered menu. Co-authored-by: Martin T. <mrt181@gmail.com>
1 parent ad31713 commit 9e3e2d3

2 files changed

Lines changed: 114 additions & 4 deletions

File tree

β€Ž@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.test.tsβ€Ž

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,53 @@ describe("enum list", () => {
393393
]);
394394
});
395395

396+
test("should normalize emojis missing VS16 (U+FE0F) for consistent terminal alignment", () => {
397+
const ENUM_RULE_LIST = ["build", "revert", "ci"];
398+
setRules({
399+
"type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST],
400+
} as any);
401+
402+
setPromptConfig({
403+
questions: {
404+
type: {
405+
enum: {
406+
build: {
407+
description: "Build system changes",
408+
emoji: "πŸ› ",
409+
},
410+
revert: {
411+
description: "Reverts a commit",
412+
emoji: "πŸ—‘",
413+
},
414+
ci: {
415+
description: "CI config changes",
416+
emoji: "βš™οΈ",
417+
},
418+
},
419+
},
420+
},
421+
});
422+
423+
const enumList = getRuleQuestionConfig("type")?.enumList;
424+
expect(enumList).toEqual([
425+
{
426+
name: "πŸ› \uFE0F build: Build system changes",
427+
value: "build",
428+
short: "build",
429+
},
430+
{
431+
name: "πŸ—‘\uFE0F revert: Reverts a commit",
432+
value: "revert",
433+
short: "revert",
434+
},
435+
{
436+
name: "βš™οΈ ci: CI config changes",
437+
value: "ci",
438+
short: "ci",
439+
},
440+
]);
441+
});
442+
396443
test("should handle no enums having emojis correctly", () => {
397444
const ENUM_RULE_LIST = ["feat", "fix", "chore"];
398445
setRules({
@@ -438,7 +485,7 @@ describe("enum list", () => {
438485
});
439486

440487
test("should include the emoji in the value when `emojiInHeader` is true", () => {
441-
const ENUM_RULE_LIST = ["feat", "fix"];
488+
const ENUM_RULE_LIST = ["feat", "fix", "build", "revert"];
442489
setRules({
443490
"type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST],
444491
} as any);
@@ -456,23 +503,46 @@ describe("enum list", () => {
456503
description: "Bug fixes",
457504
emoji: "πŸ›",
458505
},
506+
build: {
507+
description: "Build changes",
508+
emoji: "πŸ› ",
509+
},
510+
revert: {
511+
description: "Revert commit",
512+
emoji: "πŸ—‘",
513+
},
459514
},
460515
},
461516
},
462517
});
463518

464519
const enumList = getRuleQuestionConfig("type")?.enumList;
520+
465521
expect(enumList).toEqual([
466522
{
467-
name: "✨ feat: Features",
523+
// ✨ is Emoji_Presentation (width 2). No \uFE0F added.
524+
name: "✨ feat: Features",
468525
value: "✨ feat",
469526
short: "feat",
470527
},
471528
{
472-
name: "πŸ› fix: Bug fixes",
529+
// πŸ› is Emoji_Presentation (width 2). No \uFE0F added.
530+
name: "πŸ› fix: Bug fixes",
473531
value: "πŸ› fix",
474532
short: "fix",
475533
},
534+
{
535+
// πŸ›  is NOT presentation-default. \uFE0F IS added.
536+
name: "πŸ› \uFE0F build: Build changes",
537+
value: "πŸ› \uFE0F build",
538+
short: "build",
539+
},
540+
{
541+
// πŸ—‘ is NOT presentation-default. \uFE0F IS added.
542+
name: "πŸ—‘\uFE0F revert: Revert commit",
543+
value: "πŸ—‘\uFE0F revert",
544+
short: "revert",
545+
},
476546
]);
477547
});
478548

β€Ž@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.tsβ€Ž

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/// <reference lib="es2023.intl" />
12
import { RuleField } from "@commitlint/types";
23
import { QuestionConfig } from "../Question.js";
34
import { getPromptMessages, getPromptQuestions } from "../store/prompts.js";
@@ -14,6 +15,44 @@ import {
1415
ruleIsDisabled,
1516
} from "../utils/rules.js";
1617

18+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
19+
const isPresentation = /^\p{Emoji_Presentation}/u;
20+
const isEmojiBase = /^\p{Emoji}/u;
21+
22+
/**
23+
* Appends Unicode Variation Selector 16 (U+FE0F) to emojis missing it,
24+
* forcing emoji-width (2 col, like ✨) presentation in terminals. Without VS16,
25+
* emojis like πŸ›  (U+1F6E0) and πŸ—‘ (U+1F5D1) render at text-width (1 col),
26+
* breaking column alignment in interactive menus.
27+
*/
28+
function normalizeEmoji(emoji: string): string {
29+
const trimmed = emoji.replace(/\s+$/, "");
30+
const trailing = emoji.slice(trimmed.length);
31+
32+
if (trimmed.length === 0) return emoji;
33+
34+
const segments = Array.from(segmenter.segment(trimmed));
35+
36+
if (segments.length === 1) {
37+
const char = segments[0].segment;
38+
39+
switch (true) {
40+
case char.includes("\uFE0F"):
41+
case char.includes("\uFE0E"):
42+
return emoji;
43+
44+
case isPresentation.test(char):
45+
return emoji;
46+
47+
// Is it a "Text-style" emoji base and not a number? Add VS16!
48+
case isEmojiBase.test(char) && !/^[0-9#*]$/.test(char):
49+
return trimmed + "\uFE0F" + trailing;
50+
}
51+
}
52+
53+
return emoji;
54+
}
55+
1756
export default function (rulePrefix: RuleField): QuestionConfig | null {
1857
const questions = getPromptQuestions();
1958
const questionSettings = questions[rulePrefix];
@@ -54,7 +93,8 @@ export default function (rulePrefix: RuleField): QuestionConfig | null {
5493
.map((enumName) => {
5594
const enumDescription = enumDescriptions[enumName]?.description;
5695
if (enumDescription) {
57-
const emoji = enumDescriptions[enumName]?.emoji;
96+
const rawEmoji = enumDescriptions[enumName]?.emoji;
97+
const emoji = rawEmoji ? normalizeEmoji(rawEmoji) : rawEmoji;
5898

5999
const emojiPrefix = emoji
60100
? `${emoji} `

0 commit comments

Comments
Β (0)