import { marked } from 'marked'; import textToHash from './textToHash.mjs'; import prism from './prism.mjs'; /** * Option used by `marked` the library parsing markdown. */ const markedOptions = { gfm: true, breaks: false, pedantic: false, }; const headerRegExp = /---[\r\n]([\s\S]*)[\r\n]---/; const titleRegExp = /# (.*)[\r\n]/; const descriptionRegExp = /

(.*?)<\/p>/s; const headerKeyValueRegExp = /(.*?):[\r\n]?\s+(\[[^\]]+\]|.*)/g; const emptyRegExp = /^\s*$/; /** * Same as https://github.com/markedjs/marked/blob/master/src/helpers.js * Need to duplicate because `marked` does not export `escape` function */ const escapeTest = /[&<>"']/; const escapeReplace = /[&<>"']/g; const escapeTestNoEncode = /[<>"']|&(?!#?\w+;)/; const escapeReplaceNoEncode = /[<>"']|&(?!#?\w+;)/g; const escapeReplacements = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; const getEscapeReplacement = (ch) => escapeReplacements[ch]; function escape(html, encode) { if (encode) { if (escapeTest.test(html)) { return html.replace(escapeReplace, getEscapeReplacement); } } else if (escapeTestNoEncode.test(html)) { return html.replace(escapeReplaceNoEncode, getEscapeReplacement); } return html; } function checkUrlHealth(href, linkText, context) { const url = new URL(href, 'https://mui.com/'); if (/\/{2,}$/.test(url.pathname)) { throw new Error( [ 'docs-infra: Duplicated trailing slashes. The following link:', `[${linkText}](${href}) in ${context.location} has duplicated trailing slashes, please only add one.`, '', 'See https://ahrefs.com/blog/trailing-slash/ for more details.', '', ].join('\n'), ); } // External links to MUI, ignore if (url.host !== 'mui.com') { return; } /** * Break for links like: * /material-ui/customization/theming * * It needs to be: * /material-ui/customization/theming/ */ if (!url.pathname.endsWith('/')) { throw new Error( [ 'docs-infra: Missing trailing slash. The following link:', `[${linkText}](${href}) in ${context.location} is missing a trailing slash, please add it.`, '', 'See https://ahrefs.com/blog/trailing-slash/ for more details.', '', ].join('\n'), ); } // Relative links if (href[0] !== '#' && !(href.startsWith('https://') || href.startsWith('http://'))) { /** * Break for links like: * material-ui/customization/theming/ * * It needs to be: * /material-ui/customization/theming/ */ if (href[0] !== '/') { throw new Error( [ 'docs-infra: Missing leading slash. The following link:', `[${linkText}](${href}) in ${context.location} is missing a leading slash, please add it.`, '', ].join('\n'), ); } } } /** * Extract information from the top of the markdown. * For instance, the following input: * * --- * title: Backdrop React Component * components: Backdrop * --- * * # Backdrop * * should output: * { title: 'Backdrop React Component', components: ['Backdrop'] } */ function getHeaders(markdown) { let header = markdown.match(headerRegExp); if (!header) { return { components: [], }; } header = header[1]; try { let regexMatches; const headers = {}; // eslint-disable-next-line no-cond-assign while ((regexMatches = headerKeyValueRegExp.exec(header)) !== null) { const key = regexMatches[1]; let value = regexMatches[2].replace(/(.*)/, '$1'); if (value.startsWith('[')) { // Need double quotes to JSON parse. value = value.replace(/'/g, '"'); // Remove the comma after the last value e.g. ["foo", "bar",] -> ["foo", "bar"]. value = value.replace(/,\s+\]$/g, ']'); headers[key] = JSON.parse(value); } else { // Remove quote YAML escaping. headers[key] = value.replace(/^"|"$|^'|'$/g, ''); } } if (headers.components) { headers.components = headers.components .split(',') .map((x) => x.trim()) .sort(); } else { headers.components = []; } if (headers.hooks) { headers.hooks = headers.hooks .split(',') .map((x) => x.trim()) .sort(); } else { headers.hooks = []; } return headers; } catch (err) { throw new Error( `docs-infra: ${err.message} in getHeader(markdown) with markdown: \n\n${header}\n`, ); } } function getContents(markdown) { const rep = markdown .replace(headerRegExp, '') // Remove header information .split(/^{{("(?:demo|component)":.*)}}$/gm) // Split markdown into an array, separating demos .flatMap((text) => text.split(/^()$/gmsu)) .flatMap((text) => text.split(/^()$/gmsu)) .filter((content) => !emptyRegExp.test(content)); // Remove empty lines return rep; } function getTitle(markdown) { const matches = markdown.match(titleRegExp); if (matches === null) { return ''; } return matches[1].replace(/`/g, ''); } function getDescription(markdown) { const matches = markdown.match(descriptionRegExp); if (matches === null) { return undefined; } return matches[1].trim().replace(/`/g, ''); } function getCodeblock(content) { if (!content.startsWith(']*storageKey=["|'](?!storageKey=["|'])(\S*)["|'].*>/m, )?.[1]; const blocks = [...content.matchAll(/^```(\S*) (\S*)\n(.*?)\n```/gmsu)].map( ([, language, tab, code]) => ({ language, tab, code }), ); const blocksData = blocks.filter( (block) => block.tab !== undefined && !emptyRegExp.test(block.code), ); return { type: 'codeblock', data: blocksData, storageKey, }; } function getFeatureList(content) { if (!content.startsWith(' line.startsWith('- ')) .map((line) => line.slice(2)); return ['

'].join( '', ); } /** * @param {string} markdown */ function renderMarkdown(markdown) { // Check if the markdown contains an inline list. Unordered lists are block elements and cannot be parsed inline. if (/[-*+] `([A-Za-z]+)`/g.test(markdown)) { return marked.parse(markdown, markedOptions); } // Two new lines result in a newline in the table. // All other new lines must be eliminated to prevent markdown mayhem. return marked .parseInline(markdown, markedOptions) .replace(/(\r?\n){2}/g, '
') .replace(/\r?\n/g, ' '); } // Help rank mui.com on component searches first. const noSEOadvantage = [ 'https://m2.material.io/', 'https://m3.material.io/', 'https://material.io/', 'https://getbootstrap.com/', 'https://icons.getbootstrap.com/', 'https://pictogrammers.com/', 'https://www.w3.org/', 'https://tailwindcss.com/', 'https://heroicons.com/', 'https://react-icons.github.io/', 'https://fontawesome.com/', 'https://react-spectrum.adobe.com/', 'https://headlessui.com/', 'https://refine.dev/', 'https://scaffoldhub.io/', 'https://marmelab.com/', 'https://framesxdesign.com/', ]; /** * Creates a function that MUST be used to render non-inline markdown. * It keeps track of a table of contents and hashes of its items. * This is important to create anchors that are invariant between languages. * * @typedef {object} TableOfContentsEntry * @property {TableOfContentsEntry[]} children * @property {string} hash * @property {number} level * @property {string} text * @param {object} context * @param {Record} [context.headingHashes] - WILL BE MUTATED * @param {TableOfContentsEntry[]} [context.toc] - WILL BE MUTATED * @param {string} [context.userLanguage] * @param {object} context.options */ function createRender(context) { const { headingHashes = {}, toc = [], userLanguage = 'en', options } = context; const headingHashesFallbackTranslated = {}; let headingIndex = -1; /** * @param {string} markdown */ function render(markdown) { const renderer = new marked.Renderer(); renderer.heading = function heading({ tokens, depth: level }) { // Main title, no need for an anchor. // It adds noises to the URL. // // Small title, no need for an anchor. // It reduces the risk of duplicated id and it's fewer elements in the DOM. const headingHtml = this.parser.parseInline(tokens); if (level === 1 || level >= 4) { return `${headingHtml}`; } // Remove links to avoid nested links in the TOCs let headingText = headingHtml.replace(/]*>/gi, '').replace(/<\/a>/gi, ''); // Remove `code` tags headingText = headingText.replace(/]*>/gi, '').replace(/<\/code>/gi, ''); // Standardizes the hash from the default location (en) to different locations // Need english.md file parsed first let hash; if (userLanguage === 'en') { hash = textToHash(headingText, headingHashes); } else { headingIndex += 1; hash = Object.keys(headingHashes)[headingIndex]; if (!hash) { hash = textToHash(headingText, headingHashesFallbackTranslated); } } // enable splitting of long words from function name + first arg name // Closing parens are less interesting since this would only allow breaking one character earlier. // Applying the same mechanism would also allow breaking of non-function signatures like "Community help (free)". // To detect that we enabled breaking of open/closing parens we'd need a context-sensitive parser. const displayText = headingText.replace(/([^\s]\()/g, '$1​'); // create a nested structure with 2 levels starting with level 2 e.g. // [{...level2, children: [level3, level3, level3]}, level2] if (level === 2) { toc.push({ text: displayText, level, hash, children: [], }); } else if (level === 3) { if (!toc[toc.length - 1]) { throw new Error(`docs-infra: Missing parent level for: ${headingText}\n`); } toc[toc.length - 1].children.push({ text: displayText, level, hash, }); } return [ headingHtml.includes('${headingHtml}`, ``, ].join('') : `${headingHtml}`, ``, ``, ].join(''); }; renderer.link = function link({ href, title, tokens }) { const linkText = this.parser.parseInline(tokens); let more = ''; if (title) { more += ` title="${title}"`; } if (noSEOadvantage.some((domain) => href.includes(domain))) { more = ' target="_blank" rel="noopener nofollow"'; } let finalHref = href; checkUrlHealth(href, linkText, context); if (userLanguage !== 'en' && href.startsWith('/') && !options.ignoreLanguagePages(href)) { finalHref = `/${userLanguage}${href}`; } // This logic turns link like: // https://github.com/mui/material-ui/blob/-/packages/mui-joy/src/styles/components.d.ts // into a permalink: // https://github.com/mui/material-ui/blob/v5.11.15/packages/mui-joy/src/styles/components.d.ts if (finalHref.startsWith(`${options.env.SOURCE_CODE_REPO}/blob/-/`)) { finalHref = finalHref.replace( `${options.env.SOURCE_CODE_REPO}/blob/-/`, `${options.env.SOURCE_CODE_REPO}/blob/v${options.env.LIB_VERSION}/`, ); } return `${linkText}`; }; renderer.code = ({ lang, text, escaped }) => { // https://github.com/markedjs/marked/blob/30e90e5175700890e6feb1836c57b9404c854466/src/Renderer.js#L15 const langString = (lang || '').match(/\S*/)[0]; const title = (lang || '').match(/title="([^"]*)"/)?.[1]; const out = prism(text, langString); if (out != null && out !== text) { escaped = true; text = out; } const code = `${text.replace(/\n$/, '')}\n`; if (!lang) { return `
${escaped ? code : escape(code, true)}
\n`; } return `
${title ? `
${title}
` : ''}
${
        escaped ? code : escape(code, true)
      }
${[ '
', ].join('')}\n`; }; marked.use({ extensions: [ { name: 'callout', level: 'block', start(src) { const match = src.match(/:::/); return match ? match.index : undefined; }, tokenizer(src) { const rule = /^ {0,3}(:{3,}(?=[^:\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~:]* *(?=\n|$)|$)/; const match = rule.exec(src); if (match) { const token = { type: 'callout', raw: match[0], text: match[3].trim(), severity: match[2], tokens: [], }; this.lexer.blockTokens(token.text, token.tokens); return token; } return undefined; }, renderer(token) { if (!['info', 'success', 'warning', 'error'].includes(token.severity)) { throw new Error(`docs-infra: Callout :::${token.severity} is not supported`); } return ``; }, }, ], }); return marked(markdown, { ...markedOptions, renderer }); } return render; } export { createRender, getContents, getDescription, getCodeblock, getFeatureList, getHeaders, getTitle, renderMarkdown, };