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