Skip to content

Commit 674f707

Browse files
JSKittyclaude
andcommitted
add: iamcal shortcodes (Discord-style canonical aliases) for emoji search
The keyword-only ranking couldn't tell which heart was THE heart — every colored variant tied on word match, so source order decided. Discord solves this by elevating one canonical emoji per shortcode (:heart: → ❤️, :fire: → 🔥, :pray: → 🙏); the iamcal preset that ships with emojibase-data is the same data Slack/Discord cribbed from. build-emoji.mjs now reads node_modules/emojibase-data/en/shortcodes/iamcal.json and tags each entry with its canonical shortcode. searchEmojis() promotes exact-shortcode matches to a top tier (-2.0) above the previous CLDR display-name tier (-1.0), and shortcode-prefix matches (-1.5) sit between them. The shortcode words are also folded into the keyword bag so partial search ('heart_h…' → 🫶 heart hands) still resolves. Sample queries after the change: heart -> ❤️ 😍 😻 💟 ❤️‍🔥 🫶 fire -> 🔥 🚒 🧯 ❤️‍🔥 🧑‍🚒 pray -> 🙏 🛐 📿 🤲 👏 smile -> 😄 😸 😅 😼 🙂 emojibase-data is a build-time devDependency only (~5 MB); the runtime ships only the regenerated emoji.js entries with their shortcode fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 25fa235 commit 674f707

4 files changed

Lines changed: 2002 additions & 1910 deletions

File tree

package-lock.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
},
1919
"devDependencies": {
2020
"@tauri-apps/cli": "^2.9.6",
21+
"emojibase-data": "^17.0.0",
2122
"lightningcss": "^1.31.1",
2223
"terser": "^5.46.0"
2324
},

scripts/build-emoji.mjs

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const SVG_OVERRIDES_DIR = join(__dirname, 'emoji-svg-overrides');
2727
const EMOJI_TEST_URL = 'https://unicode.org/Public/emoji/latest/emoji-test.txt';
2828
const CLDR_ANNOTATIONS_URL = 'https://cdn.jsdelivr.net/npm/cldr-annotations-full@48.1.0/annotations/en/annotations.json';
2929
const CLDR_DERIVED_URL = 'https://cdn.jsdelivr.net/npm/cldr-annotations-derived-full@48.1.0/annotationsDerived/en/annotations.json';
30+
// emojibase-data ships the iamcal shortcode set (the same Slack/Discord-style
31+
// canonical aliases like :heart: → ❤️, :pray: → 🙏, :fire: → 🔥). Loaded
32+
// from the local devDependency so the build is offline-friendly once
33+
// node_modules is populated.
34+
const IAMCAL_SHORTCODES_PATH = join(ROOT, 'node_modules', 'emojibase-data', 'en', 'shortcodes', 'iamcal.json');
3035

3136
// Skin tone modifier codepoints (1F3FB–1F3FF)
3237
const SKIN_TONES = new Set(['1F3FB', '1F3FC', '1F3FD', '1F3FE', '1F3FF']);
@@ -351,13 +356,55 @@ function cldrLookup(cldrMap, char) {
351356
return null;
352357
}
353358

354-
function buildKeywords(emojis, cldrMap, customMap) {
355-
console.log(' Merging keywords (CLDR + emoji-test.txt + custom)...');
359+
function loadIamcalShortcodes() {
360+
if (!existsSync(IAMCAL_SHORTCODES_PATH)) {
361+
throw new Error(`Missing emojibase-data. Run: npm install --save-dev emojibase-data`);
362+
}
363+
const raw = JSON.parse(readFileSync(IAMCAL_SHORTCODES_PATH, 'utf-8'));
364+
console.log(` ${Object.keys(raw).length} iamcal shortcodes loaded`);
365+
return raw;
366+
}
367+
368+
function pickIamcalShortcode(value) {
369+
if (!value) return null;
370+
return Array.isArray(value) ? value[0] : value;
371+
}
372+
373+
function lookupIamcal(iamcalMap, codepoints) {
374+
// iamcal stores some keys with FE0F (ZWJ sequences, keycaps) and some
375+
// without (most singleton emojis like ❤ U+2764). Try both forms.
376+
const upper = codepoints.map(cp => cp.toUpperCase());
377+
const withFe0f = upper.join('-');
378+
if (iamcalMap[withFe0f]) return pickIamcalShortcode(iamcalMap[withFe0f]);
379+
const withoutFe0f = upper.filter(cp => cp !== 'FE0F').join('-');
380+
if (withoutFe0f !== withFe0f && iamcalMap[withoutFe0f]) {
381+
return pickIamcalShortcode(iamcalMap[withoutFe0f]);
382+
}
383+
return null;
384+
}
385+
386+
function buildKeywords(emojis, cldrMap, customMap, iamcalMap) {
387+
console.log(' Merging keywords (iamcal + CLDR + emoji-test.txt + custom)...');
356388

357389
let cldrHits = 0;
390+
let iamcalHits = 0;
358391
for (const emoji of emojis) {
359392
const words = new Set();
360393

394+
// Layer 0: iamcal shortcode (Discord/Slack canonical alias). Stored
395+
// as a separate `shortcode` field used by the search ranker for the
396+
// top-priority exact-match tier — :heart: → ❤️, :fire: → 🔥, etc.
397+
// The shortcode words are also added to the keyword bag so partial
398+
// searches still match.
399+
const rawShortcode = lookupIamcal(iamcalMap, emoji.codepoints);
400+
if (rawShortcode) {
401+
iamcalHits++;
402+
emoji.shortcode = rawShortcode;
403+
for (const w of rawShortcode.toLowerCase().split(/[_\-\s]+/)) {
404+
if (w && !STOPWORDS.has(w)) words.add(w);
405+
}
406+
}
407+
361408
// Layer 1: emoji-test.txt name (e.g. "grinning face")
362409
for (const w of emoji.name.toLowerCase().split(/\s+/)) {
363410
if (w && !STOPWORDS.has(w)) words.add(w);
@@ -394,7 +441,7 @@ function buildKeywords(emojis, cldrMap, customMap) {
394441
emoji.display = (cldr && cldr.tts) ? cldr.tts : emoji.name;
395442
}
396443

397-
console.log(` CLDR matched ${cldrHits}/${emojis.length} emojis`);
444+
console.log(` CLDR matched ${cldrHits}/${emojis.length} emojis, iamcal matched ${iamcalHits}/${emojis.length}`);
398445
}
399446

400447
// ── Phase 5: Output ─────────────────────────────────────────────────────
@@ -428,7 +475,9 @@ function writeEmojiJs(emojis) {
428475
// Escape single quotes (shouldn't happen but be safe)
429476
const safeKeywords = emoji.keywords.replace(/'/g, "\\'");
430477
const safeDisplay = (emoji.display || '').replace(/'/g, "\\'");
431-
lines.push(` { emoji: '${emoji.char}', name: '${safeKeywords}', display: '${safeDisplay}' },`);
478+
const safeShortcode = (emoji.shortcode || '').replace(/'/g, "\\'");
479+
const shortcodeField = safeShortcode ? `, shortcode: '${safeShortcode}'` : '';
480+
lines.push(` { emoji: '${emoji.char}', name: '${safeKeywords}', display: '${safeDisplay}'${shortcodeField} },`);
432481
}
433482

434483
// Remove trailing comma from last entry
@@ -462,6 +511,7 @@ async function main() {
462511

463512
// Phase 4: Keywords
464513
const cldrMap = parseCLDR(cldrRaw, cldrDerivedRaw);
514+
const iamcalMap = loadIamcalShortcodes();
465515

466516
let customMap;
467517
if (!existsSync(CUSTOM_KEYWORDS_FILE)) {
@@ -470,7 +520,7 @@ async function main() {
470520
customMap = loadCustomKeywords();
471521
}
472522

473-
buildKeywords(available, cldrMap, customMap);
523+
buildKeywords(available, cldrMap, customMap, iamcalMap);
474524

475525
// Phase 5: Output
476526
writeEmojiJs(available);

0 commit comments

Comments
 (0)