/** * 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: 'text' | 'li' | 'code' | 'properties' | 'h0' | 'h1' | 'h2' | 'h3' | 'h4' | 'note' | 'null', * text?: string, * codeLang?: string, * noteType?: string, * lines?: string[], * liType?: 'default' | 'bullet' | 'ordinal', * children?: MarkdownNode[] * }} 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 }[]} */ let 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; } headerStack[0].children.push(node); headerStack.unshift(node); continue; } // Remaining items respect indent-based nesting. const [, indent, content] = line.match('^([ ]*)(.*)'); if (content.startsWith('```')) { /** @type {MarkdownNode} */ const node = { type: 'code', lines: [], codeLang: content.substring(3) }; 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: '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.text = tokens.join(' '); 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) { node.type = 'li'; node.text = content.substring(liType[0].length); if (content.startsWith('1.')) node.liType = 'ordinal'; else if (content.startsWith('*')) node.liType = 'bullet'; else node.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} content */ function parse(content) { return buildTree(flattenWrappedLines(content)); } /** * @param {MarkdownNode[]} nodes * @param {number=} maxColumns */ function render(nodes, maxColumns) { const result = []; let lastNode; for (let node of nodes) { if (node.type === 'null') continue; innerRenderMdNode('', node, lastNode, result, maxColumns); lastNode = node; } return result.join('\n'); } /** * @param {string} indent * @param {MarkdownNode} node * @param {MarkdownNode} lastNode * @param {number=} maxColumns * @param {string[]} result */ function innerRenderMdNode(indent, node, lastNode, result, maxColumns) { const newLine = () => { if (result[result.length - 1] !== '') result.push(''); }; if (node.type.startsWith('h')) { newLine(); const depth = +node.type.substring(1); result.push(`${'#'.repeat(depth)} ${node.text}`); let lastNode = node; for (const child of node.children || []) { innerRenderMdNode('', child, lastNode, result, maxColumns); lastNode = child; } } if (node.type === 'text') { const bothTables = node.text.startsWith('|') && lastNode && lastNode.type === 'text' && lastNode.text.startsWith('|'); const bothGen = node.text.startsWith('