mirror of
https://github.com/discourse/discourse.git
synced 2024-12-12 14:23:48 +08:00
377 lines
8.4 KiB
JavaScript
377 lines
8.4 KiB
JavaScript
import { buildEmojiUrl, isCustomEmoji } from "pretty-text/emoji";
|
|
import { translations } from "pretty-text/emoji/data";
|
|
|
|
const MAX_NAME_LENGTH = 60;
|
|
|
|
let translationTree = null;
|
|
|
|
export function resetTranslationTree() {
|
|
translationTree = null;
|
|
}
|
|
|
|
// This allows us to efficiently search for aliases
|
|
// We build a data structure that allows us to quickly
|
|
// search through our N next chars to see if any match
|
|
// one of our alias emojis.
|
|
function buildTranslationTree(customEmojiTranslation) {
|
|
let tree = [];
|
|
let lastNode;
|
|
|
|
const allTranslations = Object.assign(
|
|
{},
|
|
translations,
|
|
customEmojiTranslation || {}
|
|
);
|
|
|
|
Object.keys(allTranslations).forEach((key) => {
|
|
let node = tree;
|
|
|
|
for (let i = 0; i < key.length; i++) {
|
|
let code = key.charCodeAt(i);
|
|
let found = false;
|
|
|
|
for (let j = 0; j < node.length; j++) {
|
|
if (node[j][0] === code) {
|
|
node = node[j][1];
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
// code, children, value
|
|
let tmp = [code, []];
|
|
node.push(tmp);
|
|
lastNode = tmp;
|
|
node = tmp[1];
|
|
}
|
|
}
|
|
|
|
lastNode[2] = allTranslations[key];
|
|
});
|
|
|
|
return tree;
|
|
}
|
|
|
|
function imageFor(code, opts) {
|
|
code = code.toLowerCase();
|
|
const url = buildEmojiUrl(code, opts);
|
|
if (url) {
|
|
const title = `:${code}:`;
|
|
const classes = isCustomEmoji(code, opts) ? "emoji emoji-custom" : "emoji";
|
|
return { url, title, classes };
|
|
}
|
|
}
|
|
|
|
function getEmojiName(content, pos, state, inlineEmoji) {
|
|
if (content.charCodeAt(pos) !== 58) {
|
|
return;
|
|
}
|
|
|
|
if (pos > 0) {
|
|
let prev = content.charCodeAt(pos - 1);
|
|
if (
|
|
!inlineEmoji &&
|
|
!state.md.utils.isSpace(prev) &&
|
|
!state.md.utils.isPunctChar(String.fromCharCode(prev))
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
pos++;
|
|
if (content.charCodeAt(pos) === 58) {
|
|
return;
|
|
}
|
|
|
|
let length = 0;
|
|
while (length < MAX_NAME_LENGTH) {
|
|
length++;
|
|
|
|
if (content.charCodeAt(pos + length) === 58) {
|
|
// check for t2-t6
|
|
if (content.slice(pos + length + 1, pos + length + 4).match(/t[2-6]:/)) {
|
|
length += 3;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (pos + length > content.length) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (length === MAX_NAME_LENGTH) {
|
|
return;
|
|
}
|
|
|
|
return content.slice(pos, pos + length);
|
|
}
|
|
|
|
// straight forward :smile: to emoji image
|
|
function getEmojiTokenByName(name, state) {
|
|
let info;
|
|
if ((info = imageFor(name, state.md.options.discourse))) {
|
|
let token = new state.Token("emoji", "img", 0);
|
|
token.attrs = [
|
|
["src", info.url],
|
|
["title", info.title],
|
|
["class", info.classes],
|
|
["alt", info.title],
|
|
["loading", "lazy"],
|
|
["width", "20"],
|
|
["height", "20"],
|
|
];
|
|
|
|
return token;
|
|
}
|
|
}
|
|
|
|
function getEmojiTokenByTranslation(
|
|
content,
|
|
pos,
|
|
state,
|
|
customEmojiTranslation
|
|
) {
|
|
translationTree =
|
|
translationTree || buildTranslationTree(customEmojiTranslation);
|
|
|
|
let t = translationTree;
|
|
let start = pos;
|
|
let found = null;
|
|
|
|
while (t.length > 0 && pos < content.length) {
|
|
let matched = false;
|
|
let code = content.charCodeAt(pos);
|
|
|
|
for (let i = 0; i < t.length; i++) {
|
|
if (t[i][0] === code) {
|
|
matched = true;
|
|
found = t[i][2];
|
|
t = t[i][1];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!matched) {
|
|
return;
|
|
}
|
|
|
|
pos++;
|
|
}
|
|
|
|
if (!found) {
|
|
return;
|
|
}
|
|
|
|
// quick boundary check
|
|
if (start > 0) {
|
|
let leading = content.charAt(start - 1);
|
|
if (
|
|
!state.md.utils.isSpace(leading.charCodeAt(0)) &&
|
|
!state.md.utils.isPunctChar(leading)
|
|
) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// check trailing for punct or space
|
|
if (pos < content.length) {
|
|
let trailing = content.charCodeAt(pos);
|
|
if (!state.md.utils.isSpace(trailing)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
let token = getEmojiTokenByName(found, state);
|
|
if (token) {
|
|
return { pos, token };
|
|
}
|
|
}
|
|
|
|
function applyEmoji(
|
|
content,
|
|
state,
|
|
emojiUnicodeReplacer,
|
|
enableShortcuts,
|
|
inlineEmoji,
|
|
customEmojiTranslation,
|
|
watchedWordsReplacer
|
|
) {
|
|
let result = null;
|
|
let start = 0;
|
|
|
|
if (emojiUnicodeReplacer) {
|
|
content = emojiUnicodeReplacer(content);
|
|
}
|
|
|
|
if (watchedWordsReplacer) {
|
|
const watchedWordRegex = Object.keys(watchedWordsReplacer);
|
|
|
|
watchedWordRegex.forEach((watchedWord) => {
|
|
if (content?.match(watchedWord)) {
|
|
const regex = new RegExp(watchedWord, "g");
|
|
const matches = content.match(regex);
|
|
const replacement = watchedWordsReplacer[watchedWord].replacement;
|
|
|
|
matches.forEach(() => {
|
|
const matchingRegex = regex.exec(content);
|
|
if (matchingRegex) {
|
|
content = content.replace(matchingRegex[1], replacement);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
let end = content.length;
|
|
|
|
for (let i = 0; i < content.length - 1; i++) {
|
|
let offset = 0;
|
|
let token = null;
|
|
|
|
const name = getEmojiName(content, i, state, inlineEmoji);
|
|
|
|
if (name) {
|
|
token = getEmojiTokenByName(name, state);
|
|
if (token) {
|
|
offset = name.length + 2;
|
|
}
|
|
}
|
|
|
|
if (enableShortcuts && !token) {
|
|
// handle aliases (note: we can't do this in inline cause ; is not a split point)
|
|
const info = getEmojiTokenByTranslation(
|
|
content,
|
|
i,
|
|
state,
|
|
customEmojiTranslation
|
|
);
|
|
|
|
if (info) {
|
|
offset = info.pos - i;
|
|
token = info.token;
|
|
}
|
|
}
|
|
|
|
if (token) {
|
|
result = result || [];
|
|
|
|
if (i - start > 0) {
|
|
let text = new state.Token("text", "", 0);
|
|
text.content = content.slice(start, i);
|
|
result.push(text);
|
|
}
|
|
|
|
result.push(token);
|
|
|
|
end = start = i + offset;
|
|
i += offset - 1;
|
|
}
|
|
}
|
|
|
|
if (end < content.length) {
|
|
let text = new state.Token("text", "", 0);
|
|
text.content = content.slice(end);
|
|
result.push(text);
|
|
}
|
|
|
|
// we check for a result <= 5 because we support maximum 3 large emojis
|
|
// EMOJI SPACE EMOJI SPACE EMOJI => 5 tokens
|
|
if (result && result.length > 0 && result.length <= 5) {
|
|
// we ensure line starts and ends with an emoji
|
|
// and has no more than 3 emojis
|
|
if (
|
|
result[0].type === "emoji" &&
|
|
result[result.length - 1].type === "emoji" &&
|
|
result.filter((r) => r.type === "emoji").length <= 3
|
|
) {
|
|
let onlyEmojiLine = true;
|
|
let index = 0;
|
|
|
|
const checkNextToken = (t) => {
|
|
if (!t) {
|
|
return;
|
|
}
|
|
|
|
if (!["emoji", "text"].includes(t.type)) {
|
|
onlyEmojiLine = false;
|
|
}
|
|
|
|
// a text token should always have an emoji before
|
|
// and be a space
|
|
if (
|
|
t.type === "text" &&
|
|
((result[index - 1] && result[index - 1].type !== "emoji") ||
|
|
t.content !== " ")
|
|
) {
|
|
onlyEmojiLine = false;
|
|
}
|
|
|
|
// exit as soon as possible
|
|
if (onlyEmojiLine) {
|
|
index += 1;
|
|
checkNextToken(result[index]);
|
|
}
|
|
};
|
|
|
|
checkNextToken(result[index]);
|
|
|
|
if (onlyEmojiLine) {
|
|
result.forEach((r) => {
|
|
if (r.type === "emoji") {
|
|
applyOnlyEmojiClass(r);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function applyOnlyEmojiClass(token) {
|
|
token.attrs.forEach((attr) => {
|
|
if (attr[0] === "class") {
|
|
attr[1] = `${attr[1]} only-emoji`;
|
|
}
|
|
});
|
|
}
|
|
|
|
export function setup(helper) {
|
|
helper.registerOptions((opts, siteSettings, state) => {
|
|
opts.features.emoji = !state.disableEmojis && !!siteSettings.enable_emoji;
|
|
opts.features.emojiShortcuts = !!siteSettings.enable_emoji_shortcuts;
|
|
opts.features.inlineEmoji = !!siteSettings.enable_inline_emoji_translation;
|
|
opts.emojiSet = siteSettings.emoji_set || "";
|
|
opts.customEmoji = state.customEmoji;
|
|
opts.emojiCDNUrl = siteSettings.external_emoji_url;
|
|
});
|
|
|
|
helper.registerPlugin((md) => {
|
|
md.core.ruler.push("emoji", (state) =>
|
|
md.options.discourse.helpers.textReplace(state, (c, s) =>
|
|
applyEmoji(
|
|
c,
|
|
s,
|
|
md.options.discourse.emojiUnicodeReplacer,
|
|
md.options.discourse.features.emojiShortcuts,
|
|
md.options.discourse.features.inlineEmoji,
|
|
md.options.discourse.customEmojiTranslation,
|
|
md.options.discourse.watchedWordsReplace
|
|
)
|
|
)
|
|
);
|
|
});
|
|
|
|
helper.allowList([
|
|
"img[class=emoji]",
|
|
"img[class=emoji emoji-custom]",
|
|
"img[class=emoji emoji-custom only-emoji]",
|
|
"img[class=emoji only-emoji]",
|
|
"img[loading=lazy]",
|
|
"img[width=20]",
|
|
"img[height=20]",
|
|
]);
|
|
}
|