Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,55 @@ describe("enum list", () => {
]);
});

test("should correctly normalize emojis with skin tone modifiers", () => {
const ENUM_RULE_LIST = ["build", "mod", "sparkle"];
setRules({
"type-enum": [RuleConfigSeverity.Error, "always", ENUM_RULE_LIST],
} as any);

setPromptConfig({
questions: {
type: {
emojiInHeader: true,
enum: {
build: {
description: "Base wrench",
emoji: "🛠", // U+1F6E0
},
mod: {
description: "Wrench with skin tone",
emoji: "🛠🏽", // U+1F6E0 + U+1F3FD
},
sparkle: {
description: "Naturally wide with skin tone",
emoji: "👍🏽", // U+1F44D + U+1F3FD (Thumbs up is Emoji_Presentation)
},
},
},
},
});

const enumList = getRuleQuestionConfig("type")?.enumList;

expect(enumList).toEqual([
{
name: "🛠\uFE0F build: Base wrench",
value: "🛠\uFE0F build",
short: "build",
},
{
name: "🛠\uFE0F🏽 mod: Wrench with skin tone",
value: "🛠\uFE0F🏽 mod",
short: "mod",
},
{
name: "👍🏽 sparkle: Naturally wide with skin tone",
value: "👍🏽 sparkle",
short: "sparkle",
},
]);
});

test("should trim empty spaces from emoji in the answer", () => {
const ENUM_RULE_LIST = ["feat", "fix", "chore"];
setRules({
Expand Down
43 changes: 31 additions & 12 deletions @commitlint/cz-commitlint/src/services/getRuleQuestionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,57 @@ import {
ruleIsDisabled,
} from "../utils/rules.js";

interface GraphemeSegment {
segment: string;
index: number;
input: string;
isWordLike?: boolean;
}

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const isPresentation = /^\p{Emoji_Presentation}/u;
const isEmojiBase = /^\p{Emoji}/u;

/**
* Appends Unicode Variation Selector 16 (U+FE0F) to emojis missing it,
* forcing emoji-width (2 col, like ✨) presentation in terminals. Without VS16,
* emojis like 🛠 (U+1F6E0) and 🗑 (U+1F5D1) render at text-width (1 col),
* breaking column alignment in interactive menus.
* Normalizes emojis to ensure a consistent 2-column width in terminals by appending
* Variation Selector 16 (U+FE0F).
* * Emojis like ✨ (U+2728) are "Emoji_Presentation" by default and render at width 2.
* However, "text-style" emojis like 🛠 (U+1F6E0) or 🗑 (U+1F5D1) default to width 1
* in many terminals, breaking column alignment.
* * This function identifies single-grapheme emojis lacking presentation properties
* and inserts the VS16 immediately after the base character (before modifiers
* like skin tones) to force graphical rendering without breaking ZWJ sequences.
* @param emoji The emoji string to normalize.
* @returns The normalized emoji string with VS16 inserted where necessary.
*/
function normalizeEmoji(emoji: string): string {
const trimmed = emoji.replace(/\s+$/, "");
const trailing = emoji.slice(trimmed.length);

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

const segments = Array.from(segmenter.segment(trimmed));
const segments = Array.from(segmenter.segment(trimmed)) as GraphemeSegment[];

if (segments.length === 1) {
const char = segments[0].segment;
const cluster = segments[0].segment;
const codePoints = Array.from(cluster);
const baseChar = codePoints[0];

switch (true) {
case char.includes("\uFE0F"):
case char.includes("\uFE0E"):
case cluster.includes("\uFE0F"):
case cluster.includes("\uFE0E"):
return emoji;

case isPresentation.test(char):
case isPresentation.test(baseChar):
return emoji;

// Is it a "Text-style" emoji base and not a number? Add VS16!
case isEmojiBase.test(char) && !/^[0-9#*]$/.test(char):
return trimmed + "\uFE0F" + trailing;
// 3. If the base char is an Emoji base but not presentation:
case isEmojiBase.test(baseChar) && !/^[0-9#*]$/.test(baseChar): {
// Reconstruct: Base + VS16 + the rest of the cluster (skin tones, ZWJs, etc.)
const normalizedCluster =
baseChar + "\uFE0F" + codePoints.slice(1).join("");
return normalizedCluster + trailing;
}
}
}

Expand Down
Loading