1
mirror of https://github.com/jakejarvis/imagemoji.git synced 2025-04-25 18:05:21 -04:00

initial commit 🎉

This commit is contained in:
Jake Jarvis 2021-11-10 10:18:31 -05:00
commit 6119fe84c0
Signed by: jake
GPG Key ID: 2B0C9CF251E69A39
12 changed files with 4233 additions and 0 deletions

17
.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# http://editorconfig.org
# this file is the top-most editorconfig file
root = true
# all files
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
# site content
[*.md]
trim_trailing_whitespace = false

16
.eslintrc.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": [
"@jakejarvis/eslint-config",
"plugin:@typescript-eslint/recommended"
],
"plugins": [
"@typescript-eslint"
],
"parser": "@typescript-eslint/parser",
"env": {
"browser": true
},
"ignorePatterns": [
"dist/**"
]
}

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Set default behavior to automatically normalize line endings.
* text=auto eol=lf

10
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
versioning-strategy: increase
schedule:
interval: "daily"
commit-message:
prefix: "📦 npm:"

21
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
cache: yarn
- run: yarn install --frozen-lockfile
- run: yarn test
- run: yarn build

21
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
npm:
name: Publish to NPM
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
registry-url: https://registry.npmjs.org/
- run: yarn install --frozen-lockfile
- run: yarn publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.DS_Store
node_modules/
dist/
.npmrc
.vscode/

19
LICENSE Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2021 Jake Jarvis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

62
README.md Normal file
View File

@ -0,0 +1,62 @@
# 🖼️ imagemoji
[![CI](https://github.com/jakejarvis/imagemoji/actions/workflows/ci.yml/badge.svg)](https://github.com/jakejarvis/imagemoji/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/imagemoji?logo=npm)](https://www.npmjs.com/package/imagemoji)
[![MIT License](https://img.shields.io/github/license/jakejarvis/imagemoji)](LICENSE)
Replaces emojis in strings or DOM nodes with corresponding images of your choosing. A barebones, mostly drop-in replacement for Twemoji's [`twemoji.parse()`](https://github.com/twitter/twemoji#twemojiparse---v1) (and heavily cherry-picked from Twitter's [original script](https://github.com/twitter/twemoji/blob/master/scripts/build.js)) to cut some cruft and save a few bytes.
## Usage
### via [unpkg](https://unpkg.com/browse/imagemoji/):
```html
<html>
<body>
<p>I ❤️ emoji!</p>
<script src="https://unpkg.com/imagemoji/dist/imagemoji.min.js"></script>
<script>
imagemoji.parse(document.body);
//=> <p>I <img class="emoji" draggable="false" alt="❤️" src="https://twemoji.maxcdn.com/v/latest/svg/2764.svg"/> emoji!</p>
imagemoji.parse(document.body, (icon) => `/assets/emoji/${icon}.png`);
//=> <p>I <img class="emoji" draggable="false" alt="❤️" src="/assets/emoji/2764.png"/> emoji!</p>
</script>
</body>
</html>
```
### via NPM:
`npm install imagemoji` or `yarn add imagemoji`
```js
import imagemoji from "imagemoji";
// or:
// const imagemoji = require("imagemoji");
imagemoji.parse(document.body);
imagemoji.parse(document.body, (icon) => `/assets/emoji/${icon}.png`);
```
## API
### .parse(what, how?)
#### what
Type: `string` or `Node`
Either a plain string or a DOM node (e.g. `document.body`) containing emojis to replace with `<img>`s.
#### how
Type: `function`\
Default: `(icon: string): string => "https://twemoji.maxcdn.com/v/latest/svg/" + icon + ".svg"`
A callback function to determine the image source URL of a given emoji codepoint (always lowercase, e.g. `1f4a9` for 💩, and variations are joined with dashes, e.g. `1f468-200d-1f4bb` for 👨‍💻). Defaults to pulling SVGs from the [Twemoji CDN](https://github.com/twitter/twemoji#cdn-support).
## License
MIT

55
package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "imagemoji",
"version": "0.0.0",
"description": "🖼️ Replaces emojis in strings or DOM nodes with corresponding images",
"license": "MIT",
"author": {
"name": "Jake Jarvis",
"email": "jake@jarv.is",
"url": "https://jarv.is/"
},
"repository": {
"type": "git",
"url": "https://github.com/jakejarvis/imagemoji.git"
},
"type": "module",
"files": [
"dist"
],
"source": "./src/imagemoji.ts",
"main": "./dist/imagemoji.cjs",
"module": "./dist/imagemoji.esm.js",
"unpkg": "./dist/imagemoji.min.js",
"exports": {
"require": "./dist/imagemoji.cjs",
"import": "./dist/imagemoji.esm.js",
"browser": "./dist/imagemoji.min.js"
},
"types": "./dist/imagemoji.d.ts",
"scripts": {
"build": "microbundle --format cjs,esm,umd",
"test": "eslint .",
"prepublishOnly": "yarn build"
},
"dependencies": {},
"devDependencies": {
"@jakejarvis/eslint-config": "*",
"@typescript-eslint/eslint-plugin": "^5.3.1",
"@typescript-eslint/parser": "^5.3.1",
"eslint": "^8.2.0",
"microbundle": "^0.14.1",
"twemoji-parser": "13.1.0",
"typescript": "^4.4.4"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"keywords": [
"front-end",
"emoji",
"twemoji",
"html",
"dom",
"unicode"
]
}

246
src/imagemoji.ts Normal file
View File

@ -0,0 +1,246 @@
// NOTE: A lot of this logic was cherry-picked from Twitter's original script:
// https://github.com/twitter/twemoji/blob/master/scripts/build.js
// As such...
/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */
// RegExp based on emoji's official Unicode standards
// http://www.unicode.org/Public/UNIDATA/EmojiSources.txt
import regex from "twemoji-parser/dist/lib/regex";
// avoid using a string literal like '\u200D' here because minifiers expand it inline
const zeroWidthJoiner = String.fromCharCode(0x200d);
// nodes with type 1 which should **not** be parsed
const skipTags = /^(?:style|script|noscript|iframe|noframes|select|textarea)$/;
/**
* Shortcut to create text nodes.
*
* @param text text used to create DOM text node
* @param clean strip any variation selectors found
* @return a DOM node with the given text
*/
const createText = function (text: string, clean: boolean): Text {
return document.createTextNode(clean ? text.replace(/\ufe0f/g, "") : text);
};
/**
* Given UTF16 surrogate pairs, returns the equivalent HEX codepoint.
*
* @param unicodeSurrogates generic utf16 surrogates pair, i.e. `\uD83D\uDCA9`
* @param separator optional separator for double code points, default: `-`
* @return UTF16 transformed into codepoint string, i.e. `1F4A9`
*/
const toCodePoint = function (unicodeSurrogates: string, separator = "-"): string {
// remove possible variants (if there is a zero-width-joiner (U+200D), leave them in)
if (unicodeSurrogates.indexOf(zeroWidthJoiner) < 0) {
unicodeSurrogates = unicodeSurrogates.replace(/\ufe0f/g, "");
}
const points: string[] = [];
let char = 0;
let previous = 0;
let i = 0;
while (i < unicodeSurrogates.length) {
char = unicodeSurrogates.charCodeAt(i++);
if (previous) {
points.push((0x10000 + (previous - 0xd800 << 10) + (char - 0xdc00)).toString(16));
previous = 0;
} else if (char > 0xd800 && char <= 0xdbff) {
previous = char;
} else {
points.push(char.toString(16));
}
}
return points.join(separator);
};
/**
* Given a generic DOM nodeType 1, walk through all children and store every
* nodeType 3 (#text) found in the tree.
*
* @param node a DOM Element with probably some text in it
* @param allText the list of previously discovered text nodes
* @return same list with new discovered nodes, if any
*/
const getAllTextNodes = function (node: Node, allText: Node[] = []): Node[] {
const { childNodes } = node;
let { length } = childNodes;
while (length--) {
const subnode = childNodes[length];
const { nodeType } = subnode;
// parse emoji only in text nodes
if (nodeType === 3) {
// collect them to process emoji later
allText.push(subnode);
} else if (
nodeType === 1 &&
!("ownerSVGElement" in subnode) &&
!skipTags.test(subnode.nodeName.toLowerCase())
) {
// ignore all nodes that are not type 1, that are svg, or that
// should not be parsed as script, style, and others
getAllTextNodes(subnode, allText);
}
}
return allText;
};
/**
* DOM version of the same logic / parser: emojify all found sub-text nodes by
* placing image nodes instead.
*
* @param node generic DOM node with some text in some child node
* @param srcGenerator the callback to invoke per each found emoji to get the image source
* @return same generic node with emoji in place, if any.
*/
const parseNode = function (node: Node, srcGenerator: (icon: string) => string): Node {
const allText = getAllTextNodes(node);
let { length } = allText;
while (length--) {
const fragment = document.createDocumentFragment();
const subnode = allText[length];
const text = subnode.nodeValue || "";
let match: RegExpExecArray | null;
let modified = false;
let i = 0;
while ((match = regex.exec(text))) {
const { index } = match;
const rawEmoji = match[0]; // eslint-disable-line prefer-destructuring
const icon = toCodePoint(rawEmoji);
const src = srcGenerator(icon);
i = index + rawEmoji.length;
if (index !== i) {
fragment.appendChild(createText(text.slice(i, index), true));
}
if (icon && src) {
const img = new Image();
img.setAttribute("draggable", "false");
img.className = "emoji";
img.alt = rawEmoji;
img.src = src;
img.onerror = function () {
// remove missing images to preserve the original text intent when
// a fallback for network problems is desired
if (this.parentNode) {
this.parentNode.replaceChild(createText(this.alt, false), this);
}
};
modified = true;
fragment.appendChild(img);
} else {
fragment.appendChild(createText(rawEmoji, false));
}
}
// is there actually anything to replace in here?
if (modified) {
// any text left to be added?
if (i < text.length) {
fragment.appendChild(createText(text.slice(i), true));
}
// replace the text node only, leave intact anything else surrounding such text
subnode.parentNode?.replaceChild(fragment, subnode);
}
}
return node;
};
/**
* String/HTML version of the same logic / parser: emojify a generic text placing images tags
* instead of surrogates pair.
*
* @param str generic string with possibly some emoji in it
* @param srcGenerator the callback to invoke per each found emoji to get the image source
* @return the string with `<img>` tags replacing all found and parsed emoji
*/
const parseString = function (str: string, srcGenerator: (icon: string) => string): string {
return str.replace(regex, function (rawEmoji: string): string {
const icon = toCodePoint(rawEmoji);
const src = srcGenerator(icon);
// recycle the match string replacing the emoji with its image counterpart
return (icon && src) ? `<img class="emoji" draggable="false" alt="${rawEmoji}" src="${src}"/>` : rawEmoji;
});
};
/**
* Default callback used to generate emoji image source based on Twitter CDN.
*
* @param icon the emoji codepoint string
* @return the corresponding image URL to use
*/
const getTwemojiSvg = function (icon: string): string {
return `https://twemoji.maxcdn.com/v/latest/svg/${icon}.svg`;
};
/**
* "Emojify" a generic text or DOM Element with `<img>` tags or HTMLImage nodes.
*
* @overloads
*
* String replacement for `innerHTML` or server side operations:
* `imagemoji.parse(string)`
* `imagemoji.parse(string, Function)`
*
* HTML element tree parsing for safer operations over existing DOM:
* `imagemoji.parse(Node)`
* `imagemoji.parse(Node, Function)`
*
* @param what The source to parse and enrich with emoji
*
* string: Replace emoji matches with `<img>` tags.
* Mainly used to inject emoji via `innerHTML`
* It does **not** parse the string or validate it,
* it simply replaces found emoji with a tag.
* NOTE: be sure this won't affect security.
*
* Node: Walk through the DOM tree and find emoji
* that are inside **text node only** (nodeType === 3)
* Mainly used to put emoji in already generated DOM
* without compromising surrounding nodes and
* **avoiding** the usage of `innerHTML`.
* NOTE: Using DOM elements instead of strings should
* improve security without compromising too much
* performance compared with a less safe `innerHTML`.
*
* @param how If specified, this will be invoked per each emoji that has been
* found through the RegExp except those follwed by the invariant
* \uFE0E ("as text"). Once invoked, parameters will be:
*
* icon {string} the lower case HEX code point
* i.e. "1f4a9"
*
* @example
*
* imagemoji.parse("I \u2764\uFE0F emoji!");
* //=> I <img class="emoji" draggable="false" alt="❤️" src="https://twemoji.maxcdn.com/v/latest/svg/2764.svg"/> emoji!
*
* imagemoji.parse("I \u2764\uFE0F emoji!", (icon) => "/assets/emoji/" + icon + ".png");
* //=> I <img class="emoji" draggable="false" alt="❤️" src="/assets/emoji/2764.png"/> emoji!
*
*/
const parse = function (what: string | Node, how?: (icon: string) => string): string | Node {
// only accept custom how if it's a function
how = (typeof how === "function") ? how : getTwemojiSvg;
// if first argument is string, return html <img> tags
// otherwise use the DOM tree and parse text nodes only to inject images
return (typeof what === "string") ? parseString(what, how) : parseNode(what, how);
};
export { parse };

3759
yarn.lock Normal file

File diff suppressed because it is too large Load Diff