/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // @ts-check /** @typedef {{ * type: string, * text?: string, * children?: MarkdownNode[], * codeLang?: string, * }} MarkdownBaseNode */ /** @typedef {MarkdownBaseNode & { * type: 'text', * text: string, * }} MarkdownTextNode */ /** @typedef {MarkdownBaseNode & { * type: 'h0' | 'h1' | 'h2' | 'h3' | 'h4', * text: string, * children: MarkdownNode[] * }} MarkdownHeaderNode */ /** @typedef {MarkdownBaseNode & { * type: 'li', * text: string, * liType: 'default' | 'bullet' | 'ordinal', * children: MarkdownNode[] * }} MarkdownLiNode */ /** @typedef {MarkdownBaseNode & { * type: 'code', * lines: string[], * codeLang: string, * title?: string, * highlight?: string, * }} MarkdownCodeNode */ /** @typedef {MarkdownBaseNode & { * type: 'note', * noteType: string, * }} MarkdownNoteNode */ /** @typedef {MarkdownBaseNode & { * type: 'null', * }} MarkdownNullNode */ /** @typedef {MarkdownBaseNode & { * type: 'properties', * lines: string[], * }} MarkdownPropsNode */ /** @typedef {{ * maxColumns?: number, * omitLastCR?: boolean, * flattenText?: boolean, * renderCodeBlockTitlesInHeader?: boolean, * noteMode?: 'docusaurus' | 'compact', * }} RenderOptions */ /** @typedef {MarkdownTextNode | MarkdownLiNode | MarkdownCodeNode | MarkdownNoteNode | MarkdownHeaderNode | MarkdownNullNode | MarkdownPropsNode } MarkdownNode */ function flattenWrappedLines(content) { const inLines = content.replace(/\r\n/g, '\n').split('\n'); let inCodeBlock = false; const outLines = []; let outLineTokens = []; for (const line of inLines) { const trimmedLine = line.trim(); const singleLineExpression = line.startsWith('#'); const codeBlockBoundary = trimmedLine.startsWith('```') || trimmedLine.startsWith('---') || trimmedLine.startsWith(':::'); let flushLastParagraph = !trimmedLine || trimmedLine.startsWith('1.') || trimmedLine.startsWith('<') || trimmedLine.startsWith('>') || trimmedLine.startsWith('|') || trimmedLine.startsWith('-') || trimmedLine.startsWith('*') || line.match(/\[[^\]]+\]:.*/) || singleLineExpression; if (codeBlockBoundary) { inCodeBlock = !inCodeBlock; flushLastParagraph = true; } if (flushLastParagraph && outLineTokens.length) { outLines.push(outLineTokens.join('↵')); outLineTokens = []; } if (inCodeBlock || singleLineExpression || codeBlockBoundary) outLines.push(line); else if (trimmedLine) outLineTokens.push(outLineTokens.length ? line.trim() : line); } if (outLineTokens.length) outLines.push(outLineTokens.join('↵')); return outLines; } /** * @param {string[]} lines */ function buildTree(lines) { /** @type {MarkdownNode} */ const root = { type: 'h0', text: '', children: [] }; /** @type {MarkdownNode[]} */ const headerStack = [root]; /** @type {{ indent: string, node: MarkdownNode }[]} */ const sectionStack = []; /** * @param {string} indent * @param {MarkdownNode} node */ const appendNode = (indent, node) => { while (sectionStack.length && sectionStack[0].indent.length >= indent.length) sectionStack.shift(); const parentNode = sectionStack.length ? sectionStack[0].node : headerStack[0]; if (!parentNode.children) parentNode.children = []; parentNode.children.push(node); if (node.type === 'li') sectionStack.unshift({ indent, node }); }; for (let i = 0; i < lines.length; ++i) { let line = lines[i]; // Headers form hierarchy. const header = line.match(/^(#+)/); if (header) { const h = header[1].length; const node = /** @type {MarkdownNode} */({ type: 'h' + h, text: line.substring(h + 1), children: [] }); while (true) { const lastH = +headerStack[0].type.substring(1); if (h <= lastH) headerStack.shift(); else break; } /** @type {MarkdownNode[]}*/(headerStack[0].children).push(node); headerStack.unshift(node); continue; } // Remaining items respect indent-based nesting. const [, indent, content] = /** @type {string[]} */ (line.match('^([ ]*)(.*)')); if (content.startsWith('```')) { const [codeLang, title, highlight] = parseCodeBlockMetadata(content); /** @type {MarkdownNode} */ const node = { type: 'code', lines: [], codeLang, title, highlight, }; line = lines[++i]; while (!line.trim().startsWith('```')) { if (line && !line.startsWith(indent)) { const from = Math.max(0, i - 5); const to = Math.min(lines.length, from + 10); const snippet = lines.slice(from, to); throw new Error(`Bad code block: ${snippet.join('\n')}`); } if (line) line = line.substring(indent.length); node.lines.push(line); line = lines[++i]; } appendNode(indent, node); continue; } if (content.startsWith(':::')) { /** @type {MarkdownNode} */ const node = /** @type {MarkdownNoteNode} */ ({ type: 'note', noteType: content.substring(3) }); line = lines[++i]; const tokens = []; while (!line.trim().startsWith(':::')) { if (!line.startsWith(indent)) { const from = Math.max(0, i - 5); const to = Math.min(lines.length, from + 10); const snippet = lines.slice(from, to); throw new Error(`Bad comment block: ${snippet.join('\n')}`); } tokens.push(line.substring(indent.length)); line = lines[++i]; } node.children = parse(tokens.join('\n')); appendNode(indent, node); continue; } if (content.startsWith('---')) { /** @type {MarkdownNode} */ const node = { type: 'properties', lines: [], }; line = lines[++i]; while (!line.trim().startsWith('---')) { if (!line.startsWith(indent)) throw new Error('Bad header block ' + line); node.lines.push(line.substring(indent.length)); line = lines[++i]; } appendNode(indent, node); continue; } const liType = content.match(/^(-|1.|\*) /); const node = /** @type {MarkdownNode} */({ type: 'text', text: content }); if (liType) { const liNode = /** @type {MarkdownLiNode} */(node); liNode.type = 'li'; liNode.text = content.substring(liType[0].length); if (content.startsWith('1.')) liNode.liType = 'ordinal'; else if (content.startsWith('*')) liNode.liType = 'bullet'; else liNode.liType = 'default'; } const match = node.text?.match(/\*\*langs: (.*)\*\*(.*)/); if (match) { node.codeLang = match[1]; node.text = match[2]; } appendNode(indent, node); } return root.children; } /** * @param {String} firstLine * @returns {[string, string|undefined, string|undefined]} */ function parseCodeBlockMetadata(firstLine) { const withoutBackticks = firstLine.substring(3); const titleMatch = withoutBackticks.match(/ title="(.+)"/); const highlightMatch = withoutBackticks.match(/\{.*\}/); let codeLang = withoutBackticks; if (titleMatch || highlightMatch) codeLang = withoutBackticks.substring(0, titleMatch?.index ?? highlightMatch?.index); return [codeLang, titleMatch?.[1], highlightMatch?.[0]]; } /** * @param {string} content */ function parse(content) { return buildTree(flattenWrappedLines(content)); } /** * @param {MarkdownNode[]} nodes * @param {RenderOptions=} options */ function render(nodes, options) { const result = []; let lastNode; for (const node of nodes) { if (node.type === 'null') continue; innerRenderMdNode('', node, /** @type {MarkdownNode} */ (lastNode), result, options); lastNode = node; } if (!options?.omitLastCR && result[result.length - 1] !== '') result.push(''); return result.join('\n'); } /** * @param {string} indent * @param {MarkdownNode} node * @param {MarkdownNode} lastNode * @param {RenderOptions=} options * @param {string[]} result */ function innerRenderMdNode(indent, node, lastNode, result, options) { const newLine = () => { if (result.length && (result[result.length - 1] || '').trim() !== '') result.push(indent); }; if (node.type.startsWith('h')) { const headerNode = /** @type {MarkdownHeaderNode} */ (node); newLine(); const depth = +node.type.substring(1); result.push(`${'#'.repeat(depth)} ${headerNode.text}`); let lastNode = node; for (const child of node.children || []) { innerRenderMdNode('', child, lastNode, result, options); lastNode = child; } } if (node.type === 'text') { const bothTables = node.text.startsWith('|') && lastNode && lastNode.type === 'text' && lastNode.text.startsWith('|'); const bothGen = node.text.startsWith('