universal-emoji-parser
Version:
This tool allow parse unicode and emoji codes to html images using emojilib && Twemoji CDN
292 lines (206 loc) • 13.2 kB
Markdown
---
name: emoji-data-pipeline
description: Step-by-step explanation of how src/lib/emoji-lib.json is regenerated from upstream sources
---
# Skill: `emoji-data-pipeline`
A reference for the data pipeline that produces `src/lib/emoji-lib.json` from upstream `emojilib` and `unicode-emoji-json`. Read this when you need to understand _why_ the catalog has the shape it does, or before debugging a regeneration that produced unexpected output.
For a procedural walkthrough of running the regeneration, see [`/regenerate-emoji-lib`](../commands/regenerate-emoji-lib.md). This skill explains the algorithm.
## The two upstream sources
### `unicode-emoji-json`
A pure-data npm package — a JSON object keyed by unicode emoji literal:
```json
{
"😎": {
"name": "smiling face with sunglasses",
"slug": "smiling_face_with_sunglasses",
"group": "Smileys & Emotion",
"emoji_version": "1.0",
"unicode_version": "1.0",
"skin_tone_support": false
},
"🚀": { ... },
...
}
```
This is the **authoritative source** for canonical metadata: what the emoji is named, what its CLDR slug is, which Unicode version introduced it.
The slug (`smiling_face_with_sunglasses`) follows Unicode CLDR conventions — lowercase with underscores, derived from the official emoji name.
### `emojilib`
Another data-only package — keyed by unicode emoji literal, values are arrays of curated keywords:
```js
{
"😎": ["smiling_face_with_sunglasses", "cool", "summer", "sunglass"],
"🚀": ["rocket", "launch", "ship", "staffmode", "NASA", "space", "fly"],
...
}
```
This is the **authoritative source** for **aliases**: what shortcodes (other than the canonical slug) should resolve to each emoji. The keyword arrays come from a community-curated list that GitHub, Slack, Discord, and others use.
## The merge step
The regenerator (`test/prepareEmojiLibJson.test.ts`) merges both:
```ts
const unicodeEmojiJsonData: ObjectType = unicodeEmojiJson
const keywordSet: ObjectType = emojilib
for (const emoji in unicodeEmojiJsonData) {
unicodeEmojiJsonData[emoji].char = emoji
if (keywordSet[emoji]) {
unicodeEmojiJsonData[emoji].keywords = keywordSet[emoji]
} else {
unicodeEmojiJsonData[emoji].keywords = [unicodeEmojiJsonData[emoji].slug]
}
if (!unicodeEmojiJsonData[emoji].keywords.includes(unicodeEmojiJsonData[emoji].slug)) {
unicodeEmojiJsonData[emoji].keywords.unshift(unicodeEmojiJsonData[emoji].slug)
}
// ... apply EMOJIS_SPECIAL_CASES (next section) ...
}
```
For each emoji in `unicode-emoji-json`:
1. **Set `char`** — the unicode literal becomes a property on the entry (so consumers can do `entry.char` instead of looking up by unicode key)
2. **Use `emojilib` keywords if available** — most emojis have curated keyword arrays
3. **Fall back to `[slug]`** — for emojis where `emojilib` has no entry, the keywords are just the slug
4. **Ensure slug is in keywords** — `unshift(slug)` if it's not already there (some `emojilib` arrays don't include the slug; we want consistent behavior — `getEmojiObjectByShortcode(slug)` should always work via the keyword scan)
## The `EMOJIS_SPECIAL_CASES` overrides
Hand-curated overrides for emojis where the upstream merge produces wrong results:
```ts
const EMOJIS_SPECIAL_CASES: ObjectType = {
'☕': { include: ['coffee'] },
'🤎': { exclude: ['coffee'] },
'❤️': { include: ['heart'] },
'💘': { exclude: ['heart'] },
'👮♀️': {
include: ['policewoman', 'female-police-officer'],
exclude: ['legal', 'arrest'],
},
'✅': { include: ['white_check_mark'] },
'⏸️': { include: ['double_vertical_bar'] },
}
```
For each entry:
- **`include`** — keywords to add (with priority — they get `unshift`-ed to the start of the keyword array)
- **`exclude`** — keywords to remove
Applied during the merge:
```ts
if (EMOJIS_SPECIAL_CASES[emoji]) {
if (EMOJIS_SPECIAL_CASES[emoji].include) {
EMOJIS_SPECIAL_CASES[emoji].include.forEach((keyword) => {
if (!unicodeEmojiJsonData[emoji].keywords.includes(keyword)) {
unicodeEmojiJsonData[emoji].keywords.unshift(keyword)
}
})
}
if (EMOJIS_SPECIAL_CASES[emoji].exclude) {
unicodeEmojiJsonData[emoji].keywords = unicodeEmojiJsonData[emoji].keywords.filter(
(k) => !EMOJIS_SPECIAL_CASES[emoji].exclude.includes(k)
)
}
}
```
The `unshift` is important — `include` keywords go to position 0, giving them maximum priority in the dedup loop (next section).
## The deduplication loop
This is the most complex part. Problem: a keyword like `coffee` is naturally associated with multiple emojis (☕ in `emojilib`, 🤎 sometimes, etc.). If both keep `coffee` in their keyword arrays, then `:coffee:` resolves ambiguously — `getEmojiObjectByShortcode('coffee')` returns whichever emoji `Object.keys(emojiLibJsonData).find(...)` hits first, which is unpredictable.
Solution: **assign each keyword to exactly one emoji** — the one where it's most prominent. The dedup loop:
```ts
const emojiLibJson: EmojiLibJsonType = unicodeEmojiJsonData
const emojiLibJsonKeys: Array<string> = Object.keys(emojiLibJson)
emojiLibJsonKeys.forEach((unicodeEmoji: string) => {
const emojiObject: EmojiType = JSON.parse(JSON.stringify(emojiLibJson[unicodeEmoji]))
emojiObject.keywords.forEach((keyword: string) => {
let emojisObjectsFoundPerKeyword: Array<EmojiType> = []
emojiLibJsonKeys.forEach((unicodeEmojiInternal: string) => {
const emojiObjectInternal: EmojiType = JSON.parse(JSON.stringify(emojiLibJson[unicodeEmojiInternal]))
if (emojiObjectInternal.keywords.includes(keyword)) {
emojiObjectInternal.keyword_index_found = emojiObjectInternal.keywords.indexOf(keyword)
emojisObjectsFoundPerKeyword.push(emojiObjectInternal)
}
})
if (emojisObjectsFoundPerKeyword.length) {
emojisObjectsFoundPerKeyword = emojisObjectsFoundPerKeyword.sort(
(a, b) => a.keyword_index_found! - b.keyword_index_found!
)
emojisObjectsFoundPerKeyword.splice(0, 1)
if (emojisObjectsFoundPerKeyword.length) {
emojisObjectsFoundPerKeyword.forEach((emojiObjectFound: EmojiType) => {
if (emojiObjectFound.keyword_index_found !== 0) {
emojiLibJson[emojiObjectFound.char].keywords.splice(emojiObjectFound.keyword_index_found!, 1)
}
})
}
}
})
})
```
Walking through:
### For each emoji in the catalog:
#### For each of its keywords:
1. **Find every emoji that has this keyword** — `emojisObjectsFoundPerKeyword` collects them, with each one's `keyword_index_found` (the position of the keyword in their array)
2. **Sort by `keyword_index_found` ascending** — the emoji where this keyword appears earliest wins
3. **Drop the winner** (`splice(0, 1)`) from the list — they keep the keyword
4. **For all losers**, remove the keyword from their arrays
The `keyword_index_found !== 0` guard prevents removing a keyword that's at position 0 (the canonical slug). This is intentional — the slug must always be in its emoji's keywords for `parseToShortcode` to round-trip.
### Why O(n²)
`emojiLibJsonKeys.forEach(...)` outer × `emojiObject.keywords.forEach(...)` inner × `emojiLibJsonKeys.forEach(...)` innermost = O(n × k × n) where n=1906, k≈5 average keywords. Total iterations ≈ 18 million. Each is a cheap array operation, so the whole loop runs in ~10 seconds.
This is fine because regeneration is a manual, opt-in operation. Optimizing this loop would be a 2-day project for a result that runs once a month.
### The `JSON.parse(JSON.stringify(...))` clones
```ts
const emojiObject: EmojiType = JSON.parse(JSON.stringify(emojiLibJson[unicodeEmoji]))
```
The dedup loop **mutates** `emojiLibJson[someEmoji].keywords` while iterating. Cloning the entry first prevents the mutation from affecting the iteration. Without clones, the algorithm produces inconsistent results.
This is the **only** sanctioned use of `JSON.parse(JSON.stringify(...))` in the codebase. Don't replicate this pattern at runtime — it's expensive.
## The output
After the merge + special cases + dedup, `emojiLibJson` is the final catalog. The regenerator writes it:
```ts
const filePath: string = 'src/lib/emoji-lib-output.json'
fs.writeFileSync(filePath, JSON.stringify(emojiLibJson, null, 2))
```
The output file is **gitignored** (`.gitignore` includes `emoji-lib-output.json`). To make it the runtime catalog, you copy it manually:
```bash
cp src/lib/emoji-lib-output.json src/lib/emoji-lib.json
```
This separation lets you review the regenerated catalog before promoting it. If the diff is wrong, you can iterate on `EMOJIS_SPECIAL_CASES` and re-run without committing a broken intermediate state.
## Reading the diff
After regeneration, the diff between `emoji-lib.json` (committed) and `emoji-lib-output.json` (just generated) shows what changed. Common patterns:
### "All emojis show a diff in keyword order"
The dedup algorithm is **sensitive to iteration order** — `Object.keys()` in JS is mostly insertion-order, but for non-integer string keys (which unicode emojis are), order is officially "implementation-defined." If `unicode-emoji-json` reorders its entries between versions, the dedup picks different winners.
This usually means an upstream version bump rearranged things. Spot-check a few specific emojis to confirm the _meaningful_ keywords are still correct, then accept.
### "A specific emoji has a totally different keywords array"
Either:
- **`emojilib` updated its keywords** for that emoji
- **A new emoji was added that shares keywords** and won the dedup over the existing one
Check `EMOJIS_SPECIAL_CASES` — if the affected emoji is one we explicitly support an alias for, add an `include` override.
### "A previously-resolving shortcode now returns undefined"
The keyword was reassigned to a different emoji during dedup. Add an `EMOJIS_SPECIAL_CASES` `include` for the original emoji to lock in the keyword.
### "Catalog count went down"
`unicode-emoji-json` removed entries. Rare. Check the upstream changelog. If the removal is a bug, pin to the previous version of `unicode-emoji-json` and file an issue upstream.
## Adding a new shortcode alias
Use case: someone wants `:rocketship:` to resolve to 🚀.
1. Open `test/prepareEmojiLibJson.test.ts`
2. Add to `EMOJIS_SPECIAL_CASES`:
```ts
'🚀': { include: ['rocketship'] },
```
3. Regenerate
4. Confirm 🚀's keyword array now starts with `rocketship` (because of `unshift`)
5. The dedup loop will give 🚀 priority over any other emoji that had `rocketship` (unlikely — `rocketship` isn't in `emojilib`'s defaults)
After regeneration, `getEmojiObjectByShortcode('rocketship')` finds 🚀 via the keyword scan.
Procedure: [`/add-special-case`](../commands/add-special-case.md).
## Removing an unwanted alias
Use case: `:legal:` was resolving to 👮♀️ (police officer), which is wrong — `legal` should be unrelated to police imagery.
1. Add to `EMOJIS_SPECIAL_CASES`:
```ts
'👮♀️': { exclude: ['legal'] },
```
2. Regenerate
Now `getEmojiObjectByShortcode('legal')` returns whatever other emoji has `legal` in its keywords (or undefined).
## Why the dedup ordering can surprise
Suppose two emojis (A and B) both have keyword `cool` at the same position (say, index 2). The dedup sorts by `keyword_index_found` ascending — when ties happen, JavaScript's `Array.sort` is **stable** in modern engines (V8, JSC), but historically wasn't guaranteed. This means tied-position emojis are ordered by **catalog iteration order**, which is `Object.keys(emojiLibJsonData)`.
If `unicode-emoji-json` changes its insertion order between versions, ties resolve differently — even if neither `emojilib` nor our overrides changed. This is the "unexpected diffs after upstream bump" scenario.
To force a specific outcome, use `EMOJIS_SPECIAL_CASES.include` to push the keyword to position 0 in the emoji you want to win.
## What the regenerator doesn't do
- **Doesn't validate slugs** — if `unicode-emoji-json` ships a malformed slug, it propagates. Sanity-check `slug.match(/^[a-z0-9_]+$/)` for any new entries
- **Doesn't sort keywords alphabetically** — order matters for dedup, so we preserve `emojilib`'s curation
- **Doesn't deduplicate keywords _within_ a single emoji's array** — if `emojilib` has `["smile", "happy", "smile"]`, the duplicate stays. Hasn't been a problem yet
- **Doesn't enforce a maximum number of keywords per emoji** — if upstream bloats, the catalog bloats. Bundle size is the constraint
## Future improvements
If the regeneration becomes painful:
- **Cache the dedup output** by hash of input — re-running with unchanged inputs would be instant
- **Replace O(n²) with an inverted index** — for each keyword, build a list of (emoji, position), then for each list keep the lowest-position one
- **Move regeneration out of the test suite** into a dedicated script (`scripts/regenerate-catalog.ts`) — `it.skip` is a clever hack but brittle
None of these are urgent. The current pipeline runs once a month and takes 10 seconds.