v1 checkpoint

This commit is the initial checkpoint for a functioning converter with
all tests passing.
This commit is contained in:
evilchili committed 2026-10-16 13:00:11 -07:00
1 parent 158c71bce6
commit 965fc09d99
58 files changed
+2921 -3224

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
import * as tags from "./tags";
export declare const defaultTags: tags.TagCollection;
+6 -2
View File
@@ -45,10 +45,14 @@ exports.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()
]);
+70
View File
@@ -0,0 +1,70 @@
import type { Token, TagCollectionType } from './types';
export declare function escapeHtml(source: string): string;
/**
* A configurable markdown <=> HTML converter.
*
* Examples:
*
* const hopdown = new HopDown();
*
* const html = hopdown.toHTML('**bold**');
* html === '<p><strong>bold</strong></p>';
*
* const markdown = hopdown.toMarkdown(html);
* markdown === '**bold**';
*
* const node = hopdown.toEditorNode('**bold**');
* (node as HTMLElement).outerHTML === (
* "<div>" +
* "<span class='delim strong'>**</span>" +
* "bold" +
* "<span class='delim strong'>" +
* "</div>"
* );
*/
export declare class HopDown {
tags: TagCollectionType;
private tokenizer;
constructor(tags?: TagCollectionType | null);
/**
* Convert a markdown string to tokens.
*/
tokenize(source: string): Token[];
/**
* Convert an HTML string or a DocumentFragment to markdown.
*/
toMarkdown: (html: string | DocumentFragment) => string;
/**
* Convert a markdown string to a DocumentFragment consisting of wysiwyg editor nodes.
*/
toEditorNode: (markdown: string) => DocumentFragment;
toHTML: (markdown: string) => string;
/**
* Recursive entrypoint for converting markdown to html.
*/
inlineToHTML: (markdown: string) => string;
/**
* Recursive entrypoint for converting markdown to editor nodes.
*/
private inlineToEditorNodes;
/**
* Convert an HTML node to a markdown string. Used by toMarkdown().
*/
private nodeToMarkdown;
/**
* Convert a token stream to editor nodes.
*/
private tokensToEditorNodes;
/**
* Convert a token stream to HTML. Matches open/close delimiter
* pairs and wraps their content in the appropriate HTML tags.
* Unmatched delimiters are emitted as literal text.
*/
private tokensToHTML;
/**
* Match open/close delimiter pairs in a token stream. Unmatched
* openers/closers are converted to text tokens so they render
* as literal characters.
*/
private pairDelimiters;
}
+11 -159
View File
@@ -39,7 +39,6 @@ function escapeHtml(source) {
*/
class HopDown {
tags;
referenceLinks = new Map();
tokenizer;
constructor(tags) {
this.tags = tags || defaults_1.defaultTags;
@@ -49,13 +48,7 @@ class HopDown {
* Convert a markdown string to tokens.
*/
tokenize(source) {
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);
}
/**
* Convert an HTML string or a DocumentFragment to markdown.
@@ -70,58 +63,8 @@ class HopDown {
else {
md = this.nodeToMarkdown(html);
}
return md.replace(/\n{3,}/g, '\n\n').trim();
};
/**
* Convert a markdown string to an HTML string.
*/
toHTML = (markdown) => {
const output = [];
const preprocessed = 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;
let matched = false;
let token;
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;
};
/**
* Convert a markdown string to a DocumentFragment consisting of wysiwyg editor nodes.
@@ -130,6 +73,9 @@ class HopDown {
const tokens = this.tokenize(markdown);
return this.tokensToEditorNodes(tokens);
};
toHTML = (markdown) => {
return '<p>' + this.inlineToHTML(markdown) + '</p>';
};
/**
* Recursive entrypoint for converting markdown to html.
*/
@@ -144,33 +90,6 @@ class HopDown {
const tokens = this.tokenize(markdown);
return this.tokensToEditorNodes(tokens);
};
/**
* Process a markdown string by splitting it into lines, stripping empty lines, and capturing reference links.
*/
preprocessMarkdown(markdown) {
const blankLine = /^\s*$/;
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
const referenceLinks = new Map;
const output = [];
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().
*/
@@ -198,49 +117,6 @@ class HopDown {
const ret = tag.toMarkdown(node, this.nodeToMarkdown);
return ret;
};
/**
* Replace [text][ref] and [text][] with [text](url) using the
* reference definitions collected during block parsing.
*/
resolveReferenceLinks(text) {
if (this.referenceLinks.size === 0) {
return text;
}
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
return text.replace(refLink, (...args) => {
const groups = args[args.length - 1];
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.
*/
normalizeUnderscores(text) {
// 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 = (_, run) => '*'.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.
*/
@@ -258,14 +134,6 @@ class HopDown {
return div;
};
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) {
@@ -273,14 +141,14 @@ class HopDown {
}
if (token.role == 'open') {
if (grouping.length) {
fragment.append(groupAsNode());
fragment.appendChild(groupAsNode());
}
grouping = [thisNode];
continue;
}
if (token.role == 'close') {
grouping.push(thisNode);
fragment.append(groupAsNode());
fragment.appendChild(groupAsNode());
grouping = [];
continue;
}
@@ -309,19 +177,6 @@ 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;
@@ -331,7 +186,7 @@ class HopDown {
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.
*/
@@ -343,14 +198,11 @@ class HopDown {
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',
role: 'block',
value: token.value,
};
continue;
@@ -371,7 +223,7 @@ class HopDown {
}
if (!matched) {
result[index] = {
role: 'text',
role: 'block',
value: token.value,
};
}
View File
File renamed without changes.
View File
File renamed without changes.
-18
View File
@@ -1,18 +0,0 @@
import * as tags from "./tags";
export const defaultTags = new tags.TagCollection([
new tags.Bold(),
new tags.Italic(),
new tags.BoldItalic(),
new tags.Strikethrough(),
new tags.Code(),
new tags.Anchor(),
new tags.HardBreak(),
new tags.FencedCode(),
new tags.HorizontalRule(),
new tags.Heading(),
new tags.Blockquote(),
new tags.OrderedList(),
new tags.UnorderedList(),
new tags.Table(),
new tags.Paragraph()
]);
-378
View File
@@ -1,378 +0,0 @@
/*
* hopdown.ts
* - Configurable markdown <=> HTML converter.
*/
import { defaultTags } from "./defaults";
import { Tokenizer } from "./tokenizer";
export function escapeHtml(source) {
return source
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* A configurable markdown <=> HTML converter.
*
* Examples:
*
* const hopdown = new HopDown();
*
* const html = hopdown.toHTML('**bold**');
* html === '<p><strong>bold</strong></p>';
*
* const markdown = hopdown.toMarkdown(html);
* markdown === '**bold**';
*
* const node = hopdown.toEditorNode('**bold**');
* (node as HTMLElement).outerHTML === (
* "<div>" +
* "<span class='delim strong'>**</span>" +
* "bold" +
* "<span class='delim strong'>" +
* "</div>"
* );
*/
export class HopDown {
tags;
referenceLinks = new Map();
tokenizer;
constructor(tags) {
this.tags = tags || defaultTags;
this.tokenizer = new Tokenizer(this.tags);
}
/**
* Convert a markdown string to tokens.
*/
tokenize(source) {
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;
}
/**
* Convert an HTML string or a DocumentFragment to markdown.
*/
toMarkdown = (html) => {
var md = '';
if (typeof (html) == 'string') {
const container = document.createElement('div');
container.innerHTML = html;
md = this.nodeToMarkdown(container);
}
else {
md = this.nodeToMarkdown(html);
}
return md.replace(/\n{3,}/g, '\n\n').trim();
};
/**
* Convert a markdown string to an HTML string.
*/
toHTML = (markdown) => {
const output = [];
const preprocessed = 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;
let matched = false;
let token;
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');
};
/**
* Convert a markdown string to a DocumentFragment consisting of wysiwyg editor nodes.
*/
toEditorNode = (markdown) => {
const tokens = this.tokenize(markdown);
return this.tokensToEditorNodes(tokens);
};
/**
* Recursive entrypoint for converting markdown to html.
*/
inlineToHTML = (markdown) => {
const tokens = this.tokenize(markdown);
return this.tokensToHTML(tokens);
};
/**
* Recursive entrypoint for converting markdown to editor nodes.
*/
inlineToEditorNodes = (markdown) => {
const tokens = this.tokenize(markdown);
return this.tokensToEditorNodes(tokens);
};
/**
* Process a markdown string by splitting it into lines, stripping empty lines, and capturing reference links.
*/
preprocessMarkdown(markdown) {
const blankLine = /^\s*$/;
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
const referenceLinks = new Map;
const output = [];
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().
*/
nodeToMarkdown = (node) => {
// DocumentFragment
if (node.nodeType == 11) {
return Array.from(node.childNodes).map(child => {
return this.nodeToMarkdown(child);
}).join('');
}
// text
if (node.nodeType == 3) {
return node.textContent || '';
// not a block-level element
}
else if (node.nodeType !== 1) {
return '';
}
const tag = this.tags.getByElementName(node.nodeName);
if (!tag) {
return Array.from(node.childNodes)
.map(child => this.nodeToMarkdown(child))
.join('');
}
const ret = tag.toMarkdown(node, this.nodeToMarkdown);
return ret;
};
/**
* Replace [text][ref] and [text][] with [text](url) using the
* reference definitions collected during block parsing.
*/
resolveReferenceLinks(text) {
if (this.referenceLinks.size === 0) {
return text;
}
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
return text.replace(refLink, (...args) => {
const groups = args[args.length - 1];
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.
*/
normalizeUnderscores(text) {
// 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 = (_, run) => '*'.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.
*/
tokensToEditorNodes(tokens) {
// First pass: match open/close pairs using a stack
const paired = this.pairDelimiters(tokens);
// Second pass: build HTML from paired tokens
const fragment = document.createDocumentFragment();
var grouping = [];
const groupAsNode = () => {
const div = document.createElement('div');
for (const node of grouping) {
div.appendChild(node);
}
return div;
};
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) {
continue;
}
if (token.role == 'open') {
if (grouping.length) {
fragment.append(groupAsNode());
}
grouping = [thisNode];
continue;
}
if (token.role == 'close') {
grouping.push(thisNode);
fragment.append(groupAsNode());
grouping = [];
continue;
}
if (!grouping.length) {
fragment.appendChild(thisNode.cloneNode(true));
}
continue;
}
const text = document.createTextNode(token.value);
grouping.push(text);
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.
* Unmatched delimiters are emitted as literal text.
*/
tokensToHTML(tokens) {
// First pass: match open/close pairs using a stack
const paired = this.pairDelimiters(tokens);
// 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
* openers/closers are converted to text tokens so they render
* as literal characters.
*/
pairDelimiters(tokens) {
const openStack = [];
const result = [...tokens];
// Track which delimiter types are currently open to prevent
// forbidden nesting (e.g. <del> inside <del>, <em> inside <em>)
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;
}
}
-696
View File
@@ -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(`(?<!${escapedChar})` +
`${escaped}` +
`(?!${escapedChar})` +
`([^\\x01\\x02]+)$`);
});
}
validate() {
const inline = this.ordered.filter(t => { 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 `<A HREF="${escapeHtml(token.href || '')}"${titleAttr}>${escapeHtml(token.value)}</A>`;
}
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 '<BR>'; }
toMarkdown() { return ' \n'; }
}
/**
* Fenced code blocks: lines between ``` delimiters become <pre><code>.
*
* converter.toHTML('```js\nlet x = 1;\n```')
* // <pre><code class="language-js">let x = 1;</code></pre>
*/
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 = /^(?<fence>`{3,}|~{3,})(?<lang>.*)/;
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 `<pre${langAttr}>${escapeHtml(token.content)}</pre>`;
}
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 <hr>.
*
* converter.toHTML('---') // '<hr>'
*/
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 = /^(?<hashes>#{1,6})\s+(?<content>.*?)(?:\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 = /^(?<marker>=+|-+)\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 `<h${level} id='${id}'>${callback(token.content)}</h${level}>`;
}
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')
* // <blockquote><p>hello\nworld</p></blockquote>
*/
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<br>')
.replace(/\n\n(.+?)(\n\n|$)/g, '<p>$1</p>');
return '<blockquote>' + breaks + '</blockquote>';
}
toMarkdown(element, callback) {
// Each <p> inside the blockquote is a paragraph group.
// Within a <p>, <br> 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 <br> 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 => '<li>' + callback(item.text) + item.sub + '</li>').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 = '<thead><tr>'
+ headers.map((text, column) => cell('th', text, column)).join('')
+ '</tr></thead>';
const body = rows.map(row => '<tr>' + row.map((text, column) => cell('td', text, column)).join('') + '</tr>').join('');
return `<table>${head}<tbody>${body}</tbody></table>`;
}
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 '<p>' + callback(token.content) + '</p>';
}
}
-320
View File
@@ -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(/^(?<spaces> {2,})\n/);
if (spaceMatch?.groups) {
flushText();
tokens.push({ role: 'break', value: '<br>' });
position += spaceMatch[0].length;
continue;
}
}
// HTML entity resolution: &name; or &#digits; or &#xhex;
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: <url> 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 = /^&(?<name>[a-zA-Z]+);/;
const numericPattern = /^&#(?<code>\d+);/;
const hexPattern = /^&#x(?<hex>[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 = /^\[(?<text>[^\[\]]+)\]\((?<href>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\)/;
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 <url> at the start of the string.
*/
matchAutolink(text) {
const pattern = /^<(?<url>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;
}
}
-1
View File
@@ -1 +0,0 @@
export {};
+232
View File
@@ -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<string, TagType>;
ordered: TagType[];
map: Map<string, TagType[]>;
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 <pre><code>.
*
* converter.toHTML('```js\nlet x = 1;\n```')
* // <pre><code class="language-js">let x = 1;</code></pre>
*/
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 <hr>.
*
* converter.toHTML('---') // '<hr>'
*/
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')
* // <blockquote><p>hello\nworld</p></blockquote>
*/
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 <br> 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<string, any> | null;
toHTML(token: Token, callback: Function): string;
toMarkdown(element: HTMLElement, callback: Function): string;
private parseRow;
private parseAligns;
}
export {};
+352 -335
View File
@@ -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(`(?<!${escapedChar})` +
`${escaped}` +
`(?!${escapedChar})` +
`([^\\x01\\x02]+)$`);
});
//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.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 = /(?<captured>\[(?<text>[^\[\]]+)\]\((?<url>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\))/;
autolink_pattern = /^(?<captured>(?<url>[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 `<A HREF="${(0, hopdown_1.escapeHtml)(token.href || '')}"${titleAttr}>${(0, hopdown_1.escapeHtml)(token.value)}</A>`;
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 `<A HREF="${url}" TITLE="${title}">${text}</A>`;
}
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 '<BR>'; }
toMarkdown() { return ' \n'; }
}
@@ -210,50 +244,40 @@ exports.HardBreak = HardBreak;
* converter.toHTML('```js\nlet x = 1;\n```')
* // <pre><code class="language-js">let x = 1;</code></pre>
*/
class FencedCode extends BlockTag {
opener = '```';
closer = '```';
class FencedCode extends Tag {
opener = ['```'];
closer = '';
pattern = /(?<captured>^(?<fence>```)(?<lang>\w*)\s*\n(?<code>(.+\n)+)\k<fence>)/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 = /^(?<fence>`{3,}|~{3,})(?<lang>.*)/;
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 `<pre${langAttr}>${(0, hopdown_1.escapeHtml)(token.content)}</pre>`;
const val = token.value.slice(token.groups.fence.length + token.groups.lang.length, token.value.length - token.groups.fence.length);
return `<pre${langAttr}>${(0, hopdown_1.escapeHtml)(val)}</pre>`;
}
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('---') // '<hr>'
*/
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 = /^(?<captured>(\*{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 = /^(?<hashes>#{1,6})\s+(?<content>.*?)(?:\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 = /^(?<marker>=+|-+)\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 = /^(?<captured>(?<hashes>\#{1,6})\s+(?<content>.*?$))/m;
escapePattern = /[.*+?^${}()|[\]\\]/g;
opener = ['#'];
closer = '';
isBlock = true;
toHTML(token, callback) {
const level = token.meta.level;
const id = this.anchorId(token.content);
return `<h${level} id='${id}'>${callback(token.content)}</h${level}>`;
const level = token.groups.hashes.length;
const id = this.anchorId(token.groups.content);
return `<h${level} id='${id}'>${callback(token.groups.content)}</h${level}>`;
}
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')
* // <blockquote><p>hello\nworld</p></blockquote>
*/
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<br>')
.replace(/\n\n(.+?)(\n\n|$)/g, '<p>$1</p>');
return '<blockquote>' + breaks + '</blockquote>';
}
toMarkdown(element, callback) {
// Each <p> inside the blockquote is a paragraph group.
// Within a <p>, <br> 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 = /(?<captured>(?:\>\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 <br> 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) + "<br>";
continue;
}
html += pFlag ? "</p>" : "<p>";
pFlag = !pFlag;
}
html = html.replace(/\<br\>$/g, pFlag ? '</p>' : '');
html += `</${this.element[0].toLowerCase()}>`;
return html;
}
toMarkdown(element, callback) {
// Each <p> inside the blockquote is a paragraph group.
// Within a <p>, <br> 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 = /(?<captured>(?<lines>^(?:\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 => '<li>' + callback(item.text) + item.sub + '</li>').join('')
+ `</${tag}>`;
var html = `<${tag}>`;
for (var item of items) {
const res = callback(item.text);
html += '<li>' + (res.firstElementChild?.innerHTML || res) + item.sub + '</li>';
}
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 = /(?<captured>(?:\|.+?\|\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 = '<thead><tr>'
+ headers.map((text, column) => cell('th', text, column)).join('')
+ block.headers.map((text, column) => cell('th', text, column)).join('')
+ '</tr></thead>';
const body = rows.map(row => '<tr>' + row.map((text, column) => cell('td', text, column)).join('') + '</tr>').join('');
const body = block.rows.map((row) => '<tr>' + row.map((text, column) => cell('td', text, column)).join('') + '</tr>').join('');
return `<table>${head}<tbody>${body}</tbody></table>`;
}
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 '<p>' + callback(token.content) + '</p>';
}
}
exports.Paragraph = Paragraph;
+12
View File
@@ -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;
}
+40
View File
@@ -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 = /(?<captured>\[(?<text>[^\[\]]+)\]\((?<url>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\))/;
autolink_pattern = /^(?<captured>(?<url>[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 `<A HREF="${url}" TITLE="${title}">${text}</A>`;
}
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;
+24
View File
@@ -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<string, TagType>;
ordered: TagType[];
map: Map<string, TagType[]>;
constructor(tags: TagType[]);
private validate;
getByElementName(element: string): TagType | undefined;
}
+157
View File
@@ -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;
+25
View File
@@ -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')
* // <blockquote><p>hello\nworld</p></blockquote>
*/
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 <br> elements.
*/
private paragraphToLines;
toHTML(token: Token, callback: Function): string;
toMarkdown(element: HTMLElement, callback: Function): string;
toEditorNode(token: Token, callback: Function): Node;
}
+110
View File
@@ -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')
* // <blockquote><p>hello\nworld</p></blockquote>
*/
class Blockquote extends base_1.Tag {
element = ['BLOCKQUOTE'];
opener = ['> '];
closer = '';
isBlock = true;
template = '> Quote\n> continues here';
pattern = /(?<captured>(?:\>\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 <br> 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) + "<br>";
continue;
}
html += pFlag ? "</p>" : "<p>";
pFlag = !pFlag;
}
html = html.replace(/\<br\>$/g, pFlag ? '</p>' : '');
html += `</${this.element[0].toLowerCase()}>`;
return html;
}
toMarkdown(element, callback) {
// Each <p> inside the blockquote is a paragraph group.
// Within a <p>, <br> 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;
+20
View File
@@ -0,0 +1,20 @@
import { Tag } from "./base";
import type { Token } from '../types';
/**
* Fenced code blocks: lines between ``` delimiters become <pre><code>.
*
* converter.toHTML('```js\nlet x = 1;\n```')
* // <pre><code class="language-js">let x = 1;</code></pre>
*/
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;
}
+48
View File
@@ -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 <pre><code>.
*
* converter.toHTML('```js\nlet x = 1;\n```')
* // <pre><code class="language-js">let x = 1;</code></pre>
*/
class FencedCode extends base_1.Tag {
opener = ['```'];
closer = '';
pattern = /(?<captured>^(?<fence>```)(?<lang>\w*)\s*\n(?<code>(.+\n)+)\k<fence>)/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 `<pre${langAttr}>${(0, hopdown_1.escapeHtml)(val)}</pre>`;
}
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;
+10
View File
@@ -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;
}
+14
View File
@@ -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 '<BR>'; }
toMarkdown() { return ' \n'; }
}
exports.HardBreak = HardBreak;
+34
View File
@@ -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[];
}
+64
View File
@@ -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 = /^(?<captured>(?<hashes>\#{1,6})\s+(?<content>.*?$))/m;
escapePattern = /[.*+?^${}()|[\]\\]/g;
opener = ['#'];
closer = '';
isBlock = true;
toHTML(token, callback) {
const level = token.groups.hashes.length;
const id = this.anchorId(token.groups.content);
return `<h${level} id='${id}'>${callback(token.groups.content)}</h${level}>`;
}
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;
;
+19
View File
@@ -0,0 +1,19 @@
import { Tag } from "./base";
import type { Token } from '../types';
/**
* Three or more *, -, or _ on a line become <hr>.
*
* converter.toHTML('---') // '<hr>'
*/
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;
}
+28
View File
@@ -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 <hr>.
*
* converter.toHTML('---') // '<hr>'
*/
class HorizontalRule extends base_1.Tag {
element = ['HR'];
template = '***';
opener = ['***', '___', '---'];
isBlock = true;
replaceSelection = false;
pattern = /^(?<captured>(\*{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;
+10
View File
@@ -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";
+26
View File
@@ -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);
+27
View File
@@ -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[];
}
+35
View File
@@ -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;
+55
View File
@@ -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 {};
+161
View File
@@ -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 = /(?<captured>(?<lines>^(?:\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 += '<li>' + (res.firstElementChild?.innerHTML || res) + item.sub + '</li>';
}
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;
+22
View File
@@ -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<string, any> | null;
toHTML(token: Token, callback: Function): string;
toMarkdown(element: HTMLElement, callback: Function): string;
private parseRow;
private parseAligns;
}
+110
View File
@@ -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 = /(?<captured>(?:\|.+?\|\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 = '<thead><tr>'
+ block.headers.map((text, column) => cell('th', text, column)).join('')
+ '</tr></thead>';
const body = block.rows.map((row) => '<tr>' + row.map((text, column) => cell('td', text, column)).join('') + '</tr>').join('');
return `<table>${head}<tbody>${body}</tbody></table>`;
}
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;
+64
View File
@@ -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 <url> 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;
}
+35 -74
View File
@@ -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(/^(?<spaces> {2,})\n/);
if (spaceMatch?.groups) {
flushText();
tokens.push({ role: 'break', value: '<br>' });
tokens.push({ role: 'block', value: '<br>' });
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: <url> 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;
+41
View File
@@ -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<string, string>;
}
/**
* 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<string, TagType>;
ordered: TagType[];
map: Map<string, TagType[]>;
getByElementName(element: string): TagType | undefined;
}
View File
File renamed without changes.