Skip to content

Commit 420f1ef

Browse files
author
Martin T.
committed
fix(cz-commitlint): handle modifiers correctly
When adding VS16 to emojis with modifiers, VS16 can not be appended at the end - this creates an invalid unicode symbol. VS16 must be appended between emoji and modifiers.
1 parent 9e3e2d3 commit 420f1ef

2 files changed

Lines changed: 80 additions & 12 deletions

File tree

@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,55 @@ describe("enum list", () => {
546546
]);
547547
});
548548

549+
test("should correctly normalize emojis with skin tone modifiers", () => {
550+
const ENUM_RULE_LIST = ["build", "mod", "sparkle"];
551+
setRules({
552+
"type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST],
553+
} as any);
554+
555+
setPromptConfig({
556+
questions: {
557+
type: {
558+
emojiInHeader: true,
559+
enum: {
560+
build: {
561+
description: "Base wrench",
562+
emoji: "🛠", // U+1F6E0
563+
},
564+
mod: {
565+
description: "Wrench with skin tone",
566+
emoji: "🛠🏽", // U+1F6E0 + U+1F3FD
567+
},
568+
sparkle: {
569+
description: "Naturally wide with skin tone",
570+
emoji: "👍🏽", // U+1F44D + U+1F3FD (Thumbs up is Emoji_Presentation)
571+
},
572+
},
573+
},
574+
},
575+
});
576+
577+
const enumList = getRuleQuestionConfig("type")?.enumList;
578+
579+
expect(enumList).toEqual([
580+
{
581+
name: "🛠\uFE0F build: Base wrench",
582+
value: "🛠\uFE0F build",
583+
short: "build",
584+
},
585+
{
586+
name: "🛠\uFE0F🏽 mod: Wrench with skin tone",
587+
value: "🛠\uFE0F🏽 mod",
588+
short: "mod",
589+
},
590+
{
591+
name: "👍🏽 sparkle: Naturally wide with skin tone",
592+
value: "👍🏽 sparkle",
593+
short: "sparkle",
594+
},
595+
]);
596+
});
597+
549598
test("should trim empty spaces from emoji in the answer", () => {
550599
const ENUM_RULE_LIST = ["feat", "fix", "chore"];
551600
setRules({

@commitlint/cz-commitlint/src/services/getRuleQuestionConfig.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,38 +15,57 @@ import {
1515
ruleIsDisabled,
1616
} from "../utils/rules.js";
1717

18+
interface GraphemeSegment {
19+
segment: string;
20+
index: number;
21+
input: string;
22+
isWordLike?: boolean;
23+
}
24+
1825
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
1926
const isPresentation = /^\p{Emoji_Presentation}/u;
2027
const isEmojiBase = /^\p{Emoji}/u;
2128

2229
/**
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.
30+
* Normalizes emojis to ensure a consistent 2-column width in terminals by appending
31+
* Variation Selector 16 (U+FE0F).
32+
* * Emojis like ✨ (U+2728) are "Emoji_Presentation" by default and render at width 2.
33+
* However, "text-style" emojis like 🛠 (U+1F6E0) or 🗑 (U+1F5D1) default to width 1
34+
* in many terminals, breaking column alignment.
35+
* * This function identifies single-grapheme emojis lacking presentation properties
36+
* and inserts the VS16 immediately after the base character (before modifiers
37+
* like skin tones) to force graphical rendering without breaking ZWJ sequences.
38+
* @param emoji The emoji string to normalize.
39+
* @returns The normalized emoji string with VS16 inserted where necessary.
2740
*/
2841
function normalizeEmoji(emoji: string): string {
2942
const trimmed = emoji.replace(/\s+$/, "");
3043
const trailing = emoji.slice(trimmed.length);
3144

3245
if (trimmed.length === 0) return emoji;
3346

34-
const segments = Array.from(segmenter.segment(trimmed));
47+
const segments = Array.from(segmenter.segment(trimmed)) as GraphemeSegment[];
3548

3649
if (segments.length === 1) {
37-
const char = segments[0].segment;
50+
const cluster = segments[0].segment;
51+
const codePoints = Array.from(cluster);
52+
const baseChar = codePoints[0];
3853

3954
switch (true) {
40-
case char.includes("\uFE0F"):
41-
case char.includes("\uFE0E"):
55+
case cluster.includes("\uFE0F"):
56+
case cluster.includes("\uFE0E"):
4257
return emoji;
4358

44-
case isPresentation.test(char):
59+
case isPresentation.test(baseChar):
4560
return emoji;
4661

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;
62+
// 3. If the base char is an Emoji base but not presentation:
63+
case isEmojiBase.test(baseChar) && !/^[0-9#*]$/.test(baseChar): {
64+
// Reconstruct: Base + VS16 + the rest of the cluster (skin tones, ZWJs, etc.)
65+
const normalizedCluster =
66+
baseChar + "\uFE0F" + codePoints.slice(1).join("");
67+
return normalizedCluster + trailing;
68+
}
5069
}
5170
}
5271

0 commit comments

Comments
 (0)