]> Untitled Git - lemmy-ui.git/blob - src/shared/markdown.ts
Typescript linter fixes
[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 localCommunityLinkParser(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             // Determine the new href
105             let href;
106             if (match[0].startsWith("!")) {
107               href = "/c/" + match[0].substring(1);
108             } else if (match[0].startsWith("/m/")) {
109               href = "/c/" + match[0].substring(3);
110             } else {
111               href = match[0];
112             }
113
114             if (match[0].startsWith("/u/")) {
115               linkClass = "user-link";
116             }
117
118             const linkOpenToken = new state.Token("link_open", "a", 1);
119             linkOpenToken.attrs = [
120               ["href", href],
121               ["class", linkClass],
122             ];
123             const textToken = new state.Token("text", "", 0);
124             textToken.content = match[0];
125             const linkCloseToken = new state.Token("link_close", "a", -1);
126
127             newTokens.push(linkOpenToken, textToken, linkCloseToken);
128
129             lastIndex =
130               (match.index !== undefined ? match.index : 0) + match[0].length;
131           }
132
133           // If there is plain text after the last match, add it as a separate token
134           if (lastIndex < text.length) {
135             const textToken = new state.Token("text", "", 0);
136             textToken.content = text.slice(lastIndex);
137             newTokens.push(textToken);
138           }
139
140           // Replace the original token with the new tokens
141           inlineTokens.splice(j, 1, ...newTokens);
142         }
143       }
144     }
145   });
146 }
147
148 export function setupMarkdown() {
149   const markdownItConfig: MarkdownIt.Options = {
150     html: false,
151     linkify: true,
152     typographer: true,
153   };
154
155   // const emojiDefs = Array.from(customEmojisLookup.entries()).reduce(
156   //   (main, [key, value]) => ({ ...main, [key]: value }),
157   //   {}
158   // );
159   md = new MarkdownIt(markdownItConfig)
160     .use(markdown_it_sub)
161     .use(markdown_it_sup)
162     .use(markdown_it_footnote)
163     .use(markdown_it_html5_embed, html5EmbedConfig)
164     .use(markdown_it_container, "spoiler", spoilerConfig)
165     .use(localCommunityLinkParser);
166   // .use(markdown_it_emoji, {
167   //   defs: emojiDefs,
168   // });
169
170   mdNoImages = new MarkdownIt(markdownItConfig)
171     .use(markdown_it_sub)
172     .use(markdown_it_sup)
173     .use(markdown_it_footnote)
174     .use(markdown_it_html5_embed, html5EmbedConfig)
175     .use(markdown_it_container, "spoiler", spoilerConfig)
176     .use(localCommunityLinkParser)
177     // .use(markdown_it_emoji, {
178     //   defs: emojiDefs,
179     // })
180     .disable("image");
181   const defaultRenderer = md.renderer.rules.image;
182   md.renderer.rules.image = function (
183     tokens: Token[],
184     idx: number,
185     options: MarkdownIt.Options,
186     env: any,
187     self: Renderer
188   ) {
189     //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
190     const item = tokens[idx] as any;
191     const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
192     const src: string = item.attrs[0][1];
193     const isCustomEmoji = customEmojisLookup.get(title) != undefined;
194     if (!isCustomEmoji) {
195       return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
196     }
197     const alt_text = item.content;
198     return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
199   };
200   md.renderer.rules.table_open = function () {
201     return '<table class="table">';
202   };
203 }
204
205 export function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
206   const groupedEmojis = groupBy(
207     custom_emoji_views,
208     x => x.custom_emoji.category
209   );
210   for (const [category, emojis] of Object.entries(groupedEmojis)) {
211     customEmojis.push({
212       id: category,
213       name: category,
214       emojis: emojis.map(emoji => ({
215         id: emoji.custom_emoji.shortcode,
216         name: emoji.custom_emoji.shortcode,
217         keywords: emoji.keywords.map(x => x.keyword),
218         skins: [{ src: emoji.custom_emoji.image_url }],
219       })),
220     });
221   }
222   customEmojisLookup = new Map(
223     custom_emoji_views.map(view => [view.custom_emoji.shortcode, view])
224   );
225 }
226
227 export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
228   const emoji: EmojiMartCustomEmoji = {
229     id: custom_emoji_view.custom_emoji.shortcode,
230     name: custom_emoji_view.custom_emoji.shortcode,
231     keywords: custom_emoji_view.keywords.map(x => x.keyword),
232     skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
233   };
234   const categoryIndex = customEmojis.findIndex(
235     x => x.id == custom_emoji_view.custom_emoji.category
236   );
237   if (categoryIndex == -1) {
238     customEmojis.push({
239       id: custom_emoji_view.custom_emoji.category,
240       name: custom_emoji_view.custom_emoji.category,
241       emojis: [emoji],
242     });
243   } else {
244     const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
245       x => x.id == custom_emoji_view.custom_emoji.shortcode
246     );
247     if (emojiIndex == -1) {
248       customEmojis[categoryIndex].emojis.push(emoji);
249     } else {
250       customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
251     }
252   }
253   customEmojisLookup.set(
254     custom_emoji_view.custom_emoji.shortcode,
255     custom_emoji_view
256   );
257 }
258
259 export function removeFromEmojiDataModel(id: number) {
260   let view: CustomEmojiView | undefined;
261   for (const item of customEmojisLookup.values()) {
262     if (item.custom_emoji.id === id) {
263       view = item;
264       break;
265     }
266   }
267   if (!view) return;
268   const categoryIndex = customEmojis.findIndex(
269     x => x.id == view?.custom_emoji.category
270   );
271   const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
272     x => x.id == view?.custom_emoji.shortcode
273   );
274   customEmojis[categoryIndex].emojis = customEmojis[
275     categoryIndex
276   ].emojis.splice(emojiIndex, 1);
277
278   customEmojisLookup.delete(view?.custom_emoji.shortcode);
279 }
280
281 export function getEmojiMart(
282   onEmojiSelect: (e: any) => void,
283   customPickerOptions: any = {}
284 ) {
285   const pickerOptions = {
286     ...customPickerOptions,
287     onEmojiSelect: onEmojiSelect,
288     custom: customEmojis,
289   };
290   return new Picker(pickerOptions);
291 }
292
293 export function setupTribute() {
294   return new Tribute({
295     noMatchTemplate: function () {
296       return "";
297     },
298     collection: [
299       // Emojis
300       {
301         trigger: ":",
302         menuItemTemplate: (item: any) => {
303           const shortName = `:${item.original.key}:`;
304           return `${item.original.val} ${shortName}`;
305         },
306         selectTemplate: (item: any) => {
307           const customEmoji = customEmojisLookup.get(
308             item.original.key
309           )?.custom_emoji;
310           if (customEmoji == undefined) return `${item.original.val}`;
311           else
312             return `![${customEmoji.alt_text}](${customEmoji.image_url} "${customEmoji.shortcode}")`;
313         },
314         values: Object.entries(emojiShortName)
315           .map(e => {
316             return { key: e[1], val: e[0] };
317           })
318           .concat(
319             Array.from(customEmojisLookup.entries()).map(k => ({
320               key: k[0],
321               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}" />`,
322             }))
323           ),
324         allowSpaces: false,
325         autocompleteMode: true,
326         // TODO
327         // menuItemLimit: mentionDropdownFetchLimit,
328         menuShowMinLength: 2,
329       },
330       // Persons
331       {
332         trigger: "@",
333         selectTemplate: (item: any) => {
334           const it: PersonTribute = item.original;
335           return `[${it.key}](${it.view.person.actor_id})`;
336         },
337         values: debounce(async (text: string, cb: any) => {
338           cb(await personSearch(text));
339         }),
340         allowSpaces: false,
341         autocompleteMode: true,
342         // TODO
343         // menuItemLimit: mentionDropdownFetchLimit,
344         menuShowMinLength: 2,
345       },
346
347       // Communities
348       {
349         trigger: "!",
350         selectTemplate: (item: any) => {
351           const it: CommunityTribute = item.original;
352           return `[${it.key}](${it.view.community.actor_id})`;
353         },
354         values: debounce(async (text: string, cb: any) => {
355           cb(await communitySearch(text));
356         }),
357         allowSpaces: false,
358         autocompleteMode: true,
359         // TODO
360         // menuItemLimit: mentionDropdownFetchLimit,
361         menuShowMinLength: 2,
362       },
363     ],
364   });
365 }
366
367 interface EmojiMartCategory {
368   id: string;
369   name: string;
370   emojis: EmojiMartCustomEmoji[];
371 }
372
373 interface EmojiMartCustomEmoji {
374   id: string;
375   name: string;
376   keywords: string[];
377   skins: EmojiMartSkin[];
378 }
379
380 interface EmojiMartSkin {
381   src: string;
382 }