@@ -27,6 +27,11 @@ const SVG_OVERRIDES_DIR = join(__dirname, 'emoji-svg-overrides');
2727const EMOJI_TEST_URL = 'https://unicode.org/Public/emoji/latest/emoji-test.txt' ;
2828const CLDR_ANNOTATIONS_URL = 'https://cdn.jsdelivr.net/npm/cldr-annotations-full@48.1.0/annotations/en/annotations.json' ;
2929const CLDR_DERIVED_URL = 'https://cdn.jsdelivr.net/npm/cldr-annotations-derived-full@48.1.0/annotationsDerived/en/annotations.json' ;
30+ // Unicode publishes a logarithmic-bucket frequency ranking of ~1454 emojis
31+ // at this URL (rank 0 = most-used pair 😂 ❤️, higher = less-used). Used as
32+ // a tiebreaker when ranking-tier scores match, so e.g. searching "smile"
33+ // promotes 😄 (popular) over 😼 (rare) on equal score.
34+ const UNICODE_FREQUENCY_URL = 'https://home.unicode.org/emoji/emoji-frequency/' ;
3035// emojibase-data ships the iamcal shortcode set (the same Slack/Discord-style
3136// canonical aliases like :heart: → ❤️, :pray: → 🙏, :fire: → 🔥). Loaded
3237// from the local devDependency so the build is offline-friendly once
@@ -80,12 +85,13 @@ function toTwemojiFilename(codepoints) {
8085
8186async function downloadAll ( ) {
8287 console . log ( 'Phase 1: Downloading Unicode data...' ) ;
83- const [ emojiTestRaw , cldrRaw , cldrDerivedRaw ] = await Promise . all ( [
88+ const [ emojiTestRaw , cldrRaw , cldrDerivedRaw , frequencyRaw ] = await Promise . all ( [
8489 cachedFetch ( EMOJI_TEST_URL , 'emoji-test.txt' ) ,
8590 cachedFetch ( CLDR_ANNOTATIONS_URL , 'cldr-annotations.json' ) ,
8691 cachedFetch ( CLDR_DERIVED_URL , 'cldr-annotations-derived.json' ) ,
92+ cachedFetch ( UNICODE_FREQUENCY_URL , 'unicode-emoji-frequency.html' ) ,
8793 ] ) ;
88- return { emojiTestRaw, cldrRaw, cldrDerivedRaw } ;
94+ return { emojiTestRaw, cldrRaw, cldrDerivedRaw, frequencyRaw } ;
8995}
9096
9197// ── Phase 2: Parse emoji-test.txt ───────────────────────────────────────
@@ -356,6 +362,29 @@ function cldrLookup(cldrMap, char) {
356362 return null ;
357363}
358364
365+ function parseFrequencyTable ( html ) {
366+ // Unicode's frequency page is a WordPress-rendered HTML table with rows
367+ // like: <td...>RANK</td><td...>EMOJI EMOJI ...</td>
368+ // We extract grapheme clusters from the emoji column to handle ZWJ
369+ // sequences and variation selectors correctly.
370+ const rowRe = / < t d [ ^ > ] * > ( \d + ) \s * < \/ t d > \s * < t d [ ^ > ] * > ( [ ^ < ] + ) < \/ t d > / g;
371+ const ranks = new Map ( ) ;
372+ const segmenter = new Intl . Segmenter ( undefined , { granularity : 'grapheme' } ) ;
373+ let m ;
374+ while ( ( m = rowRe . exec ( html ) ) !== null ) {
375+ const rank = parseInt ( m [ 1 ] , 10 ) ;
376+ const emojiText = m [ 2 ] ;
377+ for ( const seg of segmenter . segment ( emojiText ) ) {
378+ const e = seg . segment ;
379+ if ( ! e || / ^ \s + $ / . test ( e ) ) continue ;
380+ const prev = ranks . get ( e ) ;
381+ if ( prev === undefined || rank < prev ) ranks . set ( e , rank ) ;
382+ }
383+ }
384+ console . log ( ` ${ ranks . size } emojis in Unicode frequency table` ) ;
385+ return ranks ;
386+ }
387+
359388function loadIamcalShortcodes ( ) {
360389 if ( ! existsSync ( IAMCAL_SHORTCODES_PATH ) ) {
361390 throw new Error ( `Missing emojibase-data. Run: npm install --save-dev emojibase-data` ) ;
@@ -383,12 +412,23 @@ function lookupIamcal(iamcalMap, codepoints) {
383412 return null ;
384413}
385414
386- function buildKeywords ( emojis , cldrMap , customMap , iamcalMap ) {
415+ function buildKeywords ( emojis , cldrMap , customMap , iamcalMap , frequencyMap ) {
387416 console . log ( ' Merging keywords (iamcal + CLDR + emoji-test.txt + custom)...' ) ;
388417
418+ // Unranked emojis fall back to a value larger than the highest observed
419+ // rank, so they sort behind every ranked one without affecting score-tier
420+ // ordering.
421+ const UNRANKED = 99 ;
422+ let freqHits = 0 ;
389423 let cldrHits = 0 ;
390424 let iamcalHits = 0 ;
391425 for ( const emoji of emojis ) {
426+ // Look up frequency rank — try with and without FE0F, since the
427+ // Unicode page uses fully-qualified forms inconsistently.
428+ let rank = frequencyMap . get ( emoji . char ) ;
429+ if ( rank === undefined ) rank = frequencyMap . get ( emoji . char . replaceAll ( '️' , '' ) ) ;
430+ if ( rank !== undefined ) freqHits ++ ;
431+ emoji . freq = rank !== undefined ? rank : UNRANKED ;
392432 const words = new Set ( ) ;
393433
394434 // Layer 0: iamcal shortcode (Discord/Slack canonical alias). Stored
@@ -441,7 +481,7 @@ function buildKeywords(emojis, cldrMap, customMap, iamcalMap) {
441481 emoji . display = ( cldr && cldr . tts ) ? cldr . tts : emoji . name ;
442482 }
443483
444- console . log ( ` CLDR matched ${ cldrHits } /${ emojis . length } emojis, iamcal matched ${ iamcalHits } /${ emojis . length } ` ) ;
484+ console . log ( ` CLDR matched ${ cldrHits } /${ emojis . length } emojis, iamcal matched ${ iamcalHits } /${ emojis . length } , frequency matched ${ freqHits } / ${ emojis . length } ` ) ;
445485}
446486
447487// ── Phase 5: Output ─────────────────────────────────────────────────────
@@ -477,7 +517,8 @@ function writeEmojiJs(emojis) {
477517 const safeDisplay = ( emoji . display || '' ) . replace ( / ' / g, "\\'" ) ;
478518 const safeShortcode = ( emoji . shortcode || '' ) . replace ( / ' / g, "\\'" ) ;
479519 const shortcodeField = safeShortcode ? `, shortcode: '${ safeShortcode } '` : '' ;
480- lines . push ( ` { emoji: '${ emoji . char } ', name: '${ safeKeywords } ', display: '${ safeDisplay } '${ shortcodeField } },` ) ;
520+ const freqField = ( typeof emoji . freq === 'number' && emoji . freq < 99 ) ? `, freq: ${ emoji . freq } ` : '' ;
521+ lines . push ( ` { emoji: '${ emoji . char } ', name: '${ safeKeywords } ', display: '${ safeDisplay } '${ shortcodeField } ${ freqField } },` ) ;
481522 }
482523
483524 // Remove trailing comma from last entry
@@ -499,7 +540,7 @@ async function main() {
499540 console . log ( '=== build-emoji.mjs ===\n' ) ;
500541
501542 // Phase 1: Download
502- const { emojiTestRaw, cldrRaw, cldrDerivedRaw } = await downloadAll ( ) ;
543+ const { emojiTestRaw, cldrRaw, cldrDerivedRaw, frequencyRaw } = await downloadAll ( ) ;
503544
504545 // Phase 2: Parse
505546 const emojis = parseEmojiTest ( emojiTestRaw ) ;
@@ -512,6 +553,7 @@ async function main() {
512553 // Phase 4: Keywords
513554 const cldrMap = parseCLDR ( cldrRaw , cldrDerivedRaw ) ;
514555 const iamcalMap = loadIamcalShortcodes ( ) ;
556+ const frequencyMap = parseFrequencyTable ( frequencyRaw ) ;
515557
516558 let customMap ;
517559 if ( ! existsSync ( CUSTOM_KEYWORDS_FILE ) ) {
@@ -520,7 +562,7 @@ async function main() {
520562 customMap = loadCustomKeywords ( ) ;
521563 }
522564
523- buildKeywords ( available , cldrMap , customMap , iamcalMap ) ;
565+ buildKeywords ( available , cldrMap , customMap , iamcalMap , frequencyMap ) ;
524566
525567 // Phase 5: Output
526568 writeEmojiJs ( available ) ;
0 commit comments