playwright/utils/markdown.js

513 lines
14 KiB
JavaScript
Raw Normal View History

/**
* 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.
*/
2020-12-29 04:38:00 +03:00
// @ts-check
/** @typedef {{
* type: string,
2020-12-29 04:38:00 +03:00
* text?: string,
* children?: MarkdownNode[],
2020-12-29 04:38:00 +03:00
* 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,
* }} MarkdownCodeNode */
/** @typedef {MarkdownBaseNode & {
* type: 'note',
* text: string,
* noteType: string,
* }} MarkdownNoteNode */
/** @typedef {MarkdownBaseNode & {
* type: 'null',
* }} MarkdownNullNode */
/** @typedef {MarkdownBaseNode & {
* type: 'properties',
* lines: string[],
* }} MarkdownPropsNode */
2022-11-21 20:30:32 +03:00
/** @typedef {{
* maxColumns?: number,
* omitLastCR?: boolean,
* flattenText?: boolean
* renderCodeBlockTitlesInHeader?: boolean
2022-11-21 20:30:32 +03:00
* }} RenderOptions
*/
/** @typedef {MarkdownTextNode | MarkdownLiNode | MarkdownCodeNode | MarkdownNoteNode | MarkdownHeaderNode | MarkdownNullNode | MarkdownPropsNode } MarkdownNode */
2020-12-29 04:38:00 +03:00
function flattenWrappedLines(content) {
2021-01-09 02:00:14 +03:00
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) {
2022-11-21 20:30:32 +03:00
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)
2022-11-21 20:30:32 +03:00
outLines.push(outLineTokens.join('↵'));
return outLines;
}
2020-12-29 04:38:00 +03:00
/**
* @param {string[]} lines
*/
function buildTree(lines) {
2020-12-29 04:38:00 +03:00
/** @type {MarkdownNode} */
const root = {
2020-12-24 06:35:43 +03:00
type: 'h0',
2020-12-29 04:38:00 +03:00
text: '<root>',
children: []
};
2020-12-29 04:38:00 +03:00
/** @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;
2020-12-29 04:38:00 +03:00
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] = parseCodeBlockMetadata(content);
/** @type {MarkdownNode} */
const node = {
type: 'code',
lines: [],
codeLang,
title,
};
line = lines[++i];
while (!line.trim().startsWith('```')) {
if (line && !line.startsWith(indent)) {
2021-01-09 02:00:14 +03:00
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;
}
2021-01-12 23:14:27 +03:00
if (content.startsWith(':::')) {
/** @type {MarkdownNode} */
const node = /** @type {MarkdownNoteNode} */ ({
2021-01-12 23:14:27 +03:00
type: 'note',
noteType: content.substring(3)
});
2021-01-12 23:14:27 +03:00
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];
}
2022-11-21 20:30:32 +03:00
node.text = tokens.join('↵');
2021-01-12 23:14:27 +03:00
appendNode(indent, node);
continue;
}
2021-01-02 02:17:27 +03:00
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]}
*/
function parseCodeBlockMetadata(firstLine) {
const withoutBackticks = firstLine.substring(3);
const match = withoutBackticks.match(/ title="(.+)"$/);
if (match)
return [withoutBackticks.substring(0, match.index), match[1]];
return [withoutBackticks, undefined];
}
2020-12-29 04:38:00 +03:00
/**
* @param {string} content
*/
function parse(content) {
return buildTree(flattenWrappedLines(content));
}
2020-12-29 04:38:00 +03:00
/**
* @param {MarkdownNode[]} nodes
2022-11-21 20:30:32 +03:00
* @param {RenderOptions=} options
2020-12-29 04:38:00 +03:00
*/
2022-11-21 20:30:32 +03:00
function render(nodes, options) {
const result = [];
2020-12-04 05:05:36 +03:00
let lastNode;
for (let node of nodes) {
2022-06-11 03:34:31 +03:00
if (node.type === 'null')
continue;
2022-11-21 20:30:32 +03:00
innerRenderMdNode('', node, /** @type {MarkdownNode} */ (lastNode), result, options);
2020-12-04 05:05:36 +03:00
lastNode = node;
}
2022-11-21 20:30:32 +03:00
if (!options?.omitLastCR && result[result.length - 1] !== '')
result.push('');
2020-12-04 05:05:36 +03:00
return result.join('\n');
}
2020-12-29 04:38:00 +03:00
/**
* @param {string} indent
2020-12-29 04:38:00 +03:00
* @param {MarkdownNode} node
* @param {MarkdownNode} lastNode
2022-11-21 20:30:32 +03:00
* @param {RenderOptions=} options
2020-12-29 04:38:00 +03:00
* @param {string[]} result
*/
2022-11-21 20:30:32 +03:00
function innerRenderMdNode(indent, node, lastNode, result, options) {
2020-12-04 05:05:36 +03:00
const newLine = () => {
if (result.length && (result[result.length - 1] || '').trim() !== '')
result.push(indent);
2020-12-04 05:05:36 +03:00
};
2020-12-24 06:35:43 +03:00
if (node.type.startsWith('h')) {
const headerNode = /** @type {MarkdownHeaderNode} */ (node);
2020-12-24 06:35:43 +03:00
newLine();
2020-12-29 04:38:00 +03:00
const depth = +node.type.substring(1);
result.push(`${'#'.repeat(depth)} ${headerNode.text}`);
2020-12-24 06:35:43 +03:00
let lastNode = node;
for (const child of node.children || []) {
2022-11-21 20:30:32 +03:00
innerRenderMdNode('', child, lastNode, result, options);
2020-12-24 06:35:43 +03:00
lastNode = child;
}
2020-12-04 05:05:36 +03:00
}
2020-12-24 06:35:43 +03:00
if (node.type === 'text') {
const bothTables = node.text.startsWith('|') && lastNode && lastNode.type === 'text' && lastNode.text.startsWith('|');
const bothGen = node.text.startsWith('<!--') && lastNode && lastNode.type === 'text' && lastNode.text.startsWith('<!--');
2020-12-24 06:35:43 +03:00
const bothComments = node.text.startsWith('>') && lastNode && lastNode.type === 'text' && lastNode.text.startsWith('>');
const bothLinks = node.text.match(/\[[^\]]+\]:/) && lastNode && lastNode.type === 'text' && lastNode.text.match(/\[[^\]]+\]:/);
if (!bothTables && !bothGen && !bothComments && !bothLinks && lastNode && lastNode.text)
2020-12-04 05:05:36 +03:00
newLine();
2022-11-21 20:30:32 +03:00
result.push(wrapText(node.text, options, indent));
2021-01-02 02:17:27 +03:00
return;
2020-12-04 05:05:36 +03:00
}
2020-12-24 06:35:43 +03:00
if (node.type === 'code') {
2020-12-04 05:05:36 +03:00
newLine();
result.push(`${indent}\`\`\`${node.codeLang}${(options?.renderCodeBlockTitlesInHeader && node.title) ? ' title="' + node.title + '"' : ''}`);
if (!options?.renderCodeBlockTitlesInHeader && node.title)
result.push(`${indent}// ${node.title}`);
2020-12-24 06:35:43 +03:00
for (const line of node.lines)
result.push(indent + line);
result.push(`${indent}\`\`\``);
2020-12-04 05:05:36 +03:00
newLine();
2021-01-02 02:17:27 +03:00
return;
}
2021-01-12 23:14:27 +03:00
if (node.type === 'note') {
newLine();
result.push(`${indent}:::${node.noteType}`);
2022-11-21 20:30:32 +03:00
result.push(wrapText(node.text, options, indent));
2021-01-12 23:14:27 +03:00
result.push(`${indent}:::`);
newLine();
return;
}
2021-01-02 02:17:27 +03:00
if (node.type === 'properties') {
result.push(`${indent}---`);
for (const line of node.lines)
result.push(indent + line);
result.push(`${indent}---`);
newLine();
return;
2020-12-04 05:05:36 +03:00
}
2020-12-24 06:35:43 +03:00
if (node.type === 'li') {
let char;
switch (node.liType) {
case 'bullet': char = '*'; break;
case 'default': char = '-'; break;
case 'ordinal': char = '1.'; break;
}
2022-11-21 20:30:32 +03:00
result.push(wrapText(node.text, options, `${indent}${char} `));
const newIndent = indent + ' '.repeat(char.length + 1);
for (const child of node.children || []) {
2022-11-21 20:30:32 +03:00
innerRenderMdNode(newIndent, child, lastNode, result, options);
lastNode = child;
}
2020-12-04 05:05:36 +03:00
}
}
2020-12-29 23:12:46 +03:00
/**
* @param {string} text
*/
function tokenizeNoBreakLinks(text) {
2020-12-29 23:12:46 +03:00
const links = [];
// Don't wrap simple links with spaces.
text = text.replace(/\[[^\]]+\]/g, match => {
links.push(match);
return `[${links.length - 1}]`;
});
return text.split(' ').map(c => c.replace(/\[(\d+)\]/g, (_, p1) => links[+p1]));
}
/**
* @param {string} text
2022-11-21 20:30:32 +03:00
* @param {RenderOptions|undefined} options
* @param {string} prefix
* @returns {string}
*/
function wrapText(text, options, prefix) {
if (options?.flattenText)
text = text.replace(/↵/g, ' ');
const lines = text.split(/[\n↵]/);
const result = /** @type {string[]} */([]);
const indent = ' '.repeat(prefix.length);
for (const line of lines) {
result.push(wrapLine(line, options?.maxColumns, result.length ? indent : prefix));
}
return result.join('\n');
}
/**
* @param {string} textLine
* @param {number|undefined} maxColumns
* @param {string} prefix
* @returns {string}
2020-12-29 23:12:46 +03:00
*/
2022-11-21 20:30:32 +03:00
function wrapLine(textLine, maxColumns, prefix) {
2020-12-29 23:12:46 +03:00
if (!maxColumns)
2022-11-21 20:30:32 +03:00
return prefix + textLine;
if (textLine.trim().startsWith('|'))
return prefix + textLine;
const indent = ' '.repeat(prefix.length);
2020-12-29 23:12:46 +03:00
const lines = [];
maxColumns -= indent.length;
2022-11-21 20:30:32 +03:00
const words = tokenizeNoBreakLinks(textLine);
2020-12-29 23:12:46 +03:00
let line = '';
for (const word of words) {
if (line.length && line.length + word.length < maxColumns) {
line += ' ' + word;
} else {
if (line)
lines.push(line);
line = (lines.length ? indent : prefix) + word;
2020-12-29 23:12:46 +03:00
}
}
if (line)
lines.push(line);
return lines.join('\n');
}
2020-12-29 04:38:00 +03:00
/**
* @param {MarkdownNode} node
*/
2020-12-24 06:35:43 +03:00
function clone(node) {
const copy = { ...node };
copy.children = copy.children ? copy.children.map(c => clone(c)) : undefined;
return copy;
}
2020-12-29 04:38:00 +03:00
/**
* @param {MarkdownNode[]} nodes
2021-01-02 02:17:27 +03:00
* @param {function(MarkdownNode, number): void} visitor
2020-12-29 04:38:00 +03:00
*/
function visitAll(nodes, visitor) {
for (const node of nodes)
visit(node, visitor);
2020-12-24 06:35:43 +03:00
}
/**
2020-12-29 04:38:00 +03:00
* @param {MarkdownNode} node
2021-01-02 02:17:27 +03:00
* @param {function(MarkdownNode, number): void} visitor
*/
2021-01-02 02:17:27 +03:00
function visit(node, visitor, depth = 0) {
visitor(node, depth);
2020-12-29 04:38:00 +03:00
for (const n of node.children || [])
2021-01-02 02:17:27 +03:00
visit(n, visitor, depth + 1);
}
/**
* @param {MarkdownNode[]} nodes
* @param {boolean=} h3
2021-01-02 02:17:27 +03:00
* @returns {string}
*/
function generateToc(nodes, h3) {
2021-01-02 02:17:27 +03:00
const result = [];
visitAll(nodes, (node, depth) => {
if (node.type === 'h1' || node.type === 'h2' || (h3 && node.type === 'h3')) {
2021-01-02 02:17:27 +03:00
let link = node.text.toLowerCase();
link = link.replace(/[ ]+/g, '-');
link = link.replace(/[^\w-_]/g, '');
2021-01-02 02:17:27 +03:00
result.push(`${' '.repeat(depth * 2)}- [${node.text}](#${link})`);
}
});
return result.join('\n');
}
/**
* @param {MarkdownNode[]} nodes
* @param {string} language
* @return {MarkdownNode[]}
*/
function filterNodesForLanguage(nodes, language) {
const result = nodes.filter(node => {
if (!node.children)
return true;
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
if (child.type !== 'li' || child.liType !== 'bullet' || !child.text.startsWith('langs:'))
continue;
const onlyText = child.text.substring('langs:'.length);
if (!onlyText)
return true;
const only = onlyText.split(',').map(l => l.trim());
node.children.splice(i, 1);
return only.includes(language);
}
return true;
});
result.forEach(n => {
if (!n.children)
return;
n.children = filterNodesForLanguage(n.children, language);
});
return result;
}
module.exports = { parse, render, clone, visitAll, visit, generateToc, filterNodesForLanguage, wrapText };