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