]> Untitled Git - lemmy-ui.git/blob - src/shared/markdown.ts
Merge remote-tracking branch 'origin/main' into feat/vote-components
[lemmy-ui.git] / src / shared / markdown.ts
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_sub from "markdown-it-sub";
14 import markdown_it_sup from "markdown-it-sup";
15 import Renderer from "markdown-it/lib/renderer";
16 import Token from "markdown-it/lib/token";
17 import { instanceLinkRegex } from "./config";
18
19 export let Tribute: any;
20
21 export let md: MarkdownIt = new MarkdownIt();
22
23 export let mdNoImages: MarkdownIt = new MarkdownIt();
24
25 export const customEmojis: EmojiMartCategory[] = [];
26
27 export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
28   string,
29   CustomEmojiView
30 >();
31
32 if (isBrowser()) {
33   Tribute = require("tributejs");
34 }
35
36 export function mdToHtml(text: string) {
37   return { __html: md.render(text) };
38 }
39
40 export function mdToHtmlNoImages(text: string) {
41   return { __html: mdNoImages.render(text) };
42 }
43
44 export function mdToHtmlInline(text: string) {
45   return { __html: md.renderInline(text) };
46 }
47
48 const spoilerConfig = {
49   validate: (params: string) => {
50     return params.trim().match(/^spoiler\s+(.*)$/);
51   },
52
53   render: (tokens: any, idx: any) => {
54     var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
55
56     if (tokens[idx].nesting === 1) {
57       // opening tag
58       return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
59     } else {
60       // closing tag
61       return "</details>\n";
62     }
63   },
64 };
65
66 const html5EmbedConfig = {
67   html5embed: {
68     useImageSyntax: true, // Enables video/audio embed with ![]() syntax (default)
69     attributes: {
70       audio: 'controls preload="metadata"',
71       video: 'width="100%" max-height="100%" controls loop preload="metadata"',
72     },
73   },
74 };
75
76 function localInstanceLinkParser(md: MarkdownIt) {
77   md.core.ruler.push("replace-text", state => {
78     for (let i = 0; i < state.tokens.length; i++) {
79       if (state.tokens[i].type !== "inline") {
80         continue;
81       }
82       const inlineTokens: Token[] = state.tokens[i].children || [];
83       for (let j = inlineTokens.length - 1; j >= 0; j--) {
84         if (
85           inlineTokens[j].type === "text" &&
86           new RegExp(instanceLinkRegex).test(inlineTokens[j].content)
87         ) {
88           const text = inlineTokens[j].content;
89           const matches = Array.from(text.matchAll(instanceLinkRegex));
90
91           let lastIndex = 0;
92           const newTokens: Token[] = [];
93
94           let linkClass = "community-link";
95
96           for (const match of matches) {
97             // If there is plain text before the match, add it as a separate token
98             if (match.index !== undefined && match.index > lastIndex) {
99               const textToken = new state.Token("text", "", 0);
100               textToken.content = text.slice(lastIndex, match.index);
101               newTokens.push(textToken);
102             }
103
104             let href;
105             if (match[0].startsWith("!")) {
106               href = "/c/" + match[0].substring(1);
107             } else if (match[0].startsWith("/m/")) {
108               href = "/c/" + match[0].substring(3);
109             } else {
110               href = match[0];
111               if (match[0].startsWith("/u/")) {
112                 linkClass = "user-link";
113               }
114             }
115
116             const linkOpenToken = new state.Token("link_open", "a", 1);
117             linkOpenToken.attrs = [
118               ["href", href],
119               ["class", linkClass],
120             ];
121             const textToken = new state.Token("text", "", 0);
122             textToken.content = match[0];
123             const linkCloseToken = new state.Token("link_close", "a", -1);
124
125             newTokens.push(linkOpenToken, textToken, linkCloseToken);
126
127             lastIndex =
128               (match.index !== undefined ? match.index : 0) + match[0].length;
129           }
130
131           // If there is plain text after the last match, add it as a separate token
132           if (lastIndex < text.length) {
133             const textToken = new state.Token("text", "", 0);
134             textToken.content = text.slice(lastIndex);
135             newTokens.push(textToken);
136           }
137
138           inlineTokens.splice(j, 1, ...newTokens);
139         }
140       }
141     }
142   });
143 }
144
145 export function setupMarkdown() {
146   const markdownItConfig: MarkdownIt.Options = {
147     html: false,
148     linkify: true,
149     typographer: true,
150   };
151
152   // const emojiDefs = Array.from(customEmojisLookup.entries()).reduce(
153   //   (main, [key, value]) => ({ ...main, [key]: value }),
154   //   {}
155   // );
156   md = new MarkdownIt(markdownItConfig)
157     .use(markdown_it_sub)
158     .use(markdown_it_sup)
159     .use(markdown_it_footnote)
160     .use(markdown_it_html5_embed, html5EmbedConfig)
161     .use(markdown_it_container, "spoiler", spoilerConfig)
162     .use(localInstanceLinkParser);
163   // .use(markdown_it_emoji, {
164   //   defs: emojiDefs,
165   // });
166
167   mdNoImages = new MarkdownIt(markdownItConfig)
168     .use(markdown_it_sub)
169     .use(markdown_it_sup)
170     .use(markdown_it_footnote)
171     .use(markdown_it_html5_embed, html5EmbedConfig)
172     .use(markdown_it_container, "spoiler", spoilerConfig)
173     .use(localInstanceLinkParser)
174     // .use(markdown_it_emoji, {
175     //   defs: emojiDefs,
176     // })
177     .disable("image");
178   const defaultRenderer = md.renderer.rules.image;
179   md.renderer.rules.image = function (
180     tokens: Token[],
181     idx: number,
182     options: MarkdownIt.Options,
183     env: any,
184     self: Renderer
185   ) {
186     //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
187     const item = tokens[idx] as any;
188     const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
189     const src: string = item.attrs[0][1];
190     const isCustomEmoji = customEmojisLookup.get(title) != undefined;
191     if (!isCustomEmoji) {
192       return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
193     }
194     const alt_text = item.content;
195     return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
196   };
197   md.renderer.rules.table_open = function () {
198     return '<table class="table">';
199   };
200 }
201
202 export function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
203   const groupedEmojis = groupBy(
204     custom_emoji_views,
205     x => x.custom_emoji.category
206   );
207   for (const [category, emojis] of Object.entries(groupedEmojis)) {
208     customEmojis.push({
209       id: category,
210       name: category,
211       emojis: emojis.map(emoji => ({
212         id: emoji.custom_emoji.shortcode,
213         name: emoji.custom_emoji.shortcode,
214         keywords: emoji.keywords.map(x => x.keyword),
215         skins: [{ src: emoji.custom_emoji.image_url }],
216       })),
217     });
218   }
219   customEmojisLookup = new Map(
220     custom_emoji_views.map(view => [view.custom_emoji.shortcode, view])
221   );
222 }
223
224 export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
225   const emoji: EmojiMartCustomEmoji = {
226     id: custom_emoji_view.custom_emoji.shortcode,
227     name: custom_emoji_view.custom_emoji.shortcode,
228     keywords: custom_emoji_view.keywords.map(x => x.keyword),
229     skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
230   };
231   const categoryIndex = customEmojis.findIndex(
232     x => x.id == custom_emoji_view.custom_emoji.category
233   );
234   if (categoryIndex == -1) {
235     customEmojis.push({
236       id: custom_emoji_view.custom_emoji.category,
237       name: custom_emoji_view.custom_emoji.category,
238       emojis: [emoji],
239     });
240   } else {
241     const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
242       x => x.id == custom_emoji_view.custom_emoji.shortcode
243     );
244     if (emojiIndex == -1) {
245       customEmojis[categoryIndex].emojis.push(emoji);
246     } else {
247       customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
248     }
249   }
250   customEmojisLookup.set(
251     custom_emoji_view.custom_emoji.shortcode,
252     custom_emoji_view
253   );
254 }
255
256 export function removeFromEmojiDataModel(id: number) {
257   let view: CustomEmojiView | undefined;
258   for (const item of customEmojisLookup.values()) {
259     if (item.custom_emoji.id === id) {
260       view = item;
261       break;
262     }
263   }
264   if (!view) return;
265   const categoryIndex = customEmojis.findIndex(
266     x => x.id == view?.custom_emoji.category
267   );
268   const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
269     x => x.id == view?.custom_emoji.shortcode
270   );
271   customEmojis[categoryIndex].emojis = customEmojis[
272     categoryIndex
273   ].emojis.splice(emojiIndex, 1);
274
275   customEmojisLookup.delete(view?.custom_emoji.shortcode);
276 }
277
278 export function getEmojiMart(
279   onEmojiSelect: (e: any) => void,
280   customPickerOptions: any = {}
281 ) {
282   const pickerOptions = {
283     ...customPickerOptions,
284     onEmojiSelect: onEmojiSelect,
285     custom: customEmojis,
286   };
287   return new Picker(pickerOptions);
288 }
289
290 export function setupTribute() {
291   return new Tribute({
292     noMatchTemplate: function () {
293       return "";
294     },
295     collection: [
296       // Emojis
297       {
298         trigger: ":",
299         menuItemTemplate: (item: any) => {
300           const shortName = `:${item.original.key}:`;
301           return `${item.original.val} ${shortName}`;
302         },
303         selectTemplate: (item: any) => {
304           const customEmoji = customEmojisLookup.get(
305             item.original.key
306           )?.custom_emoji;
307           if (customEmoji == undefined) return `${item.original.val}`;
308           else
309             return `![${customEmoji.alt_text}](${customEmoji.image_url} "${customEmoji.shortcode}")`;
310         },
311         values: Object.entries(emojiShortName)
312           .map(e => {
313             return { key: e[1], val: e[0] };
314           })
315           .concat(
316             Array.from(customEmojisLookup.entries()).map(k => ({
317               key: k[0],
318               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}" />`,
319             }))
320           ),
321         allowSpaces: false,
322         autocompleteMode: true,
323         // TODO
324         // menuItemLimit: mentionDropdownFetchLimit,
325         menuShowMinLength: 2,
326       },
327       // Persons
328       {
329         trigger: "@",
330         selectTemplate: (item: any) => {
331           const it: PersonTribute = item.original;
332           return `[${it.key}](${it.view.person.actor_id})`;
333         },
334         values: debounce(async (text: string, cb: any) => {
335           cb(await personSearch(text));
336         }),
337         allowSpaces: false,
338         autocompleteMode: true,
339         // TODO
340         // menuItemLimit: mentionDropdownFetchLimit,
341         menuShowMinLength: 2,
342       },
343
344       // Communities
345       {
346         trigger: "!",
347         selectTemplate: (item: any) => {
348           const it: CommunityTribute = item.original;
349           return `[${it.key}](${it.view.community.actor_id})`;
350         },
351         values: debounce(async (text: string, cb: any) => {
352           cb(await communitySearch(text));
353         }),
354         allowSpaces: false,
355         autocompleteMode: true,
356         // TODO
357         // menuItemLimit: mentionDropdownFetchLimit,
358         menuShowMinLength: 2,
359       },
360     ],
361   });
362 }
363
364 interface EmojiMartCategory {
365   id: string;
366   name: string;
367   emojis: EmojiMartCustomEmoji[];
368 }
369
370 interface EmojiMartCustomEmoji {
371   id: string;
372   name: string;
373   keywords: string[];
374   skins: EmojiMartSkin[];
375 }
376
377 interface EmojiMartSkin {
378   src: string;
379 }