@mui/internal-markdown 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,481 @@
1
+ const { marked } = require('marked');
2
+ const textToHash = require('./textToHash');
3
+ const prism = require('./prism');
4
+
5
+ /**
6
+ * Option used by `marked` the library parsing markdown.
7
+ */
8
+ const markedOptions = {
9
+ gfm: true,
10
+ tables: true,
11
+ breaks: false,
12
+ pedantic: false,
13
+ sanitize: false,
14
+ smartLists: true,
15
+ smartypants: false,
16
+ headerPrefix: false,
17
+ headerIds: false,
18
+ mangle: false,
19
+ };
20
+
21
+ const headerRegExp = /---[\r\n]([\s\S]*)[\r\n]---/;
22
+ const titleRegExp = /# (.*)[\r\n]/;
23
+ const descriptionRegExp = /<p class="description">(.*?)<\/p>/s;
24
+ const headerKeyValueRegExp = /(.*?):[\r\n]?\s+(\[[^\]]+\]|.*)/g;
25
+ const emptyRegExp = /^\s*$/;
26
+
27
+ /**
28
+ * Same as https://github.com/markedjs/marked/blob/master/src/helpers.js
29
+ * Need to duplicate because `marked` does not export `escape` function
30
+ */
31
+ const escapeTest = /[&<>"']/;
32
+ const escapeReplace = /[&<>"']/g;
33
+ const escapeTestNoEncode = /[<>"']|&(?!#?\w+;)/;
34
+ const escapeReplaceNoEncode = /[<>"']|&(?!#?\w+;)/g;
35
+ const escapeReplacements = {
36
+ '&': '&amp;',
37
+ '<': '&lt;',
38
+ '>': '&gt;',
39
+ '"': '&quot;',
40
+ "'": '&#39;',
41
+ };
42
+ const getEscapeReplacement = (ch) => escapeReplacements[ch];
43
+ function escape(html, encode) {
44
+ if (encode) {
45
+ if (escapeTest.test(html)) {
46
+ return html.replace(escapeReplace, getEscapeReplacement);
47
+ }
48
+ } else if (escapeTestNoEncode.test(html)) {
49
+ return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
50
+ }
51
+
52
+ return html;
53
+ }
54
+
55
+ function checkUrlHealth(href, linkText, context) {
56
+ const url = new URL(href, 'https://mui.com/');
57
+
58
+ if (/\/{2,}$/.test(url.pathname)) {
59
+ throw new Error(
60
+ [
61
+ 'docs-infra: Duplicated trailing slashes. The following link:',
62
+ `[${linkText}](${href}) in ${context.location} has duplicated trailing slashes, please only add one.`,
63
+ '',
64
+ 'See https://ahrefs.com/blog/trailing-slash/ for more details.',
65
+ '',
66
+ ].join('\n'),
67
+ );
68
+ }
69
+
70
+ // External links to MUI, ignore
71
+ if (url.host !== 'mui.com') {
72
+ return;
73
+ }
74
+
75
+ /**
76
+ * Break for links like:
77
+ * /material-ui/customization/theming
78
+ *
79
+ * It needs to be:
80
+ * /material-ui/customization/theming/
81
+ */
82
+ if (url.pathname[url.pathname.length - 1] !== '/') {
83
+ throw new Error(
84
+ [
85
+ 'docs-infra: Missing trailing slash. The following link:',
86
+ `[${linkText}](${href}) in ${context.location} is missing a trailing slash, please add it.`,
87
+ '',
88
+ 'See https://ahrefs.com/blog/trailing-slash/ for more details.',
89
+ '',
90
+ ].join('\n'),
91
+ );
92
+ }
93
+
94
+ // Relative links
95
+ if (href[0] !== '#' && !(href.startsWith('https://') || href.startsWith('http://'))) {
96
+ /**
97
+ * Break for links like:
98
+ * material-ui/customization/theming/
99
+ *
100
+ * It needs to be:
101
+ * /material-ui/customization/theming/
102
+ */
103
+ if (href[0] !== '/') {
104
+ throw new Error(
105
+ [
106
+ 'docs-infra: Missing leading slash. The following link:',
107
+ `[${linkText}](${href}) in ${context.location} is missing a leading slash, please add it.`,
108
+ '',
109
+ ].join('\n'),
110
+ );
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Extract information from the top of the markdown.
117
+ * For instance, the following input:
118
+ *
119
+ * ---
120
+ * title: Backdrop React Component
121
+ * components: Backdrop
122
+ * ---
123
+ *
124
+ * # Backdrop
125
+ *
126
+ * should output:
127
+ * { title: 'Backdrop React Component', components: ['Backdrop'] }
128
+ */
129
+ function getHeaders(markdown) {
130
+ let header = markdown.match(headerRegExp);
131
+
132
+ if (!header) {
133
+ return {
134
+ components: [],
135
+ };
136
+ }
137
+
138
+ header = header[1];
139
+
140
+ try {
141
+ let regexMatches;
142
+ const headers = {};
143
+
144
+ // eslint-disable-next-line no-cond-assign
145
+ while ((regexMatches = headerKeyValueRegExp.exec(header)) !== null) {
146
+ const key = regexMatches[1];
147
+ let value = regexMatches[2].replace(/(.*)/, '$1');
148
+ if (value[0] === '[') {
149
+ // Need double quotes to JSON parse.
150
+ value = value.replace(/'/g, '"');
151
+ // Remove the comma after the last value e.g. ["foo", "bar",] -> ["foo", "bar"].
152
+ value = value.replace(/,\s+\]$/g, ']');
153
+ headers[key] = JSON.parse(value);
154
+ } else {
155
+ // Remove trailing single quote yml escaping.
156
+ headers[key] = value.replace(/^'|'$/g, '');
157
+ }
158
+ }
159
+
160
+ if (headers.components) {
161
+ headers.components = headers.components
162
+ .split(',')
163
+ .map((x) => x.trim())
164
+ .sort();
165
+ } else {
166
+ headers.components = [];
167
+ }
168
+
169
+ if (headers.hooks) {
170
+ headers.hooks = headers.hooks
171
+ .split(',')
172
+ .map((x) => x.trim())
173
+ .sort();
174
+ } else {
175
+ headers.hooks = [];
176
+ }
177
+
178
+ return headers;
179
+ } catch (err) {
180
+ throw new Error(
181
+ `docs-infra: ${err.message} in getHeader(markdown) with markdown: \n\n${header}\n`,
182
+ );
183
+ }
184
+ }
185
+
186
+ function getContents(markdown) {
187
+ const rep = markdown
188
+ .replace(headerRegExp, '') // Remove header information
189
+ .split(/^{{("(?:demo|component)":.*)}}$/gm) // Split markdown into an array, separating demos
190
+ .flatMap((text) => text.split(/^(<codeblock.*?<\/codeblock>)$/gmsu))
191
+ .filter((content) => !emptyRegExp.test(content)); // Remove empty lines
192
+ return rep;
193
+ }
194
+
195
+ function getTitle(markdown) {
196
+ const matches = markdown.match(titleRegExp);
197
+
198
+ if (matches === null) {
199
+ return '';
200
+ }
201
+
202
+ return matches[1].replace(/`/g, '');
203
+ }
204
+
205
+ function getDescription(markdown) {
206
+ const matches = markdown.match(descriptionRegExp);
207
+ if (matches === null) {
208
+ return undefined;
209
+ }
210
+
211
+ return matches[1].trim().replace(/`/g, '');
212
+ }
213
+
214
+ function getCodeblock(content) {
215
+ if (content.startsWith('<codeblock')) {
216
+ const storageKey = content.match(/^<codeblock [^>]*storageKey=["|'](\S*)["|'].*>/m)?.[1];
217
+ const blocks = [...content.matchAll(/^```(\S*) (\S*)\n(.*?)\n```/gmsu)].map(
218
+ ([, language, tab, code]) => ({ language, tab, code }),
219
+ );
220
+
221
+ const blocksData = blocks.filter(
222
+ (block) => block.tab !== undefined && !emptyRegExp.test(block.code),
223
+ );
224
+
225
+ return {
226
+ type: 'codeblock',
227
+ data: blocksData,
228
+ storageKey,
229
+ };
230
+ }
231
+ return undefined;
232
+ }
233
+
234
+ /**
235
+ * @param {string} markdown
236
+ */
237
+ function renderMarkdown(markdown) {
238
+ // Check if the markdown contains an inline list. Unordered lists are block elements and cannot be parsed inline.
239
+ if (/[-*+] `([A-Za-z]+)`/g.test(markdown)) {
240
+ return marked.parse(markdown, markedOptions);
241
+ }
242
+ // Two new lines result in a newline in the table.
243
+ // All other new lines must be eliminated to prevent markdown mayhem.
244
+ return marked
245
+ .parseInline(markdown, markedOptions)
246
+ .replace(/(\r?\n){2}/g, '<br>')
247
+ .replace(/\r?\n/g, ' ');
248
+ }
249
+
250
+ // Help rank mui.com on component searches first.
251
+ const noSEOadvantage = [
252
+ 'https://m2.material.io/',
253
+ 'https://m3.material.io/',
254
+ 'https://material.io/',
255
+ 'https://getbootstrap.com/',
256
+ 'https://icons.getbootstrap.com/',
257
+ 'https://pictogrammers.com/',
258
+ 'https://www.w3.org/',
259
+ 'https://tailwindcss.com/',
260
+ 'https://heroicons.com/',
261
+ 'https://react-icons.github.io/',
262
+ 'https://fontawesome.com/',
263
+ 'https://www.radix-ui.com/',
264
+ 'https://react-spectrum.adobe.com/',
265
+ 'https://headlessui.com/',
266
+ 'https://refine.dev/',
267
+ 'https://scaffoldhub.io/',
268
+ ];
269
+
270
+ /**
271
+ * Creates a function that MUST be used to render non-inline markdown.
272
+ * It keeps track of a table of contents and hashes of its items.
273
+ * This is important to create anchors that are invariant between languages.
274
+ *
275
+ * @typedef {object} TableOfContentsEntry
276
+ * @property {TableOfContentsEntry[]} children
277
+ * @property {string} hash
278
+ * @property {number} level
279
+ * @property {string} text
280
+ * @param {object} context
281
+ * @param {Record<string, string>} context.headingHashes - WILL BE MUTATED
282
+ * @param {TableOfContentsEntry[]} context.toc - WILL BE MUTATED
283
+ * @param {string} context.userLanguage
284
+ * @param {object} context.options
285
+ */
286
+ function createRender(context) {
287
+ const { headingHashes, toc, userLanguage, options } = context;
288
+ const headingHashesFallbackTranslated = {};
289
+ let headingIndex = -1;
290
+
291
+ /**
292
+ * @param {string} markdown
293
+ */
294
+ function render(markdown) {
295
+ const renderer = new marked.Renderer();
296
+ renderer.heading = (headingHtml, level) => {
297
+ // Main title, no need for an anchor.
298
+ // It adds noises to the URL.
299
+ //
300
+ // Small title, no need for an anchor.
301
+ // It reduces the risk of duplicated id and it's fewer elements in the DOM.
302
+ if (level === 1 || level >= 4) {
303
+ return `<h${level}>${headingHtml}</h${level}>`;
304
+ }
305
+
306
+ // Remove links to avoid nested links in the TOCs
307
+ let headingText = headingHtml.replace(/<a\b[^>]*>/gi, '').replace(/<\/a>/gi, '');
308
+ // Remove `code` tags
309
+ headingText = headingText.replace(/<code\b[^>]*>/gi, '').replace(/<\/code>/gi, '');
310
+
311
+ // Standardizes the hash from the default location (en) to different locations
312
+ // Need english.md file parsed first
313
+ let hash;
314
+ if (userLanguage === 'en') {
315
+ hash = textToHash(headingText, headingHashes);
316
+ } else {
317
+ headingIndex += 1;
318
+ hash = Object.keys(headingHashes)[headingIndex];
319
+ if (!hash) {
320
+ hash = textToHash(headingText, headingHashesFallbackTranslated);
321
+ }
322
+ }
323
+
324
+ // enable splitting of long words from function name + first arg name
325
+ // Closing parens are less interesting since this would only allow breaking one character earlier.
326
+ // Applying the same mechanism would also allow breaking of non-function signatures like "Community help (free)".
327
+ // To detect that we enabled breaking of open/closing parens we'd need a context-sensitive parser.
328
+ const displayText = headingText.replace(/([^\s]\()/g, '$1&#8203;');
329
+
330
+ // create a nested structure with 2 levels starting with level 2 e.g.
331
+ // [{...level2, children: [level3, level3, level3]}, level2]
332
+ if (level === 2) {
333
+ toc.push({
334
+ text: displayText,
335
+ level,
336
+ hash,
337
+ children: [],
338
+ });
339
+ } else if (level === 3) {
340
+ if (!toc[toc.length - 1]) {
341
+ throw new Error(`docs-infra: Missing parent level for: ${headingText}\n`);
342
+ }
343
+
344
+ toc[toc.length - 1].children.push({
345
+ text: displayText,
346
+ level,
347
+ hash,
348
+ });
349
+ }
350
+
351
+ return [
352
+ `<h${level} id="${hash}">`,
353
+ headingHtml,
354
+ `<a aria-labelledby="${hash}" class="anchor-link" href="#${hash}" tabindex="-1">`,
355
+ '<svg><use xlink:href="#anchor-link-icon" /></svg>',
356
+ '</a>',
357
+ `<button title="Post a comment" class="comment-link" data-feedback-hash="${hash}">`,
358
+ '<svg><use xlink:href="#comment-link-icon" /></svg>',
359
+ `</button>`,
360
+ `</h${level}>`,
361
+ ].join('');
362
+ };
363
+ renderer.link = (href, linkTitle, linkText) => {
364
+ let more = '';
365
+
366
+ if (linkTitle) {
367
+ more += ` title="${linkTitle}"`;
368
+ }
369
+
370
+ if (noSEOadvantage.some((domain) => href.indexOf(domain) !== -1)) {
371
+ more = ' target="_blank" rel="noopener nofollow"';
372
+ }
373
+
374
+ let finalHref = href;
375
+
376
+ checkUrlHealth(href, linkText, context);
377
+
378
+ if (userLanguage !== 'en' && href.indexOf('/') === 0 && !options.ignoreLanguagePages(href)) {
379
+ finalHref = `/${userLanguage}${href}`;
380
+ }
381
+
382
+ // This logic turns link like:
383
+ // https://github.com/mui/material-ui/blob/-/packages/mui-joy/src/styles/components.d.ts
384
+ // into a permalink:
385
+ // https://github.com/mui/material-ui/blob/v5.11.15/packages/mui-joy/src/styles/components.d.ts
386
+ if (finalHref.startsWith(`${options.env.SOURCE_CODE_REPO}/blob/-/`)) {
387
+ finalHref = finalHref.replace(
388
+ `${options.env.SOURCE_CODE_REPO}/blob/-/`,
389
+ `${options.env.SOURCE_CODE_REPO}/blob/v${options.env.LIB_VERSION}/`,
390
+ );
391
+ }
392
+
393
+ return `<a href="${finalHref}"${more}>${linkText}</a>`;
394
+ };
395
+ renderer.code = (code, infostring, escaped) => {
396
+ // https://github.com/markedjs/marked/blob/30e90e5175700890e6feb1836c57b9404c854466/src/Renderer.js#L15
397
+ const lang = (infostring || '').match(/\S*/)[0];
398
+ const out = prism(code, lang);
399
+ if (out != null && out !== code) {
400
+ escaped = true;
401
+ code = out;
402
+ }
403
+
404
+ code = `${code.replace(/\n$/, '')}\n`;
405
+
406
+ if (!lang) {
407
+ return `<pre><code>${escaped ? code : escape(code, true)}</code></pre>\n`;
408
+ }
409
+
410
+ return `<div class="MuiCode-root"><pre><code class="language-${escape(lang, true)}">${
411
+ escaped ? code : escape(code, true)
412
+ }</code></pre>${[
413
+ '<button data-ga-event-category="code" data-ga-event-action="copy-click" aria-label="Copy the code" class="MuiCode-copy">',
414
+ '<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24" data-testid="ContentCopyRoundedIcon">',
415
+ '<use class="MuiCode-copy-icon" xlink:href="#copy-icon" />',
416
+ '<use class="MuiCode-copied-icon" xlink:href="#copied-icon" />',
417
+ '</svg>',
418
+ '<span class="MuiCode-copyKeypress"><span>(or</span> $keyC<span>)</span></span></button></div>',
419
+ ].join('')}\n`;
420
+ };
421
+
422
+ marked.use({
423
+ extensions: [
424
+ {
425
+ name: 'callout',
426
+ level: 'block',
427
+ start(src) {
428
+ const match = src.match(/:::/);
429
+ return match ? match.index : undefined;
430
+ },
431
+ tokenizer(src) {
432
+ const rule =
433
+ /^ {0,3}(:{3,}(?=[^:\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~:]* *(?=\n|$)|$)/;
434
+ const match = rule.exec(src);
435
+ if (match) {
436
+ const token = {
437
+ type: 'callout',
438
+ raw: match[0],
439
+ text: match[3].trim(),
440
+ severity: match[2],
441
+ tokens: [],
442
+ };
443
+ this.lexer.blockTokens(token.text, token.tokens);
444
+ return token;
445
+ }
446
+ return undefined;
447
+ },
448
+
449
+ renderer(token) {
450
+ return `<aside class="MuiCallout-root MuiCallout-${token.severity}">
451
+ ${
452
+ ['info', 'success', 'warning', 'error'].includes(token.severity)
453
+ ? [
454
+ '<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24" data-testid="ContentCopyRoundedIcon">',
455
+ `<use class="MuiCode-copied-icon" xlink:href="#${token.severity}-icon" />`,
456
+ '</svg>',
457
+ ].join('\n')
458
+ : ''
459
+ }
460
+ <div class="MuiCallout-content">
461
+ ${this.parser.parse(token.tokens)}\n</div></aside>`;
462
+ },
463
+ },
464
+ ],
465
+ });
466
+
467
+ return marked(markdown, { ...markedOptions, renderer });
468
+ }
469
+
470
+ return render;
471
+ }
472
+
473
+ module.exports = {
474
+ createRender,
475
+ getContents,
476
+ getDescription,
477
+ getCodeblock,
478
+ getHeaders,
479
+ getTitle,
480
+ renderMarkdown,
481
+ };