Ody / js /emojiShortcodes.js
ANJD's picture
Upload 387 files
4ac029f verified
Raw
History Blame Contribute Delete
13.9 kB
// static/js/emojiShortcodes.js
//
// Emoji shortcode โ†’ Unicode conversion (issue #345).
//
// Chat models frequently emit GitHub/Slack-style `:shortcode:` text โ€” e.g.
// `:blush:`, `:fire:`, `:microphone:` โ€” instead of the actual emoji character.
// Nothing in the render pipeline used to translate these, so they showed up as
// literal `:blush:` text in the chat bubble.
//
// This module turns the common shortcode set into the real Unicode emoji. The
// chat renderer (markdown.js โ†’ svgifyEmoji) runs this BEFORE its existing
// Unicode-emoji โ†’ monochrome-SVG pass, so a converted `:blush:` renders as the
// same theme-tinted single-color line icon as any other emoji (project rule:
// never colorful emoji), not as a colored system glyph.
//
// Pure and browser-free on purpose: no DOM, no imports, so it can be unit
// tested with plain `node` (see tests/test_emoji_shortcodes_js.py).
// Canonical map of common shortcode โ†’ Unicode emoji. Names follow the GitHub
// convention (lowercase, underscore-separated). A handful of well-known aliases
// (`+1`, `thumbsup`, `grinning_face`, โ€ฆ) point at the same glyph so the most
// frequent model spellings all resolve.
export const EMOJI_SHORTCODES = {
// โ”€โ”€ Smileys & emotion โ”€โ”€
grinning: '๐Ÿ˜€', grinning_face: '๐Ÿ˜€',
smiley: '๐Ÿ˜ƒ', smiley_face: '๐Ÿ˜ƒ',
smile: '๐Ÿ˜„',
grin: '๐Ÿ˜',
laughing: '๐Ÿ˜†', satisfied: '๐Ÿ˜†',
sweat_smile: '๐Ÿ˜…',
rofl: '๐Ÿคฃ', rolling_on_the_floor_laughing: '๐Ÿคฃ',
joy: '๐Ÿ˜‚',
slightly_smiling_face: '๐Ÿ™‚', slight_smile: '๐Ÿ™‚',
upside_down_face: '๐Ÿ™ƒ', upside_down: '๐Ÿ™ƒ',
wink: '๐Ÿ˜‰', winking_face: '๐Ÿ˜‰',
blush: '๐Ÿ˜Š', smiling_face_with_smiling_eyes: '๐Ÿ˜Š',
innocent: '๐Ÿ˜‡',
smiling_face_with_three_hearts: '๐Ÿฅฐ',
heart_eyes: '๐Ÿ˜', heart_eyes_face: '๐Ÿ˜',
star_struck: '๐Ÿคฉ',
kissing_heart: '๐Ÿ˜˜',
kissing: '๐Ÿ˜—',
kissing_closed_eyes: '๐Ÿ˜š',
kissing_smiling_eyes: '๐Ÿ˜™',
yum: '๐Ÿ˜‹',
stuck_out_tongue: '๐Ÿ˜›',
stuck_out_tongue_winking_eye: '๐Ÿ˜œ',
zany_face: '๐Ÿคช',
stuck_out_tongue_closed_eyes: '๐Ÿ˜',
money_mouth_face: '๐Ÿค‘',
hugs: '๐Ÿค—', hugging_face: '๐Ÿค—',
hand_over_mouth: '๐Ÿคญ',
shushing_face: '๐Ÿคซ',
thinking: '๐Ÿค”', thinking_face: '๐Ÿค”',
zipper_mouth_face: '๐Ÿค',
raised_eyebrow: '๐Ÿคจ',
neutral_face: '๐Ÿ˜',
expressionless: '๐Ÿ˜‘',
no_mouth: '๐Ÿ˜ถ',
smirk: '๐Ÿ˜', smirk_face: '๐Ÿ˜',
unamused: '๐Ÿ˜’',
roll_eyes: '๐Ÿ™„', face_with_rolling_eyes: '๐Ÿ™„',
grimacing: '๐Ÿ˜ฌ',
lying_face: '๐Ÿคฅ',
relieved: '๐Ÿ˜Œ',
pensive: '๐Ÿ˜”',
sleepy: '๐Ÿ˜ช',
drooling_face: '๐Ÿคค',
sleeping: '๐Ÿ˜ด',
mask: '๐Ÿ˜ท',
face_with_thermometer: '๐Ÿค’',
face_with_head_bandage: '๐Ÿค•',
nauseated_face: '๐Ÿคข',
vomiting_face: '๐Ÿคฎ',
sneezing_face: '๐Ÿคง',
hot_face: '๐Ÿฅต',
cold_face: '๐Ÿฅถ',
woozy_face: '๐Ÿฅด',
dizzy_face: '๐Ÿ˜ต',
exploding_head: '๐Ÿคฏ',
cowboy_hat_face: '๐Ÿค ',
partying_face: '๐Ÿฅณ',
sunglasses: '๐Ÿ˜Ž',
nerd_face: '๐Ÿค“',
monocle_face: '๐Ÿง',
confused: '๐Ÿ˜•',
worried: '๐Ÿ˜Ÿ',
slightly_frowning_face: '๐Ÿ™',
frowning_face: 'โ˜น๏ธ',
open_mouth: '๐Ÿ˜ฎ',
hushed: '๐Ÿ˜ฏ',
astonished: '๐Ÿ˜ฒ',
flushed: '๐Ÿ˜ณ',
pleading_face: '๐Ÿฅบ',
frowning: '๐Ÿ˜ฆ',
anguished: '๐Ÿ˜ง',
fearful: '๐Ÿ˜จ',
cold_sweat: '๐Ÿ˜ฐ',
disappointed_relieved: '๐Ÿ˜ฅ',
cry: '๐Ÿ˜ข',
sob: '๐Ÿ˜ญ',
scream: '๐Ÿ˜ฑ',
confounded: '๐Ÿ˜–',
persevere: '๐Ÿ˜ฃ',
disappointed: '๐Ÿ˜ž',
sweat: '๐Ÿ˜“',
weary: '๐Ÿ˜ฉ',
tired_face: '๐Ÿ˜ซ',
yawning_face: '๐Ÿฅฑ',
triumph: '๐Ÿ˜ค',
rage: '๐Ÿ˜ก', pout: '๐Ÿ˜ก', pouting_face: '๐Ÿ˜ก',
angry: '๐Ÿ˜ ',
cursing_face: '๐Ÿคฌ',
smiling_imp: '๐Ÿ˜ˆ',
imp: '๐Ÿ‘ฟ',
skull: '๐Ÿ’€',
skull_and_crossbones: 'โ˜ ๏ธ',
hankey: '๐Ÿ’ฉ', poop: '๐Ÿ’ฉ', shit: '๐Ÿ’ฉ',
clown_face: '๐Ÿคก',
japanese_ogre: '๐Ÿ‘น',
japanese_goblin: '๐Ÿ‘บ',
ghost: '๐Ÿ‘ป',
alien: '๐Ÿ‘ฝ',
space_invader: '๐Ÿ‘พ',
robot: '๐Ÿค–', robot_face: '๐Ÿค–',
// โ”€โ”€ Cats โ”€โ”€
smiley_cat: '๐Ÿ˜บ',
smile_cat: '๐Ÿ˜ธ',
joy_cat: '๐Ÿ˜น',
heart_eyes_cat: '๐Ÿ˜ป',
smirk_cat: '๐Ÿ˜ผ',
kissing_cat: '๐Ÿ˜ฝ',
scream_cat: '๐Ÿ™€',
crying_cat_face: '๐Ÿ˜ฟ',
pouting_cat: '๐Ÿ˜พ',
see_no_evil: '๐Ÿ™ˆ',
hear_no_evil: '๐Ÿ™‰',
speak_no_evil: '๐Ÿ™Š',
// โ”€โ”€ Hands & body โ”€โ”€
wave: '๐Ÿ‘‹', wave_hand: '๐Ÿ‘‹',
raised_back_of_hand: '๐Ÿคš',
raised_hand_with_fingers_splayed: '๐Ÿ–๏ธ',
hand: 'โœ‹', raised_hand: 'โœ‹',
vulcan_salute: '๐Ÿ––',
ok_hand: '๐Ÿ‘Œ',
pinched_fingers: '๐ŸคŒ',
pinching_hand: '๐Ÿค',
v: 'โœŒ๏ธ', victory_hand: 'โœŒ๏ธ',
crossed_fingers: '๐Ÿคž',
love_you_gesture: '๐ŸคŸ',
metal: '๐Ÿค˜',
call_me_hand: '๐Ÿค™',
point_left: '๐Ÿ‘ˆ',
point_right: '๐Ÿ‘‰',
point_up_2: '๐Ÿ‘†',
middle_finger: '๐Ÿ–•', fu: '๐Ÿ–•',
point_down: '๐Ÿ‘‡',
point_up: 'โ˜๏ธ',
'+1': '๐Ÿ‘', thumbsup: '๐Ÿ‘', thumbup: '๐Ÿ‘', thumbs_up: '๐Ÿ‘',
'-1': '๐Ÿ‘Ž', thumbsdown: '๐Ÿ‘Ž', thumbdown: '๐Ÿ‘Ž', thumbs_down: '๐Ÿ‘Ž',
fist_raised: 'โœŠ', fist: 'โœŠ',
fist_oncoming: '๐Ÿ‘Š', facepunch: '๐Ÿ‘Š', punch: '๐Ÿ‘Š',
fist_left: '๐Ÿค›',
fist_right: '๐Ÿคœ',
clap: '๐Ÿ‘', clapping_hands: '๐Ÿ‘',
raised_hands: '๐Ÿ™Œ',
open_hands: '๐Ÿ‘',
palms_up_together: '๐Ÿคฒ',
handshake: '๐Ÿค',
pray: '๐Ÿ™', folded_hands: '๐Ÿ™',
writing_hand: 'โœ๏ธ',
nail_care: '๐Ÿ’…',
selfie: '๐Ÿคณ',
muscle: '๐Ÿ’ช', flexed_biceps: '๐Ÿ’ช',
// โ”€โ”€ Hearts & symbols of feeling โ”€โ”€
heart: 'โค๏ธ', red_heart: 'โค๏ธ',
orange_heart: '๐Ÿงก',
yellow_heart: '๐Ÿ’›',
green_heart: '๐Ÿ’š',
blue_heart: '๐Ÿ’™',
purple_heart: '๐Ÿ’œ',
black_heart: '๐Ÿ–ค',
white_heart: '๐Ÿค',
brown_heart: '๐ŸคŽ',
broken_heart: '๐Ÿ’”',
heart_on_fire: 'โค๏ธโ€๐Ÿ”ฅ',
two_hearts: '๐Ÿ’•',
revolving_hearts: '๐Ÿ’ž',
heartbeat: '๐Ÿ’“',
heartpulse: '๐Ÿ’—',
sparkling_heart: '๐Ÿ’–',
cupid: '๐Ÿ’˜',
gift_heart: '๐Ÿ’',
heart_decoration: '๐Ÿ’Ÿ',
heavy_heart_exclamation: 'โฃ๏ธ',
// โ”€โ”€ Celebration & misc objects โ”€โ”€
fire: '๐Ÿ”ฅ', flame: '๐Ÿ”ฅ',
'100': '๐Ÿ’ฏ', hundred: '๐Ÿ’ฏ',
sparkles: 'โœจ',
star: 'โญ',
star2: '๐ŸŒŸ', glowing_star: '๐ŸŒŸ',
dizzy: '๐Ÿ’ซ',
boom: '๐Ÿ’ฅ', collision: '๐Ÿ’ฅ',
anger: '๐Ÿ’ข',
sweat_drops: '๐Ÿ’ฆ',
dash: '๐Ÿ’จ',
zzz: '๐Ÿ’ค',
tada: '๐ŸŽ‰', party_popper: '๐ŸŽ‰',
confetti_ball: '๐ŸŽŠ',
balloon: '๐ŸŽˆ',
gift: '๐ŸŽ',
trophy: '๐Ÿ†',
'1st_place_medal': '๐Ÿฅ‡',
'2nd_place_medal': '๐Ÿฅˆ',
'3rd_place_medal': '๐Ÿฅ‰',
medal_sports: '๐Ÿ…',
zap: 'โšก', lightning: 'โšก',
bulb: '๐Ÿ’ก', light_bulb: '๐Ÿ’ก',
key: '๐Ÿ”‘',
lock: '๐Ÿ”’',
unlock: '๐Ÿ”“',
bell: '๐Ÿ””',
no_bell: '๐Ÿ”•',
loudspeaker: '๐Ÿ“ข',
mega: '๐Ÿ“ฃ', megaphone: '๐Ÿ“ฃ',
speech_balloon: '๐Ÿ’ฌ',
thought_balloon: '๐Ÿ’ญ',
white_check_mark: 'โœ…',
heavy_check_mark: 'โœ”๏ธ', check_mark: 'โœ”๏ธ',
ballot_box_with_check: 'โ˜‘๏ธ',
x: 'โŒ', cross_mark: 'โŒ',
negative_squared_cross_mark: 'โŽ',
question: 'โ“',
grey_question: 'โ”',
exclamation: 'โ—', heavy_exclamation_mark: 'โ—',
grey_exclamation: 'โ•',
warning: 'โš ๏ธ',
no_entry: 'โ›”',
no_entry_sign: '๐Ÿšซ',
red_circle: '๐Ÿ”ด',
green_circle: '๐ŸŸข',
large_blue_circle: '๐Ÿ”ต',
yellow_circle: '๐ŸŸก',
white_circle: 'โšช',
black_circle: 'โšซ',
orange_circle: '๐ŸŸ ',
purple_circle: '๐ŸŸฃ',
brown_circle: '๐ŸŸค',
// โ”€โ”€ Tech, work, study โ”€โ”€
rocket: '๐Ÿš€',
eyes: '๐Ÿ‘€',
eye: '๐Ÿ‘๏ธ',
brain: '๐Ÿง ',
books: '๐Ÿ“š',
book: '๐Ÿ“–', open_book: '๐Ÿ“–',
memo: '๐Ÿ“', pencil: '๐Ÿ“',
pencil2: 'โœ๏ธ',
page_facing_up: '๐Ÿ“„',
paperclip: '๐Ÿ“Ž',
pushpin: '๐Ÿ“Œ',
round_pushpin: '๐Ÿ“',
link: '๐Ÿ”—',
bar_chart: '๐Ÿ“Š',
chart_with_upwards_trend: '๐Ÿ“ˆ',
chart_with_downwards_trend: '๐Ÿ“‰',
mag: '๐Ÿ”',
mag_right: '๐Ÿ”Ž',
globe_with_meridians: '๐ŸŒ',
earth_africa: '๐ŸŒ',
earth_americas: '๐ŸŒŽ',
earth_asia: '๐ŸŒ',
alarm_clock: 'โฐ',
hourglass_flowing_sand: 'โณ',
hourglass: 'โŒ›',
microphone: '๐ŸŽค', mic: '๐ŸŽค',
musical_note: '๐ŸŽต',
notes: '๐ŸŽถ', musical_notes: '๐ŸŽถ',
headphones: '๐ŸŽง',
camera: '๐Ÿ“ท',
camera_flash: '๐Ÿ“ธ',
clapper: '๐ŸŽฌ',
tv: '๐Ÿ“บ',
computer: '๐Ÿ’ป', laptop: '๐Ÿ’ป',
desktop_computer: '๐Ÿ–ฅ๏ธ',
iphone: '๐Ÿ“ฑ', mobile_phone: '๐Ÿ“ฑ',
telephone: 'โ˜Ž๏ธ',
wrench: '๐Ÿ”ง',
hammer: '๐Ÿ”จ',
gear: 'โš™๏ธ',
nut_and_bolt: '๐Ÿ”ฉ',
magnet: '๐Ÿงฒ',
test_tube: '๐Ÿงช',
microscope: '๐Ÿ”ฌ',
dart: '๐ŸŽฏ', bullseye: '๐ŸŽฏ',
game_die: '๐ŸŽฒ',
jigsaw: '๐Ÿงฉ',
// โ”€โ”€ Food & drink โ”€โ”€
pizza: '๐Ÿ•',
hamburger: '๐Ÿ”',
fries: '๐ŸŸ',
taco: '๐ŸŒฎ',
sushi: '๐Ÿฃ',
doughnut: '๐Ÿฉ', donut: '๐Ÿฉ',
coffee: 'โ˜•',
beer: '๐Ÿบ',
wine_glass: '๐Ÿท',
// โ”€โ”€ Animals & nature โ”€โ”€
dog: '๐Ÿถ',
cat: '๐Ÿฑ',
mouse: '๐Ÿญ',
hamster: '๐Ÿน',
rabbit: '๐Ÿฐ',
fox_face: '๐ŸฆŠ',
bear: '๐Ÿป',
panda_face: '๐Ÿผ',
koala: '๐Ÿจ',
tiger: '๐Ÿฏ',
lion: '๐Ÿฆ',
cow: '๐Ÿฎ',
pig: '๐Ÿท',
frog: '๐Ÿธ',
monkey_face: '๐Ÿต',
chicken: '๐Ÿ”',
penguin: '๐Ÿง',
bird: '๐Ÿฆ',
eagle: '๐Ÿฆ…',
duck: '๐Ÿฆ†',
owl: '๐Ÿฆ‰',
wolf: '๐Ÿบ',
horse: '๐Ÿด',
unicorn: '๐Ÿฆ„',
bee: '๐Ÿ', honeybee: '๐Ÿ',
bug: '๐Ÿ›',
butterfly: '๐Ÿฆ‹',
snail: '๐ŸŒ',
lady_beetle: '๐Ÿž',
snake: '๐Ÿ',
turtle: '๐Ÿข',
octopus: '๐Ÿ™',
crab: '๐Ÿฆ€',
tropical_fish: '๐Ÿ ',
whale: '๐Ÿณ',
shark: '๐Ÿฆˆ',
cherry_blossom: '๐ŸŒธ',
rose: '๐ŸŒน',
sunflower: '๐ŸŒป',
hibiscus: '๐ŸŒบ',
tulip: '๐ŸŒท',
seedling: '๐ŸŒฑ',
evergreen_tree: '๐ŸŒฒ',
deciduous_tree: '๐ŸŒณ',
four_leaf_clover: '๐Ÿ€',
apple: '๐ŸŽ',
green_apple: '๐Ÿ',
pear: '๐Ÿ',
tangerine: '๐ŸŠ',
lemon: '๐Ÿ‹',
banana: '๐ŸŒ',
watermelon: '๐Ÿ‰',
grapes: '๐Ÿ‡',
strawberry: '๐Ÿ“',
blueberries: '๐Ÿซ',
peach: '๐Ÿ‘',
rainbow: '๐ŸŒˆ',
sunny: 'โ˜€๏ธ', sun: 'โ˜€๏ธ',
partly_sunny: 'โ›…',
cloud: 'โ˜๏ธ',
snowflake: 'โ„๏ธ',
ocean: '๐ŸŒŠ',
// โ”€โ”€ Arrows & signs โ”€โ”€
arrow_right: 'โžก๏ธ',
arrow_left: 'โฌ…๏ธ',
arrow_up: 'โฌ†๏ธ',
arrow_down: 'โฌ‡๏ธ',
arrow_upper_right: 'โ†—๏ธ',
arrow_lower_right: 'โ†˜๏ธ',
arrow_lower_left: 'โ†™๏ธ',
arrow_upper_left: 'โ†–๏ธ',
leftwards_arrow_with_hook: 'โ†ฉ๏ธ',
arrow_right_hook: 'โ†ช๏ธ',
arrows_counterclockwise: '๐Ÿ”„',
arrows_clockwise: '๐Ÿ”ƒ',
heavy_plus_sign: 'โž•',
heavy_minus_sign: 'โž–',
heavy_division_sign: 'โž—',
heavy_multiplication_x: 'โœ–๏ธ',
infinity: 'โ™พ๏ธ',
copyright: 'ยฉ๏ธ',
registered: 'ยฎ๏ธ',
tm: 'โ„ข๏ธ',
recycle: 'โ™ป๏ธ',
checkered_flag: '๐Ÿ',
triangular_flag_on_post: '๐Ÿšฉ',
white_flag: '๐Ÿณ๏ธ',
black_flag: '๐Ÿด',
// โ”€โ”€ People & wearables โ”€โ”€
baby: '๐Ÿ‘ถ',
boy: '๐Ÿ‘ฆ',
girl: '๐Ÿ‘ง',
man: '๐Ÿ‘จ',
woman: '๐Ÿ‘ฉ',
older_man: '๐Ÿ‘ด',
older_woman: '๐Ÿ‘ต',
crown: '๐Ÿ‘‘',
gem: '๐Ÿ’Ž',
graduation_cap: '๐ŸŽ“', mortar_board: '๐ŸŽ“',
};
// `:name:` where name is letters/digits/`_`/`+`/`-`. Length โ‰ฅ1 so `:+1:` and
// `:-1:` match. Global + case-insensitive for replace; a separate non-global
// literal is used for the cheap presence check so there's no shared lastIndex
// state to reset.
const SHORTCODE_RE = /:([a-z0-9_+-]{1,40}):/gi;
/**
* Cheap test for whether `text` could contain any emoji shortcode at all.
* Lets callers skip the replace pass entirely on the common no-shortcode path.
*/
export function hasEmojiShortcode(text) {
return !!text && text.indexOf(':') !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(text);
}
// A shortcode must stand on its own โ€” flanked by whitespace, punctuation, a
// string edge, or markup, never glued to an ASCII word character. Without this
// guard, real `:name:` shortcodes that happen to sit inside a longer run of
// digits/letters get converted by mistake and mangle perfectly literal text:
// "1:100:2" โ†’ the `:100:` would become ๐Ÿ’ฏ ("1๐Ÿ’ฏ2")
// "host:fire:port", URL authorities, `key:value:` pairs, etc.
// Chat models always emit shortcodes delimited by spaces/punctuation (":fire:",
// "**:microphone:**", "nice :tada:!"), so requiring a boundary keeps every real
// shortcode working while leaving embedded colon runs untouched. `_` counts as a
// word char too (identifier-like), but `+`/`-` do not, so "C++ :fire:" still works.
const _WORDISH = /[A-Za-z0-9_]/;
function _boundedOnBothSides(str, start, end) {
const before = start > 0 ? str[start - 1] : '';
const after = end < str.length ? str[end] : '';
return !_WORDISH.test(before) && !_WORDISH.test(after);
}
/**
* Replace every known `:shortcode:` in `text` with its Unicode emoji. Unknown
* shortcodes (`:definitely_not_emoji:`), colon runs that don't form a shortcode
* (`10:30:45`, `16:9`), and known shortcodes embedded mid-token (`1:100:2`) are
* all left exactly as-is.
*/
export function replaceEmojiShortcodes(text) {
if (!text || text.indexOf(':') === -1) return text;
return text.replace(SHORTCODE_RE, (whole, name, offset, str) => {
const key = name.toLowerCase();
if (!Object.prototype.hasOwnProperty.call(EMOJI_SHORTCODES, key)) return whole;
// Only convert when the `:shortcode:` is a standalone token, not glued to a
// surrounding word/number (which would mean it's literal text, not an emoji).
if (!_boundedOnBothSides(str, offset, offset + whole.length)) return whole;
return EMOJI_SHORTCODES[key];
});
}
export default { EMOJI_SHORTCODES, replaceEmojiShortcodes, hasEmojiShortcode };