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