class MDTokenType { static Text = new MDTokenType('Text'); static Whitespace = new MDTokenType('Whitespace'); static Underscore = new MDTokenType('Underscore'); static Asterisk = new MDTokenType('Asterisk'); static Slash = new MDTokenType('Slash'); static Tilde = new MDTokenType('Tilde'); static Bang = new MDTokenType('Bang'); static Backtick = new MDTokenType('Backtick'); static Equal = new MDTokenType('Equal'); static Caret = new MDTokenType('Caret'); static Label = new MDTokenType('Label'); // content=label static URL = new MDTokenType('URL'); // content=URL, extra=title static Email = new MDTokenType('Email'); // content=email address, extra=title static SimpleLink = new MDTokenType('SimpleLink'); // content=URL static SimpleEmail = new MDTokenType('SimpleEmail'); // content=email address static Footnote = new MDTokenType('Footnote'); // content=symbol static Modifier = new MDTokenType('Modifier'); // content static HTMLTag = new MDTokenType('HTMLTag'); // content=tag string, tag=MDHTMLTag static META_AnyNonWhitespace = new MDTokenType('METAAnyNonWhitespace'); static META_OptionalWhitespace = new MDTokenType('METAOptionalWhitespace'); /** @type {string} */ name; /** * @param {string} name */ constructor(name) { this.name = name; } toString() { return `${this.constructor.name}.${this.name}`; } equals(other) { return (other instanceof MDTokenType) && other.name == this.name; } } class MDToken { /** * The original token string. * @type {string} */ original; /** @type {MDTokenType} */ type; /** @type {string|null} */ content; /** @type {string|null} */ extra; /** @type {MDHTMLTag|null} */ tag; /** @type {MDTagModifier|null} */ modifier; /** * @param {string} original * @param {MDTokenType} type * @param {string|MDTagModifier|null} content * @param {string|null} extra * @param {MDHTMLTag|null} tag */ constructor(original, type, content=null, extra=null, tag=null) { this.original = original; this.type = type; if (content instanceof MDTagModifier) { this.content = null; this.modifier = content; } else { this.content = content; this.modifier = null; } this.extra = extra; this.tag = tag; } toString() { return `(${this.constructor.name} type=${this.type.toString()} content=${this.content})`; } /** * Searches an array of MDToken for the given pattern of MDTokenTypes. * If found, returns an object with the given keys. * - `tokens: MDToken[]` - the subarray of `tokensToSearch` that match the pattern * - `index: number` - index into `tokensToSearch` of first matching token * * @param {MDToken[]|MDNode[]} tokensToSearch - mixed array of `MDToken` and `MDNode` elements * @param {MDTokenType[]} pattern - contiguous run of token types to find * @param {number} startIndex - token index to begin searching (defaults to 0) * @returns {object|null} match object as described, or `null` if not found */ static findFirstTokens(tokensToSearch, pattern, startIndex=0) { var matched = []; for (var t = startIndex; t < tokensToSearch.length; t++) { var matchedAll = true; matched = []; var patternOffset = 0; for (var p = 0; p < pattern.length; p++) { var t0 = t + p + patternOffset; if (t0 >= tokensToSearch.length) return null; let token = tokensToSearch[t0]; let elem = pattern[p]; if (elem == MDTokenType.META_OptionalWhitespace) { if (token instanceof MDToken && token.type == MDTokenType.Whitespace) { matched.push(token); } else { patternOffset--; } } else if (elem == MDTokenType.META_AnyNonWhitespace) { if (token instanceof MDToken && token.type == MDTokenType.Whitespace) { matchedAll = false; break; } matched.push(token); } else { if (!(token instanceof MDToken) || token.type != elem) { matchedAll = false; break; } matched.push(token); } } if (matchedAll) { return { 'tokens': matched, 'index': t, }; } } return null; } /** * Searches an array of MDToken for a given starting pattern and ending * pattern and returns match info about both and the tokens in between. * * If `contentValidator` is specified, it will be called with the content * tokens of a potential match. If the validator returns `true`, the result * will be accepted and returned by this method. If the validator returns * `false`, this method will keep looking for another matching pair. If no * validator is given the first match will be returned regardless of content. * * If a match is found, returns an object with the given keys: * - `startTokens: MDToken[]` - tokens that matched `startPattern` * - `contentTokens: MDToken[]` - tokens between the start and end pattern. May be an empty array. * - `endTokens: MDToken[]` - tokens that matched `endPattern` * - `startIndex: number` - index into `tokensToSearch` where `startPattern` begins * - `contentIndex: number` - index into `tokensToSearch` of the first token that is between the start and end patterns * - `endIndex: number` - index into `tokensToSearch` where `endPattern` begins * - `totalLength: number` - total number of matched tokens * * @param {MDToken[]} tokensToSearch - array of `MDToken` to search in * @param {MDTokenType[]} startPattern - array of `MDTokenType` to find first * @param {MDTokenType[]} endPattern - array of `MDTokenType` to find positioned after `startPattern` * @param {function|null} contentValidator - optional validator function. If provided, will be passed an array of inner `MDToken`, and the function can return `true` to accept the contents or `false` to keep searching * @param {number} startIndex - token index where searching should begin * @returns {object|null} match object */ static findPairedTokens(tokensToSearch, startPattern, endPattern, contentValidator=null, startIndex=0) { for (var s = startIndex; s < tokensToSearch.length; s++) { var startMatch = this.findFirstTokens(tokensToSearch, startPattern, s); if (startMatch === null) return null; var endStart = startMatch.index + startMatch.tokens.length; while (endStart < tokensToSearch.length) { var endMatch = this.findFirstTokens(tokensToSearch, endPattern, endStart); if (endMatch === null) break; var contents = tokensToSearch.slice(startMatch.index + startMatch.tokens.length, endMatch.index); if (contents.length > 0 && (contentValidator === null || contentValidator(contents))) { return { 'startTokens': startMatch.tokens, 'contentTokens': contents, 'endTokens': endMatch.tokens, 'startIndex': startMatch.index, 'contentIndex': startMatch.index + startMatch.tokens.length, 'endIndex': endMatch.index, 'totalLength': endMatch.index + endMatch.tokens.length - startMatch.index, }; } else { // Contents rejected. Try next end match. endStart = endMatch.index + 1; } } // No end matches. Increment start match. s = startMatch.index; } return null; } equals(other) { if (!(other instanceof MDToken)) return false; if (other.original !== this.original) return false; if (!other.type.equals(this.type)) return false; if (other.content !== this.content) return false; if (other.extra !== this.extra) return false; if (!MDUtils.equal(other.tag, this.tag)) return false; if (!MDUtils.equals(other.modifier, this.modifier)) return false; return true } } class MDUtils { // Modified from https://urlregex.com/ to remove capture groups. Matches fully qualified URLs only. static baseURLRegex = /(?:(?:(?:[a-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[a-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[a-z0-9\.\-]+)(?:(?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/i; // Modified from https://emailregex.com/ to remove capture groups. static baseEmailRegex = /(?:(?:[^<>()\[\]\\.,;:\s@"]+(?:\.[^<>()\[\]\\.,;:\s@"]+)*)|(?:".+"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(?:(?:[a-z\-0-9]+\.)+[a-z]{2,}))/i; /** * Escapes special HTML characters. * * @param {string} str - string to escape * @param {boolean} encodeNewlinesAsBreaks - whether to convert newline characters to `
` tags * @returns {string} escaped HTML */ static escapeHTML(str, encodeNewlinesAsBreaks=false) { if (typeof str !== 'string') return ''; var html = str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); if (encodeNewlinesAsBreaks) { html = html.replace(/\n/g, "
\n"); } return html; } /** * Converts HTML entities to characters. HTML tags are ignored. * @param {string} html * @returns {string} plain text */ static unescapeHTML(html, decodeBRsAsNewlines=false) { if (decodeBRsAsNewlines) { html = html.replace(/\n?/g, "\n"); } const doc = (new DOMParser()).parseFromString(html, "text/html"); return doc.documentElement.textContent; } /** * Encodes characters as HTML numeric entities to make it marginally more * difficult for web scrapers to grab sensitive info. If `text` starts with * `mailto:` only the email address following it will be obfuscated. * * @param {string} text - text to escape * @returns {string} escaped HTML */ static escapeObfuscated(text) { if (text.startsWith('mailto:')) { return 'mailto:' + this.escapeObfuscated(text.substring(7)); } var html = ''; for (var p = 0; p < text.length; p++) { const cp = text.codePointAt(p); html += `&#${cp};`; } return html; } /** * Removes illegal characters from an HTML attribute name. * @param {string} name * @returns {string} */ static scrubAttributeName(name) { return name.replace(/[\t\n\f \/>"'=]+/, ''); } /** * Strips one or more leading indents from a line or lines of markdown. An * indent is defined as 4 spaces or one tab. Incomplete indents (i.e. 1-3 * spaces) are treated like one indent level. * * @param {string|string[]} line - string or strings to strip * @param {number} levels - how many indent levels to strip * @returns {string|string[]} stripped lines */ static stripIndent(line, levels=1) { const regex = new RegExp(`^(?: {1,4}|\t){${levels}}`); return (line instanceof Array) ? line.map((l) => l.replace(regex, '')) : line.replace(regex, ''); } /** * Returns a copy of an array without any whitespace-only lines at the end. * * @param {String[]} lines - text lines * @returns {String[]} - text lines without trailing blank lines */ static withoutTrailingBlankLines(lines) { var stripped = lines.slice(); while (stripped.length > 0 && stripped[stripped.length - 1].trim().length == 0) { stripped.pop(); } return stripped; } /** * Tests if an array of lines contains at least one blank. A blank line * can contain whitespace. * * @param {String[]} lines * @returns {boolean} whether `lines` contains any whitespace-only lines */ static containsBlankLine(lines) { for (const line of lines) { if (line.trim().length == 0) return true; } return false; } /** * Counts the number of indent levels in a line of text. Partial indents * (1 to 3 spaces) are counted as one indent level unless `fullIndentsOnly` * is `true`. * * @param {string} line - line of markdown * @param {boolean} fullIndentsOnly - whether to only count full indent levels (4 spaces or a tab) * @returns {number} number of indent levels found */ static countIndents(line, fullIndentsOnly=false) { // normalize indents to tabs return line.replace(fullIndentsOnly ? /(?: {4}|\t)/g : /(?: {1,4}|\t)/g, "\t") // remove content after indent .replace(/^(\t*)(.*?)$/, '$1') // count tabs .length; } /** * Attempts to parse a label from the beginning of `line`. A label is of the * form `[content]`. If found, returns an array with element 0 being the * entire label and element 1 being the content of the label. * * @param {string} line * @returns {string[]|null} match groups or null if not found */ static tokenizeLabel(line) { if (!line.startsWith('[')) return null; var parenCount = 0; var bracketCount = 0; for (var p = 1; p < line.length; p++) { let ch = line.substring(p, p + 1); if (ch == '\\') { p++; } else if (ch == '(') { parenCount++; } else if (ch == ')') { parenCount--; if (parenCount < 0) return null; } else if (ch == '[') { bracketCount++; } else if (ch == ']') { if (bracketCount > 0) { bracketCount--; } else { return [ line.substring(0, p + 1), line.substring(1, p) ]; } } } return null; } static #urlWithTitleRegex = /^\((\S+?)\s+"(.*?)"\)/i; // 1=URL, 2=title static #urlRegex = /^\((\S+?)\)/i; // 1=URL /** * Attempts to parse a URL from the beginning of `line`. A URL is of the * form `(url)` or `(url "title")`. If found, returns an array with element * 0 being the entire URL token, 1 is the URL, 2 is the optional title. * * @param {string} line * @returns {string[]} token tuple */ static tokenizeURL(line) { var groups; if (groups = this.#urlWithTitleRegex.exec(line)) { if (this.tokenizeEmail(line)) return null; // make sure it's not better described as an email address return groups; } if (groups = this.#urlRegex.exec(line)) { if (this.tokenizeEmail(line)) return null; return [...groups, null]; } return null; } static #emailWithTitleRegex = new RegExp("^\\(\\s*(" + MDUtils.baseEmailRegex.source + ")\\s+\"(.*?)\"\\s*\\)", "i"); // 1=email, 2=title static #emailRegex = new RegExp("^\\(\\s*(" + MDUtils.baseEmailRegex.source + ")\\s*\\)", "i"); // 1=email /** * Attempts to parse an email address from the beginning of `line`. An * email address is of the form `(user@example.com)` or `(user@example.com "link title")`. * If found, returns an array with element 0 being the entire token, 1 is the * email address, and 2 is the optional link title. * * @param {string} line * @returns {string[]} token tuple */ static tokenizeEmail(line) { var groups; if (groups = this.#emailWithTitleRegex.exec(line)) { return groups; } if (groups = this.#emailRegex.exec(line)) { return [...groups, null]; } return null; } /** * Describes the type of a variable for debugging. * * @param {any} value - value * @returns {String} description of type */ static typename(value) { if (value === null) return 'null'; if (value instanceof Object) { return value.constructor.name; } return typeof value; } static #equalArrays(a, b) { if (a === b) return true; if (!(a instanceof Array) || !(b instanceof Array)) return false; if (a == null || b == null) return false; if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (!this.equal(a[i], b[i])) return false; } return true; } static #equalObjects(a, b) { if (a === b) return true; if (!(a instanceof Object) || !(b instanceof Object)) return false; if (a == null || b == null) return false; if (a.equals !== undefined) { return a.equals(b); } for (const key of Object.keys(a)) { if (!this.equal(a[key], b[key])) return false; } for (const key of Object.keys(b)) { if (!this.equal(a[key], b[key])) return false; } return true; } /** * Tests for equality on lots of different kinds of values including objects * and arrays. Will use `.equals` on objects that implement it. * * @param {any} a * @param {any} b * @returns {boolean} */ static equal(a, b, floatDifferencePercent=0.0) { if (a instanceof Array && b instanceof Array) { return this.#equalArrays(a, b); } if (a instanceof Object && b instanceof Object) { return this.#equalObjects(a, b); } if (typeof a == 'number' && typeof b == 'number') { if (a === b) return true; const delta = b - a; const ratio = delta / a; return Math.abs(ratio) <= floatDifferencePercent; } return a == b; } /** * @param {string} text */ static escapeRegex(text) { // Partially following escaping scheme from not-yet-widely-supported RegExp.escape. // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape const escapeHex = function(ch) { const codepoint = ch.codePointAt(0); const s = '00' + codepoint.toString(16); return `\\x${s.substring(s.length - 2)}`; } var escaped = ''; const l = text.length; for (var i = 0; i < l; i++) { const ch = text.substring(i, i + 1); if (i == 0 && /[a-zA-Z0-9]/.exec(ch)) { escaped += escapeHex(ch); } else if ("^$\\.*+?()[]{}|/".indexOf(ch) >= 0) { escaped += `\\${ch}`; } else if (",-=<>#&!%:;@~'`\"".indexOf(ch) >= 0) { escaped += escapeHex(ch); } else if (ch == '\f') { escaped += "\\f"; } else if (ch == '\n') { escaped += "\\n"; } else if (ch == '\r') { escaped += "\\r"; } else if (ch == '\t') { escaped += "\\t"; } else if (ch == '\v') { escaped += "\\v"; } else { escaped += ch; } } return escaped; } /** * Recursively search and replaces nodes in a tree. The given `replacer` is * passed every node in the tree. If the function returns a new `MDNode` the * original will be replaced with it. If the function returns `null` no * change will be made to that node. Traversal is depth-first. * * @param {MDState} state * @param {MDNode[]} nodes * @param {function} replacer - takes a node as an argument, returns either a new node or `null` to leave it unchanged */ static replaceNodes(state, nodes, replacer) { for (var i = 0; i < nodes.length; i++) { var originalNode = nodes[i]; const replacement = replacer(originalNode); if (replacement !== null) { nodes.splice(i, 1, replacement); } else { this.replaceNodes(state, originalNode.children, replacer); } } } } /** * Parsing and rendering state */ class MDState { /** * Ascends the parent chain to the root `MDState` instance. This should be * used when referencing most stored fields except `lines` and `p`. * * @type {MDState} */ get root() { return this.#parent ? this.#parent.root : this; } /** * Lines of the markdown document. The current line index is pointed to by `p`. * * @returns {string[]} markdown lines */ get lines() { return this.#lines; } /** * The current line in `lines`. * * @returns {string|null} current line or `null` if out of content */ get currentLine() { return (this.p < this.#lines.length) ? this.#lines[this.p] : null; } /** * Current line pointer into array `lines`. * * @type {number} line pointer */ p = 0; /** @type {string[]} */ #lines = []; /** @type {MDState|null} */ #parent = null; /** @type {MDConfig} */ config; /** * Array of `MDReader`s sorted by block reading priority. * @type {MDReader[]} */ readersByBlockPriority = []; /** * Array of `MDReader`s sorted by tokenization priority. * @type {MDReader[]} */ readersByTokenPriority = []; /** * Tuples of `pass:number` and `MDReader` sorted substitution priority. * @type {Array} */ readersBySubstitutePriority = []; /** * Mapping of reference symbols to URLs. * @type {object} */ #referenceToURL = {}; /** * Mapping of reference symbols to titles. * @type {object} */ #referenceToTitle = {}; /** @type {MDHTMLFilter} */ tagFilter; static #textWhitespaceRegex = /^(\s*)(?:(\S|\S.*\S)(\s*?))?$/; // 1=leading WS, 2=text, 3=trailing WS /** * @param {string[]} lines - lines of markdown text * @param {MDConfig} config * @param {MDReader[]} readersByBlockPriority * @param {MDReader[]} readersByTokenPriority * @param {Array} readersBySubstitutePriority - tuple arrays of priority and MDReader */ constructor(lines, config=null, readersByBlockPriority=null, readersByTokenPriority=null, readersBySubstitutePriority=null, tagFilter=null) { this.#lines = lines; this.config = config; this.readersByBlockPriority = readersByBlockPriority this.readersByTokenPriority = readersByTokenPriority this.readersBySubstitutePriority = readersBySubstitutePriority this.tagFilter = tagFilter; } /** * Creates a copy of this state with new lines. Useful for parsing nested * content. * * @param {string[]} lines * @returns {MDState} copied sub-state */ copy(lines) { let cp = new MDState(lines); cp.#parent = this; cp.config = this.config; return cp; } /** * Tests if there are at least `minCount` lines available to read. If `p` * is not provided it will be relative to `this.p`. * * @param {number} minCount - minimum number of lines * @param {number|null} p - line pointer, or `null` to use `this.p` * @returns {boolean} whether at least the given number of lines is available */ hasLines(minCount, p=null) { let relativeTo = (p === null) ? this.p : p; return relativeTo + minCount <= this.lines.length; } /** * Reads and returns an array of blocks from the current line pointer. * * @returns {MDBlockNode[]} parsed blocks */ readBlocks() { var blocks = []; while (this.hasLines(1)) { let block = this.#readNextBlock(); if (block) { blocks.push(block); } else { break; } } return blocks; } /** * Creates a simple `MDBlockNode` if no other registered blocks match. * * @returns {MDBlockNode|null} fallback block */ #readFallbackBlock() { if (this.p >= this.lines.length) return null; const lines = MDUtils.withoutTrailingBlankLines(this.lines.slice(this.p)); if (lines.length == 0) return null; this.p = this.lines.length; return this.inlineMarkdownToNode(lines.join("\n")); } /** * Attempts to read one block from the current line pointer. The pointer * will be positioned just after the end of the block. * * @param {MDState} state * @returns {MDBlockNode|null} */ #readNextBlock() { while (this.hasLines(1) && this.lines[this.p].trim().length == 0) { this.p++; } if (!this.hasLines(1)) return null; for (const reader of this.root.readersByBlockPriority) { const startP = this.p; const block = reader.readBlock(this); if (block) { if (this.p == startP) { throw new Error(`${reader.constructor.name} returned an ${block.constructor.name} without incrementing MDState.p. This could lead to an infinite loop.`); } return block; } } const fallback = this.#readFallbackBlock(); return fallback; } /** * @param {string} line * @returns {MDToken[]} */ #inlineMarkdownToTokens(line) { if (this.#parent) return this.#parent.#inlineMarkdownToTokens(line); var tokens = []; var text = ''; var expectLiteral = false; /** * Flushes accumulated content in `text` to `tokens`. */ const endText = function() { if (text.length == 0) return; const textGroups = MDState.#textWhitespaceRegex.exec(text); if (textGroups !== null) { if (textGroups[1].length > 0) { tokens.push(new MDToken(textGroups[1], MDTokenType.Whitespace, textGroups[1])); } if (textGroups[2] !== undefined && textGroups[2].length > 0) { tokens.push(new MDToken(textGroups[2], MDTokenType.Text, textGroups[2])); } if (textGroups[3] !== undefined && textGroups[3].length > 0) { tokens.push(new MDToken(textGroups[3], MDTokenType.Whitespace, textGroups[3])); } } else { tokens.push(new MDToken(text, MDTokenType.Text, text)); } text = ''; } for (var p = 0; p < line.length; p++) { const ch = line.substring(p, p + 1); const remainder = line.substring(p); if (expectLiteral) { text += ch; expectLiteral = false; continue; } if (ch == '\\') { expectLiteral = true; continue; } var found = false; for (const reader of this.root.readersByTokenPriority) { const token = reader.readToken(this, remainder); if (token === null) continue; if (token === undefined) { console.warn(`${reader.constructor.name}.readToken returned undefined instead of null`); } endText(); tokens.push(token); if (token.original == null || token.original.length == 0) { throw new Error(`${reader.constructor.name} returned a token with an empty .original. This would cause an infinite loop.`); } p += token.original.length - 1; found = true; break; } if (!found) { text += ch; } } endText(); return tokens; } /** * Converts a line of markdown to an `MDInlineNode`. * * @param {string|string[]} line * @returns {MDInlineNode} */ inlineMarkdownToNode(line) { let nodes = this.inlineMarkdownToNodes(line); return (nodes.length == 1) ? nodes[0] : new MDInlineNode(nodes); } /** * Converts a line of markdown to an array of `MDInlineNode`s. * * @param {string|string[]} line * @returns {MDInlineNode[]} */ inlineMarkdownToNodes(line) { var tokens = this.#inlineMarkdownToTokens((line instanceof Array) ? line.join('\n') : line); return this.tokensToNodes(tokens); } /** * Converts a mixed array of `MDToken` and `MDInlineNode` elements into an array * of only `MDInlineNode`. * * @param {MDToken[]|MDInlineNode[]} tokens * @returns {MDInlineNode[]} */ tokensToNodes(tokens) { var nodes = tokens.slice(); // Perform repeated substitutions, converting sequences of tokens into // nodes, until no more substitutions can be made. var anyChanges = false; do { anyChanges = false; for (const readerTuple of this.root.readersBySubstitutePriority) { /** @type {number} */ const pass = readerTuple[0]; /** @type {MDReader} */ const reader = readerTuple[1]; const changed = reader.substituteTokens(this, pass, nodes); if (!changed) continue; anyChanges = true; break; } } while (anyChanges); // Convert any remaining tokens to nodes, apply CSS modifiers. var lastNode = null; const me = this; nodes = nodes.map(function(node) { if (node instanceof MDToken) { /** @type {MDToken} */ const token = node; if (token.type == MDTokenType.Modifier && lastNode) { me.root.tagFilter.scrubModifier(token.modifier); token.modifier.applyTo(lastNode); lastNode = null; return new MDTextNode(''); } lastNode = null; return new MDTextNode(token.original); } else if (node instanceof MDNode) { lastNode = (node instanceof MDTextNode) ? null : node; return node; } else { throw new Error(`Unexpected node type ${node.constructor.name}`); } }); return nodes; } /** * Defines a URL by reference symbol. * * @param {string} reference - case-insensitive reference symbol * @param {string} url - URL to map the symbol to * @param {string|null} title - optional link title */ defineURL(reference, url, title=null) { this.root.#referenceToURL[reference.toLowerCase()] = url; if (title !== null) this.root.#referenceToTitle[reference.toLowerCase()] = title; } /** * Returns the URL associated with a reference symbol. * * @param {string} reference - case-insensitive reference symbol * @returns {string|null} URL for the given reference, or `null` if not defined */ urlForReference(reference) { return this.root.#referenceToURL[reference.toLowerCase()] ?? null; } /** * Returns the link title associated with a reference symbol. * * @param {string} reference - case-insensitive reference symbol * @returns {string|null} link title for the given reference, or `null` if not defined */ urlTitleForReference(reference) { return this.root.#referenceToTitle[reference.toLowerCase()] ?? null; } } // -- Readers --------------------------------------------------------------- /** * Base class for readers of various markdown syntax. A `Markdown` instance can * be created with any combination of subclasses of these to customize the * flavor of markdown parsed. * * Parsing occurs in three phases, and `MDReader` implementations can implement * any combination of these. * 1. **Blocks** - Processing an array of lines to find block-level structures, * such as paragraphs, lists, tables, blockquotes, etc. and converting them * into block-level `MDNode`s. Override `readBlock`. * 2. **Inline tokens** - Carving up single lines of markdown into tokens for * inline formatting, such as strong, emphasis, links, images, etc. * Override `readToken`. * 3. **Inline substitution** - Finding patterns of tokens and substituting them * with `MDNode`s. Override `substituteTokens`. (`readToken` and * `substituteTokens` are usually overridden together.) * * Readers may have similar, ambiguous syntax (such as `**strong**` and * `*emphasis*`) and need to process in a certain order. This can be done by * overriding the `compare` methods to influence which readers to put before * others in each phase. Furthermore, substitution can occur in multiple passes * if necessary. These two mechanisms can be used to resolve ambiguities. */ class MDReader { /** * Called before processing begins. `state.lines` is populated and the * line pointer `state.p` will be at `0`. Default implementation does nothing. * * @param {MDState} state */ preProcess(state) {} /** * Attempts to read an `MDBlockNode` subclass at the current line pointer * `state.p`. Only matches if the block pattern starts at the line pointer, * not elsewhere in the `state.lines` array. If a block is found, `state.p` * should be incremented to the next line _after_ the block structure and * a `MDBlockNode` subclass instance is returned. If no block is found, * returns `null`. * * @param {MDState} state * @returns {MDBlockNode|null} found block, or `null` if not found */ readBlock(state) { return null; } /** * Attempts to read a token from the beginning of `line`. Only the start of * the given `line` is considered. If a matching token is found, an * `MDToken` is returned. Otherwise `null` is returned. * * @param {MDState} state * @param {string} line - string to check for a leading token * @returns {MDToken|null} found token, or `null` if not found */ readToken(state, line) { return null; } /** * Attempts to find a pattern in `tokens` and perform an in-place substitution * with one or more `MDNode` subclass instances. * * @param {MDState} state * @param {number} pass - what substitution pass this is, starting with 1 * @param {Array} tokens - mixed array of `MDToken` and `MDInlineNode` elements * @returns {boolean} `true` if a substitution was performed, `false` if not */ substituteTokens(state, pass, tokens) { return false; } /** * Called after all parsing has completed. An array `blocks` is passed of all * top-level `MDBlockNode` elements is passed which can be altered in-place * via `.splice` operations if necessary. * * `MDNode.visitChildren` is useful for recursively looking for certain * `MDNode` instances. `MDUtils.replaceNodes` is useful for swapping in * replacements. * * @param {MDState} state * @param {MDBlockNode[]} blocks */ postProcess(state, blocks) {} /** * @param {MDReader} other * @returns {number} -1 if this should be before other, 0 if the same or don't care, 1 if this should be after other */ compareBlockOrdering(other) { return 0; } /** * @param {MDReader} other * @returns {number} */ compareTokenizeOrdering(other) { return 0; } /** * @param {MDReader} other * @param {number} pass * @returns {number} */ compareSubstituteOrdering(other, pass) { return 0; } get substitutionPassCount() { return 1; } /** * For sorting readers with ordering preferences. The `compare` methods * don't have the properties of normal sorting compares so need to sort * differently. * * @param {MDReader[]} arr - array to sort * @param {function} compareFn - comparison function, taking two array element * arguments and returning -1, 0, or 1 for a < b, a == b, and a > b, * respectively * @param {function} idFn - function for returning a unique hashable id for * the array element * @returns {MDReader[]} sorted array */ static #kahnTopologicalSort(arr, compareFn, idFn) { const graph = {}; const inDegrees = {}; const valuesById = {}; // Build the graph and compute in-degrees for (const elem of arr) { const id = idFn(elem); graph[id] = []; inDegrees[id] = 0; valuesById[id] = elem; } for (let i = 0; i < arr.length; i++) { const elemA = arr[i]; const idA = idFn(elemA); for (let j = 0; j < arr.length; j++) { if (i === j) continue; const elemB = arr[j]; const idB = idFn(elemB); const comparisonResult = compareFn(elemA, elemB); if (comparisonResult < 0) { graph[idA].push(idB); inDegrees[idB]++; } else if (comparisonResult > 0) { graph[idB].push(idA); inDegrees[idA]++; } } } // Initialize the queue with zero-inDegree nodes const queue = []; for (const elemId in inDegrees) { if (inDegrees[elemId] === 0) { queue.push(elemId); } } // Process the queue and build the topological order list const sorted = []; while (queue.length > 0) { const elemId = queue.shift(); sorted.push(valuesById[elemId]); delete valuesById[elemId]; for (const neighbor of graph[elemId]) { inDegrees[neighbor]--; if (inDegrees[neighbor] === 0) { queue.push(neighbor); } } } // Anything left over can go at the end. No ordering dependencies. for (const elemId in valuesById) { sorted.push(valuesById[elemId]); } return sorted; } /** * @param {MDReader[]} readers * @returns {MDReader[]} */ static sortReaderForBlocks(readers) { const sorted = readers.slice(); return MDReader.#kahnTopologicalSort(sorted, (a, b) => { return a.compareBlockOrdering(b); // if (ab != 0) return ab; // return -b.compareBlockOrdering(a); }, (elem) => elem.constructor.name); } /** * @param {MDReader[]} readers * @returns {MDReader[]} */ static sortReadersForTokenizing(readers) { const sorted = readers.slice(); return MDReader.#kahnTopologicalSort(sorted, (a, b) => { return a.compareTokenizeOrdering(b); // if (ab != 0) return ab; // return -b.compareTokenizeOrdering(a); }, (elem) => elem.constructor.name); } /** * @param {MDReader[]} readers * @returns {MDReader[]} */ static sortReadersForSubstitution(readers) { var tuples = []; var maxPass = 1; for (const reader of readers) { const passCount = reader.substitutionPassCount; for (var pass = 1; pass <= passCount; pass++) { tuples.push([ pass, reader ]); } maxPass = Math.max(maxPass, pass); } var result = []; for (var pass = 1; pass <= maxPass; pass++) { var readersThisPass = tuples.filter((tup) => tup[0] == pass); const passResult = MDReader.#kahnTopologicalSort(readersThisPass, (a, b) => { const aReader = a[1]; const bReader = b[1]; return aReader.compareSubstituteOrdering(bReader, pass); }, (elem) => `${elem[1].constructor.name}:${elem[0]}`); result = result.concat(passResult); } return result; } } /** * Reads markdown blocks for headings denoted with the underline syntax. * * Example: * * > ```markdown * > Heading 1 * > ======== * > ``` */ class MDUnderlinedHeadingReader extends MDReader { readBlock(state) { var p = state.p; if (!state.hasLines(2)) return null; var modifier; let contentLine = state.lines[p++].trim(); [contentLine, modifier] = MDTagModifier.fromLine(contentLine, state); let underLine = state.lines[p++].trim(); if (contentLine == '') return null; if (/^=+$/.exec(underLine)) { state.p = p; let block = new MDHeadingNode(1, state.inlineMarkdownToNodes(contentLine)); if (modifier) modifier.applyTo(block); return block; } if (/^\-+$/.exec(underLine)) { state.p = p; let block = new MDHeadingNode(2, state.inlineMarkdownToNodes(contentLine)); if (modifier) modifier.applyTo(block); return block; } return null; } } /** * Reads markdown blocks for headings denoted with hash marks. Heading levels 1 to * 6 are supported. * * Examples: * * > ```markdown * > # Heading 1 * > * > ## Heading 2 * > * > # Enclosing Hashes Are Optional # * > * > ## Trailing Hashes Don't Have to Match in Number #### * > ``` */ class MDHashHeadingReader extends MDReader { static #hashHeadingRegex = /^(#{1,6})\s*([^#].*?)\s*\#*\s*$/; // 1=hashes, 2=content readBlock(state) { var p = state.p; let line = state.lines[p++]; var modifier; [line, modifier] = MDTagModifier.fromLine(line, state); var groups = MDHashHeadingReader.#hashHeadingRegex.exec(line); if (groups === null) return null; state.p = p; const level = groups[1].length; const content = groups[2]; let block = new MDHeadingNode(level, state.inlineMarkdownToNodes(content)); if (modifier) modifier.applyTo(block); return block; } } class MDSubtextReader extends MDReader { static #subtextRegex = /^\-#\s*(.*?)\s*$/; // 1=content readBlock(state) { var p = state.p; let line = state.lines[p++]; var modifier; [line, modifier] = MDTagModifier.fromLine(line, state); var groups = MDSubtextReader.#subtextRegex.exec(line); if (groups === null) return null; state.p = p; const content = groups[1]; let block = new MDSubtextNode(state.inlineMarkdownToNodes(content)); if (modifier) modifier.applyTo(block); return block; } compareBlockOrdering(other) { if (other instanceof MDUnorderedListReader) { return -1; } return 0; } } /** * Reads markdown blocks for blockquoted text. * * Example: * * > ```markdown * > > Blockquoted text * > ``` */ class MDBlockQuoteReader extends MDReader { readBlock(state) { var blockquoteLines = []; var p = state.p; while (p < state.lines.length) { let line = state.lines[p++]; if (line.startsWith(">")) { blockquoteLines.push(line); } else { break; } } if (blockquoteLines.length > 0) { let contentLines = blockquoteLines.map(function(line) { return line.substring(1).replace(/^ {0,3}\t?/, ''); }); let substate = state.copy(contentLines); let quotedBlocks = substate.readBlocks(); state.p = p; return new MDBlockquoteNode(quotedBlocks); } return null; } } /** * Abstract base class for ordered and unordered lists. */ class _MDListReader extends MDReader { #readItemLines(state, firstLineStartPos) { var p = state.p; var lines = []; var seenBlankLine = false; var stripTrailingBlankLines = true; while (state.hasLines(1, p)) { const isFirstLine = p == state.p; var line = state.lines[p++]; if (isFirstLine) { line = line.substring(firstLineStartPos); } if (/^(?:\*|\+|\-|\d+\.)\s+/.exec(line)) { // Found next list item stripTrailingBlankLines = false; // because this signals extra spacing intended break; } const isBlankLine = line.trim().length == 0; const isIndented = /^\s+\S/.exec(line) !== null; if (isBlankLine) { seenBlankLine = true; } else if (!isIndented && seenBlankLine) { // Post-list content break; } lines.push(line); } lines = MDUtils.withoutTrailingBlankLines(lines); return MDUtils.stripIndent(lines); } /** * @param {MDState} state * @param {number} firstLineStart * @return {MDBlockNode} */ _readListItemContent(state, firstLineStartPos) { const itemLines = this.#readItemLines(state, firstLineStartPos); state.p += Math.max(itemLines.length, 1); if (itemLines.length == 1) { return state.inlineMarkdownToNode(itemLines[0]); } const hasBlankLines = itemLines.filter((line) => line.trim().length == 0).length > 0; if (hasBlankLines) { const substate = state.copy(itemLines); const blocks = substate.readBlocks(); return (blocks.length == 1) ? blocks[0] : new MDNode(blocks); } // Multiline content with no blank lines. Search for new block // boundaries without the benefit of a blank line to demarcate it. for (var p = 1; p < itemLines.length; p++) { const line = itemLines[p]; if (/^(?:\*|\-|\+|\d+\.)\s+/.exec(line)) { // Nested list found const firstBlock = state.inlineMarkdownToNode(itemLines.slice(0, p).join("\n")); const substate = state.copy(itemLines.slice(p)); const blocks = substate.readBlocks(); return new MDNode([ firstBlock, ...blocks ]); } } // Ok, give up and just do a standard block read { const substate = state.copy(itemLines); const blocks = substate.readBlocks(); return (blocks.length == 1) ? blocks[0] : new MDNode(blocks); } } readBlock(state) { throw new Error(`Abstract readBlock must be overridden in ${this.constructor.name}`); } } /** * Block reader for unordered (bulleted) lists. * * Example: * * > ```markdown * > * First item * > * Second item * > * Third item * > ``` */ class MDUnorderedListReader extends _MDListReader { static #unorderedListRegex = /^([\*\+\-]\s+)(.*)$/; // 1=bullet, 2=content /** * @param {MDState} state * @returns {MDListItemNode|null} */ #readUnorderedListItem(state) { var p = state.p; let line = state.lines[p]; let groups = MDUnorderedListReader.#unorderedListRegex.exec(line); if (groups === null) return null; const firstLineOffset = groups[1].length; return new MDListItemNode(this._readListItemContent(state, firstLineOffset)); } readBlock(state) { var items = []; var item = null; do { item = this.#readUnorderedListItem(state); if (item) items.push(item); } while (item); if (items.length == 0) return null; return new MDUnorderedListNode(items); } } /** * Block reader for ordered (numbered) lists. The number of the first item is * used to begin counting. The subsequent items increase by 1, regardless of * their value. * * Example: * * > ```markdown * > 1. First * > 2. Second * > 3. Third * > ``` */ class MDOrderedListReader extends _MDListReader { static #orderedListRegex = /^(\d+)(\.\s+)(.*)$/; // 1=number, 2=dot, 3=content /** * @param {MDState} state * @returns {MDListItemNode|null} */ #readOrderedListItem(state) { var p = state.p; let line = state.lines[p]; let groups = MDOrderedListReader.#orderedListRegex.exec(line); if (groups === null) return null; const ordinal = parseInt(groups[1]); const firstLineOffset = groups[1].length + groups[2].length; return new MDListItemNode(this._readListItemContent(state, firstLineOffset), ordinal); } readBlock(state) { var items = []; var item = null; do { item = this.#readOrderedListItem(state); if (item) items.push(item); } while (item); if (items.length == 0) return null; return new MDOrderedListNode(items, items[0].ordinal); } } /** * Block reader for code blocks denoted by pairs of triple tickmarks. * * Example: * * > ```markdown * > \`\`\` * > function formattedAsCode() { * > } * > \`\`\` * > ``` */ class MDFencedCodeBlockReader extends MDReader { readBlock(state) { if (!state.hasLines(2)) return null; var p = state.p; let openFenceLine = state.lines[p++]; var modifier; [openFenceLine, modifier] = MDTagModifier.fromLine(openFenceLine, state); const match = /^```\s*([a-z0-9]*)\s*$/.exec(openFenceLine); if (match === null) return null; const language = match[1].length > 0 ? match[1] : null; var codeLines = []; while (state.hasLines(1, p)) { let line = state.lines[p++]; if (line.trim() == '```') { state.p = p; let block = new MDCodeBlockNode(codeLines.join("\n"), language); if (modifier) modifier.applyTo(block); return block; } codeLines.push(line); } return null; } } /** * Block reader for code blocks denoted by indenting text. * * Example (indent spaces rendered visibly for clarity): * * > ```markdown * > ⎵⎵⎵⎵function formattedAsCode() { * > ⎵⎵⎵⎵} * > ``` */ class MDIndentedCodeBlockReader extends MDReader { readBlock(state) { var p = state.p; var codeLines = []; while (state.hasLines(1, p)) { let line = state.lines[p++]; if (MDUtils.countIndents(line, true) < 1) { p--; break; } codeLines.push(MDUtils.stripIndent(line)); } if (codeLines.length == 0) return null; state.p = p; return new MDCodeBlockNode(codeLines.join("\n")); } } /** * Block reader for horizontal rules. Composed of three or more hypens or * asterisks on a line by themselves, with or without intermediate whitespace. * * Examples: * * > ```markdown * > --- * > * > - - - * > * > * * * * * * > * > **** * > ``` */ class MDHorizontalRuleReader extends MDReader { static #horizontalRuleRegex = /^\s*(?:\-(?:\s*\-){2,}|\*(?:\s*\*){2,})\s*$/; readBlock(state) { var p = state.p; let line = state.lines[p++]; var modifier; [line, modifier] = MDTagModifier.fromLine(line, state); if (MDHorizontalRuleReader.#horizontalRuleRegex.exec(line)) { state.p = p; let block = new MDHorizontalRuleNode(); if (modifier) modifier.applyTo(block); return block; } return null; } compareBlockOrdering(other) { if (other instanceof MDUnorderedListReader) { return -1; } return 0; } } /** * Block reader for tables. * * Examples: * * > ```markdown * > Name | Age * > --- | --- * > Joe | 34 * > Alice | 25 * > * > | Leading | And Trailing | * > | - | - | * > | Required | for single column tables | * > * > | Left aligned column | Center aligned | Right aligned | * > | :-- | :--: | --: | * > | Joe | x | 34 | * > ``` */ class MDTableReader extends MDReader { /** * @param {MDState} state * @param {boolean} isHeader * @return {MDTableRowNode|null} */ #readTableRow(state, isHeader) { if (!state.hasLines(1)) return null; var p = state.p; let line = MDTagModifier.strip(state.lines[p++].trim()); if (/.*\|.*/.exec(line) === null) return null; if (line.startsWith('|')) line = line.substring(1); if (line.endsWith('|')) line = line.substring(0, line.length - 1); let cellTokens = line.split('|'); let cells = cellTokens.map(function(token) { let content = state.inlineMarkdownToNode(token.trim()); return isHeader ? new MDTableHeaderCellNode(content) : new MDTableCellNode(content); }); state.p = p; return new MDTableRowNode(cells); } /** * @param {string} line * @returns {string[]} */ #parseColumnAlignments(line) { line = line.trim(); if (line.startsWith('|')) line = line.substring(1); if (line.endsWith('|')) line = line.substring(0, line.length - 1); return line.split(/\s*\|\s*/).map(function(token) { if (token.startsWith(':')) { if (token.endsWith(':')) { return 'center'; } return 'left'; } else if (token.endsWith(':')) { return 'right'; } return null; }); } static #tableDividerRegex = /^\s*[|]?\s*(?:[:]?-+[:]?)(?:\s*\|\s*[:]?-+[:]?)*\s*[|]?\s*$/; readBlock(state) { if (!state.hasLines(2)) return null; let startP = state.p; let firstLine = state.lines[startP]; var modifier = MDTagModifier.fromLine(firstLine, state)[1]; let headerRow = this.#readTableRow(state, true); if (headerRow === null) { state.p = startP; return null; } let dividerLine = state.lines[state.p++]; let dividerGroups = MDTableReader.#tableDividerRegex.exec(dividerLine); if (dividerGroups === null) { state.p = startP; return null; } let columnAlignments = this.#parseColumnAlignments(dividerLine); var bodyRows = []; while (state.hasLines(1)) { let row = this.#readTableRow(state, false); if (row === null) break; bodyRows.push(row); } let table = new MDTableNode(headerRow, bodyRows); table.columnAlignments = columnAlignments; if (modifier) modifier.applyTo(table); return table; } } /** * Block reader for definition lists. Definitions go directly under terms starting * with a colon. * * Example: * * > ```markdown * > markdown * > : a language for generating HTML from simplified syntax * > parser * > : code that converts human-readable code into machine language * > ``` */ class MDDefinitionListReader extends MDReader { readBlock(state) { var p = state.p; var groups; var termCount = 0; var definitionCount = 0; var defLines = []; while (state.hasLines(1, p)) { let line = state.lines[p++]; if (line.trim().length == 0) { break; } if (/^\s+/.exec(line)) { if (defLines.length == 0) return null; defLines[defLines.length - 1] += "\n" + line; } else if (/^:\s+/.exec(line)) { defLines.push(line); definitionCount++; } else { defLines.push(line); termCount++; } } if (termCount == 0 || definitionCount == 0) return null; let blocks = defLines.map(function(line) { if (groups = /^:\s+(.*?)$/s.exec(line)) { return new MDDefinitionListDefinitionNode(state.inlineMarkdownToNodes(groups[1])); } else { return new MDDefinitionListTermNode(state.inlineMarkdownToNodes(line)); } }); state.p = p; return new MDDefinitionListNode(blocks); } } /** * Block reader for defining footnote contents. Footnotes can be defined anywhere * in the document but will always be rendered at the end of a page or end of * the document. * * Examples: * * > ```markdown * > [^1]: Content of a footnote. Anywhere `[^1]` appears in the * > main text, it will hyperlink to this content at the bottom * > of the document. There will also be backlinks at the end * > of this footnote to all references to it. * > ``` */ class MDFootnoteReader extends MDReader { static #footnoteWithTitleRegex = /^\[\^([^\]]+?)\s+"(.*?)"\]/; // 1=symbol, 2=title static #footnoteRegex = /^\[\^([^\]]+?)\]/; // 1=symbol /** * @param {MDState} state * @param {string} symbol * @param {MDNode[]} content */ #defineFootnote(state, symbol, footnote) { var footnotes = state.root['footnotes'] ?? {}; footnotes[symbol] = footnote; state.root['footnotes'] = footnotes; } /** * @param {MDState} state * @param {string} symbol * @param {number} unique */ #registerUniqueInstance(state, symbol, unique) { var footnoteInstances = state.root['footnoteInstances']; var instances = footnoteInstances[symbol] ?? []; instances.push(unique); footnoteInstances[symbol] = instances; } #idForFootnoteSymbol(state, symbol) { var footnoteIds = state.root['footnoteIds']; const existing = footnoteIds[symbol]; if (existing) return existing; var nextFootnoteId = state.root['nextFootnoteId']; const id = nextFootnoteId++; footnoteIds[symbol] = id; state.root['nextFootnoteId'] = nextFootnoteId; return id; } preProcess(state) { state.root['footnoteInstances'] = {}; state.root['footnotes'] = {}; state.root['footnoteIds'] = {}; state.root['nextFootnoteId'] = 1; } /** * @param {MDState} state */ readBlock(state) { var p = state.p; let groups = /^\s*\[\^\s*([^\]]+)\s*\]:\s+(.*)\s*$/.exec(state.lines[p++]); if (groups === null) return null; let symbol = groups[1]; let def = groups[2]; while (state.hasLines(1, p)) { let line = state.lines[p++]; if (/^\s+/.exec(line)) { def += "\n" + line; } else { p--; break; } } let content = state.inlineMarkdownToNodes(def); this.#defineFootnote(state, symbol, content); state.p = p; return new MDNode(); // empty } readToken(state, line) { var groups; if (groups = MDFootnoteReader.#footnoteWithTitleRegex.exec(line)) { return new MDToken(groups[0], MDTokenType.Footnote, groups[1], groups[2]); } if (groups = MDFootnoteReader.#footnoteRegex.exec(line)) { return new MDToken(groups[0], MDTokenType.Footnote, groups[1]); } return null; } substituteTokens(state, pass, tokens) { var match; if (match = MDToken.findFirstTokens(tokens, [ MDTokenType.Footnote ])) { let symbol = match.tokens[0].content; tokens.splice(match.index, 1, new MDFootnoteNode(symbol)); return true; } return false; } /** * @param {MDState} state * @param {MDBlockNode[]} blocks */ postProcess(state, blocks) { var nextOccurrenceId = 1; for (const block of blocks) { const me = this; block.visitChildren((function(node) { if (!(node instanceof MDFootnoteNode)) return; node.footnoteId = me.#idForFootnoteSymbol(state, node.symbol); node.occurrenceId = nextOccurrenceId++; node.displaySymbol = `${node.footnoteId}`; me.#registerUniqueInstance(state, node.symbol, node.occurrenceId); }).bind(this)); } if (Object.keys(state.footnotes).length == 0) return; blocks.push(new MDFootnoteListNode()); } compareBlockOrdering(other) { if (other instanceof MDLinkReader || other instanceof MDImageReader) { return -1; } return 0; } compareTokenizeOrdering(other) { if (other instanceof MDLinkReader || other instanceof MDImageReader) { return -1; } return 0; } compareSubstituteOrdering(other, pass) { if (other instanceof MDLinkReader || other instanceof MDImageReader) { return -1; } return 0; } } /** * Block reader for abbreviation definitions. Anywhere the abbreviation appears * in the text will have its definition available when hovering over it. * Definitions can appear anywhere in the document. Their content should only * contain simple text, not markdown. * * Example: * * > ```markdown * > *[HTML]: Hyper Text Markup Language * > ``` */ class MDAbbreviationReader extends MDReader { /** * @param {MDState} state * @param {string} abbreviation * @param {string} definition */ #defineAbbreviation(state, abbreviation, definition) { state.abbreviations[abbreviation] = definition; const regex = new RegExp("\\b(" + MDUtils.escapeRegex(abbreviation) + ")\\b", "ig"); state.abbreviationRegexes[abbreviation] = regex; } preProcess(state) { state.root['abbreviations'] = {}; state.root['abbreviationRegexes'] = {}; } readBlock(state) { var p = state.p; let line = state.lines[p++]; let groups = /^\s*\*\[([^\]]+?)\]:\s+(.*?)\s*$/.exec(line); if (groups === null) return null; let abbrev = groups[1]; let def = groups[2]; this.#defineAbbreviation(state, abbrev, def); state.p = p; return new MDNode(); // empty } /** * @param {MDState} state * @param {MDNode[]} blocks */ postProcess(state, blocks) { const abbreviations = state.root['abbreviations']; const regexes = state.root['abbreviationRegexes']; MDUtils.replaceNodes(state, blocks, (original) => { if (!(original instanceof MDTextNode)) return null; var changed = false; var elems = [ original.text ]; // mix of strings and MDNodes for (var i = 0; i < elems.length; i++) { var text = elems[i]; if (typeof text !== 'string') continue; for (const abbreviation in abbreviations) { const groups = regexes[abbreviation].exec(text); if (groups === null) continue; const definition = abbreviations[abbreviation]; const prefix = text.substring(0, groups.index); const suffix = text.substring(groups.index + groups[0].length); elems.splice(i, 1, prefix, new MDAbbreviationNode(groups[0], definition), suffix); i = -1; // start over changed = true; break; } } if (!changed) return null; const nodes = elems.map((elem) => typeof elem === 'string' ? new MDTextNode(elem) : elem); return new MDNode(nodes); }); } } /** * Block reader for simple paragraphs. Paragraphs are separated by a blank (or * whitespace-only) line. This reader is prioritized after every other reader * since there is no distinguishing syntax. */ class MDParagraphReader extends MDReader { readBlock(state) { var paragraphLines = []; var p = state.p; while (p < state.lines.length) { let line = state.lines[p++]; if (line.trim().length == 0) { break; } paragraphLines.push(line); } if (state.p == 0 && p >= state.lines.length) { // If it's the entire document don't wrap it in a paragraph return null; } if (paragraphLines.length > 0) { state.p = p; let content = paragraphLines.join("\n"); return new MDParagraphNode(state.inlineMarkdownToNodes(content)); } return null; } compareBlockOrdering(other) { return 1; // always dead last } } /** * Abstract base class for readers that look for one or more delimiting tokens * around some content. */ class MDSimplePairInlineReader extends MDReader { get substitutionPassCount() { return 4; } /** * Attempts a substitution of a matched pair of delimiting token types. * If successful, the substitution is performed on `tokens` and `true` is * returned, otherwise `false` is returned and the array is untouched. * * If `this.substitutionPassCount` is greater than 1, the first pass * will reject matches with the delimiting character inside the content * tokens. If the reader uses a single pass or a subsequent pass is performed * with multiple pass any contents will be accepted. * * @param {MDState} state * @param {number} pass * @param {MDToken[]} tokens * @param {class} nodeClass * @param {MDTokenType} delimiter * @param {number} count - how many times the token is repeated to form the delimiter * @returns {boolean} `true` if substitution performed, `false` if not */ attemptPair(state, pass, tokens, nodeClass, delimiter, count=1, plaintext=false) { // We do four passes. #1: doubles without inner tokens, #2: singles // without inner tokens, #3: doubles with paired inner tokens, // #4: singles with paired inner tokens if (count == 1 && pass != 2 && pass != 4) return; if (count == 2 && pass != 1 && pass != 3) return; let delimiters = Array(count).fill(delimiter); const isFirstOfMultiplePasses = this.substitutionPassCount > 1 && pass == 1; let match = MDToken.findPairedTokens(tokens, delimiters, delimiters, function(content) { const firstType = content[0] instanceof MDToken ? content[0].type : null; const lastType = content[content.length - 1] instanceof MDToken ? content[content.length - 1].type : null; if (firstType == MDTokenType.Whitespace) return false; if (lastType == MDTokenType.Whitespace) return false; for (const token of content) { // Don't allow nesting if (token.constructor == nodeClass) return false; } if (isFirstOfMultiplePasses) { var innerCount = 0; for (let token of content) { if (token instanceof MDToken && token.type == delimiter) innerCount++; } if ((innerCount % 2) != 0) return false; } return true; }); if (match === null) return false; let content = (plaintext) ? match.contentTokens.map((token) => token.original).join('') : state.tokensToNodes(match.contentTokens); tokens.splice(match.startIndex, match.totalLength, new nodeClass(content)); return true; } } class MDEmphasisReader extends MDSimplePairInlineReader { readToken(state, line) { if (line.startsWith('*')) return new MDToken('*', MDTokenType.Asterisk); if (line.startsWith('_')) return new MDToken('_', MDTokenType.Underscore); return null; } substituteTokens(state, pass, tokens) { if (this.attemptPair(state, pass, tokens, MDEmphasisNode, MDTokenType.Asterisk)) return true; if (this.attemptPair(state, pass, tokens, MDEmphasisNode, MDTokenType.Underscore)) return true; return false; } compareSubstituteOrdering(other, pass) { if (other instanceof MDStrongReader) { return 1; } return 0; } } class MDStrongReader extends MDSimplePairInlineReader { readToken(state, line) { if (line.startsWith('*')) return new MDToken('*', MDTokenType.Asterisk); if (line.startsWith('_')) return new MDToken('_', MDTokenType.Underscore); return null; } substituteTokens(state, pass, tokens) { if (this.attemptPair(state, pass, tokens, MDStrongNode, MDTokenType.Asterisk, 2)) return true; if (this.attemptPair(state, pass, tokens, MDStrongNode, MDTokenType.Underscore, 2)) return true; return false; } compareSubstituteOrdering(other, pass) { if (other instanceof MDEmphasisReader) { return -1; } return 0; } } class MDStrikethroughReader extends MDSimplePairInlineReader { readToken(state, line) { if (line.startsWith('~')) return new MDToken('~', MDTokenType.Tilde); return null; } substituteTokens(state, pass, tokens) { if (this.attemptPair(state, pass, tokens, MDStrikethroughNode, MDTokenType.Tilde, 2)) return true; if (state.config.strikethroughSingleTildeEnabled) { if (this.attemptPair(state, pass, tokens, MDStrikethroughNode, MDTokenType.Tilde)) return true; } return false; } } class MDUnderlineReader extends MDSimplePairInlineReader { readToken(state, line) { if (line.startsWith('_')) return new MDToken('_', MDTokenType.Underscore); return null; } substituteTokens(state, pass, tokens) { return this.attemptPair(state, pass, tokens, MDUnderlineNode, MDTokenType.Underscore, 2); } compareSubstituteOrdering(other, pass) { if (other instanceof MDStrongReader) { return -1; } return 0; } } class MDHighlightReader extends MDSimplePairInlineReader { readToken(state, line) { if (line.startsWith('=')) return new MDToken('=', MDTokenType.Equal); return null; } substituteTokens(state, pass, tokens) { return this.attemptPair(state, pass, tokens, MDHighlightNode, MDTokenType.Equal, 2); } } class MDLinkReader extends MDReader { static #simpleEmailRegex = new RegExp("^<(" + MDUtils.baseEmailRegex.source + ")>", "i"); // 1=email static #simpleURLRegex = new RegExp("^<(" + MDUtils.baseURLRegex.source + ")>", "i"); // 1=URL readToken(state, line) { var groups; if (groups = MDUtils.tokenizeLabel(line)) { return new MDToken(groups[0], MDTokenType.Label, groups[1]); } if (groups = MDUtils.tokenizeEmail(line)) { return new MDToken(groups[0], MDTokenType.Email, groups[1], groups[2]); } if (groups = MDUtils.tokenizeURL(line)) { return new MDToken(groups[0], MDTokenType.URL, groups[1], groups[2]); } if (groups = MDLinkReader.#simpleEmailRegex.exec(line)) { return new MDToken(groups[0], MDTokenType.SimpleEmail, groups[1]); } if (groups = MDLinkReader.#simpleURLRegex.exec(line)) { return new MDToken(groups[0], MDTokenType.SimpleLink, groups[1]); } return null; } substituteTokens(state, pass, tokens) { var match; if (match = MDToken.findFirstTokens(tokens, [ MDTokenType.Label, MDTokenType.META_OptionalWhitespace, MDTokenType.URL ])) { let text = match.tokens[0].content; let url = match.tokens[match.tokens.length - 1].content; let title = match.tokens[match.tokens.length - 1].extra; tokens.splice(match.index, match.tokens.length, new MDLinkNode(url, state.inlineMarkdownToNode(text), title)); return true; } if (match = MDToken.findFirstTokens(tokens, [ MDTokenType.Label, MDTokenType.META_OptionalWhitespace, MDTokenType.Email ])) { let text = match.tokens[0].content; let email = match.tokens[match.tokens.length - 1].content; let url = `mailto:${email}`; let title = match.tokens[match.tokens.length - 1].extra; tokens.splice(match.index, match.tokens.length, new MDLinkNode(url, state.inlineMarkdownToNodes(text), title)); return true; } if (match = MDToken.findFirstTokens(tokens, [ MDTokenType.SimpleEmail ])) { const token = match.tokens[0]; const link = `mailto:${token.content}`; const node = new MDLinkNode(link, new MDObfuscatedTextNode(token.content)); tokens.splice(match.index, 1, node); return true; } if (match = MDToken.findFirstTokens(tokens, [ MDTokenType.SimpleLink ])) { const token = match.tokens[0]; const link = token.content; const node = new MDLinkNode(link, new MDTextNode(link)); tokens.splice(match.index, 1, node); return true; } return false; } } /** * Block reader for URL definitions. Links in the document can include a * reference instead of a verbatim URL so it can be defined in one place and * reused in many places. These can be defined anywhere in the document. Nothing * of the definition is rendered in the document. * * Example: * * > ```markdown * > [foo]: https://example.com * > ``` */ class MDReferencedLinkReader extends MDLinkReader { /** * @param {MDState} state */ readBlock(state) { var p = state.p; let line = state.lines[p++]; var symbol; var url; var title = null; let groups = /^\s*\[(.+?)]:\s*(\S+)\s+"(.*?)"\s*$/.exec(line); if (groups) { symbol = groups[1]; url = groups[2]; title = groups[3]; } else { groups = /^\s*\[(.+?)]:\s*(\S+)\s*$/.exec(line); if (groups) { symbol = groups[1]; url = groups[2]; } else { return null; } } state.defineURL(symbol, url, title); state.p = p; return new MDNode([]); // empty } substituteTokens(state, pass, tokens) { var match; if (match = MDToken.findFirstTokens(tokens, [ MDTokenType.Label, MDTokenType.META_OptionalWhitespace, MDTokenType.Label ])) { let text = match.tokens[0].content; let ref = match.tokens[match.tokens.length - 1].content; tokens.splice(match.index, match.tokens.length, new MDReferencedLinkNode(ref, state.inlineMarkdownToNodes(text))); return true; } return false; } } class MDImageReader extends MDLinkReader { readToken(state, line) { const s = super.readToken(state, line); if (s) return s; if (line.startsWith('!')) return new MDToken('!', MDTokenType.Bang); return null; } substituteTokens(state, pass, tokens) { var match; if (match = MDToken.findFirstTokens(tokens, [ MDTokenType.Bang, MDTokenType.Label, MDTokenType.META_OptionalWhitespace, MDTokenType.URL ])) { let alt = match.tokens[1].content; let url = match.tokens[match.tokens.length - 1].content; let title = match.tokens[match.tokens.length - 1].extra; const node = new MDImageNode(url, alt); if (title !== null) { node.attributes['title'] = title; } tokens.splice(match.index, match.tokens.length, node); return true; } return false; } compareSubstituteOrdering(other, pass) { if (other.constructor === MDLinkReader || other.constructor === MDReferencedLinkReader) { return -1; } return 0; } } class MDReferencedImageReader extends MDReferencedLinkReader { readToken(state, line) { const s = super.readToken(state, line); if (s) return s; if (line.startsWith('!')) return new MDToken('!', MDTokenType.Bang); return null; } substituteTokens(state, pass, tokens) { var match; if (match = MDToken.findFirstTokens(tokens, [ MDTokenType.Bang, MDTokenType.Label, MDTokenType.META_OptionalWhitespace, MDTokenType.Label ])) { let alt = match.tokens[1].content; let ref = match.tokens[match.tokens.length - 1].content; tokens.splice(match.index, match.tokens.length, new MDReferencedImageNode(ref, alt)); return true; } return false; } compareSubstituteOrdering(other, pass) { if (other.constructor === MDLinkReader || other.constructor === MDReferencedLinkReader) { return -1; } return 0; } } class MDCodeSpanReader extends MDSimplePairInlineReader { readToken(state, line) { if (line.startsWith('`')) return new MDToken('`', MDTokenType.Backtick); return null; } substituteTokens(state, pass, tokens) { if (this.attemptPair(state, pass, tokens, MDCodeNode, MDTokenType.Backtick, 2, true)) return true; if (this.attemptPair(state, pass, tokens, MDCodeNode, MDTokenType.Backtick, 1, true)) return true; } } class MDSubscriptReader extends MDSimplePairInlineReader { readToken(state, line) { if (line.startsWith('~')) return new MDToken('~', MDTokenType.Tilde); return null; } preProcess(state) { // Causes a conflict state.config.strikethroughSingleTildeEnabled = false; } substituteTokens(state, pass, tokens) { return this.attemptPair(state, pass, tokens, MDSubscriptNode, MDTokenType.Tilde); } compareSubstituteOrdering(other, pass) { if (other instanceof MDStrikethroughReader) { return -1; } return 0; } } class MDSuperscriptReader extends MDSimplePairInlineReader { readToken(state, line) { if (line.startsWith('^')) return new MDToken('^', MDTokenType.Caret); return null; } substituteTokens(state, pass, tokens) { return this.attemptPair(state, pass, tokens, MDSuperscriptNode, MDTokenType.Caret); } } class MDHTMLTagReader extends MDReader { readToken(state, line) { const tag = MDHTMLTag.fromLineStart(line, state); if (tag === null) return null; if (!state.root.tagFilter.isValidTagName(tag.tagName)) return null; state.root.tagFilter.scrubTag(tag); return new MDToken(tag.original, MDTokenType.HTMLTag, null, null, tag) } substituteTokens(state, pass, tokens) { var match; if (match = MDToken.findFirstTokens(tokens, [ MDTokenType.HTMLTag ])) { const tag = match.tokens[0].tag tokens.splice(match.index, match.tokens.length, new MDHTMLTagNode(tag)) return true; } return false; } } class MDModifierReader extends MDReader { readToken(state, line) { var modifier = MDTagModifier.fromStart(line); if (modifier) return new MDToken(modifier.original, MDTokenType.Modifier, modifier); return null; } substituteTokens(state, pass, tokens) { // Modifiers are applied elsewhere, and if they're not it's fine if they're // rendered as the original syntax. return false; } } // -- Document nodes -------------------------------------------------------- class MDNode { /** * Array of CSS classes to add to the node when rendered as HTML. * @type {string[]} */ cssClasses = []; /** @type {string|null} */ cssId = null; /** * Mapping of CSS attributes to values. * @type {object} */ cssStyles = {}; /** * Mapping of arbitrary attributes and values to add to this node's top-level * tag when rendered as HTML. For `class`, `id`, and `style` attributes, use * `cssClasses`, `cssId`, and `cssStyles` instead. * @type {object} */ attributes = {}; /** * All child nodes in this node. * @type {MDNode[]} */ children; /** * @param {MDNode[]} children */ constructor(children=[]) { if (children instanceof Array) { for (const elem of children) { if (!(elem instanceof MDNode)) { throw new Error(`${this.constructor.name} expects children of type MDNode[] or MDNode, got array with ${MDUtils.typename(elem)} element`); } } this.children = children; } else if (children instanceof MDNode) { this.children = [ children ]; } else { throw new Error(`${this.constructor.name} expects children of type MDNode[] or MDNode, got ${MDUtils.typename(children)}`); } } /** * Renders this node and any children as an HTML string. If the node has no * content an empty string should be returned. * * @param {MDState} state * @returns {string} HTML string */ toHTML(state) { return MDNode.toHTML(this.children, state); } /** * Renders this node and any children as a plain text string. The conversion * should only render ordinary text, not attempt markdown-like formatting * (e.g. list items should not be prefixed with asterisks, only have their * content text returned). If the node has no renderable content an empty * string should be returned. * * @param {MDState} state * @returns {string} plaintext string */ toPlaintext(state) { return MDNode.toPlaintext(this.children, state); } /** * Helper that renders an HTML fragment of the attributes to apply to the * root HTML tag representation of this node. * * Example result with a couple `cssClasses`, a `cssId`, and a custom * `attributes` key-value pair: * * ``` * class="foo bar" id="baz" lang="en" * ``` * * The value includes a leading space if it's non-empty so that it can be * concatenated directly after the tag name and before the closing `>`. * * @returns {string} HTML fragment */ _htmlAttributes() { var html = ''; if (this.cssClasses.length > 0) { html += ` class="${this.cssClasses.join(' ')}"`; } if (this.cssId !== null && this.cssId.length > 0) { html += ` id="${this.cssId}"`; } var styles = []; for (const key in this.cssStyles) { styles.push(`${key}: ${this.cssStyles[key]};`) } if (styles.length > 0) { html += ` style="${MDUtils.escapeHTML(styles.join(' '))}"`; } for (const key in this.attributes) { if (key == 'class' || key == 'id' || key == 'style') continue; const value = `${this.attributes[key]}`; const cleanKey = MDUtils.scrubAttributeName(key); if (cleanKey.length == 0) continue; const cleanValue = MDUtils.escapeHTML(value); html += ` ${cleanKey}="${cleanValue}"`; } return html; } /** * Helper that renders the children of this node to HTML. Mostly for use by * subclasses in their `toHTML` implementation. * * @param {MDState} state * @returns {string} */ _childHTML(state) { return this.children.map((child) => child.toHTML(state)).join(''); } /** * @param {MDState} state * @param {string} tagName * @param {boolean} innerNewLines * @returns {string} */ _simplePairedTagHTML(state, tagName, innerNewLines=false) { const openTagSuffix = this.children[0] instanceof MDBlockNode ? '\n' : '' const closeTagPrefix = this.children[this.children.length - 1] instanceof MDBlockNode ? '\n' : ''; const closeTagSuffix = this instanceof MDBlockNode ? '\n' : ''; return `<${tagName}${this._htmlAttributes()}>${openTagSuffix}${this._childHTML(state)}${closeTagPrefix}${closeTagSuffix}`; } /** * Calls the given callback function with every child node, recursively. * Nodes are visited depth-first. * * @param {function} fn - callback that takes one `MDNode` argument */ visitChildren(fn) { if (this.children === undefined || !Array.isArray(this.children)) { return; } for (const child of this.children) { fn(child); child.visitChildren(fn); } } /** * @param {MDNode[]} nodes * @param {MDState} state * @returns {string} */ static toHTML(nodes, state) { return nodes.map((node) => node.toHTML(state) + (node instanceof MDBlockNode ? '\n' : '')).join(''); } /** * @param {MDNode[]} nodes * @param {MDState} state * @returns {string} */ static toPlaintext(nodes, state) { return nodes.map((node) => node.toPlaintext(state)).join(''); } } class MDBlockNode extends MDNode {} class MDParagraphNode extends MDBlockNode { toHTML(state) { return this._simplePairedTagHTML(state, 'p'); } } class MDHeadingNode extends MDBlockNode { /** @type {number} */ level; constructor(level, children) { super(children); if (typeof level !== 'number' || (level < 1 || level > 6)) { throw new Error(`${this.constructor.name} requires heading level 1 to 6`); } this.level = level; } toHTML(state) { return this._simplePairedTagHTML(state, `h${this.level}`); } } class MDSubtextNode extends MDBlockNode { toHTML(state) { if (this.cssClasses.indexOf('subtext') < 0) { this.cssClasses.push('subtext'); } return this._simplePairedTagHTML(state, 'div'); } } class MDHorizontalRuleNode extends MDBlockNode { toHTML(state) { return ``; } } class MDBlockquoteNode extends MDBlockNode { toHTML(state) { return this._simplePairedTagHTML(state, 'blockquote', true); } } class MDUnorderedListNode extends MDBlockNode { /** @type {MDListItemNode[]} children */ /** * @param {MDListItemNode[]} children */ constructor(children) { super(children); } toHTML(state) { return this._simplePairedTagHTML(state, 'ul', true); } } class MDOrderedListNode extends MDBlockNode { /** @type {MDListItemNode[]} children */ /** @type {number|null} */ startOrdinal; /** * @param {MDListItemNode[]} children * @param {number|null} startOrdinal */ constructor(children, startOrdinal=null) { super(children); this.startOrdinal = startOrdinal; } toHTML(state) { if (this.startOrdinal !== null && this.startOrdinal != 1) this.attributes['start'] = this.startOrdinal; return this._simplePairedTagHTML(state, 'ol', true); } } class MDListItemNode extends MDBlockNode { /** @type {number|null} */ ordinal; /** * @param {MDNode|MDNode[]} children * @param {number|null} ordinal */ constructor(children, ordinal=null) { super(children); this.ordinal = ordinal; } toHTML(state) { return this._simplePairedTagHTML(state, 'li'); } } class MDCodeBlockNode extends MDBlockNode { /** @type {string} */ text; /** @type {string|null} */ language; /** * @param {string} text * @param {string|null} language */ constructor(text, language=null) { super([]); this.text = text; this.language = language; } toHTML(state) { const languageModifier = (this.language !== null) ? ` class="language-${this.language}"` : ''; return `${MDUtils.escapeHTML(this.text)}\n`; } } class MDTableNode extends MDBlockNode { /** @param {MDTableRowNode[]} children */ /** @type {MDTableRowNode} */ get headerRow() { return this.#headerRow; } set headerRow(newValue) { this.#headerRow = newValue; this.#recalculateChildren(); } #headerRow; /** @type {MDTableRowNode[]} */ get bodyRows() { return this.#bodyRows; } set bodyRows(newValue) { this.#bodyRows = newValue; this.#recalculateChildren(); } #bodyRows; /** * How to align each column. Columns beyond the length of the array or with * corresponding `null` elements will have no alignment set. Values should * be valid CSS `text-align` values. * * @type {string[]} */ columnAlignments = []; /** * @param {MDTableRowNode} headerRow * @param {MDTableRowNode[]} bodyRows */ constructor(headerRow, bodyRows) { super([ headerRow, ...bodyRows ]); this.#headerRow = headerRow; this.#bodyRows = bodyRows; } #recalculateChildren() { this.children = [ this.#headerRow, ...this.#bodyRows ]; } #applyAlignments() { this.children.forEach((child) => this.#applyAlignmentsToRow(child)); } /** * @param {MDTableRowNode} row */ #applyAlignmentsToRow(row) { for (const [columnIndex, cell] of row.children.entries()) { const alignment = columnIndex < this.columnAlignments.length ? this.columnAlignments[columnIndex] : null; this.#applyAlignmentToCell(cell, alignment); } } /** * @param {MDTableCellNode} cell * @param {string|null} alignment */ #applyAlignmentToCell(cell, alignment) { if (alignment) { cell.cssStyles['text-align'] = alignment; } else { delete cell.cssStyles['text-align']; } } toHTML(state) { this.#applyAlignments(); var html = ''; html += `\n`; html += '\n'; html += this.headerRow.toHTML(state) + '\n'; html += '\n'; html += '\n'; html += MDNode.toHTML(this.bodyRows, state) + '\n'; html += '\n'; html += '\n'; return html; } } class MDTableRowNode extends MDBlockNode { /** @type {MDTableCellNode[]} children */ toHTML(state) { return this._simplePairedTagHTML(state, 'tr', true); } } class MDTableCellNode extends MDBlockNode { toHTML(state) { return this._simplePairedTagHTML(state, 'td'); } } class MDTableHeaderCellNode extends MDBlockNode { toHTML(state) { return this._simplePairedTagHTML(state, 'th'); } } class MDDefinitionListNode extends MDBlockNode { toHTML(state) { return this._simplePairedTagHTML(state, 'dl', true); } } class MDDefinitionListTermNode extends MDBlockNode { toHTML(state) { return this._simplePairedTagHTML(state, 'dt'); } } class MDDefinitionListDefinitionNode extends MDBlockNode { toHTML(state) { return this._simplePairedTagHTML(state, 'dd'); } } class MDFootnoteListNode extends MDBlockNode { toHTML(state) { const footnotes = state.footnotes; var symbolOrder = Object.keys(footnotes); if (Object.keys(footnotes).length == 0) return ''; const footnoteUniques = state.root.footnoteInstances; var html = ''; html += '

'; html += '
    '; for (const symbol of symbolOrder) { /** @type {MDNode[]} */ let content = footnotes[symbol]; if (!content) continue; const contentHTML = MDNode.toHTML(content, state); html += `
  1. ${contentHTML}`; const uniques = footnoteUniques[symbol]; if (uniques) { for (const unique of uniques) { html += ` ↩︎`; } } html += `
  2. \n`; } html += '
'; html += '
'; return html; } toPlaintext(state) { const footnotes = state.footnotes; var symbolOrder = Object.keys(footnotes); if (Object.keys(footnotes).length == 0) return ''; var text = ''; for (const symbol of symbolOrder) { let content = footnotes[symbol]; if (!content) continue; text += `${symbol}. ${content.toPlaintext(state)}\n`; } return text.trim(); } } class MDInlineNode extends MDNode {} class MDTextNode extends MDInlineNode { text; constructor(text) { super([]); this.text = text; } toHTML(state) { return MDUtils.escapeHTML(this.text); } toPlaintext(state) { return this.text; } } class MDObfuscatedTextNode extends MDTextNode { toHTML(state) { return MDUtils.escapeObfuscated(this.text); } } class MDEmphasisNode extends MDInlineNode { toHTML(state) { return this._simplePairedTagHTML(state, 'em'); } } class MDStrongNode extends MDInlineNode { toHTML(state) { return this._simplePairedTagHTML(state, 'strong'); } } class MDStrikethroughNode extends MDInlineNode { toHTML(state) { return this._simplePairedTagHTML(state, 's'); } } class MDUnderlineNode extends MDInlineNode { toHTML(state) { return this._simplePairedTagHTML(state, 'u'); } } class MDHighlightNode extends MDInlineNode { toHTML(state) { return this._simplePairedTagHTML(state, 'mark'); } } class MDSuperscriptNode extends MDInlineNode { toHTML(state) { return this._simplePairedTagHTML(state, 'sup'); } } class MDSubscriptNode extends MDInlineNode { toHTML(state) { return this._simplePairedTagHTML(state, 'sub'); } } class MDCodeNode extends MDInlineNode { /** @type {string} */ text; constructor(text) { super([]); this.text = text; } toHTML(state) { return `${MDUtils.escapeHTML(this.text)}`; } } class MDFootnoteNode extends MDInlineNode { /** * Symbol the author used to match up the footnote to its content definition. * @type {string} */ symbol; /** * The superscript symbol rendered in HTML. May be the same or different * than `symbol`. * @type {string} display symbol */ displaySymbol = null; /** * Unique ID for the footnote definition. * @type {number|null} */ footnoteId = null; /** * Unique number for backlinking to a footnote occurrence. Populated by * `MDFootnoteReader.postProcess`. * @type {number|null} */ occurrenceId = null; /** * @param {string} symbol * @param {string|null} title */ constructor(symbol, title=null) { super([]); this.symbol = symbol; if (title) this.attributes['title'] = title; } toHTML(state) { if (this.differentiator !== null) { return `${MDUtils.escapeHTML(this.displaySymbol ?? this.symbol)}`; } return ``; } } class MDLinkNode extends MDInlineNode { /** @type {string} */ href; /** * @param {string} href * @param {MDNode[]|MDNode} children */ constructor(href, children, title=null) { super(children); this.href = href; if (title !== null) this.attributes['title'] = title; } toHTML(state) { var escapedLink; if (this.href.startsWith('mailto:')) { escapedLink = MDUtils.escapeObfuscated(this.href); } else { escapedLink = MDUtils.escapeHTML(this.href); } return `${this._childHTML(state)}`; } } class MDReferencedLinkNode extends MDLinkNode { /** @type {string} */ reference; constructor(reference, children) { super('', children); this.reference = reference; } /** * @param {MDState} state */ toHTML(state) { if (this.href === '') { const url = state.urlForReference(this.reference); if (url) this.href = url; const title = state.urlTitleForReference(this.reference); if (title) this.attributes['title'] = title; } return super.toHTML(state); } } class MDImageNode extends MDInlineNode { /** @type {string} */ src; /** @type {string|null} */ alt; /** * @param {string} src * @param {string|null} alt */ constructor(src, alt) { super([]); this.src = src; this.alt = alt; } toHTML(state) { var html = `${MDUtils.escapeHTML(this.alt)}`; return html; } } class MDReferencedImageNode extends MDImageNode { /** @type {string} */ reference; /** * @param {string} reference * @param {string|null} alt */ constructor(reference, alt='') { super('', alt, []); this.reference = reference; } toHTML(state) { if (this.src === '') { this.src = state.urlForReference(this.reference); this.attributes['title'] = state.urlTitleForReference(this.reference); } return super.toHTML(state); } } class MDAbbreviationNode extends MDInlineNode { /** @type {string} */ abbreviation; /** @type {string} */ get definition() { return this.attributes['title'] ?? null; } set definition(newValue) { this.attributes['title'] = newValue; } /** * @param {string} abbreviation * @param {string} definition */ constructor(abbreviation, definition) { super([]); this.abbreviation = abbreviation; this.attributes['title'] = definition; } toHTML(state) { return `${MDUtils.escapeHTML(this.abbreviation)}`; } } class MDLineBreakNode extends MDInlineNode { toHTML(state) { return '
'; } toPlaintext(state) { return '\n'; } } class MDHTMLTagNode extends MDInlineNode { /** @type {MDHTMLTag} */ tag; constructor(tag) { super([]); this.tag = tag; } toHTML(state) { return this.tag.toString(); } } // -- Other ----------------------------------------------------------------- /** * Helps to reject unapproved HTML, tag attributes, and CSS. */ class MDHTMLFilter { /** * Mapping of permitted lowercase tag names to objects containing allowable * attributes for those tags. Does not need to include those attributes * defined in `allowableGlobalAttributes`. * * Values are objects with allowable lowercase attribute names mapped to * allowable value patterns. A `*` means any value is acceptable. Multiple * allowable values can be joined together with `|`. These special symbols * represent certain kinds of values and can be used in combination or in * place of literal values. * * - `{classlist}`: A list of legal CSS classnames, separated by spaces * - `{int}`: An integer * - `{none}`: No value (an attribute with no `=` or value, like `checked`) * - `{style}`: One or more CSS declarations, separated by semicolons (simple * `key: value;` syntax only) * - `{url}`: A URL * @type {object} */ allowableTags = { 'address': { 'cite': '{url}', }, 'h1': {}, 'h2': {}, 'h3': {}, 'h4': {}, 'h5': {}, 'h6': {}, 'blockquote': {}, 'dl': {}, 'dt': {}, 'dd': {}, 'div': {}, 'hr': {}, 'ul': {}, 'ol': { 'start': '{int}', 'type': 'a|A|i|I|1', }, 'li': { 'value': '{int}', }, 'p': {}, 'pre': {}, 'table': {}, 'thead': {}, 'tbody': {}, 'tfoot': {}, 'tr': {}, 'td': {}, 'th': {}, 'a': { 'href': '{url}', 'target': '*', }, 'abbr': {}, 'b': {}, 'br': {}, 'cite': {}, 'code': {}, 'data': { 'value': '*', }, 'dfn': {}, 'em': {}, 'i': {}, 'kbd': {}, 'mark': {}, 'q': { 'cite': '{url}', }, 's': {}, 'samp': {}, 'small': {}, 'span': {}, 'strong': {}, 'sub': {}, 'sup': {}, 'time': { 'datetime': '*', }, 'u': {}, 'var': {}, 'wbr': {}, 'img': { 'alt': '*', 'href': '{url}', }, 'figure': {}, 'figcaption': {}, 'del': {}, 'ins': {}, 'details': {}, 'summary': {}, }; /** * Mapping of allowable lowercase global attributes to their permitted * values. Uses same value pattern syntax as described in `allowableTags`. * @type {object} */ allowableGlobalAttributes = { 'class': '{classlist}', 'data-*': '*', 'dir': 'ltr|rtl|auto', 'id': '*', 'lang': '*', 'style': '{style}', 'title': '*', 'translate': 'yes|no|{none}', }; /** * Mapping of allowable CSS style names to their allowable value patterns. * @type {object} */ allowableStyleKeys = { 'background-color': '{color}', 'color': '{color}', }; /** * Scrubs all forbidden attributes from an HTML tag. * * @param {MDHTMLTag} tag - HTML tag */ scrubTag(tag) { for (const name of Object.keys(tag.attributes)) { if (!this.isValidAttributeName(tag.tagName, name)) { delete tag.attributes[name]; } if (!this.isValidAttributeValue(tag.tagName, name, tag.attributes[name])) { delete tag.attributes[name]; } } } /** * Scrubs all forbidden attributes from an HTML modifier. * * @param {MDTagModifier} modifier * @param {string|null} tagName - HTML tag name, if known, otherwise only * global attributes will be permitted */ scrubModifier(modifier, tagName) { if (modifier.cssClasses.length > 0) { const classList = modifier.cssClasses.join(' '); if (!this.isValidAttributeValue(tagName, 'class', classList)) { modifier.cssClasses = []; } } if (modifier.cssId !== null) { if (!this.isValidAttributeValue(tagName, 'id', modifier.cssId)) { modifier.cssId = null; } } if (!this.isValidAttributeName(tagName, 'style')) { modifier.cssStyles = {}; } else { for (const key of Object.keys(modifier.cssStyles)) { const val = modifier.cssStyles[key]; if (!this.isValidStyleValue(key, val)) { delete modifier.cssStyles[key]; } } } for (const key of Object.keys(modifier.attributes)) { const val = modifier.attributes[key]; if (!this.isValidAttributeValue(tagName, key, val)) { delete modifier.attributes[key]; } } } /** * Tests if an HTML tag name is permitted. * * @param {string} tagName * @returns {boolean} */ isValidTagName(tagName) { return this.allowableTags[tagName.toLowerCase()] !== undefined; } /** * Tests if an HTML attribute name is permitted. * * @param {string|null} tagName - HTML tag name or null to only check global * attributes * @param {string} attributeName - attribute name * @returns {boolean} */ isValidAttributeName(tagName, attributeName) { const lcAttributeName = attributeName.toLowerCase(); if (this.allowableGlobalAttributes[lcAttributeName] !== undefined) { return true; } for (const pattern in this.allowableGlobalAttributes) { if (pattern.endsWith('*') && lcAttributeName.startsWith(pattern.substring(0, pattern.length - 1))) { return true; } } if (tagName === null) return false; const lcTagName = tagName.toLowerCase(); const tagAttributes = this.allowableTags[lcTagName]; if (tagAttributes) { return tagAttributes[lcAttributeName] !== undefined; } return false; } /** * Tests if an attribute value is allowable. * * @param {string|null} tagName * @param {string} attributeName * @param {string} attributeValue * @returns {boolean} */ isValidAttributeValue(tagName, attributeName, attributeValue) { const lcAttributeName = attributeName.toLowerCase(); const globalPattern = this.allowableGlobalAttributes[attributeName.toLowerCase()]; if (globalPattern !== undefined) { return this.#attributeValueMatchesPattern(attributeValue, globalPattern); } for (const namePattern in this.allowableGlobalAttributes) { if (namePattern.endsWith('*') && lcAttributeName.startsWith(namePattern.substring(0, namePattern.length - 1))) { return this.#attributeValueMatchesPattern(attributeValue, this.allowableGlobalAttributes[namePattern]); } } if (tagName === null) return false; const lcTagName = tagName.toLowerCase(); const tagAttributes = this.allowableTags[lcTagName]; if (tagAttributes === undefined) return false; const valuePattern = tagAttributes[lcAttributeName]; if (valuePattern === undefined) return false; return this.#attributeValueMatchesPattern(attributeValue, valuePattern); } static #permissiveURLRegex = /^\S+$/; static #integerRegex = /^[\-]?\d+$/; static #classListRegex = /^-?[_a-zA-Z]+[_a-zA-Z0-9-]*(?:\s+-?[_a-zA-Z]+[_a-zA-Z0-9-]*)*$/; /** * @param {string} value * @param {string} pattern * @returns {boolean} */ #attributeValueMatchesPattern(value, pattern) { const options = pattern.split('|'); for (const option of options) { switch (option) { case '*': return true; case '{classlist}': if (MDHTMLFilter.#classListRegex.exec(value)) return true; break; case '{int}': if (MDHTMLFilter.#integerRegex.exec(value)) return true; break; case '{none}': if (value === true) return true; break; case '{style}': if (this.isValidStyleDeclaration(value)) return true; break; case '{url}': if (MDHTMLFilter.#permissiveURLRegex.exec(value)) return true; break; default: if (value === option) return true; break; } } return false; } /** * Tests if a string of one or more style `key: value;` declarations is * fully allowable. * * @param {string} styles * @returns {boolean} */ isValidStyleDeclaration(styles) { const settings = styles.split(';'); for (const setting of settings) { if (setting.trim().length == 0) continue; const parts = setting.split(':'); if (parts.length != 2) return false; const name = parts[0].trim(); if (!this.isValidStyleKey(name)) return false; const value = parts[1].trim(); if (!this.isValidStyleValue(name, value)) return false; } return true; } /** * Tests if a CSS style key is allowable. * * @param {string} key - CSS key * @returns {boolean} */ isValidStyleKey(key) { return this.allowableStyleKeys[key] !== undefined; } /** * Tests if a CSS style value is allowable. * * @param {string} key * @param {string} value * @returns {boolean} */ isValidStyleValue(key, value) { const pattern = this.allowableStyleKeys[key]; if (pattern === undefined) return false; const options = pattern.split('|'); for (const option of options) { switch (option) { case '{color}': if (this.#isValidCSSColor(value)) return true; default: if (value === option) return true; } } return false; } static #styleColorRegex = /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$|^[a-zA-Z]+$/i; #isValidCSSColor(value) { return MDHTMLFilter.#styleColorRegex.exec(value) !== null; } } class MDHTMLTag { /** @type {string} */ original; /** @type {string} */ tagName; /** @type {boolean} */ isCloser; /** @type {object} */ attributes; /** * @param {string} original * @param {string} tagName * @param {boolean} isCloser * @param {object} attributes */ constructor(original, tagName, isCloser, attributes) { this.original = original; this.tagName = tagName; this.isCloser = isCloser; this.attributes = attributes; } toString() { var html = '<'; if (this.isCloser) html += '/'; html += this.tagName; for (const key in this.attributes) { const safeName = MDUtils.scrubAttributeName(key); const value = this.attributes[key]; if (value === true) { html += ` ${safeName}`; } else { const escapedValue = MDUtils.escapeHTML(value); html += ` ${safeName}="${escapedValue}"`; } } html += '>'; return html; } equals(other) { if (!(other instanceof MDHTMLTag)) return false; if (other.tagName != this.tagName) return false; if (other.isCloser != this.isCloser) return false; return MDUtils.equal(other.attributes, this.attributes); } static #htmlTagNameFirstRegex = /[a-z]/i; static #htmlTagNameMedialRegex = /[a-z0-9]/i; static #htmlAttributeNameFirstRegex = /[a-z]/i; static #htmlAttributeNameMedialRegex = /[a-z0-9-]/i; static #whitespaceCharRegex = /\s/; /** * @param {string} line * @returns {MDHTMLTag|null} HTML tag if possible */ static fromLineStart(line) { let expectOpenBracket = 0; let expectCloserOrName = 1; let expectName = 2; let expectAttributeNameOrEnd = 3; let expectEqualsOrAttributeOrEnd = 4; let expectAttributeValue = 5; let expectCloseBracket = 6; var isCloser = false; var tagName = ''; var attributeName = ''; var attributeValue = ''; var attributeQuote = null; var attributes = {}; var fullTag = null; let endAttribute = function(unescape=false) { if (attributeName.length > 0) { if (attributeValue.length > 0 || attributeQuote) { attributes[attributeName] = unescape ? MDUtils.unescapeHTML(attributeValue) : attributeValue; } else { attributes[attributeName] = true; } } attributeName = ''; attributeValue = ''; attributeQuote = null; }; var expect = expectOpenBracket; for (var p = 0; p < line.length && fullTag === null; p++) { let ch = line.substring(p, p + 1); let isWhitespace = this.#whitespaceCharRegex.exec(ch) !== null; switch (expect) { case expectOpenBracket: if (ch != '<') return null; expect = expectCloserOrName; break; case expectCloserOrName: if (ch == '/') { isCloser = true; } else { p--; } expect = expectName; break; case expectName: if (tagName.length == 0) { if (this.#htmlTagNameFirstRegex.exec(ch) === null) return null; tagName += ch; } else { if (this.#htmlTagNameMedialRegex.exec(ch)) { tagName += ch; } else { p--; expect = (isCloser) ? expectCloseBracket : expectAttributeNameOrEnd; } } break; case expectAttributeNameOrEnd: if (attributeName.length == 0) { if (isWhitespace) { // skip whitespace } else if (ch == '/') { expect = expectCloseBracket; } else if (ch == '>') { fullTag = line.substring(0, p + 1); break; } else if (this.#htmlAttributeNameFirstRegex.exec(ch)) { attributeName += ch; } else { return null; } } else if (isWhitespace) { expect = expectEqualsOrAttributeOrEnd; } else if (ch == '/') { endAttribute(); expect = expectCloseBracket; } else if (ch == '>') { endAttribute(); fullTag = line.substring(0, p + 1); break; } else if (ch == '=') { expect = expectAttributeValue; } else if (this.#htmlAttributeNameMedialRegex.exec(ch)) { attributeName += ch; } else { return null; } break; case expectEqualsOrAttributeOrEnd: if (ch == '=') { expect = expectAttributeValue; } else if (isWhitespace) { // skip whitespace } else if (ch == '/') { expect = expectCloseBracket; } else if (ch == '>') { fullTag = line.substring(0, p + 1); break; } else if (this.#htmlAttributeNameFirstRegex.exec(ch)) { endAttribute(); expect = expectAttributeNameOrEnd; p--; } break; case expectAttributeValue: if (attributeValue.length == 0) { if (attributeQuote === null) { if (isWhitespace) { // skip whitespace } else if (ch == '"' || ch == "'") { attributeQuote = ch; } else { attributeQuote = ''; // explicitly unquoted p--; } } else { if (ch === attributeQuote) { // Empty string endAttribute(attributeQuote != ''); expect = expectAttributeNameOrEnd; } else if (attributeQuote === '' && (ch == '/' || ch == '>')) { return null; } else { attributeValue += ch; } } } else { if (ch === attributeQuote) { endAttribute(); expect = expectAttributeNameOrEnd; } else if (attributeQuote === '' && isWhitespace) { endAttribute(); expect = expectAttributeNameOrEnd; } else { attributeValue += ch; } } break; case expectCloseBracket: if (isWhitespace) { // ignore whitespace } else if (ch == '>') { fullTag = line.substring(0, p + 1); break; } break; } } if (fullTag === null) return null; endAttribute(); return new MDHTMLTag(fullTag, tagName, isCloser, attributes); } } class MDTagModifier { /** @type {string} */ original; /** @type {string[]} */ cssClasses = []; /** @type {string|null} */ cssId = null; /** @type {object} */ cssStyles = {}; /** @type {object} */ attributes = {}; static #baseClassRegex = /\.([a-z_\-][a-z0-9_\-]*?)/i; static #baseIdRegex = /#([a-z_\-][a-z0-9_\-]*?)/i; static #baseAttributeRegex = /([a-z0-9]+?)=([^\s\}]+?)/i; static #baseRegex = /\{([^}]+?)}/i; static #leadingClassRegex = new RegExp('^' + this.#baseRegex.source, 'i'); static #trailingClassRegex = new RegExp('^(.*?)\\s*' + this.#baseRegex.source + '\\s*$', 'i'); static #classRegex = new RegExp('^' + this.#baseClassRegex.source + '$', 'i'); // 1=classname static #idRegex = new RegExp('^' + this.#baseIdRegex.source + '$', 'i'); // 1=id static #attributeRegex = new RegExp('^' + this.#baseAttributeRegex.source + '$', 'i'); // 1=attribute name, 2=attribute value /** * @param {MDNode} node */ applyTo(node) { if (node instanceof MDNode) { node.cssClasses = node.cssClasses.concat(this.cssClasses); if (this.cssId) node.cssId = this.cssId; for (const name in this.attributes) { node.attributes[name] = this.attributes[name]; } for (const name in this.cssStyles) { node.cssStyles[name] = this.cssStyles[name]; } } } equals(other) { if (!(other instanceof MDTagModifier)) return false; if (!MDUtils.equal(other.cssClasses, this.cssClasses)) return false; if (other.cssId !== this.cssId) return false; if (!MDUtils.equal(other.attributes, this.attributes)) return false; return true; } toString() { return this.original; } static #styleToObject(styleValue) { const pairs = styleValue.split(';'); var styles = {}; for (const pair of pairs) { const keyAndValue = pair.split(':'); if (keyAndValue.length != 2) continue; styles[keyAndValue[0]] = keyAndValue[1]; } return styles; } static #fromContents(contents) { let modifierTokens = contents.split(/\s+/); let mod = new MDTagModifier(); mod.original = `{${contents}}`; var groups; for (const token of modifierTokens) { if (token.trim() == '') continue; if (groups = this.#classRegex.exec(token)) { mod.cssClasses.push(groups[1]); } else if (groups = this.#idRegex.exec(token)) { mod.cssId = groups[1]; } else if (groups = this.#attributeRegex.exec(token)) { if (groups[1] == 'style') { mod.cssStyles = this.#styleToObject(groups[2]); } else { mod.attributes[groups[1]] = groups[2]; } } else { return null; } } return mod; } /** * Extracts modifier from line. * @param {string} line * @param {MDState} state * @returns {Array} Tuple with remaining line and MDTagModifier. */ static fromLine(line, state) { if (state) { var found = false; for (const reader of state.root.readersByBlockPriority) { if (reader instanceof MDModifierReader) { found = true; break; } } if (!found) return [ line, null ]; } let groups = this.#trailingClassRegex.exec(line); if (groups === null) return [ line, null ]; let bareLine = groups[1]; let mod = this.#fromContents(groups[2]); return [ bareLine, mod ]; } /** * Extracts modifier from head of string. * @param {string} line * @returns {MDTagModifier} */ static fromStart(line) { let groups = this.#leadingClassRegex.exec(line); if (groups === null) return null; return this.#fromContents(groups[1]); } /** * @param {string} line * @returns {string} */ static strip(line) { let groups = this.#trailingClassRegex.exec(line); if (groups === null) return line; return groups[1]; } } class MDConfig { strikethroughSingleTildeEnabled = true; } class Markdown { /** * Set of standard readers. * @type {MDReader[]} */ static standardReaders = [ new MDUnderlinedHeadingReader(), new MDHashHeadingReader(), new MDBlockQuoteReader(), new MDHorizontalRuleReader(), new MDUnorderedListReader(), new MDOrderedListReader(), new MDFencedCodeBlockReader(), new MDIndentedCodeBlockReader(), new MDParagraphReader(), new MDStrongReader(), new MDEmphasisReader(), new MDCodeSpanReader(), new MDImageReader(), new MDLinkReader(), new MDHTMLTagReader(), ]; /** * All supported readers. * @type {MDReader[]} */ static allReaders = [ ...this.standardReaders, new MDSubtextReader(), new MDTableReader(), new MDDefinitionListReader(), new MDFootnoteReader(), new MDAbbreviationReader(), new MDUnderlineReader(), new MDSubscriptReader(), new MDStrikethroughReader(), new MDHighlightReader(), new MDSuperscriptReader(), new MDReferencedImageReader(), new MDReferencedLinkReader(), new MDModifierReader(), ]; /** * Shared instance of a parser with standard syntax. */ static standardParser = new Markdown(this.standardReaders); /** * Shared instance of a parser with all supported syntax. */ static completeParser = new Markdown(this.allReaders); /** * @type {MDConfig} */ config; /** * Filter for what non-markdown HTML is permitted. HTML generated as a * result of markdown is unaffected. */ tagFilter = new MDHTMLFilter(); #readers; /** @type {MDReader[]} */ #readersByBlockPriority; /** @type {MDReader[]} */ #readersByTokenPriority; /** @type {Array} */ #readersBySubstitutePriority; /** * Creates a Markdown parser with the given syntax readers. * * @param {MDReader[]} readers * @param {MDConfig} config */ constructor(readers=Markdown.allReaders, config=new MDConfig()) { this.#readers = readers; this.config = config; this.#readersByBlockPriority = MDReader.sortReaderForBlocks(readers); this.#readersByTokenPriority = MDReader.sortReadersForTokenizing(readers); this.#readersBySubstitutePriority = MDReader.sortReadersForSubstitution(readers); } /** * Converts a markdown string to an HTML string. * * @param {string} markdown * @returns {string} HTML */ toHTML(markdown) { const lines = markdown.split(/(?:\n|\r|\r\n)/); const state = new MDState(lines, this.config, this.#readersByBlockPriority, this.#readersByTokenPriority, this.#readersBySubstitutePriority, this.tagFilter); for (const reader of this.#readers) { reader.preProcess(state); } const nodes = state.readBlocks(); for (const reader of this.#readers) { reader.postProcess(state, nodes); } return MDNode.toHTML(nodes, state); } }