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