// Blocks
// - Paragraph
// - Header 1-6 # ## ### #### ##### ###### or === ---
// - Blockquote (nestable) >
// - Unordered list (nestable) *_
// - Ordered list (nestable) 1._
// - Code block ```\ncode\n``` or 4 spaces/tab indent
// - Horizontal rule --- - - - * * * etc
// - Table -|-
// - Definition list term\n: definition\n: alternate definition
// - Footnote (bottom) citation[^1]
// - Abbreviation (definition) *[ABC]: Abbrev Blah Cat
// Inline
// - Link [text](https://url)
// - Emphasis *emphasized*
// - Strong **bold**
// - Inline code `code`
// - Strikethrough ~strike~
// - Image {.cssclass}
// - Footnote (inline) [^1]: footnote text
// - Abbreviation (inline)
class _MDHAlign {
static Left = new _MDHAlign('Left');
static Center = new _MDHAlign('Center');
static Right = new _MDHAlign('Right');
constructor(name) {
this.name = name;
}
toString() {
return `_MDHAlign.${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 _MDSpan {
toHTML(config) {
throw new Error(self.constructor.name + ".toHTML not implemented");
}
static toHTML(spans, config) {
return spans.map((span) => span.toHTML(config)).join("");
}
}
class _MDMultiSpan extends _MDSpan {
/** @var {_MDSpan[]} */
content;
/**
* @param {_MDSpan[]} content
*/
constructor(content) {
super();
this.content = content;
}
toHTML() {
return _MDSpan.toHTML(this.content);
}
}
class _MDTextSpan extends _MDSpan {
/** @param {String} text */
text;
/**
* @param {String} text
*/
constructor(text) {
super();
this.text = text;
}
toHTML(config) {
return this.text.replace('<', '<');
}
}
class _MDHTMLSpan extends _MDSpan {
/** @param {String} html */
html;
/**
* @param {String} html
*/
constructor(html) {
super();
this.html = html;
}
toHTML(config) {
return this.html;
}
}
class _MDLink extends _MDSpan {
/** @var {String} */
link;
/** @var {String|null} */
target = null;
/** @var {_MDSpan} */
content;
/**
* @param {String} link
* @param {_MDSpan} content
*/
constructor(link, content) {
super();
this.link = link;
this.content = content;
}
toHTML(config) {
let escapedLink = this.link.replace('"', '"');
var html = `' + this.content.toHTML(config) + '';
return html;
}
}
class _MDReferencedLink extends _MDLink {
/** @var {String} id */
id;
constructor(id, content) {
super(null, content);
this.id = id;
}
toHTML(config) {
if (this.link) {
return super.toHTML(config);
} else {
let contentHTML = this.content.toHTML(config);
return `[${contentHTML}][${this.id}]`;
}
}
}
class _MDEmphasis extends _MDSpan {
/** @var {_MDSpan} content */
#content;
/**
* @param {_MDSpan} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(config) {
let contentHTML = this.#content.toHTML(config);
return `${contentHTML}`;
}
}
class _MDStrong extends _MDSpan {
/** @var {_MDSpan} content */
#content;
/**
* @param {_MDSpan} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(config) {
let contentHTML = this.#content.toHTML(config);
return `${contentHTML}`;
}
}
class _MDStrikethrough extends _MDSpan {
/** @var {_MDSpan} content */
#content;
/**
* @param {_MDSpan} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(config) {
let contentHTML = this.#content.toHTML(config);
return `${contentHTML}`;
}
}
class _MDInlineCode extends _MDSpan {
/** @var {_MDSpan} content */
#content;
/**
* @param {_MDSpan} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(config) {
let contentHTML = this.#content.toHTML(config);
return `${contentHTML}`;
}
}
class _MDImage extends _MDSpan {
/** @var {String} */
source;
/** @var {String|null} */
alt;
/**
* @param {String} source
*/
constructor(source, alt) {
super();
this.source = source;
this.alt = alt;
}
toHTML(config) {
let escapedSource = this.source.replace('"', '"');
let html = `
';
return html;
}
}
class _MDReferencedImage extends _MDImage {
/** @var {String} */
id;
/**
* @param {String} id
*/
constructor(id, alt) {
super(null, alt);
this.id = id;
}
toHTML(config) {
if (this.source) {
return super.toHTML(config);
} else {
let altEscaped = this.alt.replace('"', '"');
let idEscaped = this.id.replace('"', '"');
return `![${altEscaped}][${idEscaped}]`;
}
}
}
class _MDFootnoteReference extends _MDSpan {
/** @var {String} */
symbol;
/** @var {Number} */
differentiator = 0;
/**
* @param {String} symbol
*/
constructor(symbol) {
super();
this.symbol = symbol;
}
toHTML(config) {
return `${this.symbol}`;
}
}
class _MDAbbreviationReference extends _MDSpan {
/** @var {_MDSpan} content */
#content;
/** @var {String} definition */
#definition;
/**
* @param {_MDSpan} content
*/
constructor(content, definition) {
super();
this.#content = content;
this.#definition = definition;
}
toHTML(config) {
let contentHTML = this.#content.toHTML(config);
let definitionEscaped = this.#definition.replace('"', '"');
return `${contentHTML}`;
}
}
class _MDBlock {
toHTML(config) {
throw new Error(self.constructor.name + ".toHTML not implemented");
}
/**
* @param {_MDBlock[]} blocks
* @returns {String}
*/
static toHTML(blocks, config) {
return blocks.map((block) => block.toHTML(config)).join("\n");
}
}
class _MDMultiBlock extends _MDBlock {
/** @var {_MDBlock[]} */
#blocks;
/**
* @param {_MDBlock[]} blocks
*/
constructor(blocks) {
super();
this.#blocks = blocks;
}
toHTML(config) {
return _MDBlock.toHTML(this.#blocks, config);
}
}
class _MDParagraph extends _MDBlock {
/** @var {_MDBlock} */
content;
/**
* @param {_MDBlock} content
*/
constructor(content) {
super();
this.content = content;
}
toHTML(config) {
let contentHTML = this.content.toHTML(config);
return `${contentHTML}
\n`;
}
}
class _MDHeader 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(config) {
let contentHTML = this.content.toHTML(config);
return `${contentHTML}\n`;
}
}
class _MDBlockquote extends _MDBlock {
/** @var {_MDBlock[]} */
content;
/**
* @param {_MDBlock[]} content
*/
constructor(content) {
super();
this.content = content;
}
toHTML(config) {
let contentHTML = _MDBlock.toHTML(this.content, config);
return `\n${contentHTML}\n
`;
}
}
class _MDUnorderedList extends _MDBlock {
/** @var {_MDListItem[]} */
items;
/**
* @param {_MDListItem[]} items
*/
constructor(items) {
super();
this.items = items;
}
toHTML(config) {
let contentHTML = _MDBlock.toHTML(this.items);
return ``;
}
}
class _MDOrderedList extends _MDBlock {
/** @var {_MDListItem[]} */
items;
/**
* @param {_MDListItem[]} items
*/
constructor(items) {
super();
this.items = items;
}
toHTML(config) {
let contentHTML = _MDBlock.toHTML(this.items);
return `\n${contentHTML}\n
`;
}
}
class _MDListItem extends _MDBlock {
/** @var {_MDBlock} */
content;
/**
* @param {_MDBlock} content
*/
constructor(content) {
super();
this.content = content;
}
toHTML(config) {
let contentHTML = this.content.toHTML(config);
return `${contentHTML}`;
}
}
class _MDCodeBlock extends _MDBlock {
/** @var {String} */
#code;
/**
* @param {String} code
*/
constructor(code) {
super();
this.#code = code;
}
toHTML(config) {
return `${this.#code}
`;
}
}
class _MDHorizontalRule extends _MDBlock {
toHTML(config) {
return "
\n";
}
}
class _MDTableCell extends _MDBlock {
/** @var {_MDBlock} */
#content;
/** @var {_MDHAlign|null} */
align = null;
/**
* @param {_MDBlock} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(config) {
let contentHTML = this.#content.toHTML(config);
let alignAttribute = _MDHAlign.toHTMLAttribute(this.align);
return `${contentHTML} | `;
}
}
class _MDTableHeaderCell extends _MDTableCell {
toHTML(config) {
let html = super.toHTML(config);
let groups = /^$/.exec(html);
return ` | `;
}
}
class _MDTableRow extends _MDBlock {
/** @var {_MDTableCell[]|_MDTableHeaderCell[]} */
#cells;
/**
* @param {_MDTableCell[]|_MDTableHeaderCell[]} 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(config) {
let cellsHTML = _MDBlock.toHTML(this.#cells, config);
return ` | \n${cellsHTML}\n
`;
}
}
class _MDTable extends _MDBlock {
/** @var {_MDTableRow} */
#headerRow;
/** @var {_MDTableRow[]} */
#bodyRows;
/**
* @param {_MDTableRow} headerRow
* @param {_MDTableRow[]} bodyRows
*/
constructor(headerRow, bodyRows) {
super();
this.#headerRow = headerRow;
this.#bodyRows = bodyRows;
}
toHTML(config) {
let headerRowHTML = this.#headerRow.toHTML(config);
let bodyRowsHTML = _MDBlock.toHTML(this.#bodyRows);
return `\n\n${headerRowHTML}\n\n\n${bodyRowsHTML}\n\n
`;
}
}
class _MDDefinitionList extends _MDBlock {
/** @var {_MDBlock[]} */
#content;
/**
* @param {_MDBlock[]} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(config) {
let contentHTML = _MDBlock.toHTML(this.#content);
return `\n${contentHTML}\n
`;
}
}
class _MDDefinitionTerm extends _MDBlock {
/** @var {_MDBlock} */
#content;
/**
* @param {_MDBlock} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(config) {
let contentHTML = this.#content.toHTML(config);
return `${contentHTML}`;
}
}
class _MDDefinitionDefinition extends _MDBlock {
/** @var {_MDBlock} */
#content;
/**
* @param {_MDBlock} content
*/
constructor(content) {
super();
this.#content = content;
}
toHTML(config) {
let contentHTML = this.#content.toHTML(config);
return `${contentHTML}`;
}
}
class _MDFootnoteContent extends _MDBlock {
/** @var {String} */
#id;
/** @var {_MDBlock} */
#content;
/**
* @param {String} id
* @param {_MDBlock} content
*/
constructor(id, content) {
super();
this.#id = id;
this.#content = content;
}
toHTML(config) {
// TODO: Forward and back links
// TODO: Deferring footnotes to end of document
//
//-
//
Footnote
//
//
return '';
}
}
class _MDAbbreviationOccurrence extends _MDBlock {
/** @var {String} */
#label;
/** @var {String} */
#definition;
/**
* @param {String} label
* @param {String} definition
*/
constructor(label, definition) {
super();
this.#label = label;
this.#definition = definition;
}
toHTML(config) {
return `${this.#label}`;
}
}
class _MDInline extends _MDBlock {
/** @var {String} */
#raw;
/**
* @param {String} raw
*/
constructor(raw) {
super();
this.#raw = raw;
}
toHTML(config) {
return this.#raw;
}
}
// Blocks that immediately start a new block
// - Headers
// - Blockquote
// - Code block ```\ncode\n```
// Blocks that need blank line first
// - HR --- - - - *** * * * * * *
// - Lists
// - Table
// - Code block [4+spaces]code
// - Definition list term\n: definition\n: alternate def
// Unknown blocks
// - Footnotes some text[^1] [^1]: first footnote content
// - Abbreviations *[HTML]: Hyper Text
// Inline styles
// - Links
// - Italic
// - Bold
// - `code`
// - Strikethrough
// - Images {.cssclass}
// - Literals \*
class _MDState {
/** @var {String[]} */
lines = [];
/** @var {object} */
abbreviations = {};
/** @var {object} */
footnotes = {};
/** @var {number} */
p = 0;
copy() {
let cp = new _MDState();
cp.abbreviations = this.abbreviations;
cp.footnotes = this.footnotes;
cp.p = this.p;
return cp;
}
/** @param {_MDState} other */
apply(other) {
this.abbreviations = other.abbreviations;
this.footnotes = other.footnotes;
this.p = other.p;
}
hasLines(minCount, p=-1) {
let relativeTo = (p < 0) ? this.p : p;
return relativeTo + minCount <= this.lines.length;
}
}
class MDConfig {
}
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) {
console.info("Skipping blank line " + state.p);
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.#readDefinitionList(state); if (block) return block;
block = this.#readFootnoteDef(state); if (block) return block;
block = this.#readAbbreviationDef(state); if (block) return block;
block = this.#readParagraph(state); if (block) return block;
return null;
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readInline(state, line) {
return new _MDInline(line);
}
/**
* Reads the contents of something like a list item
* @param {_MDState} state
* @param {number} firstLineStartPos
* @param {RegExp} stopRegex
* @returns {_MDBlock}
*/
static #readInteriorContent(state, firstLineStartPos, stopRegex) {
// FIXME: When reading content need to detect nested list without
// a blank line
var p = state.p;
var seenBlankLine = false;
var needsBlocks = false;
var lines = [];
while (p < state.lines.length) {
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;
}
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 (needsBlocks) {
let substate = new _MDState();
substate.lines = lines;
substate.abbreviations = state.abbreviations;
substate.footnotes = state.footnotes;
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;
let contentLine = state.lines[p++].trim();
let underLine = state.lines[p++].trim();
if (contentLine == '') return null;
if (/^=+$/.exec(underLine)) {
state.p = p;
return new _MDHeader(1, this.#readInline(state, contentLine));
}
if (/^\-+$/.exec(underLine)) {
state.p = p;
return new _MDHeader(2, this.#readInline(state, contentLine));
}
return null;
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readHashHeader(state) {
var p = state.p;
var groups = /^(#{1,6})\s*([^#].*)\s*$/.exec(state.lines[p++]);
if (groups === null) return null;
state.p = p;
return new _MDHeader(groups[1].length, this.#readInline(state, groups[2]));
}
/**
* @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 = new _MDState();
substate.lines = contentLines;
substate.abbreviations = state.abbreviations;
substate.footnotes = state.footnotes;
let quotedBlocks = this.#readBlocks(substate);
state.p = p;
return new _MDBlockquote(quotedBlocks);
}
return null;
}
/**
* @param {_MDState} state
* @returns {_MDListItem|null}
*/
static #readUnorderedListItem(state) {
var p = state.p;
let line = state.lines[p];
let groups = /^([\*\+\-]\s+)(.*)$/.exec(line);
if (groups === null) return null;
return new _MDListItem(this.#readInteriorContent(state, groups[1].length, /^[\*\+\-]\s+/));
}
/**
* @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 _MDUnorderedList(items);
}
/**
* @param {_MDState} state
* @returns {_MDListItem|null}
*/
static #readOrderedListItem(state) {
var p = state.p;
let line = state.lines[p];
let groups = /^(\d+\.\s+)(.*)$/.exec(line);
if (groups === null) return null;
return new _MDListItem(this.#readInteriorContent(state, groups[1].length, /^\d+\.\s+/));
}
/**
* @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 _MDOrderedList(items);
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readFencedCodeBlock(state) {
var p = state.p;
if (state.lines[p++].trim() != '```') return null;
var codeLines = [];
while (state.hasLines(1, p)) {
let line = state.lines[p++];
if (line.trim() == '```') {
state.p = p;
return new _MDCodeBlock(codeLines.join("\n"));
}
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"));
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readHorizontalRule(state) {
var p = state.p;
let line = state.lines[p++];
if (/^\s*(?:\-(?:\s*\-){2,}|\*(?:\s*\*){2,})\s*$/.exec(line)) {
state.p = p;
return new _MDHorizontalRule();
}
return null;
}
/**
* @param {_MDState} state
* @param {Boolean} isHeader
* @return {_MDTableRow|null}
*/
static #readTableRow(state, isHeader) {
if (!state.hasLines(1)) return null;
var p = state.p;
let line = 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 _MDTableHeaderCell(content) : new _MDTableCell(content);
});
state.p = p;
return new _MDTableRow(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;
});
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readTable(state) {
if (!state.hasLines(2)) return null;
let startP = state.p;
let headerRow = this.#readTableRow(state, true);
if (headerRow === null) {
state.p = startP;
return null;
}
let dividerLine = state.lines[state.p++];
let dividerGroups = /^\s*[|]?(?:\s*[:]?-+[:]?\s*\|)(?:\s*[:]?-+[:]?\s*)[|]?\s*$/.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);
}
return new _MDTable(headerRow, bodyRows);
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readDefinitionList(state) {
// TODO
return null;
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readFootnoteDef(state) {
// TODO
return null;
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readAbbreviationDef(state) {
// TODO
return null;
}
/**
* @param {_MDState} state
* @returns {_MDBlock|null}
*/
static #readParagraph(state) {
if (!state.hasLines(1)) return null;
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 _MDParagraph(this.#readInline(state, content));
}
return null;
}
/**
* @param {String} markdown
* @returns {String} HTML
*/
static toHTML(markdown, config=new MDConfig()) {
var state = new _MDState();
let lines = markdown.replace("\r", "").split("\n");
state.lines = lines;
let blocks = this.#readBlocks(state);
let html = _MDBlock.toHTML(blocks);
return html;
}
}