diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 6e2caf2..9d5b17e 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -27,6 +27,7 @@ updateFileExpanded, sendMessageBlockAction, } from '../lib/dispatcher.svelte' + import { resolveEmoji } from '@botarium/mrkdwn' import BlockKitRenderer from './blockkit/BlockKitRenderer.svelte' import { renderMrkdwn } from './blockkit/context' import { @@ -53,14 +54,6 @@ ) => void } - const EMOJI_MAP: Record = { - thinking_face: '🤔', - white_check_mark: '✅', - clock1: '🕐', - clock2: '🕑', - clock3: '🕒', - } - let { message, replyCount = 0, @@ -344,7 +337,7 @@ } function getEmoji(name: string): string { - return EMOJI_MAP[name] || `:${name}:` + return resolveEmoji(name) ?? `:${name}:` } diff --git a/bun.lock b/bun.lock index 5cf4b16..3bd386e 100644 --- a/bun.lock +++ b/bun.lock @@ -44,7 +44,7 @@ "zod": "^4.3.5", }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "^1.3.6", "pino-pretty": "^13.1.3", "typescript": "^5", }, @@ -106,6 +106,7 @@ "name": "@botarium/mrkdwn", "version": "0.1.0", "dependencies": { + "gemoji": "^8.1.0", "marked": "^15.0.0", }, }, @@ -800,6 +801,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gemoji": ["gemoji@8.1.0", "", {}, "sha512-HA4Gx59dw2+tn+UAa7XEV4ufUKI4fH1KgcbenVA9YKSj1QJTT0xh5Mwv5HMFNN3l2OtUe3ZIfuRwSyZS5pLIWw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], diff --git a/packages/mrkdwn/package.json b/packages/mrkdwn/package.json index 8abe4f6..9b84478 100644 --- a/packages/mrkdwn/package.json +++ b/packages/mrkdwn/package.json @@ -17,6 +17,7 @@ "test": "bun test" }, "dependencies": { + "gemoji": "^8.1.0", "marked": "^15.0.0" } } diff --git a/packages/mrkdwn/src/emoji.ts b/packages/mrkdwn/src/emoji.ts new file mode 100644 index 0000000..8229460 --- /dev/null +++ b/packages/mrkdwn/src/emoji.ts @@ -0,0 +1,30 @@ +import { nameToEmoji } from 'gemoji' + +/** Slack shortcodes that differ from gemoji's naming */ +const SLACK_ALIASES: Record = { + thinking_face: 'thinking', + party_popper: 'tada', + person_with_pouting_face: 'pouting_face', + person_frowning: 'frowning_person', + person_with_blond_hair: 'blond_haired_person', +} + +// Slack custom image emoji with no unicode equivalent (will render as :name: text): +// bowtie, simple_smile, neckbeard, feelsgood, finnadie, goberserk, godmode, +// hurtrealbad, rage1, rage2, rage3, rage4, suspect, trollface, octocat, +// squirrel, shipit + +/** Resolve a shortcode name to a unicode emoji character. */ +export function resolveEmoji(name: string): string | undefined { + return nameToEmoji[name] ?? nameToEmoji[SLACK_ALIASES[name] ?? ''] +} + +/** + * Render an emoji shortcode to an HTML span with tooltip. + * Returns null if the emoji name is not recognized. + */ +export function renderEmoji(name: string): string | null { + const emoji = resolveEmoji(name) + if (!emoji) return null + return `${emoji}${emoji}:${name}:` +} diff --git a/packages/mrkdwn/src/index.ts b/packages/mrkdwn/src/index.ts index 0d0de64..b2b9ba0 100644 --- a/packages/mrkdwn/src/index.ts +++ b/packages/mrkdwn/src/index.ts @@ -1,2 +1,4 @@ export { mrkdwnToHtml } from './mrkdwn-to-html' export { markdownToMrkdwn } from './markdown-to-mrkdwn' +export { resolveEmoji } from './emoji' +export { nameToEmoji } from 'gemoji' diff --git a/packages/mrkdwn/src/markdown-to-mrkdwn.ts b/packages/mrkdwn/src/markdown-to-mrkdwn.ts index e8590e1..664c837 100644 --- a/packages/mrkdwn/src/markdown-to-mrkdwn.ts +++ b/packages/mrkdwn/src/markdown-to-mrkdwn.ts @@ -7,10 +7,7 @@ import { Lexer, type Token, type Tokens } from 'marked' -/** Escape characters that have special meaning in Slack mrkdwn */ -function escapeMrkdwn(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>') -} +import { escapeMrkdwn } from './utils' /** Render a single inline token to mrkdwn */ function renderInlineToken(token: Token): string { diff --git a/packages/mrkdwn/src/mrkdwn-to-html.ts b/packages/mrkdwn/src/mrkdwn-to-html.ts index c05a57b..43d2bf0 100644 --- a/packages/mrkdwn/src/mrkdwn-to-html.ts +++ b/packages/mrkdwn/src/mrkdwn-to-html.ts @@ -4,65 +4,12 @@ * Custom parser that handles Slack's mrkdwn syntax including bold, italic, * strikethrough, code, links, mentions, blockquotes, lists, and emoji. * Processes block-level elements line-by-line, then applies inline formatting. + * + * Depends on ./emoji (EMOJI_MAP, renderEmoji) and ./utils (escapeHtml). */ -const EMOJI_MAP: Record = { - '+1': '\u{1F44D}', - '-1': '\u{1F44E}', - thumbsup: '\u{1F44D}', - thumbsdown: '\u{1F44E}', - heart: '\u2764\uFE0F', - smile: '\u{1F604}', - laughing: '\u{1F606}', - blush: '\u{1F60A}', - grinning: '\u{1F600}', - wink: '\u{1F609}', - joy: '\u{1F602}', - sob: '\u{1F62D}', - cry: '\u{1F622}', - thinking_face: '\u{1F914}', - white_check_mark: '\u2705', - heavy_check_mark: '\u2714\uFE0F', - x: '\u274C', - warning: '\u26A0\uFE0F', - fire: '\u{1F525}', - rocket: '\u{1F680}', - tada: '\u{1F389}', - party_popper: '\u{1F389}', - eyes: '\u{1F440}', - wave: '\u{1F44B}', - pray: '\u{1F64F}', - clap: '\u{1F44F}', - muscle: '\u{1F4AA}', - star: '\u2B50', - sparkles: '\u2728', - bulb: '\u{1F4A1}', - memo: '\u{1F4DD}', - point_right: '\u{1F449}', - point_left: '\u{1F448}', - raised_hands: '\u{1F64C}', - ok_hand: '\u{1F44C}', - 100: '\u{1F4AF}', - rotating_light: '\u{1F6A8}', - zap: '\u26A1', - boom: '\u{1F4A5}', - bug: '\u{1F41B}', - gear: '\u2699\uFE0F', - lock: '\u{1F512}', - key: '\u{1F511}', - calendar: '\u{1F4C5}', - link: '\u{1F517}', - speech_balloon: '\u{1F4AC}', -} - -/** Escape HTML entities in text content */ -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} +import { renderEmoji } from './emoji' +import { escapeHtml } from './utils' /** Apply inline mrkdwn formatting (bold, italic, strike, links, mentions, emoji) */ function formatInline(text: string): string { @@ -125,9 +72,7 @@ function formatInline(text: string): string { // Emoji: :name: text = text.replace(/:([a-z0-9_+-]+):/g, (_match, name: string) => { - const emoji = EMOJI_MAP[name] - if (!emoji) return `:${name}:` - return `${emoji}${emoji}:${name}:` + return renderEmoji(name) ?? `:${name}:` }) // Restore inline code placeholders diff --git a/packages/mrkdwn/src/utils.ts b/packages/mrkdwn/src/utils.ts new file mode 100644 index 0000000..b1f1948 --- /dev/null +++ b/packages/mrkdwn/src/utils.ts @@ -0,0 +1,13 @@ +/** Escape HTML entities in text content */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** Escape characters that have special meaning in Slack mrkdwn */ +export function escapeMrkdwn(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>') +}