)
- const openDelimiters = new Set();
- for (let index = 0; index < result.length; index++) {
- const token = result[index];
- if (token.role === 'block') {
- token.role = openDelimiters.has(token.sequence) ? 'close' : 'open';
- }
- if (token.role === 'open') {
- // Don't open a delimiter that's already open (prevents nesting)
- if (openDelimiters.has(token.sequence)) {
- result[index] = {
- role: 'text',
- value: token.value,
- };
- continue;
- }
- openStack.push(index);
- openDelimiters.add(token.sequence);
- }
- else if (token.role === 'close') {
- let matched = false;
- for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex--) {
- const openerIndex = openStack[stackIndex];
- if (result[openerIndex].sequence === token.sequence) {
- openStack.splice(stackIndex, 1);
- openDelimiters.delete(token.sequence);
- matched = true;
- break;
- }
- }
- if (!matched) {
- result[index] = {
- role: 'text',
- value: token.value,
- };
- }
- }
- }
- return result;
- }
-}
diff --git a/dist/lib/tags.js b/dist/lib/tags.js
deleted file mode 100644
index 6d56153..0000000
--- a/dist/lib/tags.js
+++ /dev/null
@@ -1,696 +0,0 @@
-import { escapeHtml } from "./hopdown";
-/**
- * Patterns shared across multiple tag classes. Defined once here
- * so the regexes aren't duplicated and can be referenced by name.
- */
-const blankLine = /^\s*$/;
-const unorderedMarker = /^[*\-]\s/;
-const orderedMarker = /^\d+\.\s/;
-const escapeRegex = /[.*+?^${}()|[\]\\]/g;
-const tableSeparator = new RegExp('^'
- + '\\|?' // optional leading pipe
- + '\\s*:?-+:?\\s*' // first column separator (optional alignment colons)
- + '('
- + '\\|' // pipe between columns
- + '\\s*:?-+:?\\s*' // subsequent column separator
- + ')*' // zero or more additional columns
- + '\\|?' // optional trailing pipe
- + '\\s*$' // end of line
-);
-export function childNodesToMarkdown(element, callback) {
- return Array.from(element.childNodes)
- .map(child => callback(child))
- .join('');
-}
-export class Tag {
- element = [''];
- opener = '';
- closer = '';
- delimiter = null;
- precedence = 50;
- isBlock = false;
- toHTML(token, callback) {
- if (token.role == 'open') {
- return this.element.map(el => { return `<${el.toUpperCase()}>`; }).join('');
- }
- if (token.role == 'close') {
- const rev = this.element;
- rev.reverse();
- return rev.map(el => { return `${el.toUpperCase()}>`; }).join('');
- }
- return token.value;
- }
- ;
- toMarkdown(element, callback) {
- const children = childNodesToMarkdown(element, callback);
- return `${this.delimiter}${children || element.textContent || ''}${this.delimiter}`;
- }
- ;
- toEditorNode(token, callback) {
- if (token.role == 'open' || token.role == 'close') {
- const span = document.createElement('span');
- span.classList.add('delim');
- for (const el of this.element) {
- span.classList.add(el.toLowerCase());
- }
- ;
- if (this.isBlock) {
- span.textContent = token.role == 'open' ? this.opener : this.closer;
- }
- else {
- span.textContent = this.delimiter;
- }
- return span;
- }
- return document.createTextNode(token.value);
- }
- ;
-}
-export class TagCollection {
- tags = {};
- ordered;
- openers;
- constructor(tags) {
- for (const tag of tags) {
- if (tag.constructor.name in this.tags) {
- throw new Error(`Duplicate tag instances found for class ${tag.constructor.name}! Do you need to subclass it?`);
- }
- this.tags[tag.constructor.name] = tag;
- }
- this.ordered = tags.sort((a, b) => {
- const p1 = a.precedence - (a.isBlock ? 100 : 0);
- const p2 = b.precedence - (b.isBlock ? 100 : 0);
- return p1 < p2 ? -1 : 1;
- });
- this.validate();
- const escapeRegex = /[.*+?^${}()|[\]\\]/g;
- this.openers = this.ordered.filter(t => { return t.delimiter !== null; }).map(tag => {
- const escaped = tag.delimiter.replace(escapeRegex, '\\$&');
- const escapedChar = tag.delimiter[0].replace(escapeRegex, '\\$&');
- return new RegExp(`(? { return !t.delimiter; });
- for (let outer = 0; outer < inline.length; outer++) {
- for (let inner = outer + 1; inner < inline.length; inner++) {
- const first = this.ordered[outer];
- const second = this.ordered[inner];
- if (!(first.delimiter && second.delimiter)) {
- continue;
- }
- const firstIsPrefix = second.delimiter.startsWith(first.delimiter);
- const secondIsPrefix = first.delimiter.startsWith(second.delimiter);
- if (!firstIsPrefix && !secondIsPrefix) {
- continue;
- }
- const longer = first.delimiter.length > second.delimiter.length ? first : second;
- const shorter = first.delimiter.length > second.delimiter.length ? second : first;
- if (longer.precedence >= shorter.precedence) {
- throw new Error(`Tag "${longer.constructor.name}" (delimiter "${longer.delimiter}") must have ` +
- `lower precedence than "${shorter.constructor.name}" (delimiter "${shorter.delimiter}") ` +
- `because its delimiter is a prefix match. ` +
- `Got ${longer.constructor.name}=${longer.precedence}, ${shorter.constructor.name}=${shorter.precedence}.`);
- }
- }
- }
- }
- getByElementName(element) {
- const el = element.toUpperCase();
- return this.ordered.filter(t => {
- return [...t.element, ...t.aliases ?? []].includes(el);
- })[0] || undefined;
- }
-}
-export class Bold extends Tag {
- element = ['STRONG'];
- aliases = ['B'];
- delimiter = '**';
- shortcut = 'Ctrl+B';
- precedence = 40;
-}
-export class Italic extends Tag {
- element = ['EM'];
- aliases = ['I'];
- delimiter = '*';
- shortcut = 'Ctrl+I';
- precedence = 30;
-}
-export class BoldItalic extends Tag {
- element = ['STRONG', 'EM'];
- delimiter = '***';
- precedence = 50;
-}
-export class Strikethrough extends Tag {
- element = ['DEL'];
- aliases = ['S', 'STRIKE'];
- delimiter = '~~';
-}
-export class Code extends Tag {
- element = ['CODE'];
- delimiter = '`';
-}
-export class Anchor extends Tag {
- element = ['A'];
- toHTML(token, callback) {
- const titleAttr = token.title
- ? ` TITLE="${escapeHtml(token.title)}"`
- : '';
- return `${escapeHtml(token.value)} `;
- }
- toMarkdown(element, callback) {
- const href = element.getAttribute('href') || '';
- const title = element.getAttribute('title');
- const titlePart = title ? ` "${title}"` : '';
- return '[' + element.innerText + '](' + href + titlePart + ')';
- }
- toEditorNode(token, callback) {
- return document.createElement('A');
- }
-}
-class BlockTag extends Tag {
- isBlock = true;
- delimiter = null;
- toMarkdown(element, callback) {
- const children = childNodesToMarkdown(element, callback);
- const ret = `${this.opener || ''}${children || element.textContent || ''}${this.closer || ''}`;
- return ret;
- }
- ;
- match(context, tags) {
- return null;
- }
-}
-export class HardBreak extends BlockTag {
- match(context) {
- return null;
- }
- toHTML() { return ' '; }
- toMarkdown() { return ' \n'; }
-}
-/**
- * Fenced code blocks: lines between ``` delimiters become .
- *
- * converter.toHTML('```js\nlet x = 1;\n```')
- * // let x = 1;
- */
-export class FencedCode extends BlockTag {
- opener = '```';
- closer = '```';
- element = ['PRE'];
- button = {
- show: true,
- label: 'Code Block',
- shortcut: 'Ctrl+Shift+E',
- };
- template = '```\ncode\n```';
- replaceSelection = true;
- match(context) {
- // Accepts both ``` and ~~~ as fence delimiters
- const fencePattern = /^(?`{3,}|~{3,})(?.*)/;
- const matched = context.lines[context.index].match(fencePattern);
- if (!matched?.groups) {
- return null;
- }
- const fence = matched.groups.fence;
- const lang = matched.groups.lang.trim();
- const code = [];
- let lineIndex = context.index + 1;
- while (lineIndex < context.lines.length && !context.lines[lineIndex].startsWith(fence)) {
- code.push(context.lines[lineIndex++]);
- }
- return {
- opener: fence + lang,
- closer: '',
- content: code.join('\n'),
- raw: '',
- consumed: lineIndex + 1 - context.index,
- meta: { lang },
- };
- }
- toHTML(token, callback) {
- const langAttr = token.meta?.lang
- ? ` class="language-${escapeHtml(token.meta.lang)}"`
- : '';
- return `${escapeHtml(token.content)} `;
- }
- toMarkdown(element, callback) {
- const lang = element.className.match(/language-(\S+)/)?.[1] || '';
- const content = element?.textContent || '';
- return '\n\n' + this.opener + `${lang}\n${content}\n` + this.closer + '\n\n';
- }
-}
-/**
- * Three or more *, -, or _ on a line become .
- *
- * converter.toHTML('---') // ' '
- */
-export class HorizontalRule extends BlockTag {
- element = ['HR'];
- button = {
- show: true,
- label: 'Divider',
- shortcut: 'Ctrl+Shift+-',
- };
- template = '---';
- replaceSelection = false;
- pattern = /^(\*{3,}|-{3,}|_{3,})\s*$/;
- match(context) {
- const matched = context.lines[context.index].match(this.pattern);
- if (!matched) {
- return null;
- }
- return {
- opener: '',
- closer: '',
- content: '',
- raw: '',
- consumed: 1,
- };
- }
- toHTML(token, callback) {
- return `<${this.element[0].toUpperCase()}>`;
- }
- toMarkdown(element, callback) {
- return '\n***\n';
- }
-}
-export class Heading extends BlockTag {
- element = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
- match(context) {
- // ATX heading: # through ######, optional closing #s
- const atxPattern = /^(?#{1,6})\s+(?.*?)(?:\s+#+\s*)?$/;
- const matched = context.lines[context.index].match(atxPattern);
- if (matched?.groups) {
- return {
- opener: matched.groups.hashes + ' ',
- content: matched.groups.content.trim(),
- closer: '',
- raw: '',
- consumed: 1,
- meta: { level: String(matched.groups.hashes.length) },
- };
- }
- if (context.index + 1 < context.lines.length) {
- const nextLine = context.lines[context.index + 1];
- const setextUnderline = /^(?=+|-+)\s*$/;
- const underlineMatch = nextLine.match(setextUnderline);
- if (underlineMatch?.groups) {
- const content = context.lines[context.index].trim();
- if (content.length > 0) {
- const level = underlineMatch.groups.marker[0] === '=' ? '1' : '2';
- return {
- opener: '',
- closer: '',
- content: content,
- raw: '',
- consumed: 2,
- meta: { level },
- };
- }
- }
- }
- return null;
- }
- toHTML(token, callback) {
- const level = token.meta.level;
- const id = this.anchorId(token.content);
- return `${callback(token.content)} `;
- }
- toMarkdown(element, callback) {
- const inner = childNodesToMarkdown(element, callback);
- const level = parseInt(element.nodeName[1]);
- return '\n\n' + '#'.repeat(level) + ' ' + inner + '\n\n';
- }
- /**
- * Generate a PascalCase anchor ID from heading text so that
- * in-page links like #MyHeading work without manual IDs.
- */
- anchorId(text) {
- return text.replace(escapeRegex, ' ').trim().split(/\s+/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join('_');
- }
-}
-/**
- * Lines prefixed with > become blockquotes. Consecutive > lines
- * are merged into a single blockquote.
- *
- * converter.toHTML('> hello\n> world')
- * // hello\nworld
- */
-export class Blockquote extends BlockTag {
- element = ['BLOCKQUOTE'];
- hidden = false;
- template = '> Quote\n> continues here';
- match(context) {
- const quotePrefix = /^>\s?/;
- if (!quotePrefix.test(context.lines[context.index])) {
- return null;
- }
- const lines = [];
- let lineIndex = context.index;
- while (lineIndex < context.lines.length && quotePrefix.test(context.lines[lineIndex])) {
- lines.push(context.lines[lineIndex++].replace(quotePrefix, ''));
- }
- return {
- opener: '> ',
- closer: '',
- content: lines.join('\n'),
- raw: '',
- consumed: lineIndex - context.index,
- };
- }
- toHTML(token, callback) {
- const inner = callback(token.content);
- const breaks = inner.replace(/([^\n])\n(?!\n)/g, '$1 ')
- .replace(/\n\n(.+?)(\n\n|$)/g, '$1
');
- return '' + breaks + ' ';
- }
- toMarkdown(element, callback) {
- // Each inside the blockquote is a paragraph group.
- // Within a
, elements create line breaks.
- // Separate paragraphs get a blank > line between them.
- const paragraphs = [];
- for (const child of Array.from(element.childNodes)) {
- const el = child;
- if (child.nodeType === 1 && el.tagName === 'P') {
- const lines = this.paragraphToLines(el, callback);
- paragraphs.push(">");
- paragraphs.push(lines.map(line => '> ' + line).join('\n'));
- }
- else {
- const text = callback(child).trim();
- if (text) {
- paragraphs.push('> ' + text);
- }
- }
- }
- return '\n\n' + paragraphs.join('\n') + '\n\n';
- }
- /**
- * Split a paragraph's content into lines at elements.
- */
- paragraphToLines(paragraph, callback) {
- const lines = [];
- let currentLine = '';
- for (const child of Array.from(paragraph.childNodes)) {
- if (child.nodeType === 1 && child.tagName === 'BR') {
- lines.push(currentLine);
- currentLine = '';
- }
- else {
- currentLine += callback(child);
- }
- }
- if (currentLine.trim()) {
- lines.push(currentLine);
- }
- return lines;
- }
-}
-/**
- * Ordered and unordered lists with arbitrary nesting. Indentation
- * determines depth; mixed list types (ul inside ol) are supported.
- *
- * converter.toHTML('- one\n- two\n 1. nested')
- */
-export class UnorderedList extends BlockTag {
- element = ['UL'];
- hidden = false;
- match(context) {
- const line = context.lines[context.index];
- const prefixPattern = /^((?:[*\-]|\d+\.)\s)/;
- const matched = line.match(prefixPattern);
- if (!matched) {
- return null;
- }
- // Count how many lines this list consumes so HopDown can
- // advance past them. The actual parsing happens in toHTML
- // where we have access to the inline converter.
- const consumed = this.countListLines(context.lines, context.index);
- return {
- opener: matched[1],
- closer: '',
- content: '',
- raw: '',
- consumed,
- meta: {
- startIndex: String(context.index),
- lines: JSON.stringify(context.lines.slice(context.index, context.index + consumed)),
- },
- };
- }
- toHTML(token, callback) {
- const lines = JSON.parse(token.meta.lines);
- const result = this.parseBlock(lines, 0, 0, callback);
- return result.html;
- }
- toMarkdown(element, callback) {
- return this.nodeToMarkdown(element, 0, callback);
- }
- countListLines(lines, start) {
- const indentedUnordered = /^(\s*)[*\-+]\s/;
- const indentedOrdered = /^(\s*)\d+\.\s/;
- let lineIndex = start;
- while (lineIndex < lines.length) {
- const line = lines[lineIndex];
- if (blankLine.test(line)) {
- break;
- }
- if (!indentedUnordered.test(line) && !indentedOrdered.test(line)) {
- break;
- }
- lineIndex++;
- }
- return lineIndex - start;
- }
- /**
- * Recursively parse markdown list lines into HTML, handling nested
- * sublists at arbitrary depth and mixed list types (ul/ol).
- */
- parseBlock(lines, start, indent, callback) {
- const prefix = new RegExp('^'
- + ' '.repeat(indent) // indentation for this nesting level
- + '([\\*\\-+]|\\d+\\.)' // bullet or number marker
- + '\\s' // space after marker
- );
- const isOl = orderedMarker.test(lines[start].trim());
- const tag = isOl ? 'ol' : 'ul';
- const items = [];
- let lineIndex = start;
- while (lineIndex < lines.length) {
- const line = lines[lineIndex];
- if (blankLine.test(line)) {
- break;
- }
- const leadingWhitespace = /^(\s*)/;
- const lineIndent = line.match(leadingWhitespace)[1].length;
- if (lineIndent < indent) {
- break;
- }
- if (lineIndent > indent) {
- const sub = this.parseBlock(lines, lineIndex, lineIndent, callback);
- items[items.length - 1].sub = sub.html;
- lineIndex = sub.end;
- continue;
- }
- if (!prefix.test(line)) {
- break;
- }
- items.push({
- text: line.replace(prefix, ''),
- sub: '',
- });
- lineIndex++;
- }
- const html = `<${tag}>`
- + items.map(item => '
' + callback(item.text) + item.sub + ' ').join('')
- + `${tag}>`;
- return {
- html,
- end: lineIndex,
- };
- }
- /**
- * Convert an HTML list back to markdown. Recurses into nested
- * sublists with 2-space indentation per depth level.
- */
- nodeToMarkdown(node, depth, callback) {
- const isOl = node.nodeName === 'OL';
- const indent = ' '.repeat(depth);
- const lines = [];
- Array.from(node.children).forEach((listItem, index) => {
- const marker = isOl ? (index + 1) + '. ' : '* ';
- const { text, sublist } = this.partitionListItem(listItem, depth, callback);
- lines.push(indent + marker + text.trim());
- if (sublist) {
- lines.push(sublist);
- }
- });
- const result = lines.join('\n');
- return depth === 0
- ? '\n\n' + result + '\n\n'
- : result;
- }
- partitionListItem(listItem, depth, callback) {
- let text = '';
- let sublist = '';
- for (const child of Array.from(listItem.childNodes)) {
- if (child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')) {
- sublist += this.nodeToMarkdown(child, depth + 1, callback);
- }
- else {
- text += callback(child);
- }
- }
- return {
- text,
- sublist,
- };
- }
-}
-export class OrderedList extends UnorderedList {
- element = ['OL'];
-}
-/**
- * Pipe-delimited tables with optional column alignment.
- *
- * converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
- */
-export class Table extends BlockTag {
- element = ['TABLE'];
- hidden = false;
- template = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
- replaceSelection = false;
- match(context) {
- const { lines, index } = context;
- if (lines[index].indexOf('|') === -1
- || index + 1 >= lines.length) {
- return null;
- }
- // Second line must be the separator row (e.g. |---|---|)
- if (!tableSeparator.test(lines[index + 1])) {
- return null;
- }
- const headers = this.parseRow(lines[index]);
- const aligns = this.parseAligns(lines[index + 1]);
- const rows = [];
- let lineIndex = index + 2;
- while (lineIndex < lines.length && lines[lineIndex].indexOf('|') !== -1 && !blankLine.test(lines[lineIndex])) {
- rows.push(this.parseRow(lines[lineIndex++]));
- }
- return {
- content: '',
- opener: '',
- closer: '',
- raw: '',
- consumed: lineIndex - index,
- meta: {
- headers: JSON.stringify(headers),
- aligns: JSON.stringify(aligns),
- rows: JSON.stringify(rows),
- },
- };
- }
- toHTML(token, callback) {
- const headers = JSON.parse(token.meta.headers);
- const aligns = JSON.parse(token.meta.aligns);
- const rows = JSON.parse(token.meta.rows);
- const cell = (tag, text, index) => {
- const align = aligns[index]
- ? ` class="align-${aligns[index]}"`
- : '';
- return `<${tag}${align}>${callback(text)}${tag}>`;
- };
- const head = ''
- + headers.map((text, column) => cell('th', text, column)).join('')
- + ' ';
- const body = rows.map(row => '' + row.map((text, column) => cell('td', text, column)).join('') + ' ').join('');
- return ``;
- }
- toMarkdown(element, callback) {
- const rows = Array.from(element.querySelectorAll('tr'));
- if (!rows.length) {
- return '';
- }
- const headers = Array.from(rows[0].querySelectorAll('th,td'))
- .map(cell => callback(cell).trim());
- const separator = Array.from(element.querySelectorAll('th')).map(h => {
- return h.className == 'align-center' ? ':---:' :
- h.className == 'align-right' ? '---:' :
- '---';
- });
- const output = [
- '| ' + headers.join(' | ') + ' |',
- '| ' + separator.join(' | ') + ' |',
- ];
- for (const row of rows.slice(1)) {
- const cells = Array.from(row.querySelectorAll('td,th'))
- .map(cell => callback(cell).trim());
- output.push('| ' + cells.join(' | ') + ' |');
- }
- return '\n\n' + output.join('\n') + '\n\n';
- }
- parseRow(line) {
- return line
- .replace(/^\|/, '')
- .replace(/\|$/, '')
- .split('|')
- .map(cell => cell.trim());
- }
- parseAligns(line) {
- const centerAlign = /^:-+:$/;
- const rightAlign = /^-+:$/;
- return this.parseRow(line).map(cell => {
- if (centerAlign.test(cell)) {
- return 'center';
- }
- if (rightAlign.test(cell)) {
- return 'right';
- }
- return null;
- });
- }
-}
-export class Paragraph extends BlockTag {
- element = ['P'];
- opener = '';
- closer = '';
- precedence = -1;
- match(context, tags) {
- const collected = [];
- let lineIndex = context.index;
- while (lineIndex < context.lines.length) {
- const line = context.lines[lineIndex];
- const nextLine = lineIndex < context.lines.length ? context.lines[lineIndex + 1] : '';
- for (const pattern of [...tags.openers, blankLine]) {
- if (pattern.test(line)) {
- break;
- }
- }
- // Table: current line has pipes and next line is a separator row
- if (line.indexOf('|') !== -1) {
- if (nextLine && tableSeparator.test(nextLine)) {
- break;
- }
- }
- collected.push(context.lines[lineIndex++]);
- }
- if (!collected.length) {
- return null;
- }
- const ret = {
- opener: '',
- closer: '',
- content: collected.join('\n'),
- raw: '',
- consumed: lineIndex - context.index,
- };
- return ret;
- }
- toHTML(token, callback) {
- return '' + callback(token.content) + '
';
- }
-}
diff --git a/dist/lib/tokenizer.js b/dist/lib/tokenizer.js
deleted file mode 100644
index e038a08..0000000
--- a/dist/lib/tokenizer.js
+++ /dev/null
@@ -1,320 +0,0 @@
-/*
- * tokenizer.ts — markdown tokenizer.
- *
- * Scans markdown text left-to-right producing a typed token stream.
- * Tokens carry their semantic role (delimiter, text, link, etc.)
- * so downstream consumers can make correct escaping and pairing
- * decisions without regex heuristics.
- *
- * const tokenizer = new Tokenizer(delimiterDefs);
- * const tokens = tokenizer.tokenize('hello **bold** end');
- * // [text "hello "] [open "**"] [text "bold"] [close "**"] [text " end"]
- */
-/**
- * Characters that count as punctuation for flanking delimiter rules.
- * A delimiter is left-flanking if preceded by whitespace/punctuation
- * and followed by non-whitespace. Right-flanking is the reverse.
- */
-const PUNCTUATION = new Set(' \t\n\u00A0.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split(''));
-/**
- * Characters that can be backslash-escaped in markdown.
- */
-const ESCAPABLE = new Set('\\`*_{}[]()#+-.!~|><'.split(''));
-/**
- * Named HTML entities recognized by the tokenizer.
- */
-const NAMED_ENTITIES = {
- 'amp': '&',
- 'lt': '<',
- 'gt': '>',
- 'quot': '"',
- 'apos': "'",
- 'nbsp': '\u00A0',
-};
-/**
- * Scans markdown text into a stream of typed tokens. Handles
- * backslash escapes, entities, flanking rules, links,
- * autolinks, HTML tags, and hard line breaks.
- *
- * const tokenizer = new Tokenizer([
- * { delimiter: '**', htmlTag: 'strong', recursive: true, precedence: 40 },
- * { delimiter: '*', htmlTag: 'em', recursive: true, precedence: 50 },
- * ]);
- * const tokens = tokenizer.tokenize('**bold**');
- */
-export class Tokenizer {
- tags;
- constructor(tags) {
- this.tags = tags.ordered;
- }
- /**
- * Tokenize a markdown string into an inline token stream.
- *
- * tokenizer.tokenize('hello **world**')
- * // [text "hello "] [open "**"] [text "world"] [close "**"]
- */
- tokenize(source) {
- const tokens = [];
- let position = 0;
- let textBuffer = '';
- const flushText = () => {
- if (textBuffer.length > 0) {
- tokens.push({
- role: 'text',
- value: textBuffer,
- });
- textBuffer = '';
- }
- };
- while (position < source.length) {
- const remaining = source.slice(position);
- // Backslash escape: \X → literal X
- if (source[position] === '\\' && position + 1 < source.length) {
- const nextChar = source[position + 1];
- if (ESCAPABLE.has(nextChar)) {
- textBuffer += '\\' + nextChar;
- position += 2;
- continue;
- }
- }
- // Hard line break: two+ trailing spaces before newline
- if (source[position] === ' ') {
- const spaceMatch = remaining.match(/^(? {2,})\n/);
- if (spaceMatch?.groups) {
- flushText();
- tokens.push({ role: 'break', value: ' ' });
- position += spaceMatch[0].length;
- continue;
- }
- }
- // HTML entity resolution: &name; or digits; or hex;
- if (source[position] === '&') {
- const resolved = this.resolveEntity(remaining);
- if (resolved) {
- textBuffer += resolved.character;
- position += resolved.length;
- continue;
- }
- }
- // Link: [text](url) or [text](url "title")
- if (source[position] === '[') {
- const link = this.matchLink(source, position);
- if (link) {
- flushText();
- tokens.push({
- role: 'link',
- value: link.text,
- href: link.href,
- title: link.title,
- });
- position += link.length;
- continue;
- }
- }
- // Autolink: and/or HTML
- if (source[position] === '<') {
- const autolink = this.matchAutolink(remaining);
- if (autolink) {
- flushText();
- tokens.push({
- role: 'link',
- value: autolink.url,
- href: autolink.url,
- tag: this.tags.filter(t => { return t.constructor.name == 'Anchor'; })[0]
- });
- position += autolink.length;
- continue;
- }
- // HTML tag passthrough
- const htmlTagMatch = this.matchHtmlTag(remaining);
- if (htmlTagMatch) {
- flushText();
- tokens.push({
- role: 'html',
- value: htmlTagMatch.tag,
- });
- position += htmlTagMatch.length;
- continue;
- }
- }
- // autolink bare URLs
- const bareUrl = this.matchBareUrl(remaining);
- if (bareUrl) {
- flushText();
- tokens.push({
- role: 'link',
- value: bareUrl.url,
- href: bareUrl.url,
- tag: this.tags.filter(t => { return t.constructor.name == 'Anchor'; })[0]
- });
- position += bareUrl.length;
- continue;
- }
- // Delimiter: check each registered delimiter
- const match = this.matchTag(source, position);
- if (match && match.sequence?.length) {
- flushText();
- tokens.push(match);
- position += match.sequence?.length;
- continue;
- }
- // Plain character
- textBuffer += source[position];
- position++;
- }
- flushText();
- return tokens;
- }
- /**
- * Try to resolve an HTML entity at the start of the string.
- * Returns the resolved character and the length consumed, or null.
- */
- resolveEntity(text) {
- const namedPattern = /^&(?[a-zA-Z]+);/;
- const numericPattern = /^(?\d+);/;
- const hexPattern = /^(?[0-9a-fA-F]+);/;
- const named = text.match(namedPattern);
- if (named?.groups) {
- const resolved = NAMED_ENTITIES[named.groups.name.toLowerCase()];
- if (resolved) {
- return {
- character: resolved,
- length: named[0].length,
- };
- }
- }
- const numeric = text.match(numericPattern);
- if (numeric?.groups) {
- return {
- character: String.fromCharCode(parseInt(numeric.groups.code, 10)),
- length: numeric[0].length,
- };
- }
- const hex = text.match(hexPattern);
- if (hex?.groups) {
- return {
- character: String.fromCharCode(parseInt(hex.groups.hex, 16)),
- length: hex[0].length,
- };
- }
- return null;
- }
- /**
- * Match a code span starting at the given position.
- * Handles single backtick delimiters only (not multi-backtick).
- */
- matchCodeSpan(source, position) {
- if (source[position] !== '`') {
- return null;
- }
- const closeIndex = source.indexOf('`', position + 1);
- if (closeIndex === -1) {
- return null;
- }
- const content = source.slice(position + 1, closeIndex);
- return {
- content,
- raw: source.slice(position, closeIndex + 1),
- };
- }
- /**
- * Match a markdown link [text](url) or [text](url "title")
- * starting at the given position. Disallows [ in link text
- * to prevent nested link ambiguity.
- */
- matchLink(source, position) {
- const linkPattern = /^\[(?[^\[\]]+)\]\((?[^\s)]+)(?:\s+"(?[^"]*)")?\)/;
- const match = source.slice(position).match(linkPattern);
- if (!match?.groups) {
- return null;
- }
- return {
- text: match.groups.text,
- href: match.groups.href,
- title: match.groups.title,
- length: match[0].length,
- };
- }
- /**
- * Match an angle-bracket autolink at the start of the string.
- */
- matchAutolink(text) {
- const pattern = /^<(?https?:\/\/[^\s>]+)>/;
- const match = text.match(pattern);
- if (!match?.groups) {
- return null;
- }
- return {
- url: match.groups.url,
- length: match[0].length,
- };
- }
- /**
- * Match a bare URL (https://...) at the start of the string.
- */
- matchBareUrl(text) {
- const pattern = /^[\w]+?:\/\/[^\s<>\x00]+/;
- const match = text.match(pattern);
- if (!match) {
- return null;
- }
- return {
- url: match[0],
- length: match[0].length,
- };
- }
- /**
- * Match an HTML tag at the start of the string.
- */
- matchHtmlTag(text) {
- const pattern = /^<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?\s*\/?>/;
- const match = text.match(pattern);
- if (!match) {
- return null;
- }
- return {
- tag: match[0],
- length: match[0].length,
- };
- }
- /**
- * Try to match a delimiter at the given position. For runs of the
- * same character (e.g. *** = 3 asterisks), the run is split into
- * the longest registered delimiter that fits, then the remainder.
- * This handles cases like **bold***italic* where *** must split
- * into ** (close bold) + * (open italic).
- */
- matchTag(source, position) {
- // Count the full run of the same character
- var sequence = source[position];
- let runLength = 0;
- while (position + runLength < source.length && source[position + runLength] === sequence) {
- runLength++;
- }
- if (runLength === 0) {
- return null;
- }
- sequence = sequence.repeat(runLength);
- // get the tag that matches this sequence
- for (const tag of this.tags) {
- const delim = tag.isBlock ? tag.opener : tag.delimiter;
- if (delim !== sequence) {
- continue;
- }
- const charBefore = position > 0 ? source[position - 1] : '\n';
- const charAfter = source[position + sequence.length];
- const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
- && charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0';
- const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
- && (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
- return {
- role: leftFlanking ? 'open' : rightFlanking ? 'close' : 'block',
- sequence: sequence,
- value: sequence,
- tag: tag,
- };
- }
- ;
- return null;
- }
-}
diff --git a/dist/lib/types.js b/dist/lib/types.js
deleted file mode 100644
index f8bdff4..0000000
--- a/dist/lib/types.js
+++ /dev/null
@@ -1 +0,0 @@
-export {};
diff --git a/dist/tags.d.ts b/dist/tags.d.ts
new file mode 100644
index 0000000..c3b97e6
--- /dev/null
+++ b/dist/tags.d.ts
@@ -0,0 +1,232 @@
+import type { Token, TokenRole, TagType, TagCollectionType } from './types';
+/**
+ * A single item in a parsed list, with optional nested sublist HTML.
+ */
+export interface ListItem {
+ text: string;
+ sub: string;
+}
+/**
+ * Result of parsing a list block: the generated HTML and the line
+ * index where the list ends (so the caller can advance past it).
+ */
+export interface ListResult {
+ html: string;
+ end: number;
+}
+export declare function childNodesToMarkdown(element: HTMLElement, callback: Function): string;
+export declare class Tag implements TagType {
+ element: string[];
+ opener: string[];
+ closer: string;
+ pattern: RegExp | null;
+ precedence: number;
+ isBlock: boolean;
+ showDelimitersInEditorNodes: boolean;
+ tokenize(role: TokenRole, sequence: string, source: string): Token | null;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ delimNodes(): [Node, Node];
+ toEditorNode(token: Token, callback: Function): Node;
+}
+export declare class TagCollection implements TagCollectionType {
+ tags: Record;
+ ordered: TagType[];
+ map: Map;
+ constructor(tags: TagType[]);
+ private validate;
+ getByElementName(element: string): TagType | undefined;
+}
+export declare class Bold extends Tag {
+ element: string[];
+ aliases: string[];
+ opener: string[];
+ precedence: number;
+}
+export declare class Italic extends Tag {
+ element: string[];
+ aliases: string[];
+ opener: string[];
+ precedence: number;
+}
+export declare class BoldItalic extends Tag {
+ element: string[];
+ opener: string[];
+ precedence: number;
+}
+export declare class Strikethrough extends Tag {
+ element: string[];
+ aliases: string[];
+ opener: string[];
+}
+export declare class Code extends Tag {
+ element: string[];
+ opener: string[];
+}
+export declare class Anchor extends Tag {
+ opener: string[];
+ element: string[];
+ pattern: RegExp;
+ autolink_pattern: RegExp;
+ showDelimitersInEditorNodes: boolean;
+ tokenize(role: TokenRole, sequence: string, source: string): Token | null;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+}
+export declare class HardBreak extends Tag {
+ element: string[];
+ template: string;
+ precedence: number;
+ pattern: RegExp;
+ isBlock: boolean;
+ toHTML(): string;
+ toMarkdown(): string;
+}
+/**
+ * Fenced code blocks: lines between ``` delimiters become .
+ *
+ * converter.toHTML('```js\nlet x = 1;\n```')
+ * // let x = 1;
+ */
+export declare class FencedCode extends Tag {
+ opener: string[];
+ closer: string;
+ pattern: RegExp;
+ isBlock: boolean;
+ element: string[];
+ template: string;
+ replaceSelection: boolean;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+}
+/**
+ * Three or more *, -, or _ on a line become .
+ *
+ * converter.toHTML('---') // ' '
+ */
+export declare class HorizontalRule extends Tag {
+ element: string[];
+ template: string;
+ opener: string[];
+ isBlock: boolean;
+ replaceSelection: boolean;
+ pattern: RegExp;
+ precedence: number;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+}
+export declare class Heading1 extends Tag {
+ element: string[];
+ pattern: RegExp;
+ escapePattern: RegExp;
+ opener: string[];
+ closer: string;
+ isBlock: boolean;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+ private anchorId;
+}
+export declare class Heading2 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
+export declare class Heading3 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
+export declare class Heading4 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
+export declare class Heading5 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
+export declare class Heading6 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
+/**
+ * Lines prefixed with > become blockquotes. Consecutive > lines
+ * are merged into a single blockquote.
+ *
+ * converter.toHTML('> hello\n> world')
+ * // hello\nworld
+ */
+export declare class Blockquote extends Tag {
+ element: string[];
+ opener: string[];
+ closer: string;
+ isBlock: boolean;
+ template: string;
+ pattern: RegExp;
+ private childToEditorNode;
+ /**
+ * Split a paragraph's content into lines at elements.
+ */
+ private paragraphToLines;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+}
+/**
+ * Ordered and unordered lists with arbitrary nesting. Indentation
+ * determines depth; mixed list types (ul inside ol) are supported.
+ *
+ * converter.toHTML('- one\n- two\n 1. nested')
+ */
+declare class List extends Tag {
+ pattern: RegExp;
+ orderedOpenerPattern: RegExp;
+ isBlock: boolean;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+ private childToEditorNode;
+ private countListLines;
+ /**
+ * Recursively parse markdown list lines into HTML, handling nested
+ * sublists at arbitrary depth and mixed list types (ul/ol).
+ */
+ private parseBlock;
+ /**
+ * Convert an HTML list back to markdown. Recurses into nested
+ * sublists with 2-space indentation per depth level.
+ */
+ private nodeToMarkdown;
+ private partitionListItem;
+}
+export declare class UnorderedList extends List {
+ element: string[];
+ opener: string[];
+ template: string;
+}
+export declare class OrderedList extends List {
+ element: string[];
+ opener: string[];
+ template: string;
+}
+/**
+ * Pipe-delimited tables with optional column alignment.
+ *
+ * converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
+ */
+export declare class Table extends Tag {
+ opener: string[];
+ element: string[];
+ template: string;
+ replaceSelection: boolean;
+ pattern: RegExp;
+ tableSeparatorPattern: RegExp;
+ isBlock: boolean;
+ showDelimitersInEditorNodes: boolean;
+ parseBlock(token: Token): Record | null;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ private parseRow;
+ private parseAligns;
+}
+export {};
diff --git a/dist/js/tags.js b/dist/tags.js
similarity index 51%
rename from dist/js/tags.js
rename to dist/tags.js
index 2d2bd6c..9354855 100644
--- a/dist/js/tags.js
+++ b/dist/tags.js
@@ -1,6 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
-exports.Paragraph = exports.Table = exports.OrderedList = exports.UnorderedList = exports.Blockquote = exports.Heading = exports.HorizontalRule = exports.FencedCode = exports.HardBreak = exports.Anchor = exports.Code = exports.Strikethrough = exports.BoldItalic = exports.Italic = exports.Bold = exports.TagCollection = exports.Tag = void 0;
+exports.Table = exports.OrderedList = exports.UnorderedList = exports.Blockquote = exports.Heading6 = exports.Heading5 = exports.Heading4 = exports.Heading3 = exports.Heading2 = exports.Heading1 = exports.HorizontalRule = exports.FencedCode = exports.HardBreak = exports.Anchor = exports.Code = exports.Strikethrough = exports.BoldItalic = exports.Italic = exports.Bold = exports.TagCollection = exports.Tag = void 0;
exports.childNodesToMarkdown = childNodesToMarkdown;
const hopdown_1 = require("./hopdown");
/**
@@ -8,19 +8,6 @@ const hopdown_1 = require("./hopdown");
* so the regexes aren't duplicated and can be referenced by name.
*/
const blankLine = /^\s*$/;
-const unorderedMarker = /^[*\-]\s/;
-const orderedMarker = /^\d+\.\s/;
-const escapeRegex = /[.*+?^${}()|[\]\\]/g;
-const tableSeparator = new RegExp('^'
- + '\\|?' // optional leading pipe
- + '\\s*:?-+:?\\s*' // first column separator (optional alignment colons)
- + '('
- + '\\|' // pipe between columns
- + '\\s*:?-+:?\\s*' // subsequent column separator
- + ')*' // zero or more additional columns
- + '\\|?' // optional trailing pipe
- + '\\s*$' // end of line
-);
function childNodesToMarkdown(element, callback) {
return Array.from(element.childNodes)
.map(child => callback(child))
@@ -28,11 +15,33 @@ function childNodesToMarkdown(element, callback) {
}
class Tag {
element = [''];
- opener = '';
+ opener = [''];
closer = '';
- delimiter = null;
+ pattern = null;
precedence = 50;
isBlock = false;
+ showDelimitersInEditorNodes = true;
+ tokenize(role, sequence, source) {
+ if (this.pattern) {
+ const match = this.pattern.exec(source);
+ if (!match?.groups) {
+ return null;
+ }
+ return {
+ 'role': 'block',
+ 'sequence': sequence,
+ 'value': match.groups.captured,
+ 'groups': match.groups,
+ 'tag': this
+ };
+ }
+ return {
+ role: role,
+ sequence: sequence,
+ value: sequence,
+ tag: this,
+ };
+ }
toHTML(token, callback) {
if (token.role == 'open') {
return this.element.map(el => { return `<${el.toUpperCase()}>`; }).join('');
@@ -42,31 +51,52 @@ class Tag {
rev.reverse();
return rev.map(el => { return `${el.toUpperCase()}>`; }).join('');
}
+ if (token.role == 'block') {
+ const res = callback(token.value);
+ return res.firstElementChild?.innerHTML || res;
+ }
return token.value;
}
;
toMarkdown(element, callback) {
const children = childNodesToMarkdown(element, callback);
- return `${this.delimiter}${children || element.textContent || ''}${this.delimiter}`;
+ return `${this.opener[0]}${children || element.textContent || ''}${this.closer || this.opener[0]}`;
}
;
- toEditorNode(token, callback) {
- if (token.role == 'open' || token.role == 'close') {
- const span = document.createElement('span');
- span.classList.add('delim');
- for (const el of this.element) {
- span.classList.add(el.toLowerCase());
- }
- ;
- if (this.isBlock) {
- span.textContent = token.role == 'open' ? this.opener : this.closer;
- }
- else {
- span.textContent = this.delimiter;
- }
- return span;
+ delimNodes() {
+ const opener = document.createElement('span');
+ opener.classList.add('delim');
+ for (const el of this.element) {
+ opener.classList.add(el.toLowerCase());
}
- return document.createTextNode(token.value);
+ ;
+ opener.textContent = this.opener[0];
+ const closer = opener.cloneNode(true);
+ closer.textContent = this.closer || this.opener[0];
+ return [opener, closer];
+ }
+ toEditorNode(token, callback) {
+ console.log(token);
+ if (['open', 'close', 'block'].indexOf(token.role) == -1) {
+ return document.createTextNode(token.value);
+ }
+ const [opener, closer] = this.delimNodes();
+ if (token.role == 'block') {
+ const div = document.createElement('div');
+ div.innerHTML = this.toHTML(token, callback);
+ const el = div.firstChild;
+ if (token.tag.showDelimitersInEditorNodes) {
+ if (this.opener || this.closer) {
+ el.insertBefore(opener, el.firstChild);
+ el.appendChild(closer);
+ }
+ }
+ return el;
+ }
+ if (token.role == 'close') {
+ return closer;
+ }
+ return opener;
}
;
}
@@ -74,7 +104,8 @@ exports.Tag = Tag;
class TagCollection {
tags = {};
ordered;
- openers;
+ map;
+ //openers: RegExp[];
constructor(tags) {
for (const tag of tags) {
if (tag.constructor.name in this.tags) {
@@ -87,37 +118,37 @@ class TagCollection {
const p2 = b.precedence - (b.isBlock ? 100 : 0);
return p1 < p2 ? -1 : 1;
});
- this.validate();
- const escapeRegex = /[.*+?^${}()|[\]\\]/g;
- this.openers = this.ordered.filter(t => { return t.delimiter !== null; }).map(tag => {
- const escaped = tag.delimiter.replace(escapeRegex, '\\$&');
- const escapedChar = tag.delimiter[0].replace(escapeRegex, '\\$&');
- return new RegExp(`(? { return !t.delimiter; });
+ const inline = this.ordered.filter(t => { return !t.opener; });
for (let outer = 0; outer < inline.length; outer++) {
for (let inner = outer + 1; inner < inline.length; inner++) {
const first = this.ordered[outer];
const second = this.ordered[inner];
- if (!(first.delimiter && second.delimiter)) {
+ if (!(first.opener && second.opener)) {
continue;
}
- const firstIsPrefix = second.delimiter.startsWith(first.delimiter);
- const secondIsPrefix = first.delimiter.startsWith(second.delimiter);
+ const firstIsPrefix = second.opener[0].startsWith(first.opener[0]);
+ const secondIsPrefix = first.opener[0].startsWith(second.opener[0]);
if (!firstIsPrefix && !secondIsPrefix) {
continue;
}
- const longer = first.delimiter.length > second.delimiter.length ? first : second;
- const shorter = first.delimiter.length > second.delimiter.length ? second : first;
+ const longer = first.opener.length > second.opener.length ? first : second;
+ const shorter = first.opener.length > second.opener.length ? second : first;
if (longer.precedence >= shorter.precedence) {
- throw new Error(`Tag "${longer.constructor.name}" (delimiter "${longer.delimiter}") must have ` +
- `lower precedence than "${shorter.constructor.name}" (delimiter "${shorter.delimiter}") ` +
- `because its delimiter is a prefix match. ` +
+ throw new Error(`Tag "${longer.constructor.name}" (opener "${longer.opener}") must have ` +
+ `lower precedence than "${shorter.constructor.name}" (opener "${shorter.opener}") ` +
+ `because its opener is a prefix match. ` +
`Got ${longer.constructor.name}=${longer.precedence}, ${shorter.constructor.name}=${shorter.precedence}.`);
}
}
@@ -134,43 +165,60 @@ exports.TagCollection = TagCollection;
class Bold extends Tag {
element = ['STRONG'];
aliases = ['B'];
- delimiter = '**';
- shortcut = 'Ctrl+B';
+ opener = ['**', '__'];
precedence = 40;
}
exports.Bold = Bold;
class Italic extends Tag {
element = ['EM'];
aliases = ['I'];
- delimiter = '*';
- shortcut = 'Ctrl+I';
+ opener = ['*', '_'];
precedence = 30;
}
exports.Italic = Italic;
class BoldItalic extends Tag {
element = ['STRONG', 'EM'];
- delimiter = '***';
+ opener = ['***'];
precedence = 50;
}
exports.BoldItalic = BoldItalic;
class Strikethrough extends Tag {
element = ['DEL'];
aliases = ['S', 'STRIKE'];
- delimiter = '~~';
+ opener = ['~~'];
}
exports.Strikethrough = Strikethrough;
class Code extends Tag {
element = ['CODE'];
- delimiter = '`';
+ opener = ['`'];
}
exports.Code = Code;
class Anchor extends Tag {
+ opener = ['['];
element = ['A'];
+ pattern = /(?\[(?[^\[\]]+)\]\((?[^\s)]+)(?:\s+"(?[^"]*)")?\))/;
+ autolink_pattern = /^(?(?[A-Za-z]+:\/\/[^\s>]+))/;
+ showDelimitersInEditorNodes = false;
+ tokenize(role, sequence, source) {
+ const match = (this.pattern.exec(source) ||
+ this.autolink_pattern.exec(source));
+ if (match?.groups) {
+ const token = {
+ 'role': 'block',
+ 'sequence': sequence,
+ 'value': match.groups.captured,
+ 'groups': match.groups,
+ 'tag': this
+ };
+ return token;
+ }
+ return null;
+ }
toHTML(token, callback) {
- const titleAttr = token.title
- ? ` TITLE="${(0, hopdown_1.escapeHtml)(token.title)}"`
- : '';
- return `${(0, hopdown_1.escapeHtml)(token.value)} `;
+ const title = (0, hopdown_1.escapeHtml)(token.groups.title || '');
+ const url = (0, hopdown_1.escapeHtml)(token.groups.url || '');
+ const text = (0, hopdown_1.escapeHtml)(token.groups.text || '') || url;
+ return `${text} `;
}
toMarkdown(element, callback) {
const href = element.getAttribute('href') || '';
@@ -178,28 +226,14 @@ class Anchor extends Tag {
const titlePart = title ? ` "${title}"` : '';
return '[' + element.innerText + '](' + href + titlePart + ')';
}
- toEditorNode(token, callback) {
- return document.createElement('A');
- }
}
exports.Anchor = Anchor;
-class BlockTag extends Tag {
+class HardBreak extends Tag {
+ element = ['BR'];
+ template = ' \n';
+ precedence = 20;
+ pattern = blankLine;
isBlock = true;
- delimiter = null;
- toMarkdown(element, callback) {
- const children = childNodesToMarkdown(element, callback);
- const ret = `${this.opener || ''}${children || element.textContent || ''}${this.closer || ''}`;
- return ret;
- }
- ;
- match(context, tags) {
- return null;
- }
-}
-class HardBreak extends BlockTag {
- match(context) {
- return null;
- }
toHTML() { return ' '; }
toMarkdown() { return ' \n'; }
}
@@ -210,50 +244,40 @@ exports.HardBreak = HardBreak;
* converter.toHTML('```js\nlet x = 1;\n```')
* // let x = 1;
*/
-class FencedCode extends BlockTag {
- opener = '```';
- closer = '```';
+class FencedCode extends Tag {
+ opener = ['```'];
+ closer = '';
+ pattern = /(?^(?```)(?\w*)\s*\n(?(.+\n)+)\k)/m;
+ isBlock = true;
element = ['PRE'];
- button = {
- show: true,
- label: 'Code Block',
- shortcut: 'Ctrl+Shift+E',
- };
- template = '```\ncode\n```';
+ template = '```\n10 PRINT "HELLO"\n20 GOTO 10\n```';
replaceSelection = true;
- match(context) {
- // Accepts both ``` and ~~~ as fence delimiters
- const fencePattern = /^(?`{3,}|~{3,})(?.*)/;
- const matched = context.lines[context.index].match(fencePattern);
- if (!matched?.groups) {
- return null;
- }
- const fence = matched.groups.fence;
- const lang = matched.groups.lang.trim();
- const code = [];
- let lineIndex = context.index + 1;
- while (lineIndex < context.lines.length && !context.lines[lineIndex].startsWith(fence)) {
- code.push(context.lines[lineIndex++]);
- }
- return {
- opener: fence + lang,
- closer: '',
- content: code.join('\n'),
- raw: '',
- consumed: lineIndex + 1 - context.index,
- meta: { lang },
- };
- }
toHTML(token, callback) {
- const langAttr = token.meta?.lang
- ? ` class="language-${(0, hopdown_1.escapeHtml)(token.meta.lang)}"`
+ const fence = token.groups.fence;
+ const lang = token.groups.lang.trim();
+ var code = token.groups.code.replace(/^\n|\n$/g, '');
+ code = `\n${code}\n`;
+ const langAttr = lang
+ ? ` class="language-${(0, hopdown_1.escapeHtml)(lang)}"`
: '';
- return `${(0, hopdown_1.escapeHtml)(token.content)} `;
+ const val = token.value.slice(token.groups.fence.length + token.groups.lang.length, token.value.length - token.groups.fence.length);
+ return `${(0, hopdown_1.escapeHtml)(val)} `;
}
toMarkdown(element, callback) {
const lang = element.className.match(/language-(\S+)/)?.[1] || '';
const content = element?.textContent || '';
- return '\n\n' + this.opener + `${lang}\n${content}\n` + this.closer + '\n\n';
+ return '\n\n' + this.opener[0] + lang + content + this.opener[0] + '\n\n';
+ }
+ toEditorNode(token, callback) {
+ const div = document.createElement('div');
+ div.innerHTML = this.toHTML(token, callback);
+ const [opener, closer] = this.delimNodes();
+ const el = div.firstChild;
+ if (el) {
+ el.insertBefore(opener, el.firstChild);
+ el.appendChild(closer);
+ }
+ return el;
}
}
exports.FencedCode = FencedCode;
@@ -262,93 +286,85 @@ exports.FencedCode = FencedCode;
*
* converter.toHTML('---') // ' '
*/
-class HorizontalRule extends BlockTag {
+class HorizontalRule extends Tag {
element = ['HR'];
- button = {
- show: true,
- label: 'Divider',
- shortcut: 'Ctrl+Shift+-',
- };
- template = '---';
+ template = '***';
+ opener = ['***', '___', '---'];
+ isBlock = true;
replaceSelection = false;
- pattern = /^(\*{3,}|-{3,}|_{3,})\s*$/;
- match(context) {
- const matched = context.lines[context.index].match(this.pattern);
- if (!matched) {
- return null;
- }
- return {
- opener: '',
- closer: '',
- content: '',
- raw: '',
- consumed: 1,
- };
- }
+ pattern = /^(?(\*{3,}|-{3,}|_{3,}))\s*$/;
+ precedence = 20;
toHTML(token, callback) {
return `<${this.element[0].toUpperCase()}>`;
}
toMarkdown(element, callback) {
- return '\n***\n';
+ return '\n' + this.opener[0] + '\n';
+ }
+ toEditorNode(token, callback) {
+ return document.createElement('hr');
}
}
exports.HorizontalRule = HorizontalRule;
-class Heading extends BlockTag {
- element = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
- match(context) {
- // ATX heading: # through ######, optional closing #s
- const atxPattern = /^(?#{1,6})\s+(?.*?)(?:\s+#+\s*)?$/;
- const matched = context.lines[context.index].match(atxPattern);
- if (matched?.groups) {
- return {
- opener: matched.groups.hashes + ' ',
- content: matched.groups.content.trim(),
- closer: '',
- raw: '',
- consumed: 1,
- meta: { level: String(matched.groups.hashes.length) },
- };
- }
- if (context.index + 1 < context.lines.length) {
- const nextLine = context.lines[context.index + 1];
- const setextUnderline = /^(?=+|-+)\s*$/;
- const underlineMatch = nextLine.match(setextUnderline);
- if (underlineMatch?.groups) {
- const content = context.lines[context.index].trim();
- if (content.length > 0) {
- const level = underlineMatch.groups.marker[0] === '=' ? '1' : '2';
- return {
- opener: '',
- closer: '',
- content: content,
- raw: '',
- consumed: 2,
- meta: { level },
- };
- }
- }
- }
- return null;
- }
+class Heading1 extends Tag {
+ element = ['H1'];
+ pattern = /^(?(?\#{1,6})\s+(?.*?$))/m;
+ escapePattern = /[.*+?^${}()|[\]\\]/g;
+ opener = ['#'];
+ closer = '';
+ isBlock = true;
toHTML(token, callback) {
- const level = token.meta.level;
- const id = this.anchorId(token.content);
- return `${callback(token.content)} `;
+ const level = token.groups.hashes.length;
+ const id = this.anchorId(token.groups.content);
+ return `${callback(token.groups.content)} `;
}
toMarkdown(element, callback) {
const inner = childNodesToMarkdown(element, callback);
const level = parseInt(element.nodeName[1]);
return '\n\n' + '#'.repeat(level) + ' ' + inner + '\n\n';
}
- /**
- * Generate a PascalCase anchor ID from heading text so that
- * in-page links like #MyHeading work without manual IDs.
- */
+ toEditorNode(token, callback) {
+ const content = token.groups.content;
+ const id = this.anchorId(content);
+ const heading = document.createElement(this.element[0]);
+ heading.innerHTML = content;
+ heading.setAttribute('id', id);
+ return heading;
+ }
anchorId(text) {
- return text.replace(escapeRegex, ' ').trim().split(/\s+/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join('_');
+ return text.replace(this.escapePattern, ' ').trim().split(/\s+/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join('_');
}
}
-exports.Heading = Heading;
+exports.Heading1 = Heading1;
+class Heading2 extends Heading1 {
+ element = ['H2'];
+ opener = ['##'];
+}
+exports.Heading2 = Heading2;
+;
+class Heading3 extends Heading1 {
+ element = ['H3'];
+ opener = ['###'];
+}
+exports.Heading3 = Heading3;
+;
+class Heading4 extends Heading1 {
+ element = ['H4'];
+ opener = ['####'];
+}
+exports.Heading4 = Heading4;
+;
+class Heading5 extends Heading1 {
+ element = ['H5'];
+ opener = ['#####'];
+}
+exports.Heading5 = Heading5;
+;
+class Heading6 extends Heading1 {
+ element = ['H6'];
+ opener = ['######'];
+}
+exports.Heading6 = Heading6;
+;
/**
* Lines prefixed with > become blockquotes. Consecutive > lines
* are merged into a single blockquote.
@@ -356,55 +372,26 @@ exports.Heading = Heading;
* converter.toHTML('> hello\n> world')
* // hello\nworld
*/
-class Blockquote extends BlockTag {
+class Blockquote extends Tag {
element = ['BLOCKQUOTE'];
- hidden = false;
+ opener = ['> '];
+ closer = '';
+ isBlock = true;
template = '> Quote\n> continues here';
- match(context) {
- const quotePrefix = /^>\s?/;
- if (!quotePrefix.test(context.lines[context.index])) {
- return null;
- }
- const lines = [];
- let lineIndex = context.index;
- while (lineIndex < context.lines.length && quotePrefix.test(context.lines[lineIndex])) {
- lines.push(context.lines[lineIndex++].replace(quotePrefix, ''));
- }
- return {
- opener: '> ',
- closer: '',
- content: lines.join('\n'),
- raw: '',
- consumed: lineIndex - context.index,
- };
- }
- toHTML(token, callback) {
- const inner = callback(token.content);
- const breaks = inner.replace(/([^\n])\n(?!\n)/g, '$1 ')
- .replace(/\n\n(.+?)(\n\n|$)/g, '$1
');
- return '' + breaks + ' ';
- }
- toMarkdown(element, callback) {
- // Each inside the blockquote is a paragraph group.
- // Within a
, elements create line breaks.
- // Separate paragraphs get a blank > line between them.
- const paragraphs = [];
- for (const child of Array.from(element.childNodes)) {
- const el = child;
- if (child.nodeType === 1 && el.tagName === 'P') {
- const lines = this.paragraphToLines(el, callback);
- paragraphs.push(">");
- paragraphs.push(lines.map(line => '> ' + line).join('\n'));
+ pattern = /(?(?:\>\s+.*?(\n|$))+)$/m;
+ childToEditorNode(node, callback) {
+ var nodes = [];
+ for (const child of node.childNodes) {
+ if (child.nodeType == 11) {
+ nodes.concat(this.childToEditorNode(child, callback));
}
- else {
- const text = callback(child).trim();
- if (text) {
- paragraphs.push('> ' + text);
- }
+ else if (child.textContent) {
+ nodes.push(child);
}
}
- return '\n\n' + paragraphs.join('\n') + '\n\n';
+ return nodes;
}
+ ;
/**
* Split a paragraph's content into lines at elements.
*/
@@ -425,6 +412,63 @@ class Blockquote extends BlockTag {
}
return lines;
}
+ toHTML(token, callback) {
+ var html = `<${this.element[0].toLowerCase()}>`;
+ var inner = [];
+ var pFlag = false;
+ for (var line of token.value.split('\n')) {
+ var text = line.replace(/^>\s*/, '');
+ if (text) {
+ html += callback(text) + " ";
+ continue;
+ }
+ html += pFlag ? "
" : "";
+ pFlag = !pFlag;
+ }
+ html = html.replace(/\ $/g, pFlag ? '
' : '');
+ html += `${this.element[0].toLowerCase()}>`;
+ return html;
+ }
+ toMarkdown(element, callback) {
+ // Each inside the blockquote is a paragraph group.
+ // Within a
, elements create line breaks.
+ // Separate paragraphs get a blank > line between them.
+ const paragraphs = [];
+ for (const child of Array.from(element.childNodes)) {
+ const el = child;
+ if (child.nodeType === 1 && el.tagName === 'P') {
+ const lines = this.paragraphToLines(el, callback);
+ paragraphs.push(this.opener[0]);
+ paragraphs.push(lines.map(line => '> ' + line).join('\n'));
+ }
+ else {
+ const text = callback(child).trim();
+ if (text) {
+ paragraphs.push('> ' + text);
+ }
+ }
+ }
+ return '\n\n' + paragraphs.join('\n') + '\n\n';
+ }
+ toEditorNode(token, callback) {
+ const [opener, closer] = this.delimNodes();
+ opener.textContent = opener.textContent.replaceAll(' ', '');
+ const bq = document.createElement('blockquote');
+ for (const line of (token.value).split('\n')) {
+ if (line === '') {
+ continue;
+ }
+ const inner = callback(line.replace(/^>\s?/, ''));
+ const children = this.childToEditorNode(inner, callback);
+ const p = document.createElement('P');
+ for (const c of children) {
+ p.innerHTML += c.innerHTML;
+ }
+ p.insertBefore(opener.cloneNode(true), p.firstChild);
+ bq.appendChild(p);
+ }
+ return bq;
+ }
}
exports.Blockquote = Blockquote;
/**
@@ -433,40 +477,40 @@ exports.Blockquote = Blockquote;
*
* converter.toHTML('- one\n- two\n 1. nested')
*/
-class UnorderedList extends BlockTag {
- element = ['UL'];
- hidden = false;
- match(context) {
- const line = context.lines[context.index];
- const prefixPattern = /^((?:[*\-]|\d+\.)\s)/;
- const matched = line.match(prefixPattern);
- if (!matched) {
- return null;
- }
- // Count how many lines this list consumes so HopDown can
- // advance past them. The actual parsing happens in toHTML
- // where we have access to the inline converter.
- const consumed = this.countListLines(context.lines, context.index);
- return {
- opener: matched[1],
- closer: '',
- content: '',
- raw: '',
- consumed,
- meta: {
- startIndex: String(context.index),
- lines: JSON.stringify(context.lines.slice(context.index, context.index + consumed)),
- },
- };
- }
+class List extends Tag {
+ pattern = /(?(?^(?:\s{2,})?(?:[-+*]|\d+\.)\s.+?(?:\n|$))+)/m;
+ orderedOpenerPattern = /^\d+\.\s/;
+ isBlock = true;
toHTML(token, callback) {
- const lines = JSON.parse(token.meta.lines);
- const result = this.parseBlock(lines, 0, 0, callback);
+ const result = this.parseBlock(token.groups.captured.split("\n"), 0, 0, callback);
return result.html;
}
toMarkdown(element, callback) {
return this.nodeToMarkdown(element, 0, callback);
}
+ toEditorNode(token, callback) {
+ const matched = token.value.match(this.pattern);
+ if (matched === null) {
+ throw new Error(`Token value does not match pattern ${this.pattern}: '${token.value}'`);
+ }
+ const parsed = this.parseBlock(token.value.split('\n'), 0, 0, callback);
+ const div = document.createElement('div');
+ div.innerHTML = parsed.html;
+ return div.firstChild;
+ }
+ childToEditorNode(node, callback) {
+ var nodes = [];
+ for (const child of node.childNodes) {
+ if (child.nodeType == 11) {
+ nodes.concat(this.childToEditorNode(child, callback));
+ }
+ else if (child.textContent) {
+ nodes.push(child);
+ }
+ }
+ return nodes;
+ }
+ ;
countListLines(lines, start) {
const indentedUnordered = /^(\s*)[*\-+]\s/;
const indentedOrdered = /^(\s*)\d+\.\s/;
@@ -487,13 +531,13 @@ class UnorderedList extends BlockTag {
* Recursively parse markdown list lines into HTML, handling nested
* sublists at arbitrary depth and mixed list types (ul/ol).
*/
- parseBlock(lines, start, indent, callback) {
+ parseBlock(lines, start = 0, indent = 0, callback) {
const prefix = new RegExp('^'
+ ' '.repeat(indent) // indentation for this nesting level
+ '([\\*\\-+]|\\d+\\.)' // bullet or number marker
+ '\\s' // space after marker
);
- const isOl = orderedMarker.test(lines[start].trim());
+ const isOl = this.orderedOpenerPattern.test(lines[start].trim());
const tag = isOl ? 'ol' : 'ul';
const items = [];
let lineIndex = start;
@@ -522,9 +566,12 @@ class UnorderedList extends BlockTag {
});
lineIndex++;
}
- const html = `<${tag}>`
- + items.map(item => '' + callback(item.text) + item.sub + ' ').join('')
- + `${tag}>`;
+ var html = `<${tag}>`;
+ for (var item of items) {
+ const res = callback(item.text);
+ html += '' + (res.firstElementChild?.innerHTML || res) + item.sub + ' ';
+ }
+ html += `${tag}>`;
return {
html,
end: lineIndex,
@@ -568,9 +615,16 @@ class UnorderedList extends BlockTag {
};
}
}
+class UnorderedList extends List {
+ element = ['UL'];
+ opener = ['*', '-'];
+ template = '* list\n* items \n* here';
+}
exports.UnorderedList = UnorderedList;
-class OrderedList extends UnorderedList {
+class OrderedList extends List {
element = ['OL'];
+ opener = ['1.'];
+ template = '1. list\n1. items \n1. here';
}
exports.OrderedList = OrderedList;
/**
@@ -578,55 +632,59 @@ exports.OrderedList = OrderedList;
*
* converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
*/
-class Table extends BlockTag {
+class Table extends Tag {
+ opener = ['|'];
element = ['TABLE'];
- hidden = false;
template = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
replaceSelection = false;
- match(context) {
- const { lines, index } = context;
- if (lines[index].indexOf('|') === -1
- || index + 1 >= lines.length) {
- return null;
- }
+ pattern = /(?(?:\|.+?\|\s*(?:\n|$))+)/m;
+ tableSeparatorPattern = new RegExp('^'
+ + '\\|?' // optional leading pipe
+ + '\\s*:?-+:?\\s*' // first column separator (optional alignment colons)
+ + '('
+ + '\\|' // pipe between columns
+ + '\\s*:?-+:?\\s*' // subsequent column separator
+ + ')*' // zero or more additional columns
+ + '\\|?' // optional trailing pipe
+ + '\\s*$' // end of line
+ );
+ isBlock = true;
+ showDelimitersInEditorNodes = false;
+ parseBlock(token) {
+ const lines = token.groups.captured.split("\n");
// Second line must be the separator row (e.g. |---|---|)
- if (!tableSeparator.test(lines[index + 1])) {
+ if (!this.tableSeparatorPattern.test(lines[1])) {
return null;
}
- const headers = this.parseRow(lines[index]);
- const aligns = this.parseAligns(lines[index + 1]);
+ const headers = this.parseRow(lines[0]);
+ const aligns = this.parseAligns(lines[1]);
const rows = [];
- let lineIndex = index + 2;
+ let lineIndex = 2;
while (lineIndex < lines.length && lines[lineIndex].indexOf('|') !== -1 && !blankLine.test(lines[lineIndex])) {
rows.push(this.parseRow(lines[lineIndex++]));
}
return {
- content: '',
- opener: '',
- closer: '',
- raw: '',
- consumed: lineIndex - index,
- meta: {
- headers: JSON.stringify(headers),
- aligns: JSON.stringify(aligns),
- rows: JSON.stringify(rows),
- },
+ headers: headers,
+ aligns: aligns,
+ rows: rows
};
}
toHTML(token, callback) {
- const headers = JSON.parse(token.meta.headers);
- const aligns = JSON.parse(token.meta.aligns);
- const rows = JSON.parse(token.meta.rows);
+ const block = this.parseBlock(token);
+ if (!block) {
+ throw new Error(`Could not parse table token! ${token.value}`);
+ }
const cell = (tag, text, index) => {
- const align = aligns[index]
- ? ` class="align-${aligns[index]}"`
+ const align = block.aligns[index]
+ ? ` class="align-${block.aligns[index]}"`
: '';
- return `<${tag}${align}>${callback(text)}${tag}>`;
+ const res = callback(text);
+ return `<${tag}${align}>` + (res.firstElementChild?.innerHTML || res) + `${tag}>`;
};
const head = ''
- + headers.map((text, column) => cell('th', text, column)).join('')
+ + block.headers.map((text, column) => cell('th', text, column)).join('')
+ ' ';
- const body = rows.map(row => '' + row.map((text, column) => cell('td', text, column)).join('') + ' ').join('');
+ const body = block.rows.map((row) => '' + row.map((text, column) => cell('td', text, column)).join('') + ' ').join('');
return ``;
}
toMarkdown(element, callback) {
@@ -674,44 +732,3 @@ class Table extends BlockTag {
}
}
exports.Table = Table;
-class Paragraph extends BlockTag {
- element = ['P'];
- opener = '';
- closer = '';
- precedence = -1;
- match(context, tags) {
- const collected = [];
- let lineIndex = context.index;
- while (lineIndex < context.lines.length) {
- const line = context.lines[lineIndex];
- const nextLine = lineIndex < context.lines.length ? context.lines[lineIndex + 1] : '';
- for (const pattern of [...tags.openers, blankLine]) {
- if (pattern.test(line)) {
- break;
- }
- }
- // Table: current line has pipes and next line is a separator row
- if (line.indexOf('|') !== -1) {
- if (nextLine && tableSeparator.test(nextLine)) {
- break;
- }
- }
- collected.push(context.lines[lineIndex++]);
- }
- if (!collected.length) {
- return null;
- }
- const ret = {
- opener: '',
- closer: '',
- content: collected.join('\n'),
- raw: '',
- consumed: lineIndex - context.index,
- };
- return ret;
- }
- toHTML(token, callback) {
- return '' + callback(token.content) + '
';
- }
-}
-exports.Paragraph = Paragraph;
diff --git a/dist/tags/anchor.d.ts b/dist/tags/anchor.d.ts
new file mode 100644
index 0000000..a764261
--- /dev/null
+++ b/dist/tags/anchor.d.ts
@@ -0,0 +1,12 @@
+import { Tag } from "./base";
+import type { Token, TokenRole } from '../types';
+export declare class Anchor extends Tag {
+ opener: string[];
+ element: string[];
+ pattern: RegExp;
+ autolink_pattern: RegExp;
+ showDelimitersInEditorNodes: boolean;
+ tokenize(role: TokenRole, sequence: string, source: string): Token | null;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+}
diff --git a/dist/tags/anchor.js b/dist/tags/anchor.js
new file mode 100644
index 0000000..a236556
--- /dev/null
+++ b/dist/tags/anchor.js
@@ -0,0 +1,40 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Anchor = void 0;
+const base_1 = require("./base");
+const hopdown_1 = require("../hopdown");
+class Anchor extends base_1.Tag {
+ opener = ['['];
+ element = ['A'];
+ pattern = /(?\[(?[^\[\]]+)\]\((?[^\s)]+)(?:\s+"(?[^"]*)")?\))/;
+ autolink_pattern = /^(?(?[A-Za-z]+:\/\/[^\s>]+))/;
+ showDelimitersInEditorNodes = false;
+ tokenize(role, sequence, source) {
+ const match = (this.pattern.exec(source) ||
+ this.autolink_pattern.exec(source));
+ if (match?.groups) {
+ const token = {
+ 'role': 'block',
+ 'sequence': sequence,
+ 'value': match.groups.captured,
+ 'groups': match.groups,
+ 'tag': this
+ };
+ return token;
+ }
+ return null;
+ }
+ toHTML(token, callback) {
+ const title = (0, hopdown_1.escapeHtml)(token.groups.title || '');
+ const url = (0, hopdown_1.escapeHtml)(token.groups.url || '');
+ const text = (0, hopdown_1.escapeHtml)(token.groups.text || '') || url;
+ return `${text} `;
+ }
+ toMarkdown(element, callback) {
+ const href = element.getAttribute('href') || '';
+ const title = element.getAttribute('title');
+ const titlePart = title ? ` "${title}"` : '';
+ return '[' + element.innerText + '](' + href + titlePart + ')';
+ }
+}
+exports.Anchor = Anchor;
diff --git a/dist/tags/base.d.ts b/dist/tags/base.d.ts
new file mode 100644
index 0000000..f446410
--- /dev/null
+++ b/dist/tags/base.d.ts
@@ -0,0 +1,24 @@
+import type { Token, TokenRole, TagType, TagCollectionType } from '../types';
+export declare class Tag implements TagType {
+ element: string[];
+ opener: string[];
+ closer: string;
+ pattern: RegExp | null;
+ precedence: number;
+ isBlock: boolean;
+ showDelimitersInEditorNodes: boolean;
+ childNodesToMarkdown(element: HTMLElement, callback: Function): string;
+ tokenize(role: TokenRole, sequence: string, source: string): Token | null;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ delimNodes(): [Node, Node];
+ toEditorNode(token: Token, callback: Function): Node;
+}
+export declare class TagCollection implements TagCollectionType {
+ tags: Record;
+ ordered: TagType[];
+ map: Map;
+ constructor(tags: TagType[]);
+ private validate;
+ getByElementName(element: string): TagType | undefined;
+}
diff --git a/dist/tags/base.js b/dist/tags/base.js
new file mode 100644
index 0000000..ede0a58
--- /dev/null
+++ b/dist/tags/base.js
@@ -0,0 +1,157 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.TagCollection = exports.Tag = void 0;
+class Tag {
+ element = [''];
+ opener = [''];
+ closer = '';
+ pattern = null;
+ precedence = 50;
+ isBlock = false;
+ showDelimitersInEditorNodes = true;
+ childNodesToMarkdown(element, callback) {
+ return Array.from(element.childNodes)
+ .map(child => callback(child))
+ .join('');
+ }
+ tokenize(role, sequence, source) {
+ if (this.pattern) {
+ const match = this.pattern.exec(source);
+ if (!match?.groups) {
+ return null;
+ }
+ return {
+ 'role': 'block',
+ 'sequence': sequence,
+ 'value': match.groups.captured,
+ 'groups': match.groups,
+ 'tag': this
+ };
+ }
+ return {
+ role: role,
+ sequence: sequence,
+ value: sequence,
+ tag: this,
+ };
+ }
+ toHTML(token, callback) {
+ if (token.role == 'open') {
+ return this.element.map(el => { return `<${el.toUpperCase()}>`; }).join('');
+ }
+ if (token.role == 'close') {
+ const rev = this.element;
+ rev.reverse();
+ return rev.map(el => { return `${el.toUpperCase()}>`; }).join('');
+ }
+ if (token.role == 'block') {
+ const res = callback(token.value);
+ return res.firstElementChild?.innerHTML || res;
+ }
+ return token.value;
+ }
+ ;
+ toMarkdown(element, callback) {
+ const children = this.childNodesToMarkdown(element, callback);
+ return `${this.opener[0]}${children || element.textContent || ''}${this.closer || this.opener[0]}`;
+ }
+ ;
+ delimNodes() {
+ const opener = document.createElement('span');
+ opener.classList.add('delim');
+ for (const el of this.element) {
+ opener.classList.add(el.toLowerCase());
+ }
+ ;
+ opener.textContent = this.opener[0];
+ const closer = opener.cloneNode(true);
+ closer.textContent = this.closer || this.opener[0];
+ return [opener, closer];
+ }
+ toEditorNode(token, callback) {
+ console.log(token);
+ if (['open', 'close', 'block'].indexOf(token.role) == -1) {
+ return document.createTextNode(token.value);
+ }
+ const [opener, closer] = this.delimNodes();
+ if (token.role == 'block') {
+ const div = document.createElement('div');
+ div.innerHTML = this.toHTML(token, callback);
+ const el = div.firstChild;
+ if (token.tag.showDelimitersInEditorNodes) {
+ if (this.opener || this.closer) {
+ el.insertBefore(opener, el.firstChild);
+ el.appendChild(closer);
+ }
+ }
+ return el;
+ }
+ if (token.role == 'close') {
+ return closer;
+ }
+ return opener;
+ }
+ ;
+}
+exports.Tag = Tag;
+class TagCollection {
+ tags = {};
+ ordered;
+ map;
+ //openers: RegExp[];
+ constructor(tags) {
+ for (const tag of tags) {
+ if (tag.constructor.name in this.tags) {
+ throw new Error(`Duplicate tag instances found for class ${tag.constructor.name}! Do you need to subclass it?`);
+ }
+ this.tags[tag.constructor.name] = tag;
+ }
+ this.ordered = tags.sort((a, b) => {
+ const p1 = a.precedence - (a.isBlock ? 100 : 0);
+ const p2 = b.precedence - (b.isBlock ? 100 : 0);
+ return p1 < p2 ? -1 : 1;
+ });
+ //this.validate();
+ this.map = new Map();
+ for (const tag of this.ordered) {
+ for (const key of tag.opener) {
+ if (!this.map.has(key)) {
+ this.map.set(key, []);
+ }
+ this.map.get(key).push(tag);
+ }
+ }
+ }
+ validate() {
+ const inline = this.ordered.filter(t => { return !t.opener; });
+ for (let outer = 0; outer < inline.length; outer++) {
+ for (let inner = outer + 1; inner < inline.length; inner++) {
+ const first = this.ordered[outer];
+ const second = this.ordered[inner];
+ if (!(first.opener && second.opener)) {
+ continue;
+ }
+ const firstIsPrefix = second.opener[0].startsWith(first.opener[0]);
+ const secondIsPrefix = first.opener[0].startsWith(second.opener[0]);
+ if (!firstIsPrefix && !secondIsPrefix) {
+ continue;
+ }
+ const longer = first.opener.length > second.opener.length ? first : second;
+ const shorter = first.opener.length > second.opener.length ? second : first;
+ if (longer.precedence >= shorter.precedence) {
+ throw new Error(`Tag "${longer.constructor.name}" (opener "${longer.opener}") must have ` +
+ `lower precedence than "${shorter.constructor.name}" (opener "${shorter.opener}") ` +
+ `because its opener is a prefix match. ` +
+ `Got ${longer.constructor.name}=${longer.precedence}, ${shorter.constructor.name}=${shorter.precedence}.`);
+ }
+ }
+ }
+ }
+ getByElementName(element) {
+ const el = element.toUpperCase();
+ return this.ordered.filter(t => {
+ return [...t.element, ...t.aliases ?? []].includes(el);
+ })[0] || undefined;
+ }
+}
+exports.TagCollection = TagCollection;
diff --git a/dist/tags/blockquote.d.ts b/dist/tags/blockquote.d.ts
new file mode 100644
index 0000000..23a8bc7
--- /dev/null
+++ b/dist/tags/blockquote.d.ts
@@ -0,0 +1,25 @@
+import { Tag } from "./base";
+import type { Token } from '../types';
+/**
+ * Lines prefixed with > become blockquotes. Consecutive > lines
+ * are merged into a single blockquote.
+ *
+ * converter.toHTML('> hello\n> world')
+ * // hello\nworld
+ */
+export declare class Blockquote extends Tag {
+ element: string[];
+ opener: string[];
+ closer: string;
+ isBlock: boolean;
+ template: string;
+ pattern: RegExp;
+ private childToEditorNode;
+ /**
+ * Split a paragraph's content into lines at elements.
+ */
+ private paragraphToLines;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+}
diff --git a/dist/tags/blockquote.js b/dist/tags/blockquote.js
new file mode 100644
index 0000000..8d754b3
--- /dev/null
+++ b/dist/tags/blockquote.js
@@ -0,0 +1,110 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Blockquote = void 0;
+const base_1 = require("./base");
+/**
+ * Lines prefixed with > become blockquotes. Consecutive > lines
+ * are merged into a single blockquote.
+ *
+ * converter.toHTML('> hello\n> world')
+ * // hello\nworld
+ */
+class Blockquote extends base_1.Tag {
+ element = ['BLOCKQUOTE'];
+ opener = ['> '];
+ closer = '';
+ isBlock = true;
+ template = '> Quote\n> continues here';
+ pattern = /(?(?:\>\s+.*?(\n|$))+)$/m;
+ childToEditorNode(node, callback) {
+ var nodes = [];
+ for (const child of node.childNodes) {
+ if (child.nodeType == 11) {
+ nodes.concat(this.childToEditorNode(child, callback));
+ }
+ else if (child.textContent) {
+ nodes.push(child);
+ }
+ }
+ return nodes;
+ }
+ ;
+ /**
+ * Split a paragraph's content into lines at elements.
+ */
+ paragraphToLines(paragraph, callback) {
+ const lines = [];
+ let currentLine = '';
+ for (const child of Array.from(paragraph.childNodes)) {
+ if (child.nodeType === 1 && child.tagName === 'BR') {
+ lines.push(currentLine);
+ currentLine = '';
+ }
+ else {
+ currentLine += callback(child);
+ }
+ }
+ if (currentLine.trim()) {
+ lines.push(currentLine);
+ }
+ return lines;
+ }
+ toHTML(token, callback) {
+ var html = `<${this.element[0].toLowerCase()}>`;
+ var inner = [];
+ var pFlag = false;
+ for (var line of token.value.split('\n')) {
+ var text = line.replace(/^>\s*/, '');
+ if (text) {
+ html += callback(text) + " ";
+ continue;
+ }
+ html += pFlag ? "
" : "";
+ pFlag = !pFlag;
+ }
+ html = html.replace(/\ $/g, pFlag ? '
' : '');
+ html += `${this.element[0].toLowerCase()}>`;
+ return html;
+ }
+ toMarkdown(element, callback) {
+ // Each inside the blockquote is a paragraph group.
+ // Within a
, elements create line breaks.
+ // Separate paragraphs get a blank > line between them.
+ const paragraphs = [];
+ for (const child of Array.from(element.childNodes)) {
+ const el = child;
+ if (child.nodeType === 1 && el.tagName === 'P') {
+ const lines = this.paragraphToLines(el, callback);
+ paragraphs.push(this.opener[0]);
+ paragraphs.push(lines.map(line => '> ' + line).join('\n'));
+ }
+ else {
+ const text = callback(child).trim();
+ if (text) {
+ paragraphs.push('> ' + text);
+ }
+ }
+ }
+ return '\n\n' + paragraphs.join('\n') + '\n\n';
+ }
+ toEditorNode(token, callback) {
+ const [opener, closer] = this.delimNodes();
+ opener.textContent = opener.textContent.replaceAll(' ', '');
+ const bq = document.createElement('blockquote');
+ for (const line of (token.value).split('\n')) {
+ if (line === '') {
+ continue;
+ }
+ const inner = callback(line.replace(/^>\s?/, ''));
+ const children = this.childToEditorNode(inner, callback);
+ const p = document.createElement('P');
+ for (const c of children) {
+ p.innerHTML += c.innerHTML;
+ }
+ p.insertBefore(opener.cloneNode(true), p.firstChild);
+ bq.appendChild(p);
+ }
+ return bq;
+ }
+}
+exports.Blockquote = Blockquote;
diff --git a/dist/tags/fenced.d.ts b/dist/tags/fenced.d.ts
new file mode 100644
index 0000000..735ca61
--- /dev/null
+++ b/dist/tags/fenced.d.ts
@@ -0,0 +1,20 @@
+import { Tag } from "./base";
+import type { Token } from '../types';
+/**
+ * Fenced code blocks: lines between ``` delimiters become
.
+ *
+ * converter.toHTML('```js\nlet x = 1;\n```')
+ * // let x = 1;
+ */
+export declare class FencedCode extends Tag {
+ opener: string[];
+ closer: string;
+ pattern: RegExp;
+ isBlock: boolean;
+ element: string[];
+ template: string;
+ replaceSelection: boolean;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+}
diff --git a/dist/tags/fenced.js b/dist/tags/fenced.js
new file mode 100644
index 0000000..ff5824b
--- /dev/null
+++ b/dist/tags/fenced.js
@@ -0,0 +1,48 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.FencedCode = void 0;
+const base_1 = require("./base");
+const hopdown_1 = require("../hopdown");
+/**
+ * Fenced code blocks: lines between ``` delimiters become .
+ *
+ * converter.toHTML('```js\nlet x = 1;\n```')
+ * // let x = 1;
+ */
+class FencedCode extends base_1.Tag {
+ opener = ['```'];
+ closer = '';
+ pattern = /(?^(?```)(?\w*)\s*\n(?(.+\n)+)\k)/m;
+ isBlock = true;
+ element = ['PRE'];
+ template = '```\n10 PRINT "HELLO"\n20 GOTO 10\n```';
+ replaceSelection = true;
+ toHTML(token, callback) {
+ const fence = token.groups.fence;
+ const lang = token.groups.lang.trim();
+ var code = token.groups.code.replace(/^\n|\n$/g, '');
+ code = `\n${code}\n`;
+ const langAttr = lang
+ ? ` class="language-${(0, hopdown_1.escapeHtml)(lang)}"`
+ : '';
+ const val = token.value.slice(token.groups.fence.length + token.groups.lang.length, token.value.length - token.groups.fence.length);
+ return `${(0, hopdown_1.escapeHtml)(val)} `;
+ }
+ toMarkdown(element, callback) {
+ const lang = element.className.match(/language-(\S+)/)?.[1] || '';
+ const content = element?.textContent || '';
+ return '\n\n' + this.opener[0] + lang + content + this.opener[0] + '\n\n';
+ }
+ toEditorNode(token, callback) {
+ const div = document.createElement('div');
+ div.innerHTML = this.toHTML(token, callback);
+ const [opener, closer] = this.delimNodes();
+ const el = div.firstChild;
+ if (el) {
+ el.insertBefore(opener, el.firstChild);
+ el.appendChild(closer);
+ }
+ return el;
+ }
+}
+exports.FencedCode = FencedCode;
diff --git a/dist/tags/hardbreak.d.ts b/dist/tags/hardbreak.d.ts
new file mode 100644
index 0000000..10b3d08
--- /dev/null
+++ b/dist/tags/hardbreak.d.ts
@@ -0,0 +1,10 @@
+import { Tag } from "./base";
+export declare class HardBreak extends Tag {
+ element: string[];
+ template: string;
+ precedence: number;
+ pattern: RegExp;
+ isBlock: boolean;
+ toHTML(): string;
+ toMarkdown(): string;
+}
diff --git a/dist/tags/hardbreak.js b/dist/tags/hardbreak.js
new file mode 100644
index 0000000..abc2101
--- /dev/null
+++ b/dist/tags/hardbreak.js
@@ -0,0 +1,14 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.HardBreak = void 0;
+const base_1 = require("./base");
+class HardBreak extends base_1.Tag {
+ element = ['BR'];
+ template = ' \n';
+ precedence = 20;
+ pattern = /^\s*$/;
+ isBlock = true;
+ toHTML() { return ' '; }
+ toMarkdown() { return ' \n'; }
+}
+exports.HardBreak = HardBreak;
diff --git a/dist/tags/heading.d.ts b/dist/tags/heading.d.ts
new file mode 100644
index 0000000..b814e5c
--- /dev/null
+++ b/dist/tags/heading.d.ts
@@ -0,0 +1,34 @@
+import { Tag } from "./base";
+import type { Token } from '../types';
+export declare class Heading1 extends Tag {
+ element: string[];
+ pattern: RegExp;
+ escapePattern: RegExp;
+ opener: string[];
+ closer: string;
+ isBlock: boolean;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+ private anchorId;
+}
+export declare class Heading2 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
+export declare class Heading3 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
+export declare class Heading4 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
+export declare class Heading5 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
+export declare class Heading6 extends Heading1 {
+ element: string[];
+ opener: string[];
+}
diff --git a/dist/tags/heading.js b/dist/tags/heading.js
new file mode 100644
index 0000000..f84b10d
--- /dev/null
+++ b/dist/tags/heading.js
@@ -0,0 +1,64 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Heading6 = exports.Heading5 = exports.Heading4 = exports.Heading3 = exports.Heading2 = exports.Heading1 = void 0;
+const base_1 = require("./base");
+class Heading1 extends base_1.Tag {
+ element = ['H1'];
+ pattern = /^(?(?\#{1,6})\s+(?.*?$))/m;
+ escapePattern = /[.*+?^${}()|[\]\\]/g;
+ opener = ['#'];
+ closer = '';
+ isBlock = true;
+ toHTML(token, callback) {
+ const level = token.groups.hashes.length;
+ const id = this.anchorId(token.groups.content);
+ return `${callback(token.groups.content)} `;
+ }
+ toMarkdown(element, callback) {
+ const inner = this.childNodesToMarkdown(element, callback);
+ const level = parseInt(element.nodeName[1]);
+ return '\n\n' + '#'.repeat(level) + ' ' + inner + '\n\n';
+ }
+ toEditorNode(token, callback) {
+ const content = token.groups.content;
+ const id = this.anchorId(content);
+ const heading = document.createElement(this.element[0]);
+ heading.innerHTML = content;
+ heading.setAttribute('id', id);
+ return heading;
+ }
+ anchorId(text) {
+ return text.replace(this.escapePattern, ' ').trim().split(/\s+/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join('_');
+ }
+}
+exports.Heading1 = Heading1;
+class Heading2 extends Heading1 {
+ element = ['H2'];
+ opener = ['##'];
+}
+exports.Heading2 = Heading2;
+;
+class Heading3 extends Heading1 {
+ element = ['H3'];
+ opener = ['###'];
+}
+exports.Heading3 = Heading3;
+;
+class Heading4 extends Heading1 {
+ element = ['H4'];
+ opener = ['####'];
+}
+exports.Heading4 = Heading4;
+;
+class Heading5 extends Heading1 {
+ element = ['H5'];
+ opener = ['#####'];
+}
+exports.Heading5 = Heading5;
+;
+class Heading6 extends Heading1 {
+ element = ['H6'];
+ opener = ['######'];
+}
+exports.Heading6 = Heading6;
+;
diff --git a/dist/tags/hr.d.ts b/dist/tags/hr.d.ts
new file mode 100644
index 0000000..25b48ea
--- /dev/null
+++ b/dist/tags/hr.d.ts
@@ -0,0 +1,19 @@
+import { Tag } from "./base";
+import type { Token } from '../types';
+/**
+ * Three or more *, -, or _ on a line become .
+ *
+ * converter.toHTML('---') // ' '
+ */
+export declare class HorizontalRule extends Tag {
+ element: string[];
+ template: string;
+ opener: string[];
+ isBlock: boolean;
+ replaceSelection: boolean;
+ pattern: RegExp;
+ precedence: number;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+}
diff --git a/dist/tags/hr.js b/dist/tags/hr.js
new file mode 100644
index 0000000..1d6473e
--- /dev/null
+++ b/dist/tags/hr.js
@@ -0,0 +1,28 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.HorizontalRule = void 0;
+const base_1 = require("./base");
+/**
+ * Three or more *, -, or _ on a line become .
+ *
+ * converter.toHTML('---') // ' '
+ */
+class HorizontalRule extends base_1.Tag {
+ element = ['HR'];
+ template = '***';
+ opener = ['***', '___', '---'];
+ isBlock = true;
+ replaceSelection = false;
+ pattern = /^(?(\*{3,}|-{3,}|_{3,}))\s*$/;
+ precedence = 20;
+ toHTML(token, callback) {
+ return `<${this.element[0].toUpperCase()}>`;
+ }
+ toMarkdown(element, callback) {
+ return '\n' + this.opener[0] + '\n';
+ }
+ toEditorNode(token, callback) {
+ return document.createElement('hr');
+ }
+}
+exports.HorizontalRule = HorizontalRule;
diff --git a/dist/tags/index.d.ts b/dist/tags/index.d.ts
new file mode 100644
index 0000000..2ceccd4
--- /dev/null
+++ b/dist/tags/index.d.ts
@@ -0,0 +1,10 @@
+export * from "./base";
+export * from "./anchor";
+export * from "./blockquote";
+export * from "./fenced";
+export * from "./hardbreak";
+export * from "./heading";
+export * from "./hr";
+export * from "./inline";
+export * from "./list";
+export * from "./table";
diff --git a/dist/tags/index.js b/dist/tags/index.js
new file mode 100644
index 0000000..96d7a52
--- /dev/null
+++ b/dist/tags/index.js
@@ -0,0 +1,26 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() { return m[k]; } };
+ }
+ Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __exportStar = (this && this.__exportStar) || function(m, exports) {
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+__exportStar(require("./base"), exports);
+__exportStar(require("./anchor"), exports);
+__exportStar(require("./blockquote"), exports);
+__exportStar(require("./fenced"), exports);
+__exportStar(require("./hardbreak"), exports);
+__exportStar(require("./heading"), exports);
+__exportStar(require("./hr"), exports);
+__exportStar(require("./inline"), exports);
+__exportStar(require("./list"), exports);
+__exportStar(require("./table"), exports);
diff --git a/dist/tags/inline.d.ts b/dist/tags/inline.d.ts
new file mode 100644
index 0000000..bb87c04
--- /dev/null
+++ b/dist/tags/inline.d.ts
@@ -0,0 +1,27 @@
+import { Tag } from "./base";
+export declare class Bold extends Tag {
+ element: string[];
+ aliases: string[];
+ opener: string[];
+ precedence: number;
+}
+export declare class Italic extends Tag {
+ element: string[];
+ aliases: string[];
+ opener: string[];
+ precedence: number;
+}
+export declare class BoldItalic extends Tag {
+ element: string[];
+ opener: string[];
+ precedence: number;
+}
+export declare class Strikethrough extends Tag {
+ element: string[];
+ aliases: string[];
+ opener: string[];
+}
+export declare class Code extends Tag {
+ element: string[];
+ opener: string[];
+}
diff --git a/dist/tags/inline.js b/dist/tags/inline.js
new file mode 100644
index 0000000..8d04c94
--- /dev/null
+++ b/dist/tags/inline.js
@@ -0,0 +1,35 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Code = exports.Strikethrough = exports.BoldItalic = exports.Italic = exports.Bold = void 0;
+const base_1 = require("./base");
+class Bold extends base_1.Tag {
+ element = ['STRONG'];
+ aliases = ['B'];
+ opener = ['**', '__'];
+ precedence = 40;
+}
+exports.Bold = Bold;
+class Italic extends base_1.Tag {
+ element = ['EM'];
+ aliases = ['I'];
+ opener = ['*', '_'];
+ precedence = 30;
+}
+exports.Italic = Italic;
+class BoldItalic extends base_1.Tag {
+ element = ['STRONG', 'EM'];
+ opener = ['***'];
+ precedence = 50;
+}
+exports.BoldItalic = BoldItalic;
+class Strikethrough extends base_1.Tag {
+ element = ['DEL'];
+ aliases = ['S', 'STRIKE'];
+ opener = ['~~'];
+}
+exports.Strikethrough = Strikethrough;
+class Code extends base_1.Tag {
+ element = ['CODE'];
+ opener = ['`'];
+}
+exports.Code = Code;
diff --git a/dist/tags/list.d.ts b/dist/tags/list.d.ts
new file mode 100644
index 0000000..f42f3bd
--- /dev/null
+++ b/dist/tags/list.d.ts
@@ -0,0 +1,55 @@
+import { Tag } from "./base";
+import type { Token } from '../types';
+/**
+ * A single item in a parsed list, with optional nested sublist HTML.
+ */
+export interface ListItem {
+ text: string;
+ sub: string;
+}
+/**
+ * Result of parsing a list block: the generated HTML and the line
+ * index where the list ends (so the caller can advance past it).
+ */
+export interface ListResult {
+ html: string;
+ end: number;
+}
+/**
+ * Ordered and unordered lists with arbitrary nesting. Indentation
+ * determines depth; mixed list types (ul inside ol) are supported.
+ *
+ * converter.toHTML('- one\n- two\n 1. nested')
+ */
+declare class List extends Tag {
+ pattern: RegExp;
+ orderedOpenerPattern: RegExp;
+ isBlock: boolean;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ toEditorNode(token: Token, callback: Function): Node;
+ private childToEditorNode;
+ private countListLines;
+ /**
+ * Recursively parse markdown list lines into HTML, handling nested
+ * sublists at arbitrary depth and mixed list types (ul/ol).
+ */
+ private parseBlock;
+ /**
+ * Convert an HTML list back to markdown. Recurses into nested
+ * sublists with 2-space indentation per depth level.
+ */
+ private nodeToMarkdown;
+ private partitionListItem;
+}
+export declare class UnorderedList extends List {
+ element: string[];
+ opener: string[];
+ template: string;
+}
+export declare class OrderedList extends List {
+ element: string[];
+ opener: string[];
+ template: string;
+}
+export {};
diff --git a/dist/tags/list.js b/dist/tags/list.js
new file mode 100644
index 0000000..8a31c9e
--- /dev/null
+++ b/dist/tags/list.js
@@ -0,0 +1,161 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.OrderedList = exports.UnorderedList = void 0;
+const base_1 = require("./base");
+const blankLine = /^\s*$/;
+/**
+ * Ordered and unordered lists with arbitrary nesting. Indentation
+ * determines depth; mixed list types (ul inside ol) are supported.
+ *
+ * converter.toHTML('- one\n- two\n 1. nested')
+ */
+class List extends base_1.Tag {
+ pattern = /(?(?^(?:\s{2,})?(?:[-+*]|\d+\.)\s.+?(?:\n|$))+)/m;
+ orderedOpenerPattern = /^\d+\.\s/;
+ isBlock = true;
+ toHTML(token, callback) {
+ const result = this.parseBlock(token.groups.captured.split("\n"), 0, 0, callback);
+ return result.html;
+ }
+ toMarkdown(element, callback) {
+ return this.nodeToMarkdown(element, 0, callback);
+ }
+ toEditorNode(token, callback) {
+ const matched = token.value.match(this.pattern);
+ if (matched === null) {
+ throw new Error(`Token value does not match pattern ${this.pattern}: '${token.value}'`);
+ }
+ const parsed = this.parseBlock(token.value.split('\n'), 0, 0, callback);
+ const div = document.createElement('div');
+ div.innerHTML = parsed.html;
+ return div.firstChild;
+ }
+ childToEditorNode(node, callback) {
+ var nodes = [];
+ for (const child of node.childNodes) {
+ if (child.nodeType == 11) {
+ nodes.concat(this.childToEditorNode(child, callback));
+ }
+ else if (child.textContent) {
+ nodes.push(child);
+ }
+ }
+ return nodes;
+ }
+ ;
+ countListLines(lines, start) {
+ const indentedUnordered = /^(\s*)[*\-+]\s/;
+ const indentedOrdered = /^(\s*)\d+\.\s/;
+ let lineIndex = start;
+ while (lineIndex < lines.length) {
+ const line = lines[lineIndex];
+ if (blankLine.test(line)) {
+ break;
+ }
+ if (!indentedUnordered.test(line) && !indentedOrdered.test(line)) {
+ break;
+ }
+ lineIndex++;
+ }
+ return lineIndex - start;
+ }
+ /**
+ * Recursively parse markdown list lines into HTML, handling nested
+ * sublists at arbitrary depth and mixed list types (ul/ol).
+ */
+ parseBlock(lines, start = 0, indent = 0, callback) {
+ const prefix = new RegExp('^'
+ + ' '.repeat(indent) // indentation for this nesting level
+ + '([\\*\\-+]|\\d+\\.)' // bullet or number marker
+ + '\\s' // space after marker
+ );
+ const isOl = this.orderedOpenerPattern.test(lines[start].trim());
+ const tag = isOl ? 'ol' : 'ul';
+ const items = [];
+ let lineIndex = start;
+ while (lineIndex < lines.length) {
+ const line = lines[lineIndex];
+ if (blankLine.test(line)) {
+ break;
+ }
+ const leadingWhitespace = /^(\s*)/;
+ const lineIndent = line.match(leadingWhitespace)[1].length;
+ if (lineIndent < indent) {
+ break;
+ }
+ if (lineIndent > indent) {
+ const sub = this.parseBlock(lines, lineIndex, lineIndent, callback);
+ items[items.length - 1].sub = sub.html;
+ lineIndex = sub.end;
+ continue;
+ }
+ if (!prefix.test(line)) {
+ break;
+ }
+ items.push({
+ text: line.replace(prefix, ''),
+ sub: '',
+ });
+ lineIndex++;
+ }
+ var html = `<${tag}>`;
+ for (var item of items) {
+ const res = callback(item.text);
+ html += '' + (res.firstElementChild?.innerHTML || res) + item.sub + ' ';
+ }
+ html += `${tag}>`;
+ return {
+ html,
+ end: lineIndex,
+ };
+ }
+ /**
+ * Convert an HTML list back to markdown. Recurses into nested
+ * sublists with 2-space indentation per depth level.
+ */
+ nodeToMarkdown(node, depth, callback) {
+ const isOl = node.nodeName === 'OL';
+ const indent = ' '.repeat(depth);
+ const lines = [];
+ Array.from(node.children).forEach((listItem, index) => {
+ const marker = isOl ? (index + 1) + '. ' : '* ';
+ const { text, sublist } = this.partitionListItem(listItem, depth, callback);
+ lines.push(indent + marker + text.trim());
+ if (sublist) {
+ lines.push(sublist);
+ }
+ });
+ const result = lines.join('\n');
+ return depth === 0
+ ? '\n\n' + result + '\n\n'
+ : result;
+ }
+ partitionListItem(listItem, depth, callback) {
+ let text = '';
+ let sublist = '';
+ for (const child of Array.from(listItem.childNodes)) {
+ if (child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')) {
+ sublist += this.nodeToMarkdown(child, depth + 1, callback);
+ }
+ else {
+ text += callback(child);
+ }
+ }
+ return {
+ text,
+ sublist,
+ };
+ }
+}
+class UnorderedList extends List {
+ element = ['UL'];
+ opener = ['*', '-'];
+ template = '* list\n* items \n* here';
+}
+exports.UnorderedList = UnorderedList;
+class OrderedList extends List {
+ element = ['OL'];
+ opener = ['1.'];
+ template = '1. list\n1. items \n1. here';
+}
+exports.OrderedList = OrderedList;
diff --git a/dist/tags/table.d.ts b/dist/tags/table.d.ts
new file mode 100644
index 0000000..ca34855
--- /dev/null
+++ b/dist/tags/table.d.ts
@@ -0,0 +1,22 @@
+import { Tag } from "./base";
+import type { Token } from '../types';
+/**
+ * Pipe-delimited tables with optional column alignment.
+ *
+ * converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
+ */
+export declare class Table extends Tag {
+ opener: string[];
+ element: string[];
+ template: string;
+ replaceSelection: boolean;
+ pattern: RegExp;
+ tableSeparatorPattern: RegExp;
+ isBlock: boolean;
+ showDelimitersInEditorNodes: boolean;
+ parseBlock(token: Token): Record | null;
+ toHTML(token: Token, callback: Function): string;
+ toMarkdown(element: HTMLElement, callback: Function): string;
+ private parseRow;
+ private parseAligns;
+}
diff --git a/dist/tags/table.js b/dist/tags/table.js
new file mode 100644
index 0000000..e0b50d8
--- /dev/null
+++ b/dist/tags/table.js
@@ -0,0 +1,110 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Table = void 0;
+const base_1 = require("./base");
+const blankLine = /^\s*$/;
+/**
+ * Pipe-delimited tables with optional column alignment.
+ *
+ * converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
+ */
+class Table extends base_1.Tag {
+ opener = ['|'];
+ element = ['TABLE'];
+ template = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
+ replaceSelection = false;
+ pattern = /(?(?:\|.+?\|\s*(?:\n|$))+)/m;
+ tableSeparatorPattern = new RegExp('^'
+ + '\\|?' // optional leading pipe
+ + '\\s*:?-+:?\\s*' // first column separator (optional alignment colons)
+ + '('
+ + '\\|' // pipe between columns
+ + '\\s*:?-+:?\\s*' // subsequent column separator
+ + ')*' // zero or more additional columns
+ + '\\|?' // optional trailing pipe
+ + '\\s*$' // end of line
+ );
+ isBlock = true;
+ showDelimitersInEditorNodes = false;
+ parseBlock(token) {
+ const lines = token.groups.captured.split("\n");
+ // Second line must be the separator row (e.g. |---|---|)
+ if (!this.tableSeparatorPattern.test(lines[1])) {
+ return null;
+ }
+ const headers = this.parseRow(lines[0]);
+ const aligns = this.parseAligns(lines[1]);
+ const rows = [];
+ let lineIndex = 2;
+ while (lineIndex < lines.length && lines[lineIndex].indexOf('|') !== -1 && !blankLine.test(lines[lineIndex])) {
+ rows.push(this.parseRow(lines[lineIndex++]));
+ }
+ return {
+ headers: headers,
+ aligns: aligns,
+ rows: rows
+ };
+ }
+ toHTML(token, callback) {
+ const block = this.parseBlock(token);
+ if (!block) {
+ throw new Error(`Could not parse table token! ${token.value}`);
+ }
+ const cell = (tag, text, index) => {
+ const align = block.aligns[index]
+ ? ` class="align-${block.aligns[index]}"`
+ : '';
+ const res = callback(text);
+ return `<${tag}${align}>` + (res.firstElementChild?.innerHTML || res) + `${tag}>`;
+ };
+ const head = ''
+ + block.headers.map((text, column) => cell('th', text, column)).join('')
+ + ' ';
+ const body = block.rows.map((row) => '' + row.map((text, column) => cell('td', text, column)).join('') + ' ').join('');
+ return ``;
+ }
+ toMarkdown(element, callback) {
+ const rows = Array.from(element.querySelectorAll('tr'));
+ if (!rows.length) {
+ return '';
+ }
+ const headers = Array.from(rows[0].querySelectorAll('th,td'))
+ .map(cell => callback(cell).trim());
+ const separator = Array.from(element.querySelectorAll('th')).map(h => {
+ return h.className == 'align-center' ? ':---:' :
+ h.className == 'align-right' ? '---:' :
+ '---';
+ });
+ const output = [
+ '| ' + headers.join(' | ') + ' |',
+ '| ' + separator.join(' | ') + ' |',
+ ];
+ for (const row of rows.slice(1)) {
+ const cells = Array.from(row.querySelectorAll('td,th'))
+ .map(cell => callback(cell).trim());
+ output.push('| ' + cells.join(' | ') + ' |');
+ }
+ return '\n\n' + output.join('\n') + '\n\n';
+ }
+ parseRow(line) {
+ return line
+ .replace(/^\|/, '')
+ .replace(/\|$/, '')
+ .split('|')
+ .map(cell => cell.trim());
+ }
+ parseAligns(line) {
+ const centerAlign = /^:-+:$/;
+ const rightAlign = /^-+:$/;
+ return this.parseRow(line).map(cell => {
+ if (centerAlign.test(cell)) {
+ return 'center';
+ }
+ if (rightAlign.test(cell)) {
+ return 'right';
+ }
+ return null;
+ });
+ }
+}
+exports.Table = Table;
diff --git a/dist/tokenizer.d.ts b/dist/tokenizer.d.ts
new file mode 100644
index 0000000..1eff8e2
--- /dev/null
+++ b/dist/tokenizer.d.ts
@@ -0,0 +1,64 @@
+/**
+ * A single token in the inline token stream. The `role` field
+ * distinguishes structural markers from literal content, which
+ * is the key insight that makes round-trip escaping correct.
+ */
+import type { Token, TagCollectionType } from "./types";
+/**
+ * Scans markdown text into a stream of typed tokens. Handles
+ * backslash escapes, entities, flanking rules, links,
+ * autolinks, HTML tags, and hard line breaks.
+ *
+ * const tokenizer = new Tokenizer([
+ * { delimiter: '**', htmlTag: 'strong', recursive: true, precedence: 40 },
+ * { delimiter: '*', htmlTag: 'em', recursive: true, precedence: 50 },
+ * ]);
+ * const tokens = tokenizer.tokenize('**bold**');
+ */
+export declare class Tokenizer {
+ private tags;
+ constructor(tags: TagCollectionType);
+ /**
+ * Tokenize a markdown string into an inline token stream.
+ *
+ * tokenizer.tokenize('hello **world**')
+ * // [text "hello "] [open "**"] [text "world"] [close "**"]
+ */
+ tokenize(source: string): Token[];
+ /**
+ * Try to resolve an HTML entity at the start of the string.
+ * Returns the resolved character and the length consumed, or null.
+ */
+ private resolveEntity;
+ /**
+ * Match a code span starting at the given position.
+ * Handles single backtick delimiters only (not multi-backtick).
+ */
+ private matchCodeSpan;
+ /**
+ * Match a markdown link [text](url) or [text](url "title")
+ * starting at the given position. Disallows [ in link text
+ * to prevent nested link ambiguity.
+ */
+ private matchLink;
+ /**
+ * Match an angle-bracket autolink at the start of the string.
+ */
+ private matchAutolink;
+ /**
+ * Match a bare URL (https://...) at the start of the string.
+ */
+ private matchBareUrl;
+ /**
+ * Match an HTML tag at the start of the string.
+ */
+ private matchHtmlTag;
+ /**
+ * Try to match a delimiter at the given position. For runs of the
+ * same character (e.g. *** = 3 asterisks), the run is split into
+ * the longest registered delimiter that fits, then the remainder.
+ * This handles cases like **bold***italic* where *** must split
+ * into ** (close bold) + * (open italic).
+ */
+ private matchTag;
+}
diff --git a/dist/js/tokenizer.js b/dist/tokenizer.js
similarity index 75%
rename from dist/js/tokenizer.js
rename to dist/tokenizer.js
index 1bd5fe2..283672c 100644
--- a/dist/js/tokenizer.js
+++ b/dist/tokenizer.js
@@ -48,7 +48,7 @@ const NAMED_ENTITIES = {
class Tokenizer {
tags;
constructor(tags) {
- this.tags = tags.ordered;
+ this.tags = tags;
}
/**
* Tokenize a markdown string into an inline token stream.
@@ -58,12 +58,12 @@ class Tokenizer {
*/
tokenize(source) {
const tokens = [];
- let position = 0;
- let textBuffer = '';
+ var position = 0;
+ var textBuffer = '';
const flushText = () => {
if (textBuffer.length > 0) {
tokens.push({
- role: 'text',
+ role: 'block',
value: textBuffer,
});
textBuffer = '';
@@ -85,7 +85,7 @@ class Tokenizer {
const spaceMatch = remaining.match(/^(? {2,})\n/);
if (spaceMatch?.groups) {
flushText();
- tokens.push({ role: 'break', value: ' ' });
+ tokens.push({ role: 'block', value: ' ' });
position += spaceMatch[0].length;
continue;
}
@@ -99,66 +99,19 @@ class Tokenizer {
continue;
}
}
- // Link: [text](url) or [text](url "title")
- if (source[position] === '[') {
- const link = this.matchLink(source, position);
- if (link) {
- flushText();
- tokens.push({
- role: 'link',
- value: link.text,
- href: link.href,
- title: link.title,
- });
- position += link.length;
- continue;
- }
- }
- // Autolink: and/or HTML
- if (source[position] === '<') {
- const autolink = this.matchAutolink(remaining);
- if (autolink) {
- flushText();
- tokens.push({
- role: 'link',
- value: autolink.url,
- href: autolink.url,
- tag: this.tags.filter(t => { return t.constructor.name == 'Anchor'; })[0]
- });
- position += autolink.length;
- continue;
- }
- // HTML tag passthrough
- const htmlTagMatch = this.matchHtmlTag(remaining);
- if (htmlTagMatch) {
- flushText();
- tokens.push({
- role: 'html',
- value: htmlTagMatch.tag,
- });
- position += htmlTagMatch.length;
- continue;
- }
- }
- // autolink bare URLs
- const bareUrl = this.matchBareUrl(remaining);
- if (bareUrl) {
- flushText();
- tokens.push({
- role: 'link',
- value: bareUrl.url,
- href: bareUrl.url,
- tag: this.tags.filter(t => { return t.constructor.name == 'Anchor'; })[0]
- });
- position += bareUrl.length;
- continue;
- }
- // Delimiter: check each registered delimiter
- const match = this.matchTag(source, position);
- if (match && match.sequence?.length) {
+ var match = this.matchTag(source, position);
+ if (match && match.value?.length) {
flushText();
tokens.push(match);
- position += match.sequence?.length;
+ position += match.value?.length;
+ continue;
+ }
+ const anchor = this.tags.getByElementName('A');
+ match = anchor.tokenize('block', remaining[0], remaining);
+ if (match && match.value?.length) {
+ flushText();
+ tokens.push(match);
+ position += match.value?.length;
continue;
}
// Plain character
@@ -298,24 +251,32 @@ class Tokenizer {
return null;
}
sequence = sequence.repeat(runLength);
- // get the tag that matches this sequence
- for (const tag of this.tags) {
- const delim = tag.isBlock ? tag.opener : tag.delimiter;
- if (delim !== sequence) {
- continue;
+ var matches = this.tags.map.get(sequence);
+ if (!matches && runLength == 1) {
+ sequence = source.slice(position, 3);
+ for (var i = 0; i < 2; i++) {
+ sequence = sequence.slice(0, sequence.length - 1);
+ matches = this.tags.map.get(sequence);
+ if (matches) {
+ break;
+ }
}
+ }
+ if (!matches || !sequence) {
+ return null;
+ }
+ for (const tag of matches) {
const charBefore = position > 0 ? source[position - 1] : '\n';
const charAfter = source[position + sequence.length];
const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0';
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
- return {
- role: leftFlanking ? 'open' : rightFlanking ? 'close' : 'block',
- sequence: sequence,
- value: sequence,
- tag: tag,
- };
+ const role = leftFlanking ? 'open' : rightFlanking ? 'close' : 'block';
+ var token = tag.tokenize(role, sequence, source);
+ if (token) {
+ return token;
+ }
}
;
return null;
diff --git a/dist/types.d.ts b/dist/types.d.ts
new file mode 100644
index 0000000..80a77fd
--- /dev/null
+++ b/dist/types.d.ts
@@ -0,0 +1,41 @@
+export type TokenRole = 'open' | 'close' | 'block';
+export interface Token {
+ role: TokenRole;
+ value: string;
+ sequence?: string;
+ tag?: TagType;
+ groups?: {
+ [key: string]: string;
+ };
+ /** For link tokens: the href and optional title. */
+ href?: string;
+ title?: string;
+ meta?: Record;
+}
+/**
+ * A Tag is the core abstraction: it knows how to match markdown syntax,
+ * convert it to HTML, and convert the HTML back to markdown. Tags are
+ * registered by HTML selector (e.g. 'STRONG,B') so the converter can
+ * look them up during HTML→markdown conversion.
+ */
+export interface TagType {
+ element: string[];
+ opener: string[];
+ closer: string | null;
+ precedence: number;
+ isBlock: boolean;
+ template?: string;
+ pattern?: RegExp | null;
+ showDelimitersInEditorNodes?: boolean;
+ aliases?: string[];
+ tokenize(role: TokenRole, sequence: string, value: string): Token | null;
+ toHTML: (token: Token, callback: Function) => string;
+ toMarkdown: (element: HTMLElement, callback: Function) => string;
+ toEditorNode: (token: Token, callback: Function) => Node | null;
+}
+export interface TagCollectionType {
+ tags: Record;
+ ordered: TagType[];
+ map: Map;
+ getByElementName(element: string): TagType | undefined;
+}
diff --git a/dist/js/types.js b/dist/types.js
similarity index 100%
rename from dist/js/types.js
rename to dist/types.js
diff --git a/package.json b/package.json
index bece2e6..e6277a9 100644
--- a/package.json
+++ b/package.json
@@ -2,14 +2,16 @@
"name": "hopdown",
"version": "1.0.0",
"description": "Configurable HTML <=> Markdown converter for WYSIWYG editors.",
- "main": "dist/js/index.js",
- "module": "commonjs",
- "files": [
- "dist/lib/"
- ],
+
+ "main": "dist/index.js",
"types": "src/ts/index.d.ts",
+
+ "files": [
+ "dist/*"
+ ],
+
"scripts": {
- "tsc": "tsc -p tsconfig.json && tsc -p tsconfig-cjs.json",
+ "tsc": "tsc -p tsconfig.json",
"build": "npm run tsc",
"test": "npm run build && vitest"
},
diff --git a/src/ts/defaults.ts b/src/ts/defaults.ts
index 77dc035..09eb32f 100644
--- a/src/ts/defaults.ts
+++ b/src/ts/defaults.ts
@@ -11,10 +11,14 @@ export const defaultTags = new tags.TagCollection([
new tags.HardBreak(),
new tags.FencedCode(),
new tags.HorizontalRule(),
- new tags.Heading(),
+ new tags.Heading1(),
+ new tags.Heading2(),
+ new tags.Heading3(),
+ new tags.Heading4(),
+ new tags.Heading5(),
+ new tags.Heading6(),
new tags.Blockquote(),
new tags.OrderedList(),
new tags.UnorderedList(),
new tags.Table(),
- new tags.Paragraph()
]);
diff --git a/src/ts/hopdown.ts b/src/ts/hopdown.ts
index c2a9916..bf6f0b4 100644
--- a/src/ts/hopdown.ts
+++ b/src/ts/hopdown.ts
@@ -5,7 +5,7 @@
import { defaultTags } from "./defaults";
import { Tokenizer } from "./tokenizer";
-import type { Token, SourceToken, MatchContext, TagType, TagCollectionType } from './types';
+import type { Token, TagType, TagCollectionType } from './types';
export function escapeHtml(source: string): string {
@@ -17,16 +17,7 @@ export function escapeHtml(source: string): string {
}
/**
- * The preprocessor interface. see HopDown.preprocessLines.
- */
-interface preprocessedLines {
- lines:string[],
- referenceLinks: Map
-}
-
-
-/**
- * A configurable markdown <=> HTML converter.
+ * A configurable markdown <=> HTML converter.
*
* Examples:
*
@@ -40,19 +31,18 @@ interface preprocessedLines {
*
* const node = hopdown.toEditorNode('**bold**');
* (node as HTMLElement).outerHTML === (
- * "" +
+ * "
" +
* "** " +
- * "bold" +
+ * "bold" +
* "" +
* "
"
* );
*/
export class HopDown {
public tags: TagCollectionType;
- private referenceLinks: Map
= new Map();
private tokenizer: Tokenizer;
- constructor(tags: TagCollectionType | null) {
+ constructor(tags?: TagCollectionType | null) {
this.tags = tags || defaultTags;
this.tokenizer = new Tokenizer(this.tags);
}
@@ -61,15 +51,7 @@ export class HopDown {
* Convert a markdown string to tokens.
*/
public tokenize(source: string): Token[] {
- let text = source;
-
- // Resolve reference links before tokenizing
- text = this.resolveReferenceLinks(text);
-
- // Normalize _ emphasis to *
- text = this.normalizeUnderscores(text);
- const tokens = this.tokenizer.tokenize(text);
- return tokens;
+ return this.tokenizer.tokenize(source);
}
/**
@@ -84,64 +66,8 @@ export class HopDown {
} else {
md = this.nodeToMarkdown(html);
}
- return md.replace(/\n{3,}/g, '\n\n').trim();
- }
-
- /**
- * Convert a markdown string to an HTML string.
- */
- public toHTML = (markdown: string): string => {
-
- const output: string[] = [];
-
- const preprocessed: preprocessedLines = this.preprocessMarkdown(markdown);
- if (!preprocessed.lines) {
- return '';
- }
-
- const blockTags = this.tags.ordered.filter(t => {
- return t.isBlock && t.constructor.name !== 'Paragraph'
- })
- const pTag = this.tags.getByElementName('p');
- if (pTag === undefined) {
- throw new Error("Cannot parse without a paragraph tag!");
- }
-
- let lineIndex = 0;
- while (lineIndex < preprocessed.lines.length) {
- let context: MatchContext;
- let matched = false;
- let token: SourceToken | null;
- for (const tag of blockTags) {
- context = {
- lines: preprocessed.lines,
- index: lineIndex,
- text: '',
- offset: 0,
- };
- token = tag.match!(context, this.tags);
- if (!token) {
- continue;
- }
- output.push(tag.toHTML(token, this.inlineToHTML));
- lineIndex += token.consumed;
- matched = true;
- break;
- }
- if (matched) {
- continue;
- }
-
- /* always check the paragraph tag last */
- token = pTag.match!(context!, this.tags);
- if (token) {
- output.push(pTag.toHTML(token, this.inlineToHTML));
- lineIndex += token.consumed;
- continue;
- }
- lineIndex++;
- }
- return output.join('\n');
+ md = md.replace(/^\n+/, '').replace(/\n+$/, '');
+ return md;
}
/**
@@ -152,13 +78,16 @@ export class HopDown {
return this.tokensToEditorNodes(tokens);
}
+ public toHTML = (markdown: string): string => {
+ return '' + this.inlineToHTML(markdown) + '
';
+ }
/**
* Recursive entrypoint for converting markdown to html.
*/
- private inlineToHTML = (markdown: string): string => {
+ public inlineToHTML = (markdown: string): string => {
const tokens = this.tokenize(markdown);
- return this.tokensToHTML(tokens);
+ return this.tokensToHTML(tokens)
}
/**
@@ -169,41 +98,6 @@ export class HopDown {
return this.tokensToEditorNodes(tokens);
};
- /**
- * Process a markdown string by splitting it into lines, stripping empty lines, and capturing reference links.
- */
- private preprocessMarkdown(markdown: string): preprocessedLines {
-
- const blankLine = /^\s*$/;
- const refDefinition = /^\[(?[^\]]+)\]:\s+(?\S+)(?:\s+"(?[^"]*)")?$/;
-
- const referenceLinks = new Map;
- const output: string[] = [];
-
- const lines = markdown.replace(/\r\n/g, '\n').split('\n');
- for (const line of lines) {
- const match = line.match(refDefinition);
- if (match?.groups) {
- this.referenceLinks.set(
- match.groups.label.toLowerCase(),
- {
- url: match.groups.url,
- title: match.groups.title,
- },
- );
- continue;
- }
- if (!blankLine.test(line)) {
- output.push(line)
- }
- }
- return {
- lines: output,
- referenceLinks: referenceLinks
- }
- }
-
-
/**
* Convert an HTML node to a markdown string. Used by toMarkdown().
*/
@@ -235,59 +129,6 @@ export class HopDown {
return ret;
}
-
- /**
- * Replace [text][ref] and [text][] with [text](url) using the
- * reference definitions collected during block parsing.
- */
- private resolveReferenceLinks(text: string): string {
- if (this.referenceLinks.size === 0) {
- return text;
- }
- const refLink = /\[(?[^\[\]]+)\]\[(?[^\]]*)\]/g;
- return text.replace(refLink, (...args) => {
- const groups = args[args.length - 1] as Record;
- const label = (groups.label || groups.text).toLowerCase();
- const ref = this.referenceLinks.get(label);
- if (!ref) {
- return args[0];
- }
- const titlePart = ref.title ? ` "${ref.title}"` : '';
- return `[${groups.text}](${ref.url}${titlePart})`;
- });
- }
-
- /**
- * Normalize flanking underscore runs to asterisks so the tokenizer
- * only needs to handle * delimiters for emphasis.
- */
- private normalizeUnderscores(text: string): string {
- // Protect backslash-escaped underscores from normalization
- const escapePlaceholder = '\x00U\x00';
- const safeText = text.replace(/\\_/g, escapePlaceholder);
-
- const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
- const openRun = new RegExp(
- `(?<=^|${punctuation})` + // preceded by start, space, or punctuation
- `(_+)` + // one or more underscores
- `(?=\\S)`, // followed by non-whitespace
- 'g'
- );
- const closeRun = new RegExp(
- `(?<=\\S)` + // preceded by non-whitespace
- `(_+)` + // one or more underscores
- `(?=$|${punctuation})`, // followed by end, space, or punctuation
- 'g'
- );
- const toAsterisks = (_: string, run: string) => '*'.repeat(run.length);
- const normalized = safeText
- .replace(openRun, toAsterisks)
- .replace(closeRun, toAsterisks);
-
- return normalized.replace(/\x00U\x00/g, '\\_');
- }
-
-
/**
* Convert a token stream to editor nodes.
*/
@@ -310,15 +151,6 @@ export class HopDown {
};
for (const token of paired) {
- if (token.role == 'html') {
- const div = document.createElement('div');
- div.innerHTML = token.value;
- for (const node of Array.from(div.childNodes)) {
- grouping.push(node);
- }
- continue;
- }
-
if (token.tag) {
const thisNode = token.tag.toEditorNode(token, this.inlineToEditorNodes);
if (! thisNode) {
@@ -327,7 +159,7 @@ export class HopDown {
if (token.role == 'open') {
if (grouping.length) {
- fragment.append(groupAsNode());
+ fragment.appendChild(groupAsNode());
}
grouping = [thisNode];
continue;
@@ -335,7 +167,7 @@ export class HopDown {
if (token.role == 'close') {
grouping.push(thisNode);
- fragment.append(groupAsNode());
+ fragment.appendChild(groupAsNode());
grouping = [];
continue;
}
@@ -351,13 +183,14 @@ export class HopDown {
continue;
}
+
if (grouping.length) {
fragment.appendChild(groupAsNode());
}
return fragment;
}
-
+
/**
* Convert a token stream to HTML. Matches open/close delimiter
* pairs and wraps their content in the appropriate HTML tags.
@@ -371,34 +204,17 @@ export class HopDown {
// Second pass: build HTML from paired tokens
let html = '';
for (const token of paired) {
- console.log(token);
- if (token.role == 'text') {
- html += escapeHtml(token.value);
- continue;
- }
-
- if (token.role == 'html') {
- html += token.value;
- continue;
- }
-
- if (token.role == 'link') {
- html += this.tags.getByElementName('A')!.toHTML(token, this.inlineToHTML) || token.value;
- continue;
- }
-
if (token.tag) {
html += token.tag.toHTML(token, this.inlineToHTML);
continue;
}
-
html += escapeHtml(token.value);
}
return html;
}
/**
- * Matchn/close delimiter pairs in a token stream. Unmatched
+ * Match open/close delimiter pairs in a token stream. Unmatched
* openers/closers are converted to text tokens so they render
* as literal characters.
*/
@@ -413,15 +229,11 @@ export class HopDown {
for (let index = 0; index < result.length; index++) {
const token = result[index];
- if (token.role === 'block') {
- token.role = openDelimiters.has(token.sequence!) ? 'close' : 'open';
- }
-
if (token.role === 'open') {
// Don't open a delimiter that's already open (prevents nesting)
if (openDelimiters.has(token.sequence!)) {
result[index] = {
- role: 'text',
+ role: 'block',
value: token.value,
};
continue;
@@ -441,7 +253,7 @@ export class HopDown {
}
if (!matched) {
result[index] = {
- role: 'text',
+ role: 'block',
value: token.value,
};
}
diff --git a/src/ts/tags.ts b/src/ts/tags.ts
deleted file mode 100644
index 2f4219a..0000000
--- a/src/ts/tags.ts
+++ /dev/null
@@ -1,784 +0,0 @@
-import type { Token, TagType, TagCollectionType, MatchContext, SourceToken, ListItem, ListResult} from './types';
-import { escapeHtml } from "./hopdown";
-
-
-/**
- * Patterns shared across multiple tag classes. Defined once here
- * so the regexes aren't duplicated and can be referenced by name.
- */
-const blankLine = /^\s*$/;
-const unorderedMarker = /^[*\-]\s/;
-const orderedMarker = /^\d+\.\s/;
-const escapeRegex = /[.*+?^${}()|[\]\\]/g;
-const tableSeparator = new RegExp(
- '^'
- + '\\|?' // optional leading pipe
- + '\\s*:?-+:?\\s*' // first column separator (optional alignment colons)
- + '('
- + '\\|' // pipe between columns
- + '\\s*:?-+:?\\s*' // subsequent column separator
- + ')*' // zero or more additional columns
- + '\\|?' // optional trailing pipe
- + '\\s*$' // end of line
- );
-
-
-export function childNodesToMarkdown(element: HTMLElement, callback: Function): string {
- return Array.from(element.childNodes)
- .map(child => callback(child))
- .join('');
-}
-
-
-export class Tag implements TagType {
- element: string[] = [''];
- opener: string = '';
- closer: string = '';
- delimiter: string | null = null;
- precedence: number = 50;
- isBlock: boolean = false;
-
- toHTML(token: any, callback: Function): string {
- if (token.role == 'open') {
- return this.element.map(el => { return `<${el.toUpperCase()}>` }).join('');
- }
- if (token.role == 'close') {
- const rev = this.element;
- rev.reverse();
- return rev.map(el => { return `${el.toUpperCase()}>` }).join('');
- }
- return token.value;
- };
-
- toMarkdown(element: HTMLElement, callback: Function): string {
- const children = childNodesToMarkdown(element, callback);
- return `${this.delimiter}${children || element.textContent || ''}${this.delimiter}`;
- };
-
- toEditorNode(token: Token, callback: Function): Node {
- if (token.role == 'open' || token.role == 'close') {
- const span = document.createElement('span');
- span.classList.add('delim');
- for (const el of this.element) {
- span.classList.add(el.toLowerCase());
- };
-
- if (this.isBlock) {
- span.textContent = token.role == 'open' ? this.opener : this.closer;
- } else {
- span.textContent = this.delimiter;
- }
- return span;
- }
- return document.createTextNode(token.value);
- };
-
-}
-
-export class TagCollection implements TagCollectionType {
-
- tags: Record = {};
- ordered: TagType[];
- openers: RegExp[];
-
- constructor(tags: TagType[]) {
- for (const tag of tags) {
- if (tag.constructor.name in this.tags) {
- throw new Error(`Duplicate tag instances found for class ${tag.constructor.name}! Do you need to subclass it?`);
- }
- this.tags[tag.constructor.name] = tag;
- }
- this.ordered = tags.sort((a, b) => {
- const p1 = a.precedence - (a.isBlock ? 100 : 0);
- const p2 = b.precedence - (b.isBlock ? 100 : 0);
- return p1 < p2 ? -1 : 1;
- });
- this.validate();
-
- const escapeRegex = /[.*+?^${}()|[\]\\]/g;
- this.openers = this.ordered.filter(t => { return t.delimiter !== null }).map(tag => {
- const escaped = tag.delimiter!.replace(escapeRegex, '\\$&');
- const escapedChar = tag.delimiter![0].replace(escapeRegex, '\\$&');
- return new RegExp(
- `(? { return !t.delimiter });
- for (let outer = 0; outer < inline.length; outer++) {
- for (let inner = outer + 1; inner < inline.length; inner++) {
- const first = this.ordered[outer];
- const second = this.ordered[inner];
- if (!(first.delimiter && second.delimiter)) {
- continue;
- }
- const firstIsPrefix = second.delimiter.startsWith(first.delimiter);
- const secondIsPrefix = first.delimiter.startsWith(second.delimiter);
- if (!firstIsPrefix && !secondIsPrefix) {
- continue;
- }
- const longer = first.delimiter.length > second.delimiter.length ? first : second;
- const shorter = first.delimiter.length > second.delimiter.length ? second : first;
- if (longer.precedence >= shorter.precedence) {
- throw new Error(
- `Tag "${longer.constructor.name}" (delimiter "${longer.delimiter}") must have ` +
- `lower precedence than "${shorter.constructor.name}" (delimiter "${shorter.delimiter}") ` +
- `because its delimiter is a prefix match. ` +
- `Got ${longer.constructor.name}=${longer.precedence}, ${shorter.constructor.name}=${shorter.precedence}.`
- );
- }
- }
- }
- }
-
- getByElementName(element: string): TagType | undefined {
- const el = element.toUpperCase();
- return this.ordered.filter(t => {
- return [...t.element, ...t.aliases ?? []].includes(el);
- })[0] || undefined;
- }
-}
-
-export class Bold extends Tag {
- element = ['STRONG'];
- aliases = ['B'];
- delimiter = '**';
- shortcut = 'Ctrl+B';
- precedence = 40;
-
-}
-
-export class Italic extends Tag {
- element = ['EM'];
- aliases = ['I'];
- delimiter = '*';
- shortcut = 'Ctrl+I';
- precedence = 30;
-}
-
-export class BoldItalic extends Tag {
- element = ['STRONG', 'EM'];
- delimiter = '***';
- precedence = 50;
-}
-
-export class Strikethrough extends Tag {
- element = ['DEL'];
- aliases = ['S', 'STRIKE'];
- delimiter = '~~';
-}
-
-export class Code extends Tag {
- element = ['CODE'];
- delimiter = '`';
-}
-
-export class Anchor extends Tag {
- element = ['A'];
-
- toHTML(token: Token, callback: Function) {
- const titleAttr = token.title
- ? ` TITLE="${escapeHtml(token.title)}"`
- : '';
- return `${escapeHtml(token.value)} `;
- }
-
- toMarkdown(element: HTMLElement, callback: Function) {
- const href = element.getAttribute('href') || '';
- const title = element.getAttribute('title');
- const titlePart = title ? ` "${title}"` : '';
- return '[' + element.innerText + '](' + href + titlePart + ')';
- }
-
- toEditorNode(token: Token, callback: Function) {
- return document.createElement('A');
- }
-}
-
-class BlockTag extends Tag {
- isBlock: boolean = true;
- delimiter = null;
-
- toMarkdown(element: HTMLElement, callback: Function): string {
- const children = childNodesToMarkdown(element, callback);
- const ret = `${this.opener || ''}${children || element.textContent || ''}${this.closer || ''}`;
- return ret;
- };
-
- match(context: MatchContext, tags?: TagCollectionType): SourceToken | null {
- return null;
- }
-}
-
-export class HardBreak extends BlockTag {
- match(context: MatchContext) {
- return null;
- }
- toHTML(): string { return ' ' }
- toMarkdown(): string { return ' \n' }
-}
-
-
-
-/**
- * Fenced code blocks: lines between ``` delimiters become .
- *
- * converter.toHTML('```js\nlet x = 1;\n```')
- * // let x = 1;
- */
-export class FencedCode extends BlockTag {
- opener = '```';
- closer = '```';
-
- element = ['PRE'];
- button = {
- show: true,
- label: 'Code Block',
- shortcut: 'Ctrl+Shift+E',
- };
- template = '```\ncode\n```';
- replaceSelection = true;
-
- match(context: MatchContext): SourceToken | null {
- // Accepts both ``` and ~~~ as fence delimiters
- const fencePattern = /^(?`{3,}|~{3,})(?.*)/;
- const matched = context.lines[context.index].match(fencePattern);
- if (!matched?.groups) {
- return null;
- }
- const fence = matched.groups.fence;
- const lang = matched.groups.lang.trim();
- const code: string[] = [];
- let lineIndex = context.index + 1;
- while (lineIndex < context.lines.length && !context.lines[lineIndex].startsWith(fence)) {
- code.push(context.lines[lineIndex++]);
- }
- return {
- opener: fence + lang,
- closer: '',
- content: code.join('\n'),
- raw: '',
- consumed: lineIndex + 1 - context.index,
- meta: { lang },
- };
- }
-
- toHTML(token: SourceToken, callback: Function): string {
- const langAttr = token.meta?.lang
- ? ` class="language-${escapeHtml(token.meta.lang)}"`
- : '';
- return `${escapeHtml(token.content)} `;
- }
-
- toMarkdown(element: HTMLElement, callback: Function): string {
- const lang = element.className.match(/language-(\S+)/)?.[1] || '';
- const content = element?.textContent || '';
- return '\n\n' + this.opener + `${lang}\n${content}\n` + this.closer + '\n\n' ;
- }
-
-}
-
-/**
- * Three or more *, -, or _ on a line become .
- *
- * converter.toHTML('---') // ' '
- */
-export class HorizontalRule extends BlockTag {
- element = ['HR'];
- button = {
- show: true,
- label: 'Divider',
- shortcut: 'Ctrl+Shift+-',
- };
- template = '---';
- replaceSelection = false;
- pattern = /^(\*{3,}|-{3,}|_{3,})\s*$/;
-
- match(context: MatchContext): SourceToken | null {
- const matched = context.lines[context.index].match(this.pattern);
- if (!matched) {
- return null;
- }
- return {
- opener: '',
- closer: '',
- content: '',
- raw: '',
- consumed: 1,
- };
- }
-
- toHTML(token: SourceToken, callback: Function): string {
- return `<${this.element[0].toUpperCase()}>`;
- }
-
- toMarkdown(element: HTMLElement, callback: Function): string {
- return '\n***\n';
- }
-}
-
-export class Heading extends BlockTag {
- element = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
-
- match(context: MatchContext): SourceToken | null {
- // ATX heading: # through ######, optional closing #s
- const atxPattern = /^(?#{1,6})\s+(?.*?)(?:\s+#+\s*)?$/;
- const matched = context.lines[context.index].match(atxPattern);
- if (matched?.groups) {
- return {
- opener: matched.groups.hashes + ' ',
- content: matched.groups.content.trim(),
- closer: '',
- raw: '',
- consumed: 1,
- meta: { level: String(matched.groups.hashes.length) },
- };
- }
-
- if (context.index + 1 < context.lines.length) {
- const nextLine = context.lines[context.index + 1];
- const setextUnderline = /^(?=+|-+)\s*$/;
- const underlineMatch = nextLine.match(setextUnderline);
- if (underlineMatch?.groups) {
- const content = context.lines[context.index].trim();
- if (content.length > 0) {
- const level = underlineMatch.groups.marker[0] === '=' ? '1' : '2';
- return {
- opener: '',
- closer: '',
- content: content,
- raw: '',
- consumed: 2,
- meta: { level },
- };
- }
- }
- }
-
- return null;
- }
-
- toHTML(token: SourceToken, callback: Function): string {
- const level = token.meta!.level;
- const id = this.anchorId(token.content);
- return `${callback(token.content)} `;
- }
-
- toMarkdown(element: HTMLElement, callback: Function): string {
- const inner = childNodesToMarkdown(element, callback)
- const level = parseInt(element.nodeName[1]);
- return '\n\n' + '#'.repeat(level) + ' ' + inner + '\n\n';
- }
-
-
- /**
- * Generate a PascalCase anchor ID from heading text so that
- * in-page links like #MyHeading work without manual IDs.
- */
-
- private anchorId(text: string): string {
- return text.replace(escapeRegex, ' ').trim().split(/\s+/).map(word =>
- word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
- ).join('_');
- }
-
-}
-
-/**
- * Lines prefixed with > become blockquotes. Consecutive > lines
- * are merged into a single blockquote.
- *
- * converter.toHTML('> hello\n> world')
- * // hello\nworld
- */
-export class Blockquote extends BlockTag {
- element = ['BLOCKQUOTE'];
- hidden = false;
- template = '> Quote\n> continues here';
-
- match(context: MatchContext): SourceToken | null {
- const quotePrefix = /^>\s?/;
- if (!quotePrefix.test(context.lines[context.index])) {
- return null;
- }
- const lines: string[] = [];
- let lineIndex = context.index;
- while (lineIndex < context.lines.length && quotePrefix.test(context.lines[lineIndex])) {
- lines.push(context.lines[lineIndex++].replace(quotePrefix, ''));
- }
- return {
- opener: '> ',
- closer: '',
- content: lines.join('\n'),
- raw: '',
- consumed: lineIndex - context.index,
- };
- }
-
- toHTML(token: SourceToken, callback: Function): string {
- const inner = callback(token.content);
- const breaks = inner.replace(/([^\n])\n(?!\n)/g, '$1 ')
- .replace(/\n\n(.+?)(\n\n|$)/g, '$1
');
- return '' + breaks + ' ';
- }
-
- toMarkdown(element: HTMLElement, callback: Function): string {
- // Each inside the blockquote is a paragraph group.
- // Within a
, elements create line breaks.
- // Separate paragraphs get a blank > line between them.
- const paragraphs: string[] = [];
- for (const child of Array.from(element.childNodes)) {
- const el = child as HTMLElement;
- if (child.nodeType === 1 && el.tagName === 'P') {
- const lines = this.paragraphToLines(el, callback);
- paragraphs.push(">");
- paragraphs.push(lines.map(line => '> ' + line).join('\n'));
- } else {
- const text = callback(child).trim();
- if (text) {
- paragraphs.push('> ' + text);
- }
- }
- }
- return '\n\n' + paragraphs.join('\n') + '\n\n';
- }
-
- /**
- * Split a paragraph's content into lines at elements.
- */
- private paragraphToLines(paragraph: HTMLElement, callback: Function): string[] {
- const lines: string[] = [];
- let currentLine = '';
- for (const child of Array.from(paragraph.childNodes)) {
- if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
- lines.push(currentLine);
- currentLine = '';
- } else {
- currentLine += callback(child);
- }
- }
- if (currentLine.trim()) {
- lines.push(currentLine);
- }
- return lines;
- }
-
-}
-
-/**
- * Ordered and unordered lists with arbitrary nesting. Indentation
- * determines depth; mixed list types (ul inside ol) are supported.
- *
- * converter.toHTML('- one\n- two\n 1. nested')
- */
-export class UnorderedList extends BlockTag {
- element = ['UL'];
- hidden = false;
-
- match(context: MatchContext): SourceToken | null {
- const line = context.lines[context.index];
- const prefixPattern = /^((?:[*\-]|\d+\.)\s)/;
- const matched = line.match(prefixPattern);
- if (!matched) {
- return null;
- }
-
- // Count how many lines this list consumes so HopDown can
- // advance past them. The actual parsing happens in toHTML
- // where we have access to the inline converter.
- const consumed = this.countListLines(context.lines, context.index);
- return {
- opener: matched[1],
- closer: '',
- content: '',
- raw: '',
- consumed,
- meta: {
- startIndex: String(context.index),
- lines: JSON.stringify(context.lines.slice(context.index, context.index + consumed)),
- },
- };
- }
-
- toHTML(token: SourceToken, callback: Function): string {
- const lines = JSON.parse(token.meta!.lines) as string[];
- const result = this.parseBlock(lines, 0, 0, callback);
- return result.html;
- }
-
- toMarkdown(element: HTMLElement, callback: Function): string {
- return this.nodeToMarkdown(element, 0, callback);
- }
-
- private countListLines(lines: string[], start: number): number {
- const indentedUnordered = /^(\s*)[*\-+]\s/;
- const indentedOrdered = /^(\s*)\d+\.\s/;
- let lineIndex = start;
- while (lineIndex < lines.length) {
- const line = lines[lineIndex];
- if (blankLine.test(line)) {
- break;
- }
- if (!indentedUnordered.test(line) && !indentedOrdered.test(line)) {
- break;
- }
- lineIndex++;
- }
- return lineIndex - start;
- }
-
- /**
- * Recursively parse markdown list lines into HTML, handling nested
- * sublists at arbitrary depth and mixed list types (ul/ol).
- */
- private parseBlock(lines: string[], start: number, indent: number, callback: Function): ListResult {
- const prefix = new RegExp(
- '^'
- + ' '.repeat(indent) // indentation for this nesting level
- + '([\\*\\-+]|\\d+\\.)' // bullet or number marker
- + '\\s' // space after marker
- );
- const isOl = orderedMarker.test(lines[start].trim());
- const tag = isOl ? 'ol' : 'ul';
- const items: ListItem[] = [];
- let lineIndex = start;
-
- while (lineIndex < lines.length) {
- const line = lines[lineIndex];
- if (blankLine.test(line)) {
- break;
- }
- const leadingWhitespace = /^(\s*)/;
- const lineIndent = line.match(leadingWhitespace)![1].length;
- if (lineIndent < indent) {
- break;
- }
- if (lineIndent > indent) {
- const sub = this.parseBlock(lines, lineIndex, lineIndent, callback);
- items[items.length - 1].sub = sub.html;
- lineIndex = sub.end;
- continue;
- }
- if (!prefix.test(line)) {
- break;
- }
- items.push({
- text: line.replace(prefix, ''),
- sub: '',
- });
- lineIndex++;
- }
-
- const html = `<${tag}>`
- + items.map(item => '
' + callback(item.text) + item.sub + ' ').join('')
- + `${tag}>`;
- return {
- html,
- end: lineIndex,
- };
- }
-
- /**
- * Convert an HTML list back to markdown. Recurses into nested
- * sublists with 2-space indentation per depth level.
- */
- private nodeToMarkdown(node: HTMLElement, depth: number, callback: Function): string {
- const isOl = node.nodeName === 'OL';
- const indent = ' '.repeat(depth);
- const lines: string[] = [];
-
- Array.from(node.children).forEach((listItem, index) => {
- const marker = isOl ? (index + 1) + '. ' : '* ';
- const { text, sublist } = this.partitionListItem(listItem, depth, callback);
- lines.push(indent + marker + text.trim());
- if (sublist) {
- lines.push(sublist);
- }
- });
-
- const result = lines.join('\n');
- return depth === 0
- ? '\n\n' + result + '\n\n'
- : result;
- }
-
- private partitionListItem(listItem: Element, depth: number, callback: Function): { text: string; sublist: string } {
- let text = '';
- let sublist = '';
- for (const child of Array.from(listItem.childNodes)) {
- if (child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')) {
- sublist += this.nodeToMarkdown(child as HTMLElement, depth + 1, callback);
- } else {
- text += callback(child);
- }
- }
- return {
- text,
- sublist,
- };
- }
-}
-
-export class OrderedList extends UnorderedList {
- element = ['OL'];
-}
-
-/**
- * Pipe-delimited tables with optional column alignment.
- *
- * converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
- */
-export class Table extends BlockTag {
- element = ['TABLE'];
- hidden = false;
- template = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
- replaceSelection = false;
-
- match(context: MatchContext): SourceToken | null {
- const { lines, index } = context;
- if (lines[index].indexOf('|') === -1
- || index + 1 >= lines.length) {
- return null;
- }
- // Second line must be the separator row (e.g. |---|---|)
- if (!tableSeparator.test(lines[index + 1])) {
- return null;
- }
- const headers = this.parseRow(lines[index]);
- const aligns = this.parseAligns(lines[index + 1]);
- const rows: string[][] = [];
- let lineIndex = index + 2;
- while (lineIndex < lines.length && lines[lineIndex].indexOf('|') !== -1 && !blankLine.test(lines[lineIndex])) {
- rows.push(this.parseRow(lines[lineIndex++]));
- }
- return {
- content: '',
- opener: '',
- closer: '',
- raw: '',
- consumed: lineIndex - index,
- meta: {
- headers: JSON.stringify(headers),
- aligns: JSON.stringify(aligns),
- rows: JSON.stringify(rows),
- },
- };
- }
-
- toHTML(token: SourceToken, callback: Function): string {
- const headers: string[] = JSON.parse(token.meta!.headers);
- const aligns: (string | null)[] = JSON.parse(token.meta!.aligns);
- const rows: string[][] = JSON.parse(token.meta!.rows);
-
- const cell = (tag: string, text: string, index: number): string => {
- const align = aligns[index]
- ? ` class="align-${aligns[index]}"`
- : '';
- return `<${tag}${align}>${callback(text)}${tag}>`;
- };
-
- const head = ''
- + headers.map((text, column) => cell('th', text, column)).join('')
- + ' ';
- const body = rows.map(row =>
- '' + row.map((text, column) => cell('td', text, column)).join('') + ' '
- ).join('');
- return ``;
- }
-
- toMarkdown(element: HTMLElement, callback: Function): string {
- const rows = Array.from(element.querySelectorAll('tr'));
- if (!rows.length) {
- return '';
- }
- const headers = Array.from(rows[0].querySelectorAll('th,td'))
- .map(cell => callback(cell).trim());
-
- const separator = Array.from(element.querySelectorAll('th')).map( h => {
- return h.className == 'align-center' ? ':---:' :
- h.className == 'align-right' ? '---:' :
- '---';
- });
-
- const output = [
- '| ' + headers.join(' | ') + ' |',
- '| ' + separator.join(' | ') + ' |',
- ];
- for (const row of rows.slice(1)) {
- const cells = Array.from(row.querySelectorAll('td,th'))
- .map(cell => callback(cell).trim());
- output.push('| ' + cells.join(' | ') + ' |');
- }
- return '\n\n' + output.join('\n') + '\n\n';
- }
-
- private parseRow(line: string): string[] {
- return line
- .replace(/^\|/, '')
- .replace(/\|$/, '')
- .split('|')
- .map(cell => cell.trim());
- }
-
- private parseAligns(line: string): (string | null)[] {
- const centerAlign = /^:-+:$/;
- const rightAlign = /^-+:$/;
- return this.parseRow(line).map(cell => {
- if (centerAlign.test(cell)) {
- return 'center';
- }
- if (rightAlign.test(cell)) {
- return 'right';
- }
- return null;
- });
- }
-}
-
-export class Paragraph extends BlockTag {
- element = ['P'];
- opener = '';
- closer = '';
- precedence = -1;
-
- match(context: MatchContext, tags?: TagCollectionType): SourceToken | null {
- const collected: string[] = [];
- let lineIndex = context.index;
- while (lineIndex < context.lines.length) {
- const line = context.lines[lineIndex];
- const nextLine = lineIndex < context.lines.length ? context.lines[lineIndex + 1]: '';
- for (const pattern of [...tags!.openers, blankLine]) {
- if (pattern.test(line)) {
- break;
- }
- }
- // Table: current line has pipes and next line is a separator row
- if (line.indexOf('|') !== -1) {
- if (nextLine && tableSeparator.test(nextLine)) {
- break;
- }
- }
- collected.push(context.lines[lineIndex++]);
- }
- if (!collected.length) {
- return null;
- }
- const ret = {
- opener: '',
- closer: '',
- content: collected.join('\n'),
- raw: '',
- consumed: lineIndex - context.index,
- };
- return ret;
- }
-
- toHTML(token: SourceToken, callback: Function): string {
- return '' + callback(token.content) + '
';
- }
-}
diff --git a/src/ts/tags/anchor.ts b/src/ts/tags/anchor.ts
new file mode 100644
index 0000000..fbeddb4
--- /dev/null
+++ b/src/ts/tags/anchor.ts
@@ -0,0 +1,46 @@
+import { Tag } from "./base";
+import { escapeHtml } from "../hopdown";
+import type { Token, TokenRole } from '../types';
+
+
+export class Anchor extends Tag {
+ opener = ['['];
+ element = ['A'];
+ pattern = /(?\[(?[^\[\]]+)\]\((?[^\s)]+)(?:\s+"(?[^"]*)")?\))/;
+ autolink_pattern = /^(?(?[A-Za-z]+:\/\/[^\s>]+))/;
+ showDelimitersInEditorNodes = false;
+
+ tokenize(role: TokenRole, sequence: string, source: string): Token | null {
+ const match = (
+ this.pattern.exec(source) ||
+ this.autolink_pattern.exec(source)
+ );
+ if (match?.groups) {
+ const token: Token = {
+ 'role': 'block',
+ 'sequence': sequence,
+ 'value': match.groups.captured,
+ 'groups': match.groups,
+ 'tag': this
+ }
+ return token;
+ }
+ return null;
+ }
+
+ toHTML(token: Token, callback: Function) {
+ const title = escapeHtml(token.groups!.title || '');
+ const url = escapeHtml(token.groups!.url || '');
+ const text = escapeHtml(token.groups!.text || '') || url;
+ return `${text} `;
+ }
+
+ toMarkdown(element: HTMLElement, callback: Function) {
+ const href = element.getAttribute('href') || '';
+ const title = element.getAttribute('title');
+ const titlePart = title ? ` "${title}"` : '';
+ return '[' + element.innerText + '](' + href + titlePart + ')';
+ }
+}
+
+
diff --git a/src/ts/tags/base.ts b/src/ts/tags/base.ts
new file mode 100644
index 0000000..8e10cb2
--- /dev/null
+++ b/src/ts/tags/base.ts
@@ -0,0 +1,139 @@
+import type { Token, TokenRole, TagType, TagCollectionType } from '../types';
+
+export class Tag implements TagType {
+ element: string[] = [''];
+ opener: string[] = [''];
+ closer: string = '';
+ pattern: RegExp | null = null;
+ precedence: number = 50;
+ isBlock: boolean = false;
+ showDelimitersInEditorNodes: boolean = true;
+
+ childNodesToMarkdown(element: HTMLElement, callback: Function): string {
+ return Array.from(element.childNodes)
+ .map(child => callback(child))
+ .join('');
+ }
+
+ tokenize(role: TokenRole, sequence: string, source: string): Token | null {
+ if (this.pattern) {
+ const match = this.pattern!.exec(source);
+ if (!match?.groups) {
+ return null;
+ }
+ return {
+ 'role': 'block',
+ 'sequence': sequence,
+ 'value': match.groups.captured,
+ 'groups': match.groups,
+ 'tag': this
+ }
+ }
+ return {
+ role: role,
+ sequence: sequence,
+ value: sequence,
+ tag: this,
+ }
+ }
+
+ toHTML(token: Token, callback: Function): string {
+ if (token.role == 'open') {
+ return this.element.map(el => { return `<${el.toUpperCase()}>` }).join('');
+ }
+ if (token.role == 'close') {
+ const rev = this.element;
+ rev.reverse();
+ return rev.map(el => { return `${el.toUpperCase()}>` }).join('');
+ }
+ if (token.role == 'block') {
+ const res = callback(token.value);
+ return res.firstElementChild?.innerHTML || res;
+ }
+ return token.value;
+ };
+
+ toMarkdown(element: HTMLElement, callback: Function): string {
+ const children = this.childNodesToMarkdown(element, callback);
+ return `${this.opener[0]}${children || element.textContent || ''}${this.closer || this.opener[0]}`;
+ };
+
+ delimNodes(): [Node, Node] {
+ const opener = document.createElement('span');
+ opener.classList.add('delim');
+ for (const el of this.element) {
+ opener.classList.add(el.toLowerCase());
+ };
+ opener.textContent = this.opener[0];
+
+ const closer = opener.cloneNode(true);
+ closer.textContent = this.closer || this.opener[0];
+ return [opener, closer]
+ }
+
+ toEditorNode(token: Token, callback: Function): Node {
+ console.log(token);
+ if (['open', 'close', 'block'].indexOf(token.role) == -1) {
+ return document.createTextNode(token.value);
+ }
+ const [opener, closer] = this.delimNodes();
+ if (token.role == 'block') {
+ const div: HTMLElement = document.createElement('div');
+ div.innerHTML = this.toHTML(token, callback);
+ const el = div.firstChild!;
+ if (token.tag!.showDelimitersInEditorNodes) {
+ if (this.opener || this.closer) {
+ el.insertBefore(opener, el.firstChild);
+ el.appendChild(closer);
+ }
+ }
+ return el as Node;
+ }
+ if (token.role == 'close') {
+ return closer;
+ }
+ return opener;
+ };
+
+}
+
+export class TagCollection implements TagCollectionType {
+
+ tags: Record = {};
+ ordered: TagType[];
+ map: Map;
+ //openers: RegExp[];
+
+ constructor(tags: TagType[]) {
+ for (const tag of tags) {
+ if (tag.constructor.name in this.tags) {
+ throw new Error(`Duplicate tag instances found for class ${tag.constructor.name}! Do you need to subclass it?`);
+ }
+ this.tags[tag.constructor.name] = tag;
+ }
+ this.ordered = tags.sort((a, b) => {
+ const p1 = a.precedence - (a.isBlock ? 100 : 0);
+ const p2 = b.precedence - (b.isBlock ? 100 : 0);
+ return p1 < p2 ? -1 : 1;
+ });
+
+ this.map = new Map();
+ for (const tag of this.ordered) {
+ for (const key of tag.opener) {
+ if (!this.map.has(key)) {
+ this.map.set(key, []);
+ }
+ this.map.get(key)!.push(tag);
+ }
+ }
+ }
+
+ getByElementName(element: string): TagType | undefined {
+ const el = element.toUpperCase();
+ return this.ordered.filter(t => {
+ return [...t.element, ...t.aliases ?? []].includes(el);
+ })[0] || undefined;
+ }
+}
+
+
diff --git a/src/ts/tags/blockquote.ts b/src/ts/tags/blockquote.ts
new file mode 100644
index 0000000..f1bb47b
--- /dev/null
+++ b/src/ts/tags/blockquote.ts
@@ -0,0 +1,112 @@
+import { Tag } from "./base";
+import type { Token, TokenRole } from '../types';
+
+
+/**
+ * Lines prefixed with > become blockquotes. Consecutive > lines
+ * are merged into a single blockquote.
+ *
+ * converter.toHTML('> hello\n> world')
+ * // hello\nworld
+ */
+export class Blockquote extends Tag {
+ element = ['BLOCKQUOTE'];
+ opener = ['> '];
+ closer = '';
+ isBlock = true;
+
+ template = '> Quote\n> continues here';
+ pattern = /(?(?:\>\s+.*?(\n|$))+)$/m;
+
+ private childToEditorNode(node: Node, callback:Function): Node[] {
+ var nodes: Node[] = [];
+ for (const child of node.childNodes) {
+ if (child.nodeType == 11) {
+ nodes.concat(this.childToEditorNode(child, callback));
+ } else if (child.textContent) {
+ nodes.push(child);
+ }
+ }
+ return nodes;
+ };
+
+ /**
+ * Split a paragraph's content into lines at elements.
+ */
+ private paragraphToLines(paragraph: HTMLElement, callback: Function): string[] {
+ const lines: string[] = [];
+ let currentLine = '';
+ for (const child of Array.from(paragraph.childNodes)) {
+ if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
+ lines.push(currentLine);
+ currentLine = '';
+ } else {
+ currentLine += callback(child);
+ }
+ }
+ if (currentLine.trim()) {
+ lines.push(currentLine);
+ }
+ return lines;
+ }
+
+ toHTML(token: Token, callback: Function): string {
+ var html = `<${this.element[0].toLowerCase()}>`;
+ var inner = [];
+ var pFlag = false;
+ for (var line of token.value.split('\n')) {
+ var text = line.replace(/^>\s*/, '');
+ if (text) {
+ html += callback(text) + " ";
+ continue;
+ }
+ html += pFlag ? "" : "";
+ pFlag = !pFlag;
+ }
+ html = html.replace(/\ $/g, pFlag ? '
' : '');
+ html += `${this.element[0].toLowerCase()}>`;
+ return html;
+ }
+
+ toMarkdown(element: HTMLElement, callback: Function): string {
+ // Each inside the blockquote is a paragraph group.
+ // Within a
, elements create line breaks.
+ // Separate paragraphs get a blank > line between them.
+ const paragraphs: string[] = [];
+ for (const child of Array.from(element.childNodes)) {
+ const el = child as HTMLElement;
+ if (child.nodeType === 1 && el.tagName === 'P') {
+ const lines = this.paragraphToLines(el, callback);
+ paragraphs.push(this.opener[0]);
+ paragraphs.push(lines.map(line => '> ' + line).join('\n'));
+ } else {
+ const text = callback(child).trim();
+ if (text) {
+ paragraphs.push('> ' + text);
+ }
+ }
+ }
+ return '\n\n' + paragraphs.join('\n') + '\n\n';
+ }
+
+ toEditorNode(token: Token, callback: Function): Node {
+ const [opener, closer] = this.delimNodes();
+ opener.textContent = opener.textContent!.replaceAll(' ', '');
+ const bq: Node = document.createElement('blockquote');
+ for (const line of (token.value).split('\n')) {
+ if (line === '') {
+ continue;
+ }
+ const inner = callback(line.replace(/^>\s?/, ''));
+ const children: Node[] = this.childToEditorNode(inner, callback);
+ const p = document.createElement('P');
+ for (const c of children) {
+ p.innerHTML += (c as HTMLElement).innerHTML;
+ }
+ p.insertBefore(opener.cloneNode(true), p.firstChild);
+ bq.appendChild(p);
+ }
+ return bq;
+ }
+
+}
diff --git a/src/ts/tags/fenced.ts b/src/ts/tags/fenced.ts
new file mode 100644
index 0000000..41c82ca
--- /dev/null
+++ b/src/ts/tags/fenced.ts
@@ -0,0 +1,54 @@
+import { Tag } from "./base";
+import { escapeHtml } from "../hopdown";
+import type { Token, TokenRole } from '../types';
+
+
+/**
+ * Fenced code blocks: lines between ``` delimiters become
.
+ *
+ * converter.toHTML('```js\nlet x = 1;\n```')
+ * // let x = 1;
+ */
+export class FencedCode extends Tag {
+ opener = ['```'];
+ closer = '';
+ pattern = /(?^(?```)(?\w*)\s*\n(?(.+\n)+)\k)/m;
+ isBlock = true;
+
+ element = ['PRE'];
+ template = '```\n10 PRINT "HELLO"\n20 GOTO 10\n```';
+ replaceSelection = true;
+
+ toHTML(token: Token, callback: Function): string {
+ const fence = token.groups!.fence;
+ const lang = token.groups!.lang.trim();
+ var code = token.groups!.code.replace(/^\n|\n$/g, '');
+ code = `\n${code}\n`;
+ const langAttr = lang
+ ? ` class="language-${escapeHtml(lang)}"`
+ : '';
+ const val = token.value.slice(
+ token.groups!.fence.length + token.groups!.lang.length,
+ token.value.length - token.groups!.fence.length
+ );
+ return `${escapeHtml(val)} `;
+ }
+
+ toMarkdown(element: HTMLElement, callback: Function): string {
+ const lang = element.className.match(/language-(\S+)/)?.[1] || '';
+ const content = element?.textContent || '';
+ return '\n\n' + this.opener[0] + lang + content + this.opener[0] + '\n\n';
+ }
+
+ toEditorNode(token: Token, callback: Function): Node {
+ const div: HTMLElement = document.createElement('div');
+ div.innerHTML = this.toHTML(token, callback);
+ const [opener, closer] = this.delimNodes();
+ const el = div.firstChild;
+ if (el) {
+ el.insertBefore(opener, el.firstChild);
+ el.appendChild(closer);
+ }
+ return el as Node;
+ }
+}
diff --git a/src/ts/tags/hardbreak.ts b/src/ts/tags/hardbreak.ts
new file mode 100644
index 0000000..c17f315
--- /dev/null
+++ b/src/ts/tags/hardbreak.ts
@@ -0,0 +1,12 @@
+import { Tag } from "./base";
+
+
+export class HardBreak extends Tag {
+ element = ['BR'];
+ template = ' \n';
+ precedence = 20;
+ pattern = /^\s*$/;
+ isBlock = true;
+ toHTML(): string { return ' ' }
+ toMarkdown(): string { return ' \n' }
+}
diff --git a/src/ts/tags/heading.ts b/src/ts/tags/heading.ts
new file mode 100644
index 0000000..f2b5b1e
--- /dev/null
+++ b/src/ts/tags/heading.ts
@@ -0,0 +1,47 @@
+import { Tag } from "./base";
+import { escapeHtml } from "../hopdown";
+import type { Token, TokenRole } from '../types';
+
+
+export class Heading1 extends Tag {
+ element = ['H1'];
+ pattern = /^(?(?\#{1,6})\s+(?.*?$))/m;
+ escapePattern = /[.*+?^${}()|[\]\\]/g;
+ opener = ['#'];
+ closer = '';
+ isBlock = true;
+
+ toHTML(token: Token, callback: Function): string {
+ const level = token.groups!.hashes.length;
+ const id = this.anchorId(token.groups!.content);
+ return `${callback(token.groups!.content)} `;
+ }
+
+ toMarkdown(element: HTMLElement, callback: Function): string {
+ const inner = this.childNodesToMarkdown(element, callback)
+ const level = parseInt(element.nodeName[1]);
+ return '\n\n' + '#'.repeat(level) + ' ' + inner + '\n\n';
+ }
+
+ toEditorNode(token: Token, callback: Function): Node {
+ const content = token.groups!.content;
+ const id = this.anchorId(content);
+ const heading = document.createElement(this.element[0]);
+ heading.innerHTML = content;
+ heading.setAttribute('id', id);
+ return heading;
+ }
+
+ private anchorId(text: string): string {
+ return text.replace(this.escapePattern, ' ').trim().split(/\s+/).map(word =>
+ word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
+ ).join('_');
+ }
+
+}
+
+export class Heading2 extends Heading1 { element = ['H2']; opener = ['##'] };
+export class Heading3 extends Heading1 { element = ['H3']; opener = ['###'] };
+export class Heading4 extends Heading1 { element = ['H4']; opener = ['####'] };
+export class Heading5 extends Heading1 { element = ['H5']; opener = ['#####'] };
+export class Heading6 extends Heading1 { element = ['H6']; opener = ['######'] };
diff --git a/src/ts/tags/hr.ts b/src/ts/tags/hr.ts
new file mode 100644
index 0000000..2251567
--- /dev/null
+++ b/src/ts/tags/hr.ts
@@ -0,0 +1,29 @@
+import { Tag } from "./base";
+import type { Token, TokenRole } from '../types';
+
+/**
+ * Three or more *, -, or _ on a line become .
+ *
+ * converter.toHTML('---') // ' '
+ */
+export class HorizontalRule extends Tag {
+ element = ['HR'];
+ template = '***';
+ opener = ['***', '___', '---'];
+ isBlock = true;
+ replaceSelection = false;
+ pattern = /^(?(\*{3,}|-{3,}|_{3,}))\s*$/;
+ precedence = 20;
+
+ toHTML(token: Token, callback: Function): string {
+ return `<${this.element[0].toUpperCase()}>`;
+ }
+
+ toMarkdown(element: HTMLElement, callback: Function): string {
+ return '\n' + this.opener[0] + '\n';
+ }
+
+ toEditorNode(token: Token, callback: Function): Node {
+ return document.createElement('hr');
+ }
+}
diff --git a/src/ts/tags/index.ts b/src/ts/tags/index.ts
new file mode 100644
index 0000000..2ceccd4
--- /dev/null
+++ b/src/ts/tags/index.ts
@@ -0,0 +1,10 @@
+export * from "./base";
+export * from "./anchor";
+export * from "./blockquote";
+export * from "./fenced";
+export * from "./hardbreak";
+export * from "./heading";
+export * from "./hr";
+export * from "./inline";
+export * from "./list";
+export * from "./table";
diff --git a/src/ts/tags/inline.ts b/src/ts/tags/inline.ts
new file mode 100644
index 0000000..f02cf36
--- /dev/null
+++ b/src/ts/tags/inline.ts
@@ -0,0 +1,32 @@
+import { Tag } from "./base";
+
+export class Bold extends Tag {
+ element = ['STRONG'];
+ aliases = ['B'];
+ opener = ['**', '__'];
+ precedence = 40;
+}
+
+export class Italic extends Tag {
+ element = ['EM'];
+ aliases = ['I'];
+ opener = ['*', '_'];
+ precedence = 30;
+}
+
+export class BoldItalic extends Tag {
+ element = ['STRONG', 'EM'];
+ opener = ['***'];
+ precedence = 50;
+}
+
+export class Strikethrough extends Tag {
+ element = ['DEL'];
+ aliases = ['S', 'STRIKE'];
+ opener = ['~~'];
+}
+
+export class Code extends Tag {
+ element = ['CODE'];
+ opener = ['`'];
+}
diff --git a/src/ts/tags/list.ts b/src/ts/tags/list.ts
new file mode 100644
index 0000000..cdbe44f
--- /dev/null
+++ b/src/ts/tags/list.ts
@@ -0,0 +1,193 @@
+import { Tag } from "./base";
+import type { Token } from '../types';
+
+
+/**
+ * A single item in a parsed list, with optional nested sublist HTML.
+ */
+export interface ListItem {
+ text: string;
+ sub: string;
+}
+
+/**
+ * Result of parsing a list block: the generated HTML and the line
+ * index where the list ends (so the caller can advance past it).
+ */
+export interface ListResult {
+ html: string;
+ end: number;
+}
+
+
+const blankLine = /^\s*$/;
+
+/**
+ * Ordered and unordered lists with arbitrary nesting. Indentation
+ * determines depth; mixed list types (ul inside ol) are supported.
+ *
+ * converter.toHTML('- one\n- two\n 1. nested')
+ */
+class List extends Tag {
+ pattern = /(?(?^(?:\s{2,})?(?:[-+*]|\d+\.)\s.+?(?:\n|$))+)/m;
+ orderedOpenerPattern = /^\d+\.\s/;
+ isBlock = true;
+
+ toHTML(token: Token, callback: Function): string {
+ const result = this.parseBlock(token.groups!.captured.split("\n"), 0, 0, callback);
+ return result.html;
+ }
+
+ toMarkdown(element: HTMLElement, callback: Function): string {
+ return this.nodeToMarkdown(element, 0, callback);
+ }
+
+ toEditorNode(token: Token, callback: Function): Node {
+ const matched = token.value!.match(this.pattern);
+ if (matched === null) {
+ throw new Error(`Token value does not match pattern ${this.pattern}: '${token.value}'`);
+ }
+ const parsed = this.parseBlock(token.value.split('\n'), 0, 0, callback);
+ const div: HTMLElement = document.createElement('div');
+ div.innerHTML = parsed.html;
+ return div.firstChild as Node;
+ }
+
+ private childToEditorNode(node: Node, callback:Function): Node[] {
+ var nodes: Node[] = [];
+ for (const child of node.childNodes) {
+ if (child.nodeType == 11) {
+ nodes.concat(this.childToEditorNode(child, callback));
+ } else if (child.textContent) {
+ nodes.push(child);
+ }
+ }
+ return nodes;
+ };
+
+ private countListLines(lines: string[], start: number): number {
+ const indentedUnordered = /^(\s*)[*\-+]\s/;
+ const indentedOrdered = /^(\s*)\d+\.\s/;
+ let lineIndex = start;
+ while (lineIndex < lines.length) {
+ const line = lines[lineIndex];
+ if (blankLine.test(line)) {
+ break;
+ }
+ if (!indentedUnordered.test(line) && !indentedOrdered.test(line)) {
+ break;
+ }
+ lineIndex++;
+ }
+ return lineIndex - start;
+ }
+
+ /**
+ * Recursively parse markdown list lines into HTML, handling nested
+ * sublists at arbitrary depth and mixed list types (ul/ol).
+ */
+ private parseBlock(lines: string[], start: number = 0, indent: number = 0, callback: Function): ListResult {
+ const prefix = new RegExp(
+ '^'
+ + ' '.repeat(indent) // indentation for this nesting level
+ + '([\\*\\-+]|\\d+\\.)' // bullet or number marker
+ + '\\s' // space after marker
+ );
+ const isOl = this.orderedOpenerPattern.test(lines[start].trim());
+ const tag = isOl ? 'ol' : 'ul';
+
+ const items: ListItem[] = [];
+ let lineIndex = start;
+
+ while (lineIndex < lines.length) {
+ const line = lines[lineIndex];
+ if (blankLine.test(line)) {
+ break;
+ }
+ const leadingWhitespace = /^(\s*)/;
+ const lineIndent = line.match(leadingWhitespace)![1].length;
+ if (lineIndent < indent) {
+ break;
+ }
+ if (lineIndent > indent) {
+ const sub = this.parseBlock(lines, lineIndex, lineIndent, callback);
+ items[items.length - 1].sub = sub.html;
+ lineIndex = sub.end;
+ continue;
+ }
+ if (!prefix.test(line)) {
+ break;
+ }
+ items.push({
+ text: line.replace(prefix, ''),
+ sub: '',
+ });
+ lineIndex++;
+ }
+
+ var html = `<${tag}>`;
+ for (var item of items) {
+ const res = callback(item.text);
+ html += '' + (res.firstElementChild?.innerHTML || res) + item.sub + ' ';
+ }
+ html += `${tag}>`;
+ return {
+ html,
+ end: lineIndex,
+ };
+ }
+
+ /**
+ * Convert an HTML list back to markdown. Recurses into nested
+ * sublists with 2-space indentation per depth level.
+ */
+ private nodeToMarkdown(node: HTMLElement, depth: number, callback: Function): string {
+ const isOl = node.nodeName === 'OL';
+ const indent = ' '.repeat(depth);
+ const lines: string[] = [];
+
+ Array.from(node.children).forEach((listItem, index) => {
+ const marker = isOl ? (index + 1) + '. ' : '* ';
+ const { text, sublist } = this.partitionListItem(listItem, depth, callback);
+ lines.push(indent + marker + text.trim());
+ if (sublist) {
+ lines.push(sublist);
+ }
+ });
+
+ const result = lines.join('\n');
+ return depth === 0
+ ? '\n\n' + result + '\n\n'
+ : result;
+ }
+
+ private partitionListItem(listItem: Element, depth: number, callback: Function): { text: string; sublist: string } {
+ let text = '';
+ let sublist = '';
+ for (const child of Array.from(listItem.childNodes)) {
+ if (child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')) {
+ sublist += this.nodeToMarkdown(child as HTMLElement, depth + 1, callback);
+ } else {
+ text += callback(child);
+ }
+ }
+ return {
+ text,
+ sublist,
+ };
+ }
+}
+
+export class UnorderedList extends List {
+ element = ['UL'];
+ opener = ['*', '-'];
+ template = '* list\n* items \n* here';
+}
+
+export class OrderedList extends List {
+ element = ['OL'];
+ opener = ['1.'];
+ template = '1. list\n1. items \n1. here';
+}
+
+
diff --git a/src/ts/tags/table.ts b/src/ts/tags/table.ts
new file mode 100644
index 0000000..403c959
--- /dev/null
+++ b/src/ts/tags/table.ts
@@ -0,0 +1,126 @@
+import { Tag } from "./base";
+import { escapeHtml } from "../hopdown";
+import type { Token, TokenRole } from '../types';
+
+const blankLine = /^\s*$/;
+
+/**
+ * Pipe-delimited tables with optional column alignment.
+ *
+ * converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
+ */
+export class Table extends Tag {
+ opener = ['|'];
+ element = ['TABLE'];
+ template = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
+ replaceSelection = false;
+ pattern = /(?(?:\|.+?\|\s*(?:\n|$))+)/m;
+
+ tableSeparatorPattern = new RegExp(
+ '^'
+ + '\\|?' // optional leading pipe
+ + '\\s*:?-+:?\\s*' // first column separator (optional alignment colons)
+ + '('
+ + '\\|' // pipe between columns
+ + '\\s*:?-+:?\\s*' // subsequent column separator
+ + ')*' // zero or more additional columns
+ + '\\|?' // optional trailing pipe
+ + '\\s*$' // end of line
+ );
+
+ isBlock = true;
+ showDelimitersInEditorNodes = false;
+
+ parseBlock(token: Token): Record | null {
+ const lines = token.groups!.captured.split("\n");
+
+ // Second line must be the separator row (e.g. |---|---|)
+ if (!this.tableSeparatorPattern.test(lines[1])) {
+ return null;
+ }
+
+ const headers = this.parseRow(lines[0]);
+ const aligns = this.parseAligns(lines[1]);
+ const rows: string[][] = [];
+ let lineIndex = 2;
+ while (lineIndex < lines.length && lines[lineIndex].indexOf('|') !== -1 && !blankLine.test(lines[lineIndex])) {
+ rows.push(this.parseRow(lines[lineIndex++]));
+ }
+ return {
+ headers: headers,
+ aligns: aligns,
+ rows: rows
+ };
+ }
+
+ toHTML(token: Token, callback: Function): string {
+
+ const block = this.parseBlock(token);
+ if (!block) {
+ throw new Error(`Could not parse table token! ${token.value}`);
+ }
+ const cell = (tag: string, text: string, index: number): string => {
+ const align = block.aligns[index]
+ ? ` class="align-${block.aligns[index]}"`
+ : '';
+ const res = callback(text);
+ return `<${tag}${align}>` + (res.firstElementChild?.innerHTML || res) + `${tag}>`;
+ };
+
+ const head = ''
+ + block.headers.map((text: string, column: number) => cell('th', text, column)).join('')
+ + ' ';
+ const body = block.rows.map((row: string[]) =>
+ '' + row.map((text: string, column: number) => cell('td', text, column)).join('') + ' '
+ ).join('');
+ return ``;
+ }
+
+ toMarkdown(element: HTMLElement, callback: Function): string {
+ const rows = Array.from(element.querySelectorAll('tr'));
+ if (!rows.length) {
+ return '';
+ }
+ const headers = Array.from(rows[0].querySelectorAll('th,td'))
+ .map(cell => callback(cell).trim());
+
+ const separator = Array.from(element.querySelectorAll('th')).map( h => {
+ return h.className == 'align-center' ? ':---:' :
+ h.className == 'align-right' ? '---:' :
+ '---';
+ });
+
+ const output = [
+ '| ' + headers.join(' | ') + ' |',
+ '| ' + separator.join(' | ') + ' |',
+ ];
+ for (const row of rows.slice(1)) {
+ const cells = Array.from(row.querySelectorAll('td,th'))
+ .map(cell => callback(cell).trim());
+ output.push('| ' + cells.join(' | ') + ' |');
+ }
+ return '\n\n' + output.join('\n') + '\n\n';
+ }
+
+ private parseRow(line: string): string[] {
+ return line
+ .replace(/^\|/, '')
+ .replace(/\|$/, '')
+ .split('|')
+ .map(cell => cell.trim());
+ }
+
+ private parseAligns(line: string): (string | null)[] {
+ const centerAlign = /^:-+:$/;
+ const rightAlign = /^-+:$/;
+ return this.parseRow(line).map(cell => {
+ if (centerAlign.test(cell)) {
+ return 'center';
+ }
+ if (rightAlign.test(cell)) {
+ return 'right';
+ }
+ return null;
+ });
+ }
+}
diff --git a/src/ts/tokenizer.ts b/src/ts/tokenizer.ts
index 380cad5..c22d8e3 100644
--- a/src/ts/tokenizer.ts
+++ b/src/ts/tokenizer.ts
@@ -16,7 +16,7 @@
* distinguishes structural markers from literal content, which
* is the key insight that makes round-trip escaping correct.
*/
-import type { Token, TagType, TagCollectionType } from "./types";
+import type { Token, TokenRole, TagType, TagCollectionType } from "./types";
/**
* Characters that count as punctuation for flanking delimiter rules.
@@ -58,10 +58,10 @@ const NAMED_ENTITIES: Record = {
* const tokens = tokenizer.tokenize('**bold**');
*/
export class Tokenizer {
- private tags: TagType[];
+ private tags: TagCollectionType;
constructor(tags: TagCollectionType) {
- this.tags = tags.ordered;
+ this.tags = tags;
}
/**
@@ -72,13 +72,13 @@ export class Tokenizer {
*/
tokenize(source: string): Token[] {
const tokens: Token[] = [];
- let position = 0;
- let textBuffer = '';
+ var position: number = 0;
+ var textBuffer: string = '';
const flushText = () => {
if (textBuffer.length > 0) {
tokens.push({
- role: 'text',
+ role: 'block',
value: textBuffer,
});
textBuffer = '';
@@ -103,7 +103,7 @@ export class Tokenizer {
const spaceMatch = remaining.match(/^(? {2,})\n/);
if (spaceMatch?.groups) {
flushText();
- tokens.push({ role: 'break', value: ' ' });
+ tokens.push({ role: 'block', value: ' ' });
position += spaceMatch[0].length;
continue;
}
@@ -119,69 +119,20 @@ export class Tokenizer {
}
}
- // Link: [text](url) or [text](url "title")
- if (source[position] === '[') {
- const link = this.matchLink(source, position);
- if (link) {
- flushText();
- tokens.push({
- role: 'link',
- value: link.text,
- href: link.href,
- title: link.title,
- });
- position += link.length;
- continue;
- }
- }
-
- // Autolink: and/or HTML
- if (source[position] === '<') {
- const autolink = this.matchAutolink(remaining);
- if (autolink) {
- flushText();
- tokens.push({
- role: 'link',
- value: autolink.url,
- href: autolink.url,
- tag: this.tags.filter(t => { return t.constructor.name == 'Anchor' })[0]
- });
- position += autolink.length;
- continue;
- }
- // HTML tag passthrough
- const htmlTagMatch = this.matchHtmlTag(remaining);
- if (htmlTagMatch) {
- flushText();
- tokens.push({
- role: 'html',
- value: htmlTagMatch.tag,
- });
- position += htmlTagMatch.length;
- continue;
- }
- }
-
- // autolink bare URLs
- const bareUrl = this.matchBareUrl(remaining);
- if (bareUrl) {
+ var match = this.matchTag(source, position);
+ if (match && match.value?.length) {
flushText();
- tokens.push({
- role: 'link',
- value: bareUrl.url,
- href: bareUrl.url,
- tag: this.tags.filter(t => { return t.constructor.name == 'Anchor' })[0]
- });
- position += bareUrl.length;
+ tokens.push(match);
+ position += match.value?.length;
continue;
}
- // Delimiter: check each registered delimiter
- const match = this.matchTag(source, position);
- if (match && match.sequence?.length) {
+ const anchor = this.tags.getByElementName('A');
+ match = anchor!.tokenize('block', remaining[0], remaining);
+ if (match && match.value?.length) {
flushText();
tokens.push(match);
- position += match.sequence?.length;
+ position += match.value?.length;
continue;
}
@@ -341,14 +292,24 @@ export class Tokenizer {
return null;
}
sequence = sequence.repeat(runLength);
+ var matches = this.tags.map.get(sequence);
- // get the tag that matches this sequence
- for (const tag of this.tags) {
- const delim = tag.isBlock ? tag.opener : tag.delimiter;
- if (delim !== sequence) {
- continue;
+ if (!matches && runLength == 1) {
+ sequence = source.slice(position, 3);
+ for (var i=0; i<2; i++) {
+ sequence = sequence.slice(0, sequence.length - 1);
+ matches = this.tags.map.get(sequence);
+ if (matches) {
+ break;
+ }
}
+ }
+ if (!matches || !sequence) {
+ return null;
+ }
+
+ for (const tag of matches) {
const charBefore = position > 0 ? source[position - 1] : '\n';
const charAfter = source[position + sequence.length];
@@ -357,13 +318,14 @@ export class Tokenizer {
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
-
- return {
- role: leftFlanking ? 'open' : rightFlanking ? 'close' : 'block',
- sequence: sequence,
- value: sequence,
- tag: tag,
- };
+
+ const role: TokenRole = leftFlanking ? 'open' : rightFlanking ? 'close' : 'block';
+
+ var token: Token | null = tag.tokenize(role, sequence, source);
+
+ if (token) {
+ return token;
+ }
};
return null;
}
diff --git a/src/ts/types.ts b/src/ts/types.ts
index a9650e0..82d1044 100644
--- a/src/ts/types.ts
+++ b/src/ts/types.ts
@@ -1,16 +1,20 @@
+export type TokenRole = 'open' | 'close' | 'block';
+
export interface Token {
- role: 'text' | 'open' | 'close' | 'link' | 'html' | 'break' | 'block';
+ role: TokenRole;
value: string;
- // for tokens corresponding to tags;;
- tag?: TagType;
sequence?: string;
+ tag?: TagType;
+
+ groups?: { [key: string]: string; };
/** For link tokens: the href and optional title. */
href?: string;
title?: string;
-}
+ meta?: Record;
+}
/**
* A Tag is the core abstraction: it knows how to match markdown syntax,
@@ -20,70 +24,25 @@ export interface Token {
*/
export interface TagType {
element: string[];
+ opener: string[];
+ closer: string | null;
+ precedence: number;
+ isBlock: boolean;
+ template?: string;
+ pattern?: RegExp | null;
+ showDelimitersInEditorNodes?: boolean;
aliases?: string[];
- delimiter: string | null;
- opener: string | null;
- closer: string | null;
-
- match?(context: MatchContext, tags?: TagCollectionType): SourceToken | null;
-
- precedence: number;
-
- toHTML: (token: any, callback: Function) => string;
+ tokenize(role: TokenRole, sequence: string, value: string): Token | null;
+ toHTML: (token: Token, callback: Function) => string;
toMarkdown: (element: HTMLElement, callback: Function) => string;
toEditorNode: (token: Token, callback: Function) => Node | null;
-
- isBlock: boolean;
-
- hidden?: boolean;
- template?: string;
- shortcut?: string;
}
export interface TagCollectionType {
tags: Record;
ordered: TagType[];
+ map: Map;
getByElementName(element: string): TagType | undefined;
- openers: RegExp[];
-}
-
-
-/**
- * Context passed to Tag.match() during block-level scanning.
- * `lines` and `index` are for block matching; `text` and `offset`
- * are for inline matching within a single line.
- */
-export interface MatchContext {
- lines: string[];
- index: number;
- text: string;
- offset: number;
-}
-
-export interface SourceToken {
- opener: string,
- content: string;
- closer: string,
- raw: string;
- consumed: number;
- meta?: Record;
-}
-
-/**
- * A single item in a parsed list, with optional nested sublist HTML.
- */
-export interface ListItem {
- text: string;
- sub: string;
-}
-
-/**
- * Result of parsing a list block: the generated HTML and the line
- * index where the list ends (so the caller can advance past it).
- */
-export interface ListResult {
- html: string;
- end: number;
}
diff --git a/test/hopdown.test.ts b/test/hopdown.test.ts
index d3f8e0a..0e4c5ad 100644
--- a/test/hopdown.test.ts
+++ b/test/hopdown.test.ts
@@ -1,6 +1,6 @@
import { expect, test, suite, describe } from 'vitest'
-import { HopDown } from '../dist/lib';
+import { HopDown } from '../dist';
const hopdown = new HopDown();
@@ -8,7 +8,7 @@ const hopdown = new HopDown();
suite('Markdown to HTML Round-Trips', () => {
function assert(name: string, input: string, output: string, expected: string | undefined = undefined) {
- return test(name, () => {
+ return test(name, () => {
const html = hopdown.toHTML(input);
expect(html.toLowerCase()).toBe(output.toLowerCase());
expect(hopdown.toMarkdown(html)).toBe(expected !== undefined ? expected : input);
@@ -23,77 +23,52 @@ suite('Markdown to HTML Round-Trips', () => {
assert('strikethrough', '~~strike~~', 'strike
');
assert('inline code', '`code`', 'code
');
assert('bold+italic', '***bi***', 'bi
');
- assert('link', '[t](http://x)', 't
');
+ assert('link', '[t](http://x)', 't
');
assert('mixed', 'a **b** *c* `d`', 'a b c d
');
assert('code then bold', '`a` **b**', 'a b
');
assert('nested, ummatched italic', '**i* b**', 'i* b
');
- assert('nested, ummatched bold', '*b** i*', 'b** i
');
+ assert('nested, ummatched bold', '*b** i*', 'b** i
');
});
describe('block', () => {
- assert('HR ***', '***', ' ');
- assert('HR ******', '******', ' ', '***');
- assert('HR ---', '---', ' ', '***');
- assert('HR ___', '___', ' ', '***');
- assert('H1', '# h', "h ");
- assert('H2', '## h', "h ");
- assert('H3', '### h', "h ");
- assert('H4', '#### h', "h ");
- assert('H5', '##### h', "h ");
- assert('H6', '###### h', "h ");
- assert('H1 with nested', '# h **bold**', "h bold ");
- assert('Blockquote', '> foo\n> bar', "foo bar ");
- assert('Blockquote+break', '> foo\n>\n> bar', "foobar
");
- assert('Blockquote+Bold`', '> **foo**\n> bar', "foo bar ");
- assert('fenced code', '```\none\ntwo\n```', 'one\ntwo ');
- assert('fenced code+lang', '```ts\nconst foo = "";\n```', 'const foo = ""; ');
- assert('fenced code ~~~', '~~~\none\ntwo\n~~~', 'one\ntwo ', '```\none\ntwo\n```');
- assert('UL *', '* foo\n* bar', '');
- assert('UL -', '- foo\n- bar', '', '* foo\n* bar');
- assert('OL 1. 2.', '1. foo\n2. bar', 'foo bar ');
- assert('OL 1. 1.', '1. foo\n1. bar', 'foo bar ', '1. foo\n2. bar');
- assert('UL Nested', '* foo\n * bar', '');
- assert('OL in UL', '* foo\n 1. bar\n 2. baz', '');
+ assert('HR ***', '***', '
');
+ assert('HR ---', '---', '
', '***');
+ assert('HR ___', '___', '
', '***');
+ //assert('HR ******', '******', '
', '***');
+ assert('H1', '# h', "
h ");
+ assert('H2', '## h', "
h ");
+ assert('H3', '### h', "
h ");
+ assert('H4', '#### h', "
h ");
+ assert('H5', '##### h', "
h ");
+ assert('H6', '###### h', "
h ");
+ assert('H1 with nested', '# h **bold**', "
h bold ");
+ assert('Blockquote+break', '> foo\n> \n> bar', "
foobar
");
+ assert('Blockquote', '> one\n> two', "
one two ");
+ assert('Blockquote+Bold`', '> **foo**\n> bar', "
foo bar ");
+ assert('fenced code', '```\none\ntwo\n```', '
\none\ntwo\n ');
+ assert('fenced code+lang', '```ts\nconst foo = "";\n```', '
\nconst foo = "";\n ');
+ assert('UL *', '* foo\n* bar', '
');
+ assert('UL -', '- foo\n- **bar**', '
', '* foo\n* **bar**');
+ assert('OL 1. 2.', '1. foo\n2. bar', '
foo bar ');
+ assert('OL 1. 1.', '1. foo\n1. bar', '
foo bar ', '1. foo\n2. bar');
+ assert('UL Nested', '* foo\n * bar', '
');
+ assert('OL in UL', '* foo\n 1. bar\n 2. baz', '
');
});
describe('links and anchors', () => {
- assert('link', '[foo](http://x)', 'foo
');
+ assert('link', '[foo](http://x)', 'foo
');
assert('link with title', '[foo](http://x "bar")', 'foo
');
- assert('anchor', '[foo](#anchor)', 'foo
');
-
- var reflinks = (
- '\n\n' +
- '[foo]: http://foo\n' +
- '[bar]: http://bar "Bar"\n'
- );
- assert(
- 'reference link',
- '[text][foo]' + reflinks,
- 'text
',
- '[text](http://foo)'
- );
- assert(
- 'shortcut link',
- '[foo][]\n\n' + reflinks,
- 'foo
',
- '[foo](http://foo)'
- );
- assert(
- 'reference with title',
- '[text][bar]' + reflinks,
- 'text
',
- '[text](http://bar "Bar")'
- );
+ assert('anchor', '[foo](#anchor)', 'foo
');
assert(
'autolink',
'foo http://x bar',
- 'foo http://x bar
',
+ 'foo http://x bar
',
'foo [http://x](http://x) bar',
);
assert(
'anchor wrapped with bold',
'**[bold](http://x)**',
- 'bold
',
+ 'bold
',
);
});
@@ -101,68 +76,71 @@ suite('Markdown to HTML Round-Trips', () => {
// basic table
var md = (
- '| Header 1 | Header 2 |\n' +
+ '| Header 1 | Header 2 |\n' +
'| --- | --- |\n' +
'| Cell 1 | Cell 2 |'
);
var html = (
- '' +
+ '
' +
'Header 1 Header 2 ' +
- ' ' +
- 'Cell 1 Cell 2 ' +
- '
'
+ ' ' +
+ 'Cell 1 Cell 2 ' +
+ '
'
);
assert('Table', md, html);
// table with inline formatting
var md = (
- '| Header 1 | Header 2 |\n' +
+ '| Header 1 | Header 2 |\n' +
'| --- | --- |\n' +
- '| *italic* | **bold** |\n' +
+ '| *italic* | **bold** |\n' +
'| `code` | |'
);
var html = (
- '' +
+ '
' +
'Header 1 Header 2 ' +
- ' ' +
- 'italic bold ' +
- 'code ' +
- '
'
+ ' ' +
+ 'italic bold ' +
+ 'code ' +
+ '
'
);
assert('Table + inline', md, html);
// normalized markdown and cell alignment
var expected = (
- '| Header 1 | Header 2 | Header 3 |\n' +
+ '| Header 1 | Header 2 | Header 3 |\n' +
'| :---: | --- | ---: |\n' +
'| Cell 1 | Cell 2 | Cell 3 |'
);
var md = (
- '| Header 1 | Header 2 |Header 3 |\n' +
+ '| Header 1 | Header 2 |Header 3 |\n' +
'| :---: | :--- | ----------: |\n' +
'| Cell 1 | Cell 2 |Cell 3 |'
)
var html = (
- '' +
- 'Header 1 ' +
- 'Header 2 ' +
- 'Header 3 ' +
- ' ' +
- 'Cell 1 ' +
- 'Cell 2 ' +
- 'Cell 3 ' +
- '
'
+ '
' +
+ 'Header 1 ' +
+ 'Header 2 ' +
+ 'Header 3 ' +
+ ' ' +
+ 'Cell 1 ' +
+ 'Cell 2 ' +
+ 'Cell 3 ' +
+ '
'
);
assert('Table alignment', md, html, expected);
});
describe('Edge Cases', () => {
- var html = 'a SPAN ';
- assert('HTML is stripped', html, '' + html + '
', 'a SPAN')
+ assert('HTML is escaped',
+ 'some html ',
+ 'some <span>html</span>
',
+ 'some html '
+ );
assert('Bare brackets', '< foo', '< foo
', '< foo');
assert(
'HTML Entities',
@@ -172,11 +150,12 @@ suite('Markdown to HTML Round-Trips', () => {
);
assert('Escape sequence \\*', '\\*not italic\\*', '\\*not italic\\*
');
assert('Escape sequence \\`', '\\`not code\\`', '\\`not code\\`
');
+
+ assert('trailing space', '*i* ', 'i
');
});
});
-
suite('Markdown to Editor Nodes Round-Trips', () => {
function span(name: string, delim: string) {
@@ -186,20 +165,14 @@ suite('Markdown to Editor Nodes Round-Trips', () => {
return s;
}
- function assertInline(name: string, delim: string) {
+ function assertInline(name: string, markdown: string, expected: string) {
return test(name, () => {
-
- const input = delim + name + delim;
-
- const expected = document.createDocumentFragment();
- expected.appendChild(document.createElement('div'));;
- expected.childNodes[0].appendChild(span(name, delim));
- expected.childNodes[0].appendChild(document.createTextNode(name));
- expected.childNodes[0].appendChild(span(name, delim));
-
- const node = hopdown.toEditorNode(input);
- expect(node).toStrictEqual(expected);
- expect(hopdown.toMarkdown(node)).toBe(input);
+ const node = hopdown.toEditorNode(markdown);
+ var html = '';
+ for (const child of Array.from(node.children)) {
+ html += child.outerHTML;
+ }
+ expect(html).toStrictEqual(expected);
});
}
@@ -211,23 +184,113 @@ suite('Markdown to Editor Nodes Round-Trips', () => {
});
}
-
describe('inline', () => {
- assertInline('strong', '**');
- assertInline('em', '*');
+ assertInline('trailing text', '*i* fnord', '* i*
fnord
');
});
describe('block', () => {
+ assertBlock('hr ***', '***', ' ');
+ assertBlock('hr ---', '---', ' ');
+ assertBlock('hr ___', '___', ' ');
+
+ assertBlock('h1', '# one', 'one ');
+ assertBlock('h2', '## two', 'two ');
+ assertBlock('h3', '### three', 'three ');
+ assertBlock('h4', '#### four', 'four ');
+ assertBlock('h5', '##### five', 'five ');
+ assertBlock('h6', '###### six', 'six ');
+
+ assertBlock('h1 with inline markup', '# *italic* one\nfoo', '*italic* one \nfoo
');
+
assertBlock(
'Fenced Code',
'```\nfoo\n```',
(
- '' +
- '
``` \n' +
- 'foo\n' +
+ '
' +
+ '``` \n' +
+ 'foo\n' +
'``` ' +
- ' '
+ ' '
)
);
+ assertBlock(
+ 'Blockquote',
+ '> foo\n>\n> ***bar*** buz\nbaz',
+ (
+ '' +
+ '> foo
' +
+ '>
' +
+ '> ' +
+ '*** ' +
+ 'bar' +
+ '*** buz
' +
+ ' \nbaz
'
+ )
+ );
+
+ assertBlock(
+ 'Unordered List',
+ '- foo\n - bar\n- **baz**\n',
+ (
+ '' +
+ 'foo ' +
+ '** baz' +
+ '** ' +
+ ' '
+ )
+ );
+
+ assertBlock(
+ 'Ordered List with Nesting',
+ '1. foo\n * ***bar***\n1. baz',
+ (
+ '' +
+ 'foo' +
+ '*** ' +
+ 'bar' +
+ '*** ' +
+ ' ' +
+ 'baz ' +
+ ' '
+ )
+ );
+
+ describe('tables', () => {
+ var md = (
+ '| Header 1 | Header 2 |\n' +
+ '| --- | ---: |\n' +
+ '| **Cell 1** | Cell 2 |'
+ );
+
+ var html = (
+ '' +
+ '' +
+ '' +
+ 'Header 1 ' +
+ 'Header 2 ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ '' +
+ '** ' +
+ 'Cell 1' +
+ '** ' +
+ ' ' +
+ 'Cell 2 ' +
+ ' ' +
+ ' ' +
+ '
'
+ );
+ assertBlock('Table', md, html);
+
+ });
+
});
+
+ describe('link', () => {
+ assertInline('link', '[label](url)', 'label ');
+ assertInline('link', '[label](url "title")', 'label ');
+ });
+
});
diff --git a/tsconfig.json b/tsconfig.json
index 9985223..4cbc96d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,12 +1,10 @@
{
"compilerOptions": {
"strict": true,
+ "declaration": true,
"target": "esnext",
- "module": "esnext",
- "esModuleInterop": true,
- "types": ["node"],
- "forceConsistentCasingInFileNames": true,
- "outDir": "dist/lib",
+ "module": "commonjs",
+ "outDir": "./dist",
"rootDir": "src/ts"
},
"include": ["src/ts/**/*.ts"],