1 import { communitySearch, personSearch } from "@utils/app";
2 import { isBrowser } from "@utils/browser";
3 import { debounce, groupBy } from "@utils/helpers";
4 import { CommunityTribute, PersonTribute } from "@utils/types";
5 import { Picker } from "emoji-mart";
6 import emojiShortName from "emoji-short-name";
7 import { CustomEmojiView } from "lemmy-js-client";
8 import { default as MarkdownIt } from "markdown-it";
9 import markdown_it_container from "markdown-it-container";
10 // import markdown_it_emoji from "markdown-it-emoji/bare";
11 import markdown_it_footnote from "markdown-it-footnote";
12 import markdown_it_html5_embed from "markdown-it-html5-embed";
13 import markdown_it_ruby from "markdown-it-ruby";
14 import markdown_it_sub from "markdown-it-sub";
15 import markdown_it_sup from "markdown-it-sup";
16 import Renderer from "markdown-it/lib/renderer";
17 import Token from "markdown-it/lib/token";
18 import { instanceLinkRegex } from "./config";
20 export let Tribute: any;
22 export let md: MarkdownIt = new MarkdownIt();
24 export let mdNoImages: MarkdownIt = new MarkdownIt();
26 // Zero disables all rules.
27 // Only explicitly allow a limited set of rules safe for use in post titles.
28 export const mdLimited: MarkdownIt = new MarkdownIt("zero").enable([
34 export const customEmojis: EmojiMartCategory[] = [];
36 export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
42 Tribute = require("tributejs");
45 export function mdToHtml(text: string) {
46 return { __html: md.render(text) };
49 export function mdToHtmlNoImages(text: string) {
50 return { __html: mdNoImages.render(text) };
53 export function mdToHtmlInline(text: string) {
54 return { __html: mdLimited.renderInline(text) };
57 const spoilerConfig = {
58 validate: (params: string) => {
59 return params.trim().match(/^spoiler\s+(.*)$/);
62 render: (tokens: any, idx: any) => {
63 var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
65 if (tokens[idx].nesting === 1) {
67 return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
70 return "</details>\n";
75 const html5EmbedConfig = {
77 useImageSyntax: true, // Enables video/audio embed with ![]() syntax (default)
79 audio: 'controls preload="metadata"',
80 video: 'width="100%" max-height="100%" controls loop preload="metadata"',
85 function localInstanceLinkParser(md: MarkdownIt) {
86 md.core.ruler.push("replace-text", state => {
87 for (let i = 0; i < state.tokens.length; i++) {
88 if (state.tokens[i].type !== "inline") {
91 const inlineTokens: Token[] = state.tokens[i].children || [];
92 for (let j = inlineTokens.length - 1; j >= 0; j--) {
94 inlineTokens[j].type === "text" &&
95 new RegExp(instanceLinkRegex).test(inlineTokens[j].content)
97 const text = inlineTokens[j].content;
98 const matches = Array.from(text.matchAll(instanceLinkRegex));
101 const newTokens: Token[] = [];
103 let linkClass = "community-link";
105 for (const match of matches) {
106 // If there is plain text before the match, add it as a separate token
107 if (match.index !== undefined && match.index > lastIndex) {
108 const textToken = new state.Token("text", "", 0);
109 textToken.content = text.slice(lastIndex, match.index);
110 newTokens.push(textToken);
114 if (match[0].startsWith("!")) {
115 href = "/c/" + match[0].substring(1);
116 } else if (match[0].startsWith("/m/")) {
117 href = "/c/" + match[0].substring(3);
120 if (match[0].startsWith("/u/")) {
121 linkClass = "user-link";
125 const linkOpenToken = new state.Token("link_open", "a", 1);
126 linkOpenToken.attrs = [
128 ["class", linkClass],
130 const textToken = new state.Token("text", "", 0);
131 textToken.content = match[0];
132 const linkCloseToken = new state.Token("link_close", "a", -1);
134 newTokens.push(linkOpenToken, textToken, linkCloseToken);
137 (match.index !== undefined ? match.index : 0) + match[0].length;
140 // If there is plain text after the last match, add it as a separate token
141 if (lastIndex < text.length) {
142 const textToken = new state.Token("text", "", 0);
143 textToken.content = text.slice(lastIndex);
144 newTokens.push(textToken);
147 inlineTokens.splice(j, 1, ...newTokens);
154 export function setupMarkdown() {
155 const markdownItConfig: MarkdownIt.Options = {
161 // const emojiDefs = Array.from(customEmojisLookup.entries()).reduce(
162 // (main, [key, value]) => ({ ...main, [key]: value }),
165 md = new MarkdownIt(markdownItConfig)
166 .use(markdown_it_sub)
167 .use(markdown_it_sup)
168 .use(markdown_it_footnote)
169 .use(markdown_it_html5_embed, html5EmbedConfig)
170 .use(markdown_it_container, "spoiler", spoilerConfig)
171 .use(markdown_it_ruby)
172 .use(localInstanceLinkParser);
173 // .use(markdown_it_emoji, {
177 mdNoImages = new MarkdownIt(markdownItConfig)
178 .use(markdown_it_sub)
179 .use(markdown_it_sup)
180 .use(markdown_it_footnote)
181 .use(markdown_it_html5_embed, html5EmbedConfig)
182 .use(markdown_it_container, "spoiler", spoilerConfig)
183 .use(localInstanceLinkParser)
184 // .use(markdown_it_emoji, {
188 const defaultRenderer = md.renderer.rules.image;
189 md.renderer.rules.image = function (
192 options: MarkdownIt.Options,
196 //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
197 const item = tokens[idx] as any;
198 const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
199 const customEmoji = customEmojisLookup.get(title);
200 const isCustomEmoji = customEmoji != undefined;
201 if (!isCustomEmoji) {
202 return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
204 return `<img class="icon icon-emoji" src="${
205 customEmoji!.custom_emoji.image_url
206 }" title="${customEmoji!.custom_emoji.shortcode}" alt="${
207 customEmoji!.custom_emoji.alt_text
210 md.renderer.rules.table_open = function () {
211 return '<table class="table">';
215 export function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
216 const groupedEmojis = groupBy(
218 x => x.custom_emoji.category
220 for (const [category, emojis] of Object.entries(groupedEmojis)) {
224 emojis: emojis.map(emoji => ({
225 id: emoji.custom_emoji.shortcode,
226 name: emoji.custom_emoji.shortcode,
227 keywords: emoji.keywords.map(x => x.keyword),
228 skins: [{ src: emoji.custom_emoji.image_url }],
232 customEmojisLookup = new Map(
233 custom_emoji_views.map(view => [view.custom_emoji.shortcode, view])
237 export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
238 const emoji: EmojiMartCustomEmoji = {
239 id: custom_emoji_view.custom_emoji.shortcode,
240 name: custom_emoji_view.custom_emoji.shortcode,
241 keywords: custom_emoji_view.keywords.map(x => x.keyword),
242 skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
244 const categoryIndex = customEmojis.findIndex(
245 x => x.id == custom_emoji_view.custom_emoji.category
247 if (categoryIndex == -1) {
249 id: custom_emoji_view.custom_emoji.category,
250 name: custom_emoji_view.custom_emoji.category,
254 const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
255 x => x.id == custom_emoji_view.custom_emoji.shortcode
257 if (emojiIndex == -1) {
258 customEmojis[categoryIndex].emojis.push(emoji);
260 customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
263 customEmojisLookup.set(
264 custom_emoji_view.custom_emoji.shortcode,
269 export function removeFromEmojiDataModel(id: number) {
270 let view: CustomEmojiView | undefined;
271 for (const item of customEmojisLookup.values()) {
272 if (item.custom_emoji.id === id) {
278 const categoryIndex = customEmojis.findIndex(
279 x => x.id == view?.custom_emoji.category
281 const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
282 x => x.id == view?.custom_emoji.shortcode
284 customEmojis[categoryIndex].emojis = customEmojis[
286 ].emojis.splice(emojiIndex, 1);
288 customEmojisLookup.delete(view?.custom_emoji.shortcode);
291 export function getEmojiMart(
292 onEmojiSelect: (e: any) => void,
293 customPickerOptions: any = {}
295 const pickerOptions = {
296 ...customPickerOptions,
297 onEmojiSelect: onEmojiSelect,
298 custom: customEmojis,
300 return new Picker(pickerOptions);
303 export function setupTribute() {
305 noMatchTemplate: function () {
312 menuItemTemplate: (item: any) => {
313 const shortName = `:${item.original.key}:`;
314 return `${item.original.val} ${shortName}`;
316 selectTemplate: (item: any) => {
317 const customEmoji = customEmojisLookup.get(
320 if (customEmoji == undefined) return `${item.original.val}`;
322 return `![${customEmoji.alt_text}](${customEmoji.image_url} "${customEmoji.shortcode}")`;
324 values: Object.entries(emojiShortName)
326 return { key: e[1], val: e[0] };
329 Array.from(customEmojisLookup.entries()).map(k => ({
331 val: `<img class="icon icon-emoji" src="${k[1].custom_emoji.image_url}" title="${k[1].custom_emoji.shortcode}" alt="${k[1].custom_emoji.alt_text}" />`,
335 autocompleteMode: true,
337 // menuItemLimit: mentionDropdownFetchLimit,
338 menuShowMinLength: 2,
343 selectTemplate: (item: any) => {
344 const it: PersonTribute = item.original;
345 return `[${it.key}](${it.view.person.actor_id})`;
347 values: debounce(async (text: string, cb: any) => {
348 cb(await personSearch(text));
351 autocompleteMode: true,
353 // menuItemLimit: mentionDropdownFetchLimit,
354 menuShowMinLength: 2,
360 selectTemplate: (item: any) => {
361 const it: CommunityTribute = item.original;
362 return `[${it.key}](${it.view.community.actor_id})`;
364 values: debounce(async (text: string, cb: any) => {
365 cb(await communitySearch(text));
368 autocompleteMode: true,
370 // menuItemLimit: mentionDropdownFetchLimit,
371 menuShowMinLength: 2,
377 interface EmojiMartCategory {
380 emojis: EmojiMartCustomEmoji[];
383 interface EmojiMartCustomEmoji {
387 skins: EmojiMartSkin[];
390 interface EmojiMartSkin {