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