`
* element.
*
* Supports `MDTagModifier` suffix.
*/
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.
*/
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.
*/
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.
*
* Supports `MDTagModifier` suffix.
*/
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.
*/
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.
*/
class MDFootnoteReader extends MDReader {
static #footnoteWithTitleRegex = /^\[\^([^\s\[\]]+?)\s+"(.*?)"\]/; // 1=symbol, 2=title
static #footnoteRegex = /^\[\^([^\s\[\]]+?)\]/; // 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 plain 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.
*/
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'];
MDNode.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 (state.hasLines(1, $p)) {
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 two delimiting tokens
* on either side of some content. E.g. `**strong**`.
*/
class MDSimplePairInlineReader extends MDReader {
// Passes:
// 1. Syntaxes with two delimiting tokens, interior tokens of the same
// kind must be even in number
// 2. Syntaxes with one delimiting token, interior tokens of the same
// kind must be even in number
// 3. Syntaxes with two delimiting tokens, any tokens inside
// 4. Syntaxes with one delimiting token, any tokens inside
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 - pass number, starting with `1`
* @param {MDToken[]} tokens - tokens/nodes to perform substitution on
* @param {class} nodeClass - class of the node to return if matched
* @param {MDTokenType} delimiter - delimiting token
* @param {number} count - how many times the token is repeated to form the delimiter
* @param {boolean} plaintext - whether to invoke `nodeClass` with a verbatim
* content string instead of parsed `MDNode`s
* @returns {boolean} `true` if substitution was 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 false;
if (count > 1 && pass != 1 && pass != 3) return false;
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;
}
}
/**
* Reader for emphasis syntax. Denoted with a single underscore on either side of
* some text (preferred) or a single asterisk on either side.
*/
class MDEmphasisReader extends MDSimplePairInlineReader {
readToken(state, line) {
if (line.startsWith('_')) return new MDToken('_', MDTokenType.Underscore);
if (line.startsWith('*')) return new MDToken('*', MDTokenType.Asterisk);
return null;
}
substituteTokens(state, pass, tokens) {
if (this.attemptPair(state, pass, tokens, MDEmphasisNode, MDTokenType.Underscore)) return true;
if (this.attemptPair(state, pass, tokens, MDEmphasisNode, MDTokenType.Asterisk)) return true;
return false;
}
compareSubstituteOrdering(other, pass) {
if (other instanceof MDStrongReader) {
return 1;
}
return 0;
}
}
/**
* Reader for strong syntax. Denoted with two asterisks on either side of some
* text (preferred) or two underscores on either side. Note that if
* `MDUnderlineReader` is in use, it will replace the double-underscore syntax.
*/
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;
}
}
/**
* Reader for strikethrough syntax. Consists of two tildes on either side of
* some text (preferred) or single tildes on either side. Note that if
* `MDSubscriptReader` is in use, it will replace the single-tilde syntax.
*
* The number of recognized tildes can be configured.
*/
class MDStrikethroughReader extends MDSimplePairInlineReader {
/** @type {boolean} */
singleTildeEnabled = true;
/** @type {boolean} */
doubleTildeEnabled = true;
readToken(state, line) {
if (line.startsWith('~')) return new MDToken('~', MDTokenType.Tilde);
return null;
}
substituteTokens(state, pass, tokens) {
if (this.singleTildeEnabled) {
if (this.attemptPair(state, pass, tokens, MDStrikethroughNode, MDTokenType.Tilde, 2)) return true;
}
if (this.doubleTildeEnabled) {
if (this.attemptPair(state, pass, tokens, MDStrikethroughNode, MDTokenType.Tilde)) return true;
}
return false;
}
}
/**
* Reader for underline syntax. Consists of two underscores on either side of
* some text. If used with `MDStrongReader` which also looks for double
* underscores, this reader will take priority.
*/
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;
}
}
/**
* Reader for highlight syntax. Consists of pairs of equal signs on either side
* of some text.
*/
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);
}
}
/**
* Reader for inline code syntax. Consists of one or two delimiting backticks
* around text. The contents between the backticks will be rendered verbatim,
* ignoring any inner markdown syntax. To include a backtick inside, escape it
* with a backslash.
*/
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;
}
}
/**
* Reader for subscript syntax. Consists of single tildes on either side of
* some text. If used with `MDStrikethroughReader`, this reader will take
* precedence, and strikethrough can only be done with double tildes.
*/
class MDSubscriptReader extends MDSimplePairInlineReader {
readToken(state, line) {
if (line.startsWith('~')) return new MDToken('~', MDTokenType.Tilde);
return null;
}
substituteTokens(state, pass, tokens) {
return this.attemptPair(state, pass, tokens, MDSubscriptNode, MDTokenType.Tilde);
}
compareSubstituteOrdering(other, pass) {
if (other instanceof MDStrikethroughReader) {
return -1;
}
return 0;
}
}
/**
* Reader for superscript syntax. Consists of single caret characters on either
* side of some text.
*/
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);
}
}
/**
* Reads a hypertext link. Consists of link text between square brackets
* followed immediately by a URL in parentheses.
*/
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 = MDToken.tokenizeLabel(line)) {
return new MDToken(groups[0], MDTokenType.Label, groups[1]);
}
if (groups = MDToken.tokenizeEmail(line)) {
return new MDToken(groups[0], MDTokenType.Email, groups[1], groups[2]);
}
if (groups = MDToken.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;
}
}
/**
* Reader for referential URL definitions. Consists of link text between square
* brackets followed immediately by a reference symbol also in square brackets.
* The URL can be defined elsewhere on a line by itself with the symbol in square
* brackets, colon, and the URL (and optional title in quotes).
*/
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;
}
}
/**
* Reader for images. Consists of an exclamation, alt text in square brackets,
* and image URL in parentheses.
*/
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;
}
}
/**
* Reader for images with referential URL definitions. Consists of an
* exclamation, alt text in square brackets, and link symbol in square brackets.
* URL is defined the same as for `MDReferencedLinkReader`.
*/
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;
}
}
/**
* Converts line breaks within blocks into line breaks in the HTML. Not
* included in any of the default reader sets since most flavors ignore
* line breaks within blocks.
*/
class MDLineBreakReader extends MDReader {
postProcess(state, blocks) {
MDNode.replaceNodes(state, blocks, (original) => {
if (!(original instanceof MDTextNode)) return null;
const lines = original.text.split("\n");
if (lines.length == 1) return null;
var nodes = [];
for (const [i, line] of lines.entries()) {
if (i > 0) {
nodes.push(new MDLineBreakNode());
}
nodes.push(new MDTextNode(line));
}
return new MDNode(nodes);
});
}
}
/**
* Reads a verbatim HTML tag, and if it passes validation by `MDState.tagFilter`,
* will be rendered in the final HTML document. Disallowed tags will be rendered
* as plain text in the resulting document.
*/
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, 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;
}
}
/**
* Reads tag modifiers. Consists of curly braces with one or more CSS classes,
* IDs, or custom attributes separated by spaces to apply to the preceding
* node. Validation is performed on modifiers and only acceptable values are
* applied.
*/
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 --------------------------------------------------------
/**
* Base class for nodes in the assembled document tree.
*/
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)}`);
}
}
/**
* Adds a CSS class. If already present it will not be duplicated.
*
* @param {string} cssClass
* @returns {boolean} whether the class was added
*/
addClass(cssClass) {
if (this.cssClasses.indexOf(cssClass) >= 0) return false;
this.cssClasses.push(cssClass);
return true;
}
/**
* Removes a CSS class.
*
* @param {string} cssClass
* @returns {boolean} whether the class was present and removed
*/
removeClass(cssClass) {
const beforeLength = this.cssClasses.length;
this.cssClasses = this.cssClasses.filter((val) => val !== cssClass);
return this.cssClasses.length != beforeLength;
}
/**
* 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);
}
/**
* Protected helper method 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;
}
/**
* Protected helper that renders and concatenates the HTML of all children
* of this node. Mostly for use by subclasses in their `toHTML`
* implementations.
*
* @param {MDState} state
* @returns {string} concatenated HTML
*/
_childHTML(state) {
return MDNode.toHTML(this.children, state);
}
/**
* Protected helper that renders and concatenates the plaintext of all
* children of this node.
*
* @param {MDState} state
* @returns {string} concatenated plaintext
*/
_childPlaintext(state) {
return MDNode.toPlaintext(this.children, state);
}
/**
* Protected helper for rendering nodes represented by simple paired HTML
* tags. Custom CSS classes and attributes will be included in the result,
* and child content will be rendered between the tags.
*
* @param {MDState} state
* @param {string} tagName - HTML tag name, without angle braces
* @returns {string} HTML string
*/
_simplePairedTagHTML(state, tagName) {
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}${tagName}>${closeTagSuffix}`;
}
/**
* Calls the given callback function with every child node, recursively.
* Nodes are visited depth-first.
*
* @param {function} fn - callback that accepts 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);
}
}
/**
* Helper for rendering and concatenating HTML from an array of `MDNode`s.
*
* @param {MDNode[]} nodes
* @param {MDState} state
* @returns {string} HTML string
*/
static toHTML(nodes, state) {
return nodes.map((node) => node.toHTML(state) + (node instanceof MDBlockNode ? '\n' : '')).join('');
}
/**
* Helper for rendering and concatenating plaintext from an array of `MDNode`s.
*
* @param {MDNode[]} nodes
* @param {MDState} state
* @returns {string} plaintext
*/
static toPlaintext(nodes, state) {
return nodes.map((node) => node.toPlaintext(state)).join('');
}
/**
* Recursively searches and replaces nodes in a tree. The given `replacer`
* is passed every node in the tree. If `replacer` 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 instanceof MDNode) {
nodes.splice(i, 1, replacement);
} else {
this.replaceNodes(state, originalNode.children, replacer);
}
}
}
}
/**
* Marker subclass that indicates a node represents block syntax.
*/
class MDBlockNode extends MDNode {}
/**
* Paragraph block.
*/
class MDParagraphNode extends MDBlockNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'p');
}
}
/**
* A heading block with a level from 1 to 6.
*/
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}`);
}
}
/**
* A sub-text block with smaller, less prominent text.
*/
class MDSubtextNode extends MDBlockNode {
toHTML(state) {
this.addClass('subtext');
return this._simplePairedTagHTML(state, 'div');
}
}
/**
* Node for a horizontal dividing line.
*/
class MDHorizontalRuleNode extends MDBlockNode {
toHTML(state) {
return `
`;
}
}
/**
* A block quote, usually rendered indented from other text.
*/
class MDBlockquoteNode extends MDBlockNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'blockquote');
}
}
/**
* A bulleted list. Contains `MDListItemNode` children.
*/
class MDUnorderedListNode extends MDBlockNode {
/** @type {MDListItemNode[]} children */
/**
* @param {MDListItemNode[]} children
*/
constructor(children) {
super(children);
}
toHTML(state) {
return this._simplePairedTagHTML(state, 'ul');
}
}
/**
* A numbered list. Contains `MDListItemNode` children.
*/
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');
}
}
/**
* An item in a bulleted or numbered list.
*/
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');
}
}
/**
* A block of preformatted computer code. Inner markdown is ignored.
*/
class MDCodeBlockNode extends MDBlockNode {
/** @type {string} */
text;
/**
* The programming language of the content.
* @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`;
}
}
/**
* A table node with a single header row and any number of body rows.
*
* If modifying the rows, use the `headerRow` and `bodyRows` accessors,
* otherwise `children` may get out of sync.
*/
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;
}
}
/**
* Node for one row (header or body) in a table.
*/
class MDTableRowNode extends MDBlockNode {
/** @type {MDTableCellNode[]} children */
toHTML(state) {
return this._simplePairedTagHTML(state, 'tr');
}
}
/**
* Node for one cell in a table row.
*/
class MDTableCellNode extends MDBlockNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'td');
}
}
/**
* Node for a header cell in a header table row.
*/
class MDTableHeaderCellNode extends MDBlockNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'th');
}
}
/**
* Definition list with `MDDefinitionListTermNode` and
* `MDDefinitionListDefinitionNode` children.
*/
class MDDefinitionListNode extends MDBlockNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'dl');
}
}
/**
* A word or term in a definition list.
*/
class MDDefinitionListTermNode extends MDBlockNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'dt');
}
}
/**
* The definition of a word or term in a definition list. Should follow a
* definition term, or another definition to serve as an alternate.
*/
class MDDefinitionListDefinitionNode extends MDBlockNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'dd');
}
}
/**
* Block at the bottom of a document listing all the footnotes with their
* content.
*/
class MDFootnoteListNode extends MDBlockNode {
/**
* @param {MDState} state
* @param {string} symbol
* @return {number}
*/
#footnoteId(state, symbol) {
const lookup = state.root['footnoteIds'];
if (!lookup) return null;
return lookup[symbol] ?? null;
}
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 += '';
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}. ${this._childPlaintext(state)}\n`;
}
return text.trim();
}
}
/**
* Marker subclass that indicates a node represents inline syntax.
*/
class MDInlineNode extends MDNode {}
/**
* Contains plain text. Special HTML characters are escaped when rendered.
*/
class MDTextNode extends MDInlineNode {
text;
constructor(text) {
super([]);
this.text = text;
}
toHTML(state) {
return MDUtils.escapeHTML(this.text);
}
toPlaintext(state) {
return this.text;
}
}
/**
* Contains plain text which is rendered with HTML entities when rendered to
* be marginally more difficult for web scapers to decipher. Used for
* semi-sensitive info like email addresses.
*/
class MDObfuscatedTextNode extends MDTextNode {
toHTML(state) {
return MDUtils.escapeObfuscated(this.text);
}
}
/**
* Emphasized (italicized) content.
*/
class MDEmphasisNode extends MDInlineNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'em');
}
}
/**
* Strong (bold) content.
*/
class MDStrongNode extends MDInlineNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'strong');
}
}
/**
* Content rendered with a line through it.
*/
class MDStrikethroughNode extends MDInlineNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 's');
}
}
/**
* Underlined content.
*/
class MDUnderlineNode extends MDInlineNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'u');
}
}
/**
* Highlighted content. Usually rendered with a bright colored background.
*/
class MDHighlightNode extends MDInlineNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'mark');
}
}
/**
* Superscripted content.
*/
class MDSuperscriptNode extends MDInlineNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'sup');
}
}
/**
* Subscripted content.
*/
class MDSubscriptNode extends MDInlineNode {
toHTML(state) {
return this._simplePairedTagHTML(state, 'sub');
}
}
/**
* Inline plaintext indicating computer code.
*/
class MDCodeNode extends MDInlineNode {
/** @type {string} */
text;
constructor(text) {
super([]);
this.text = text;
}
toHTML(state) {
return `${MDUtils.escapeHTML(this.text)}`;
}
}
/**
* A footnote symbol in a document. Denoted as a superscripted number that can
* be clicked to go to its content at the bottom of the document.
*/
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 ``;
}
return ``;
}
}
/**
* A clickable hypertext link.
*/
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)}`;
}
}
/**
* A clickable hypertext link where the URL is defined elsewhere by reference.
*/
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);
}
}
/**
* An inline image.
*/
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 = `
`;
return html;
}
}
/**
* An inline image where the URL is defined elsewhere by reference.
*/
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 === '') {
const url = state.urlForReference(this.reference);
if (url !== null) this.src = url;
const title = state.urlTitleForReference(this.reference);
if (title !== null) this.attributes['title'] = title;
}
return super.toHTML(state);
}
}
/**
* An abbreviation that can be hovered over to see its full expansion.
*/
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)}`;
}
}
/**
* A line break that is preserved when rendered to HTML.
*/
class MDLineBreakNode extends MDInlineNode {
toHTML(state) {
return '
';
}
toPlaintext(state) {
return '\n';
}
}
/**
* A verbatim HTML tag. May be altered to strip out disallowed attributes or
* CSS values.
*/
class MDHTMLTagNode extends MDInlineNode {
/** @type {MDHTMLTag} */
tag;
constructor(tag) {
super([]);
this.tag = tag;
}
toHTML(state) {
return this.tag.toString();
}
}
// -- Main class ------------------------------------------------------------
/**
* Markdown parser.
*/
class Markdown {
/**
* Set of standard readers to handle common syntax.
* @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 except `MDLineBreakReader`.
* @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);
/**
* Filter for what non-markdown HTML is permitted. HTML generated as a
* result of markdown is unaffected.
*/
tagFilter = new MDHTMLFilter();
/** @type {MDReader[]} */
#readers;
/** @type {MDReader[]} */
#readersByBlockPriority;
/** @type {MDReader[]} */
#readersByTokenPriority;
/** @type {Array} */
#readersBySubstitutePriority;
/**
* Creates a Markdown parser with the given syntax readers.
*
* @param {MDReader[]} readers
*/
constructor(readers=Markdown.allReaders) {
this.#readers = readers;
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
* @param {string} elementIdPrefix - Optional prefix for generated element
* `id`s and links to them. For differentiating multiple markdown docs in
* the same HTML page.
* @returns {string} HTML
*/
toHTML(markdown, elementIdPrefix='') {
const lines = markdown.split(/(?:\n|\r|\r\n)/);
try {
return this.#parse(lines, elementIdPrefix);
} catch (e) {
this.#investigateException(lines, elementIdPrefix);
throw e;
}
}
/**
* @param {string[]} lines
* @param {string} elementIdPrefix
*/
#parse(lines, elementIdPrefix) {
const state = new MDState(lines);
state.readersByBlockPriority = this.#readersByBlockPriority;
state.readersByTokenPriority = this.#readersByTokenPriority
state.readersBySubstitutePriority = this.#readersBySubstitutePriority
state.tagFilter = this.tagFilter;
state.elementIdPrefix = elementIdPrefix;
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);
}
/**
* Keeps removing first and last lines of markdown to locate the source of
* an exception and prints the minimal snippet to `console.error`.
*
* @param {string[]} lines
* @param {string} elementIdPrefix
*/
#investigateException(lines, elementIdPrefix) {
var startIndex = 0;
var endIndex = lines.length;
// Keep stripping away first line until an exception stops being thrown
for (var i = 0; i < lines.length; i++) {
try {
this.#parse(lines.slice(i, endIndex), elementIdPrefix);
break;
} catch (e0) {
startIndex = i;
}
}
// Keep stripping away last line until an exception stops being thrown
for (var i = lines.length; i > startIndex; i--) {
try {
this.#parse(lines.slice(startIndex, i), elementIdPrefix);
break;
} catch (e0) {
endIndex = i;
}
}
const problematicMarkdown = lines.slice(startIndex, endIndex).join("\n");
console.error(`This portion of markdown caused an unexpected exception: ${problematicMarkdown}`);
}
}