// FIXME: Nested blockquotes require blank line
// TODO: HTML tags probably need better handling. Consider whether interior of matched tags should be interpreted as markdown.
// TODO: {.class #cssid lang=fr}
// [link](url){.class}
// TODO: Test broken/incomplete syntax thoroughly
// TODO: Sanity checks on loops/recursion?
// TODO: Tolerate whitespace between tokens (e.g. [click here] [urlref])
// TODO: Spreadsheet functions in tables
class _MDHAlign {
static Left = new _MDHAlign('Left');
static Center = new _MDHAlign('Center');
static Right = new _MDHAlign('Right');
/** @var {String} */
name;
constructor(name) {
this.name = name;
}
toString() {
return `${this.constructor.name}.${this.name}`;
}
static toHTMLAttribute(align) {
switch (align) {
case _MDHAlign.Left: return ' align="left"';
case _MDHAlign.Center: return ' align="center"';
case _MDHAlign.Right: return ' align="right"';
}
return '';
}
}
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 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');
/** @var {String} */
name;
constructor(name) {
this.name = name;
}
toString() {
return `${this.constructor.name}.${this.name}`;
}
}
class _MDToken {
/**
* The original token string.
* @var {String}
*/
original;
/** @var {_MDTokenType} */
type;
/** @var {String|null} */
content;
/** @var {String|null} */
extra;
/** @var {_MDHTMLTag|null} */
tag;
/** @var {_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;
}
}
// -- Spans -----------------------------------------------------------------
class _MDSpan {
/** @var {String[]} */
cssClasses = [];
/** @var {String|null} */
id = null;
/** @var {Object} */
attributes = {};
/**
* @param {_MDState} state
* @returns {String} HTML
*/
toHTML(state) {
throw new Error(self.constructor.name + ".toHTML not implemented");
}
htmlAttributes() {
var html = '';
if (this.cssClasses.length > 0) {
html += ` class="${this.cssClasses.join(' ')}"`;
}
if (this.id !== null) {
html += ` id="${this.id}"`;
}
for (const name in this.attributes) {
let value = this.attributes[name];
html += ` ${name}="${value.replace('"', '"')}"`;
}
return html;
}
/**
* @param {_MDSpan[]} spans
* @param {_MDState} state
*/
static toHTML(spans, state) {
return spans.map((span) => span.toHTML(state)).join("");
}
}
class _MDMultiSpan extends _MDSpan {
/** @var {_MDSpan[]} */
content;
/**
* @param {_MDSpan[]} content
*/
constructor(content) {
super();
this.content = content;
}
toHTML(state) {
return _MDSpan.toHTML(this.content, state);
}
}
class _MDTextSpan extends _MDSpan {
/** @param {String} text */
text;
/**
* @param {String} text
*/
constructor(text) {
super();
this.text = text;
}
toHTML(state) {
let html = this.text.replace('<', '<');
let abbrevs = state.abbreviations;
let regexes = state.abbreviationRegexes;
for (const abbrev in abbrevs) {
let def = abbrevs[abbrev];
let regex = regexes[abbrev];
let escapedDef = def.replace('"', '"');
html = html.replace(regex, `$1`);
}
return html;
}
}
class _MDHTMLSpan extends _MDSpan {
/** @param {String} html */
html;
/**
* @param {String} html
*/
constructor(html) {
super();
this.html = html;
}
toHTML(state) {
return this.html;
}
}
class _MDLinkSpan extends _MDSpan {
/** @var {String} */
link;
/** @var {String|null} */
target = null;
/** @var {_MDSpan} */
content;
/** @var {String|null} */
title = null;
/**
* @param {String} link
* @param {_MDSpan} content
*/
constructor(link, content, title=null) {
super();
this.link = link;
this.content = content;
this.title = title;
}
toHTML(state) {
let escapedLink = this.link.replace('"', '"');
var html = `' + this.content.toHTML(state) + '';
return html;
}
}
class _MDReferencedLinkSpan extends _MDLinkSpan {
/** @var {String} id */
id;
constructor(id, content) {
super(null, content);
this.id = id;
}
/**
* @param {_MDState} state
*/
toHTML(state) {
if (!this.link) {
let url = state.urls[this.id.toLowerCase()];
let title = state.urlTitles[this.id.toLowerCase()];
this.link = url;
this.title = title || this.title;
}
if (this.link) {
return super.toHTML(state);
} else {
let contentHTML = this.content.toHTML(state);
return `[${contentHTML}][${this.id}]`;
}
}
}
class _MDEmphasisSpan extends _MDSpan {
/** @var {_MDSpan} */
#content;
/**
* @param {_MDSpan} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(state) {
let contentHTML = this.#content.toHTML(state);
return `${contentHTML}`;
}
}
class _MDStrongSpan extends _MDSpan {
/** @var {_MDSpan} content */
#content;
/**
* @param {_MDSpan} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(state) {
let contentHTML = this.#content.toHTML(state);
return `${contentHTML}`;
}
}
class _MDStrikethroughSpan extends _MDSpan {
/** @var {_MDSpan} content */
#content;
/**
* @param {_MDSpan} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(state) {
let contentHTML = this.#content.toHTML(state);
return `${contentHTML}`;
}
}
class _MDCodeSpan extends _MDSpan {
/** @var {_MDSpan} content */
#content;
/**
* @param {_MDSpan} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(state) {
let contentHTML = this.#content.toHTML(state);
return `${contentHTML}`;
}
}
class _MDImageSpan extends _MDSpan {
/** @var {String} */
source;
/** @var {String|null} */
alt;
/** @var {String|null} */
title;
/**
* @param {String} source
*/
constructor(source, alt, title=null) {
super();
this.source = source;
this.alt = alt;
this.title = title;
}
toHTML(state) {
let escapedSource = this.source.replace('"', '"');
let html = `
';
return html;
}
}
class _MDReferencedImageSpan extends _MDImageSpan {
/** @var {String} */
id;
/**
* @param {String} id
*/
constructor(id, alt) {
super(null, alt);
this.id = id;
}
toHTML(state) {
if (!this.source) {
let url = state.urls[this.id.toLowerCase()];
let title = state.urlTitles[this.id.toLowerCase()];
this.source = url;
this.title = title || this.title;
}
if (this.source) {
return super.toHTML(state);
} else {
let altEscaped = this.alt.replace('"', '"');
let idEscaped = this.id.replace('"', '"');
return `![${altEscaped}][${idEscaped}]`;
}
}
}
class _MDFootnoteReferenceSpan extends _MDSpan {
/** @var {String} */
symbol;
/**
* @param {String} symbol
*/
constructor(symbol) {
super();
this.symbol = symbol;
}
toHTML(state) {
return ``;
}
}
// -- Blocks ----------------------------------------------------------------
class _MDBlock {
/** @var {String[]} */
cssClasses = [];
/** @var {String|null} */
id = null;
/** @var {Object} */
attributes = {};
/**
* @param {_MDState} state
*/
toHTML(state) {
throw new Error(self.constructor.name + ".toHTML not implemented");
}
htmlAttributes() {
var html = '';
if (this.cssClasses.length > 0) {
html += ` class="${this.cssClasses.join(' ')}"`;
}
if (this.id !== null) {
html += ` id="${this.id}"`;
}
for (const name in this.attributes) {
let value = this.attributes[name];
html += ` ${name}="${value.replace('"', '"')}"`;
}
return html;
}
/**
* @param {_MDBlock[]} blocks
* @param {_MDState} state
* @returns {String}
*/
static toHTML(blocks, state) {
return blocks.map((block) => block.toHTML(state)).join("\n");
}
}
class _MDMultiBlock extends _MDBlock {
/** @var {_MDBlock[]} */
#blocks;
/**
* @param {_MDBlock[]} blocks
*/
constructor(blocks) {
super();
this.#blocks = blocks;
}
toHTML(state) {
return _MDBlock.toHTML(this.#blocks, state);
}
}
class _MDParagraphBlock extends _MDBlock {
/** @var {_MDBlock} */
content;
/**
* @param {_MDBlock} content
*/
constructor(content) {
super();
this.content = content;
}
toHTML(state) {
let contentHTML = this.content.toHTML(state);
return `
${contentHTML}
\n`;
}
}
class _MDHeaderBlock extends _MDBlock {
/** @var {number} */
level;
/** @var {_MDBlock} */
content;
/**
* @param {number} level
* @param {_MDBlock} content
*/
constructor(level, content) {
super();
this.level = level;
this.content = content;
}
toHTML(state) {
let contentHTML = this.content.toHTML(state);
return `${contentHTML}\n`;
}
}
class _MDBlockquoteBlock extends _MDBlock {
/** @var {_MDBlock[]} */
content;
/**
* @param {_MDBlock[]} content
*/
constructor(content) {
super();
this.content = content;
}
toHTML(state) {
let contentHTML = _MDBlock.toHTML(this.content, state);
return `\n${contentHTML}\n
`;
}
}
class _MDUnorderedListBlock extends _MDBlock {
/** @var {_MDListItemBlock[]} */
items;
/**
* @param {_MDListItemBlock[]} items
*/
constructor(items) {
super();
this.items = items;
}
toHTML(state) {
let contentHTML = _MDBlock.toHTML(this.items, state);
return ``;
}
}
class _MDOrderedListBlock extends _MDBlock {
/** @var {_MDListItemBlock[]} */
items;
/** @var {Number|null} */
startOrdinal;
/**
* @param {_MDListItemBlock[]} items
*/
constructor(items, startOrdinal=null) {
super();
this.items = items;
this.startOrdinal = startOrdinal;
}
htmlAttributes() {
var html = super.htmlAttributes();
if (this.startOrdinal !== null) {
html += ` start="${this.startOrdinal}"`;
}
return html;
}
toHTML(state) {
let contentHTML = _MDBlock.toHTML(this.items, state);
return `\n${contentHTML}\n
`;
}
}
class _MDListItemBlock extends _MDBlock {
/** @var {_MDBlock} */
content;
/** @var {Number|null} */
ordinal;
/**
* @param {_MDBlock} content
*/
constructor(content, ordinal=null) {
super();
this.content = content;
this.ordinal = ordinal;
}
toHTML(state) {
let contentHTML = this.content.toHTML(state);
return `${contentHTML}`;
}
}
class _MDCodeBlock extends _MDBlock {
/** @var {String} */
#code;
/**
* @param {String} code
*/
constructor(code) {
super();
this.#code = code;
}
toHTML(state) {
return `${this.#code}
`;
}
}
class _MDHorizontalRuleBlock extends _MDBlock {
toHTML(state) {
return `
\n`;
}
}
class _MDTableCellBlock extends _MDBlock {
/** @var {_MDBlock} */
#content;
/** @var {_MDHAlign|null} */
align = null;
/**
* @param {_MDBlock} content
*/
constructor(content) {
super();
this.#content = content;
}
htmlAttributes() {
var html = super.htmlAttributes();
html += _MDHAlign.toHTMLAttribute(this.align);
return html;
}
toHTML(state) {
let contentHTML = this.#content.toHTML(state);
return `${contentHTML} | `;
}
}
class _MDTableHeaderCellBlock extends _MDTableCellBlock {
toHTML(state) {
let html = super.toHTML(state);
let groups = /^$/.exec(html);
return ` | `;
}
}
class _MDTableRowBlock extends _MDBlock {
/** @var {_MDTableCellBlock[]|_MDTableHeaderCellBlock[]} */
#cells;
/**
* @param {_MDTableCellBlock[]|_MDTableHeaderCellBlock[]} cells
*/
constructor(cells) {
super();
this.#cells = cells;
}
/**
* @param {_MDHAlign[]} alignments
*/
applyAlignments(alignments) {
for (var i = 0; i < this.#cells.length; i++) {
let cell = this.#cells[i];
let align = i < alignments.length ? alignments[i] : null;
cell.align = align;
}
}
toHTML(state) {
let cellsHTML = _MDBlock.toHTML(this.#cells, state);
return ` | \n${cellsHTML}\n
`;
}
}
class _MDTableBlock extends _MDBlock {
/** @var {_MDTableRowBlock} */
#headerRow;
/** @var {_MDTableRowBlock[]} */
#bodyRows;
/**
* @param {_MDTableRowBlock} headerRow
* @param {_MDTableRowBlock[]} bodyRows
*/
constructor(headerRow, bodyRows) {
super();
this.#headerRow = headerRow;
this.#bodyRows = bodyRows;
}
toHTML(state) {
let headerRowHTML = this.#headerRow.toHTML(state);
let bodyRowsHTML = _MDBlock.toHTML(this.#bodyRows, state);
return `\n\n${headerRowHTML}\n\n\n${bodyRowsHTML}\n\n
`;
}
}
class _MDDefinitionListBlock extends _MDBlock {
/** @var {_MDBlock[]} */
#content;
/**
* @param {_MDBlock[]} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(state) {
let contentHTML = _MDBlock.toHTML(this.#content, state);
return `\n${contentHTML}\n
`;
}
}
class _MDDefinitionTermBlock extends _MDBlock {
/** @var {_MDBlock} */
#content;
/**
* @param {_MDBlock} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(state) {
let contentHTML = this.#content.toHTML(state);
return `${contentHTML}`;
}
}
class _MDDefinitionDefinitionBlock extends _MDBlock {
/** @var {_MDBlock} */
#content;
/**
* @param {_MDBlock} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(state) {
let contentHTML = this.#content.toHTML(state);
return `${contentHTML}`;
}
}
class _MDInlineBlock extends _MDBlock {
/** @var {_MDSpan[]} */
#content;
/**
* @param {_MDSpan[]} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(state) {
return _MDSpan.toHTML(this.#content, state);
}
}
class _MDHTMLTag {
/** @var {String} */
fullTag;
/** @var {String} */
tagName;
/** @var {Boolean} */
isCloser;
/** @var {Object} */
attributes;
/**
* @param {String} fullTag
* @param {String} tagName
* @param {Boolean} isCloser
* @param {Object} attributes
*/
constructor(fullTag, tagName, isCloser, attributes) {
this.fullTag = fullTag;
this.tagName = tagName;
this.isCloser = isCloser;
this.attributes = attributes;
}
}
class _MDState {
/** @var {String[]} */
lines = [];
/** @var {Object} */
#abbreviations = {};
/** @var {Object} */
#abbreviationRegexes = {};
/** @var {Object} */
#footnotes = {};
/** @var {Object} */
#urlDefinitions = {};
/** @var {Object} */
#urlTitles = {};
/** @var {number} */
p = 0;
/** @var {_MDState|null} */
#parent = null;
/** @var {Object} */
get abbreviations() {
return (this.#parent) ? this.#parent.abbreviations : this.#abbreviations;
}
/** @var {Object} */
get abbreviationRegexes() {
return (this.#parent) ? this.#parent.abbreviationRegexes : this.#abbreviationRegexes;
}
/** @var {Object} */
get footnotes() {
return (this.#parent) ? this.#parent.footnotes : this.#footnotes;
}
/** @var {Object} */
get urls() {
return (this.#parent) ? this.#parent.urls : this.#urlDefinitions;
}
/** @var {Object} */
get urlTitles() {
return (this.#parent) ? this.#parent.urlTitles : this.#urlTitles;
}
/**
* @param {String[]} lines
*/
copy(lines) {
let cp = new _MDState();
cp.#parent = this;
cp.lines = lines;
cp.p = 0;
return cp;
}
/**
* @param {String} abbreviation
* @param {String} definition
*/
defineAbbreviation(abbreviation, definition) {
if (this.#parent) {
this.#parent.defineAbbreviation(abbreviation, definition);
return;
}
this.#abbreviations[abbreviation] = definition;
let regex = new RegExp("\\b(" + abbreviation + ")\\b", "ig");
this.#abbreviationRegexes[abbreviation] = regex;
}
/**
* @param {String} symbol
* @param {_MDBlock} footnote
*/
defineFootnote(symbol, footnote) {
if (this.#parent) {
this.#parent.defineFootnote(symbol, footnote);
} else {
this.#footnotes[symbol] = footnote;
}
}
defineURL(symbol, url, title=null) {
if (this.#parent) {
this.#parent.defineURL(symbol, url, title);
} else {
this.#urlDefinitions[symbol.toLowerCase()] = url;
if (title !== null) {
this.#urlTitles[symbol.toLowerCase()] = title;
}
}
}
hasLines(minCount, p=-1) {
let relativeTo = (p < 0) ? this.p : p;
return relativeTo + minCount <= this.lines.length;
}
}
class _MDTagModifier {
/** @var {String} */
original;
/** @var {String[]} */
cssClasses = [];
/** @var {String|null} */
id = null;
/** @var {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 {_MDBlock|_MDSpan} elem
*/
applyTo(elem) {
if (elem instanceof _MDBlock || elem instanceof _MDSpan) {
elem.cssClasses = elem.cssClasses.concat(this.cssClasses);
if (this.id) elem.id = this.id;
for (const name in this.attributes) {
elem.attributes[name] = this.attributes[name];
}
}
}
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.id = groups[1];
} else if (groups = this.#attributeRegex.exec(token)) {
mod.attributes[groups[1]] = groups[2];
} else {
return null;
}
}
return mod;
}
/**
* Extracts modifier from line.
* @param {String} line
* @returns {Array} Tuple with remaining line and _MDTagModifier.
*/
static fromLine(line) {
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 Markdown {
/**
* @param {String} line
*/
static #stripIndent(line, count=1) {
let regex = new RegExp(`^(?: {1,4}|\\t){${count}}`);
return line.replace(regex, '');
}
/**
* @param {String} line
* @param {Boolean} fullIndentsOnly
* @returns {Number} indent count
*/
static #countIndents(line, fullIndentsOnly=false) {
var count = 0;
var lastLine = line;
while (line.length > 0) {
line = (fullIndentsOnly)
? line.replace(/^(?: {4}|\t)/, '')
: line.replace(/^(?: {1,4}|\t)/, '');
if (line != lastLine) {
count++;
} else {
break;
}
lastLine = line;
}
return count;
}
/**
* @param {_MDState} state
* @returns {_MDBlock[]}
*/
static #readBlocks(state) {
var blocks = [];
while (state.hasLines(1)) {
let block = this.#readNextBlock(state);
if (block) {
blocks.push(block);
} else {
break;
}
}
return blocks;
}
/**
* @param {_MDState} state
* @returns {_MDBlock}
*/
static #readNextBlock(state) {
while (state.hasLines(1) && state.lines[state.p].trim().length == 0) {
state.p++;
}
var block;
block = this.#readUnderlineHeader(state); if (block) return block;
block = this.#readHashHeader(state); if (block) return block;
block = this.#readBlockQuote(state); if (block) return block;
block = this.#readUnorderedList(state); if (block) return block;
block = this.#readOrderedList(state); if (block) return block;
block = this.#readFencedCodeBlock(state); if (block) return block;
block = this.#readIndentedCodeBlock(state); if (block) return block;
block = this.#readHorizontalRule(state); if (block) return block;
block = this.#readTable(state); if (block) return block;
block = this.#readFootnoteDef(state); if (block) return block;
block = this.#readAbbreviationDef(state); if (block) return block;
block = this.#readURLDef(state); if (block) return block;
block = this.#readDefinitionList(state); if (block) return block;
block = this.#readParagraph(state); if (block) return block;
return null;
}
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 #htmlTag(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() {
if (attributeName.length > 0) {
if (attributeValue.length > 0 || attributeQuote) {
attributes[attributeName] = 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();
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);
}
static #textWhitespaceRegex = /^(\s*)(?:(\S|\S.*\S)(\s*?))?$/; // 1=leading WS, 2=text, 3=trailing WS
// 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;
static #footnoteWithTitleRegex = /^\[\^(\d+?)\s+"(.*?)"\]/; // 1=symbol, 2=title
static #footnoteRegex = /^\[\^(\d+?)\]/; // 1=symbol
// Note: label contents have to have matching pairs of [] and (). Handles images inside links.
static #labelRegex = /^\[((?:[^\[\]]*?\[[^\[\]]*?\][^\[\]]*?|[^\(\)]*?\([^\(\)]*?\)[^\(\)]*?|[^\[\]\(\)]*?)*?)\]/; // 1=content
static #urlWithTitleRegex = /^\((\S+?)\s+"(.*?)"\)/i; // 1=URL, 2=title
static #urlRegex = /^\((\S+?)\)/i; // 1=URL
static #emailWithTitleRegex = new RegExp("^\\(\\s*(" + this.#baseEmailRegex.source + ")\\s+\"(.*?)\"\\s*\\)", "i"); // 1=email, 2=title
static #emailRegex = new RegExp("^\\(\\s*(" + this.#baseEmailRegex.source + ")\\s*\\)", "i"); // 1=email
static #simpleURLRegex = new RegExp("^<" + this.#baseURLRegex.source + ">", "i"); // 1=URL
static #simpleEmailRegex = new RegExp("^<" + this.#baseEmailRegex.source + ">", "i"); // 1=email
/**
* @param {String} line
* @returns {String[]}
*/
static #matchLabel(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 == '(') {
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;
}
/**
* @param {String} line
* @returns {_MDToken[]} tokens
*/
static #tokenize(line) {
var tokens = [];
var text = '';
var expectLiteral = false;
var groups = null;
var tag = null;
var modifier = null;
const endText = function() {
if (text.length == 0) return;
let textGroups = Markdown.#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++) {
let ch = line.substring(p, p + 1);
let remainder = line.substring(p);
if (expectLiteral) {
text += ch;
expectLiteral = false;
continue;
}
if (ch == '\\') {
expectLiteral = true;
} else if (ch == '*') {
endText();
tokens.push(new _MDToken(ch, _MDTokenType.Asterisk));
} else if (ch == '_') {
endText();
tokens.push(new _MDToken(ch, _MDTokenType.Underscore));
} else if (ch == '`') {
endText();
tokens.push(new _MDToken(ch, _MDTokenType.Backtick));
} else if (ch == '~') {
endText();
tokens.push(new _MDToken(ch, _MDTokenType.Tilde));
} else if (ch == '!') {
endText();
tokens.push(new _MDToken(ch, _MDTokenType.Bang));
} else if (groups = this.#footnoteWithTitleRegex.exec(remainder)) {
// Footnote with title [^1 "Foo"]
endText();
tokens.push(new _MDToken(groups[0], _MDTokenType.Footnote, groups[1], groups[2]));
p += groups[0].length - 1;
} else if (groups = this.#footnoteRegex.exec(remainder)) {
// Footnote without title [^1]
endText();
tokens.push(new _MDToken(groups[0], _MDTokenType.Footnote, groups[1]));
p += groups[0].length - 1;
} else if (groups = this.#matchLabel(remainder)) {
// Label/ref for link/image [Foo]
endText();
tokens.push(new _MDToken(groups[0], _MDTokenType.Label, groups[1]));
p += groups[0].length - 1;
} else if (groups = this.#urlWithTitleRegex.exec(remainder)) {
// URL with title (https://foo "Bar")
endText();
tokens.push(new _MDToken(groups[0], _MDTokenType.URL, groups[1], groups[2]));
p += groups[0].length - 1;
} else if (groups = this.#emailWithTitleRegex.exec(remainder)) {
// Email address with title (user@example.com "Foo")
endText();
tokens.push(new _MDToken(groups[0], _MDTokenType.Email, groups[1], groups[2]));
p += groups[0].length - 1;
} else if (groups = this.#urlRegex.exec(remainder)) {
// URL (https://example.com)
endText();
tokens.push(new _MDToken(groups[0], _MDTokenType.URL, groups[1]));
p += groups[0].length - 1;
} else if (groups = this.#emailRegex.exec(remainder)) {
// Email (user@example.com)
endText();
tokens.push(new _MDToken(groups[0], _MDTokenType.Email, groups[1]));
p += groups[0].length - 1;
} else if (groups = this.#simpleURLRegex.exec(remainder)) {
// Simple URL
endText();
tokens.push(new _MDToken(groups[0], _MDTokenType.SimpleLink, groups[1]));
p += groups[0].length - 1;
} else if (groups = this.#simpleEmailRegex.exec(remainder)) {
// Simple email
endText();
tokens.push(new _MDToken(groups[0], _MDTokenType.SimpleEmail, groups[1]));
p += groups[0].length - 1;
} else if (tag = this.#htmlTag(remainder)) {
endText();
tokens.push(new _MDToken(tag.fullTag, _MDTokenType.HTMLTag, tag.fullTag, null, tag));
p += tag.fullTag.length - 1;
} else if (modifier = _MDTagModifier.fromStart(remainder)) {
endText();
tokens.push(new _MDToken(modifier.original, _MDTokenType.Modifier, modifier));
p += modifier.original.length - 1;
} else {
text += ch;
}
}
endText();
return tokens;
}
static #firstTokenIndex(tokens, pattern, startIndex=0) {
for (var t = startIndex; t < tokens.length; t++) {
var matchedAll = true;
for (var p = 0; p < pattern.length; p++) {
var t0 = t + p;
if (t0 >= tokens.length) return null;
let token = tokens[t0];
let elem = pattern[p];
if (elem == _MDTokenType.META_AnyNonWhitespace) {
if (token instanceof _MDToken && token.type == _MDTokenType.Whitespace) {
matchedAll = false;
break;
}
} else {
if (!(token instanceof _MDToken) || token.type != elem) {
matchedAll = false;
break;
}
}
}
if (matchedAll) {
return t;
}
}
return null;
}
/**
* @param {_MDState} state
* @param {String} line
* @returns {_MDBlock|null}
*/
static #readInline(state, line) {
var tokens = this.#tokenize(line);
return new _MDInlineBlock(this.#tokensToSpans(tokens, state));
}
/**
* @param {Array} tokens
* @returns {_MDSpan[]} spans
*/
static #tokensToSpans(tokens, state) {
var spans = tokens.slice(0, tokens.length);
var anyChanges = false;
var index, index0;
// First pass - contiguous constructs
do {
anyChanges = false;
// 
if ((index = this.#firstTokenIndex(spans, [
_MDTokenType.Bang,
_MDTokenType.Label,
_MDTokenType.URL,
])) !== null) {
let alt = spans[index + 1].content;
let url = spans[index + 2].content;
let title = spans[index + 2].extra;
spans.splice(index, 3, new _MDImageSpan(url, alt, title));
anyChanges = true;
}
// ![alt][ref]
else if ((index = this.#firstTokenIndex(spans, [
_MDTokenType.Bang,
_MDTokenType.Label,
_MDTokenType.Label,
])) !== null) {
let alt = spans[index + 1].content;
let ref = spans[index + 2].content;
spans.splice(index, 3, new _MDReferencedImageSpan(ref, alt));
anyChanges = true;
}
// [text](link.html)
else if ((index = this.#firstTokenIndex(spans, [
_MDTokenType.Label,
_MDTokenType.URL,
])) !== null) {
let text = spans[index + 0].content;
let url = spans[index + 1].content;
spans.splice(index, 2, new _MDLinkSpan(url, this.#readInline(state, text)));
anyChanges = true;
}
// [text][ref]
else if ((index = this.#firstTokenIndex(spans, [
_MDTokenType.Label,
_MDTokenType.Label,
])) !== null) {
let text = spans[index + 0].content;
let ref = spans[index + 1].content;
spans.splice(index, 2, new _MDReferencedLinkSpan(ref, this.#readInline(state, text)));
anyChanges = true;
}
// [^1]
else if ((index = this.#firstTokenIndex(spans, [
_MDTokenType.Footnote,
])) !== null) {
let symbol = spans[index].content;
spans.splice(index, 1, new _MDFootnoteReferenceSpan(symbol));
anyChanges = true;
}
} while (anyChanges);
/**
* @param {_MDTokenType[]} delimiter
* @param {Set<_MDTokenType>} disallowedInnerTokens
*/
const matchPair = function(delimiter, disallowedInnerTokens=new Set()) {
var searchStart = 0;
var hasNewStart = false;
do {
hasNewStart = false;
let startIndex = Markdown.#firstTokenIndex(spans, delimiter.concat(_MDTokenType.META_AnyNonWhitespace), searchStart);
if (startIndex === null) return null;
let endIndex = Markdown.#firstTokenIndex(spans, [_MDTokenType.META_AnyNonWhitespace].concat(delimiter), startIndex + delimiter.length);
if (endIndex === null) return null;
let contentTokens = spans.slice(startIndex + delimiter.length, endIndex + 1);
if (disallowedInnerTokens.size > 0) {
for (const token of contentTokens) {
if (token instanceof _MDToken && disallowedInnerTokens.has(token.type)) {
searchStart = startIndex + 1;
hasNewStart = true;
break;
}
}
if (hasNewStart) continue;
}
let contentSpans = Markdown.#tokensToSpans(contentTokens, state);
return {
startIndex: startIndex,
toDelete: endIndex - startIndex + delimiter.length + 1,
content: new _MDMultiSpan(contentSpans),
};
} while (hasNewStart);
return null;
};
var spanMatch = null;
// Second pass - paired constructs. Prioritize pairs with no other paired tokens inside.
const delimiterTokens = new Set([
_MDTokenType.Backtick,
_MDTokenType.Tilde,
_MDTokenType.Asterisk,
_MDTokenType.Underscore
]);
for (let disallowed of [ delimiterTokens, new Set() ]) {
do {
anyChanges = false;
// ``code``
if (spanMatch = matchPair([ _MDTokenType.Backtick, _MDTokenType.Backtick ], disallowed)) {
spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDCodeSpan(spanMatch.content));
anyChanges = true;
}
// ~~strike~~
else if (spanMatch = matchPair([ _MDTokenType.Tilde, _MDTokenType.Tilde ], disallowed)) {
spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDStrikethroughSpan(spanMatch.content));
anyChanges = true;
}
// **strong** __strong__
else if (spanMatch = (matchPair([ _MDTokenType.Asterisk, _MDTokenType.Asterisk ], disallowed) ||
matchPair([ _MDTokenType.Underscore, _MDTokenType.Underscore ], disallowed))) {
spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDStrongSpan(spanMatch.content));
anyChanges = true;
}
// `code`
if (spanMatch = matchPair([ _MDTokenType.Backtick ], disallowed)) {
spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDCodeSpan(spanMatch.content));
anyChanges = true;
}
// ~strike~
else if (spanMatch = matchPair([ _MDTokenType.Tilde ], disallowed)) {
spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDStrikethroughSpan(spanMatch.content));
anyChanges = true;
}
// *strong* _strong_
else if (spanMatch = (matchPair([ _MDTokenType.Asterisk ], disallowed) ||
matchPair([ _MDTokenType.Underscore ], disallowed))) {
spans.splice(spanMatch.startIndex, spanMatch.toDelete, new _MDEmphasisSpan(spanMatch.content));
anyChanges = true;
}
} while (anyChanges);
}
var lastSpan = null;
spans = spans.map(function(span) {
if (span instanceof _MDToken) {
if (span.type == _MDTokenType.Modifier && lastSpan) {
span.modifier.applyTo(lastSpan);
lastSpan = null;
return new _MDTextSpan('');
}
lastSpan = null;
return new _MDTextSpan(span.original);
} else if (span instanceof _MDSpan) {
lastSpan = (span instanceof _MDTextSpan) ? null : span;
return span;
} else {
throw new Error(`Unexpected span type ${span.constructor.name}`);
}
});
return spans;
}
/**
* Reads the contents of something like a list item
* @param {_MDState} state
* @param {number} firstLineStartPos
* @param {RegExp} stopRegex
* @param {Boolean} inList
* @returns {_MDBlock}
*/
static #readInteriorContent(state, firstLineStartPos, stopRegex, inList=false) {
var p = state.p;
var seenBlankLine = false;
var needsBlocks = false;
var lines = [];
var hasNestedList = false;
var firstNestedListLine = -1;
while (state.hasLines(1, p)) {
let line = state.lines[p++];
if (p == state.p + 1) {
line = line.substring(firstLineStartPos);
}
let isBlank = line.trim().length == 0;
let isIndented = /^\s+/.exec(line) !== null;
if (isBlank) {
seenBlankLine = true;
lines.push(line.trim());
} else if (stopRegex && stopRegex.exec(line)) {
p--;
break;
} else if (isIndented) {
if (seenBlankLine) {
needsBlocks = true;
}
if (inList && /^\s*(?:\*|\+|\-|\d+\.)\s+/.exec(line)) {
hasNestedList = true;
if (firstNestedListLine < 0) {
firstNestedListLine = lines.length;
}
}
lines.push(this.#stripIndent(line));
} else {
if (seenBlankLine) {
p--;
break;
}
lines.push(this.#stripIndent(line));
}
}
while (lines.length > 0 && lines[lines.length - 1].trim().length == 0) {
lines.pop();
}
if (inList && hasNestedList) {
let parentLines = lines.slice(0, firstNestedListLine);
let parentContent = this.#readInline(state, parentLines.join("\n"));
let nestedLines = lines.slice(firstNestedListLine);
let substate = state.copy(nestedLines);
let nestedContent = this.#readBlocks(substate);
state.p = p;
return new _MDMultiBlock([parentContent].concat(nestedContent));
}
if (needsBlocks) {
let substate = state.copy(lines);
let blocks = this.#readBlocks(substate);
state.p = p;
return new _MDMultiBlock(blocks);
} else {
state.p = p;
return this.#readInline(state, lines.join("\n"));
}
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readUnderlineHeader(state) {
var p = state.p;
if (!state.hasLines(2)) return null;
var modifier;
let contentLine = state.lines[p++].trim();
[contentLine, modifier] = _MDTagModifier.fromLine(contentLine);
let underLine = state.lines[p++].trim();
if (contentLine == '') return null;
if (/^=+$/.exec(underLine)) {
state.p = p;
let block = new _MDHeaderBlock(1, this.#readInline(state, contentLine));
if (modifier) modifier.applyTo(block);
return block;
}
if (/^\-+$/.exec(underLine)) {
state.p = p;
let block = new _MDHeaderBlock(2, this.#readInline(state, contentLine));
if (modifier) modifier.applyTo(block);
return block;
}
return null;
}
static #hashHeaderRegex = /^(#{1,6})\s*([^#].*?)\s*\#*\s*$/; // 1=hashes, 2=content
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readHashHeader(state) {
var p = state.p;
let line = state.lines[p++];
var modifier;
[line, modifier] = _MDTagModifier.fromLine(line);
var groups = this.#hashHeaderRegex.exec(line);
if (groups === null) return null;
state.p = p;
let block = new _MDHeaderBlock(groups[1].length, this.#readInline(state, groups[2]));
if (modifier) modifier.applyTo(block);
return block;
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readBlockQuote(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 = this.#readBlocks(substate);
state.p = p;
return new _MDBlockquoteBlock(quotedBlocks);
}
return null;
}
static #unorderedListRegex = /^([\*\+\-]\s+)(.*)$/; // 1=bullet, 2=content
static #unorderedListItemRegex = /^[\*\+\-]\s+/;
/**
* @param {_MDState} state
* @returns {_MDListItemBlock|null}
*/
static #readUnorderedListItem(state) {
var p = state.p;
let line = state.lines[p];
let groups = this.#unorderedListRegex.exec(line);
if (groups === null) return null;
return new _MDListItemBlock(this.#readInteriorContent(state, groups[1].length, this.#unorderedListItemRegex, true));
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readUnorderedList(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 _MDUnorderedListBlock(items);
}
static #orderedListRegex = /^(\d+)(\.\s+)(.*)$/; // 1=number, 2=dot, 3=content
static #orderedListItemRegex = /^\d+\.\s+/;
/**
* @param {_MDState} state
* @returns {_MDListItemBlock|null}
*/
static #readOrderedListItem(state) {
var p = state.p;
let line = state.lines[p];
let groups = this.#orderedListRegex.exec(line);
if (groups === null) return null;
let ordinal = parseInt(groups[1]);
let content = this.#readInteriorContent(state, groups[1].length + groups[2].length, this.#orderedListItemRegex, true);
return new _MDListItemBlock(content, ordinal);
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readOrderedList(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 _MDOrderedListBlock(items, items[0].ordinal);
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readFencedCodeBlock(state) {
if (!state.hasLines(2)) return null;
var p = state.p;
let openFenceLine = state.lines[p++];
var modifier;
[openFenceLine, modifier] = _MDTagModifier.fromLine(openFenceLine);
if (openFenceLine.trim() != '```') return null;
var codeLines = [];
while (state.hasLines(1, p)) {
let line = state.lines[p++];
if (line.trim() == '```') {
state.p = p;
let block = new _MDCodeBlock(codeLines.join("\n"));
if (modifier) modifier.applyTo(block);
return block;
}
codeLines.push(line);
}
return null;
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readIndentedCodeBlock(state) {
var p = state.p;
var codeLines = [];
while (state.hasLines(1, p)) {
let line = state.lines[p++];
if (this.#countIndents(line, true) < 1) {
p--;
break;
}
codeLines.push(this.#stripIndent(line));
}
if (codeLines.length == 0) return null;
state.p = p;
return new _MDCodeBlock(codeLines.join("\n"));
}
static #horizontalRuleRegex = /^\s*(?:\-(?:\s*\-){2,}|\*(?:\s*\*){2,})\s*$/;
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readHorizontalRule(state) {
var p = state.p;
let line = state.lines[p++];
var modifier;
[line, modifier] = _MDTagModifier.fromLine(line);
if (this.#horizontalRuleRegex.exec(line)) {
state.p = p;
let block = new _MDHorizontalRuleBlock();
if (modifier) modifier.applyTo(block);
return block;
}
return null;
}
/**
* @param {_MDState} state
* @param {Boolean} isHeader
* @return {_MDTableRowBlock|null}
*/
static #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 = Markdown.#readInline(state, token);
return isHeader ? new _MDTableHeaderCellBlock(content) : new _MDTableCellBlock(content);
});
state.p = p;
return new _MDTableRowBlock(cells);
}
/**
* @param {String} line
* @returns {_MDHAlign[]}
*/
static #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('|').map(function(token) {
token = token.trim();
if (token.startsWith(':')) {
if (token.endsWith(':')) {
return _MDHAlign.Center;
}
return _MDHAlign.Left;
} else if (token.endsWith(':')) {
return _MDHAlign.Right;
}
return null;
});
}
static #tableDividerRegex = /^\s*[|]?(?:\s*[:]?-+[:]?\s*\|)(?:\s*[:]?-+[:]?\s*)[|]?\s*$/;
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readTable(state) {
if (!state.hasLines(2)) return null;
let startP = state.p;
let firstLine = state.lines[startP];
var ignore, modifier;
[ignore, modifier] = _MDTagModifier.fromLine(firstLine);
let headerRow = this.#readTableRow(state, true);
if (headerRow === null) {
state.p = startP;
return null;
}
let dividerLine = state.lines[state.p++];
let dividerGroups = this.#tableDividerRegex.exec(dividerLine);
if (dividerGroups === null) {
state.p = startP;
return null;
}
let columnAlignments = this.#parseColumnAlignments(dividerLine);
headerRow.applyAlignments(columnAlignments);
var bodyRows = [];
while (state.hasLines(1)) {
let row = this.#readTableRow(state, false);
if (row === null) break;
row.applyAlignments(columnAlignments);
bodyRows.push(row);
}
let table = new _MDTableBlock(headerRow, bodyRows);
if (modifier) modifier.applyTo(table);
return table;
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readDefinitionList(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) {
p--;
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+(.*)$/.exec(line)) {
return new _MDDefinitionDefinitionBlock(Markdown.#readInline(state, groups[1]));
} else {
return new _MDDefinitionTermBlock(Markdown.#readInline(state, line));
}
});
state.p = p;
return new _MDDefinitionListBlock(blocks);
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readFootnoteDef(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;
}
}
state.p = p;
let content = this.#readInline(state, def);
state.defineFootnote(symbol, content);
state.p = p;
return new _MDMultiBlock([]);
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readAbbreviationDef(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];
state.defineAbbreviation(abbrev, def);
state.p = p;
return new _MDMultiBlock([]);
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readURLDef(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 _MDInlineBlock([]);
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readParagraph(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 (paragraphLines.length > 0) {
state.p = p;
let content = paragraphLines.join("\n");
return new _MDParagraphBlock(this.#readInline(state, content));
}
return null;
}
/**
* @param {String} html
* @param {_MDState} state
* @returns {String}
*/
static #postProcessFootnotes(html, state) {
let footnotes = state.footnotes;
if (Object.keys(footnotes).length == 0) return html;
var symbolOrder = [];
var footnoteOccurrences = {};
var footnoteIndex = 0;
html = html.replace(//g, function(match, symbol) {
footnoteIndex++;
symbol = symbol.toLowerCase();
if (!symbolOrder.includes(symbol)) {
symbolOrder.push(symbol);
}
var occurrences = footnoteOccurrences[symbol] || [];
occurrences.push(footnoteIndex);
footnoteOccurrences[symbol] = occurrences;
return ``;
});
if (footnoteIndex == 0) return html;
html += '';
return html;
}
/**
* @param {String} markdown
* @returns {String} HTML
*/
static toHTML(markdown) {
var state = new _MDState();
let lines = markdown.split(/(?:\n|\r|\r\n)/);
state.lines = lines;
let blocks = this.#readBlocks(state);
let html = _MDBlock.toHTML(blocks, state);
html = this.#postProcessFootnotes(html, state);
return html;
}
}