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.HardBreak(),
new tags.FencedCode(), new tags.FencedCode(),
new tags.HorizontalRule(), 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.Blockquote(),
new tags.OrderedList(), new tags.OrderedList(),
new tags.UnorderedList(), new tags.UnorderedList(),
new tags.Table(), 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 { class HopDown {
tags; tags;
referenceLinks = new Map();
tokenizer; tokenizer;
constructor(tags) { constructor(tags) {
this.tags = tags || defaults_1.defaultTags; this.tags = tags || defaults_1.defaultTags;
@@ -49,13 +48,7 @@ class HopDown {
* Convert a markdown string to tokens. * Convert a markdown string to tokens.
*/ */
tokenize(source) { tokenize(source) {
let text = source; return this.tokenizer.tokenize(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. * Convert an HTML string or a DocumentFragment to markdown.
@@ -70,58 +63,8 @@ class HopDown {
else { else {
md = this.nodeToMarkdown(html); md = this.nodeToMarkdown(html);
} }
return md.replace(/\n{3,}/g, '\n\n').trim(); md = md.replace(/^\n+/, '').replace(/\n+$/, '');
}; return md;
/**
* 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. * Convert a markdown string to a DocumentFragment consisting of wysiwyg editor nodes.
@@ -130,6 +73,9 @@ class HopDown {
const tokens = this.tokenize(markdown); const tokens = this.tokenize(markdown);
return this.tokensToEditorNodes(tokens); return this.tokensToEditorNodes(tokens);
}; };
toHTML = (markdown) => {
return '<p>' + this.inlineToHTML(markdown) + '</p>';
};
/** /**
* Recursive entrypoint for converting markdown to html. * Recursive entrypoint for converting markdown to html.
*/ */
@@ -144,33 +90,6 @@ class HopDown {
const tokens = this.tokenize(markdown); const tokens = this.tokenize(markdown);
return this.tokensToEditorNodes(tokens); 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(). * Convert an HTML node to a markdown string. Used by toMarkdown().
*/ */
@@ -198,49 +117,6 @@ class HopDown {
const ret = tag.toMarkdown(node, this.nodeToMarkdown); const ret = tag.toMarkdown(node, this.nodeToMarkdown);
return ret; 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. * Convert a token stream to editor nodes.
*/ */
@@ -258,14 +134,6 @@ class HopDown {
return div; return div;
}; };
for (const token of paired) { 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) { if (token.tag) {
const thisNode = token.tag.toEditorNode(token, this.inlineToEditorNodes); const thisNode = token.tag.toEditorNode(token, this.inlineToEditorNodes);
if (!thisNode) { if (!thisNode) {
@@ -273,14 +141,14 @@ class HopDown {
} }
if (token.role == 'open') { if (token.role == 'open') {
if (grouping.length) { if (grouping.length) {
fragment.append(groupAsNode()); fragment.appendChild(groupAsNode());
} }
grouping = [thisNode]; grouping = [thisNode];
continue; continue;
} }
if (token.role == 'close') { if (token.role == 'close') {
grouping.push(thisNode); grouping.push(thisNode);
fragment.append(groupAsNode()); fragment.appendChild(groupAsNode());
grouping = []; grouping = [];
continue; continue;
} }
@@ -309,19 +177,6 @@ class HopDown {
// Second pass: build HTML from paired tokens // Second pass: build HTML from paired tokens
let html = ''; let html = '';
for (const token of paired) { 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) { if (token.tag) {
html += token.tag.toHTML(token, this.inlineToHTML); html += token.tag.toHTML(token, this.inlineToHTML);
continue; continue;
@@ -331,7 +186,7 @@ class HopDown {
return html; 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 * openers/closers are converted to text tokens so they render
* as literal characters. * as literal characters.
*/ */
@@ -343,14 +198,11 @@ class HopDown {
const openDelimiters = new Set(); const openDelimiters = new Set();
for (let index = 0; index < result.length; index++) { for (let index = 0; index < result.length; index++) {
const token = result[index]; const token = result[index];
if (token.role === 'block') {
token.role = openDelimiters.has(token.sequence) ? 'close' : 'open';
}
if (token.role === 'open') { if (token.role === 'open') {
// Don't open a delimiter that's already open (prevents nesting) // Don't open a delimiter that's already open (prevents nesting)
if (openDelimiters.has(token.sequence)) { if (openDelimiters.has(token.sequence)) {
result[index] = { result[index] = {
role: 'text', role: 'block',
value: token.value, value: token.value,
}; };
continue; continue;
@@ -371,7 +223,7 @@ class HopDown {
} }
if (!matched) { if (!matched) {
result[index] = { result[index] = {
role: 'text', role: 'block',
value: token.value, 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"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); 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; exports.childNodesToMarkdown = childNodesToMarkdown;
const hopdown_1 = require("./hopdown"); 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. * so the regexes aren't duplicated and can be referenced by name.
*/ */
const blankLine = /^\s*$/; 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) { function childNodesToMarkdown(element, callback) {
return Array.from(element.childNodes) return Array.from(element.childNodes)
.map(child => callback(child)) .map(child => callback(child))
@@ -28,11 +15,33 @@ function childNodesToMarkdown(element, callback) {
} }
class Tag { class Tag {
element = ['']; element = [''];
opener = ''; opener = [''];
closer = ''; closer = '';
delimiter = null; pattern = null;
precedence = 50; precedence = 50;
isBlock = false; 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) { toHTML(token, callback) {
if (token.role == 'open') { if (token.role == 'open') {
return this.element.map(el => { return `<${el.toUpperCase()}>`; }).join(''); return this.element.map(el => { return `<${el.toUpperCase()}>`; }).join('');
@@ -42,31 +51,52 @@ class Tag {
rev.reverse(); rev.reverse();
return rev.map(el => { return `</${el.toUpperCase()}>`; }).join(''); 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; return token.value;
} }
; ;
toMarkdown(element, callback) { toMarkdown(element, callback) {
const children = childNodesToMarkdown(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) { delimNodes() {
if (token.role == 'open' || token.role == 'close') { const opener = document.createElement('span');
const span = document.createElement('span'); opener.classList.add('delim');
span.classList.add('delim'); for (const el of this.element) {
for (const el of this.element) { opener.classList.add(el.toLowerCase());
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); ;
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 { class TagCollection {
tags = {}; tags = {};
ordered; ordered;
openers; map;
//openers: RegExp[];
constructor(tags) { constructor(tags) {
for (const tag of tags) { for (const tag of tags) {
if (tag.constructor.name in this.tags) { if (tag.constructor.name in this.tags) {
@@ -87,37 +118,37 @@ class TagCollection {
const p2 = b.precedence - (b.isBlock ? 100 : 0); const p2 = b.precedence - (b.isBlock ? 100 : 0);
return p1 < p2 ? -1 : 1; return p1 < p2 ? -1 : 1;
}); });
this.validate(); //this.validate();
const escapeRegex = /[.*+?^${}()|[\]\\]/g; this.map = new Map();
this.openers = this.ordered.filter(t => { return t.delimiter !== null; }).map(tag => { for (const tag of this.ordered) {
const escaped = tag.delimiter.replace(escapeRegex, '\\$&'); for (const key of tag.opener) {
const escapedChar = tag.delimiter[0].replace(escapeRegex, '\\$&'); if (!this.map.has(key)) {
return new RegExp(`(?<!${escapedChar})` + this.map.set(key, []);
`${escaped}` + }
`(?!${escapedChar})` + this.map.get(key).push(tag);
`([^\\x01\\x02]+)$`); }
}); }
} }
validate() { 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 outer = 0; outer < inline.length; outer++) {
for (let inner = outer + 1; inner < inline.length; inner++) { for (let inner = outer + 1; inner < inline.length; inner++) {
const first = this.ordered[outer]; const first = this.ordered[outer];
const second = this.ordered[inner]; const second = this.ordered[inner];
if (!(first.delimiter && second.delimiter)) { if (!(first.opener && second.opener)) {
continue; continue;
} }
const firstIsPrefix = second.delimiter.startsWith(first.delimiter); const firstIsPrefix = second.opener[0].startsWith(first.opener[0]);
const secondIsPrefix = first.delimiter.startsWith(second.delimiter); const secondIsPrefix = first.opener[0].startsWith(second.opener[0]);
if (!firstIsPrefix && !secondIsPrefix) { if (!firstIsPrefix && !secondIsPrefix) {
continue; continue;
} }
const longer = first.delimiter.length > second.delimiter.length ? first : second; const longer = first.opener.length > second.opener.length ? first : second;
const shorter = first.delimiter.length > second.delimiter.length ? second : first; const shorter = first.opener.length > second.opener.length ? second : first;
if (longer.precedence >= shorter.precedence) { if (longer.precedence >= shorter.precedence) {
throw new Error(`Tag "${longer.constructor.name}" (delimiter "${longer.delimiter}") must have ` + throw new Error(`Tag "${longer.constructor.name}" (opener "${longer.opener}") must have ` +
`lower precedence than "${shorter.constructor.name}" (delimiter "${shorter.delimiter}") ` + `lower precedence than "${shorter.constructor.name}" (opener "${shorter.opener}") ` +
`because its delimiter is a prefix match. ` + `because its opener is a prefix match. ` +
`Got ${longer.constructor.name}=${longer.precedence}, ${shorter.constructor.name}=${shorter.precedence}.`); `Got ${longer.constructor.name}=${longer.precedence}, ${shorter.constructor.name}=${shorter.precedence}.`);
} }
} }
@@ -134,43 +165,60 @@ exports.TagCollection = TagCollection;
class Bold extends Tag { class Bold extends Tag {
element = ['STRONG']; element = ['STRONG'];
aliases = ['B']; aliases = ['B'];
delimiter = '**'; opener = ['**', '__'];
shortcut = 'Ctrl+B';
precedence = 40; precedence = 40;
} }
exports.Bold = Bold; exports.Bold = Bold;
class Italic extends Tag { class Italic extends Tag {
element = ['EM']; element = ['EM'];
aliases = ['I']; aliases = ['I'];
delimiter = '*'; opener = ['*', '_'];
shortcut = 'Ctrl+I';
precedence = 30; precedence = 30;
} }
exports.Italic = Italic; exports.Italic = Italic;
class BoldItalic extends Tag { class BoldItalic extends Tag {
element = ['STRONG', 'EM']; element = ['STRONG', 'EM'];
delimiter = '***'; opener = ['***'];
precedence = 50; precedence = 50;
} }
exports.BoldItalic = BoldItalic; exports.BoldItalic = BoldItalic;
class Strikethrough extends Tag { class Strikethrough extends Tag {
element = ['DEL']; element = ['DEL'];
aliases = ['S', 'STRIKE']; aliases = ['S', 'STRIKE'];
delimiter = '~~'; opener = ['~~'];
} }
exports.Strikethrough = Strikethrough; exports.Strikethrough = Strikethrough;
class Code extends Tag { class Code extends Tag {
element = ['CODE']; element = ['CODE'];
delimiter = '`'; opener = ['`'];
} }
exports.Code = Code; exports.Code = Code;
class Anchor extends Tag { class Anchor extends Tag {
opener = ['['];
element = ['A']; 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) { toHTML(token, callback) {
const titleAttr = token.title const title = (0, hopdown_1.escapeHtml)(token.groups.title || '');
? ` TITLE="${(0, hopdown_1.escapeHtml)(token.title)}"` const url = (0, hopdown_1.escapeHtml)(token.groups.url || '');
: ''; const text = (0, hopdown_1.escapeHtml)(token.groups.text || '') || url;
return `<A HREF="${(0, hopdown_1.escapeHtml)(token.href || '')}"${titleAttr}>${(0, hopdown_1.escapeHtml)(token.value)}</A>`; return `<A HREF="${url}" TITLE="${title}">${text}</A>`;
} }
toMarkdown(element, callback) { toMarkdown(element, callback) {
const href = element.getAttribute('href') || ''; const href = element.getAttribute('href') || '';
@@ -178,28 +226,14 @@ class Anchor extends Tag {
const titlePart = title ? ` "${title}"` : ''; const titlePart = title ? ` "${title}"` : '';
return '[' + element.innerText + '](' + href + titlePart + ')'; return '[' + element.innerText + '](' + href + titlePart + ')';
} }
toEditorNode(token, callback) {
return document.createElement('A');
}
} }
exports.Anchor = Anchor; exports.Anchor = Anchor;
class BlockTag extends Tag { class HardBreak extends Tag {
element = ['BR'];
template = ' \n';
precedence = 20;
pattern = blankLine;
isBlock = true; 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>'; } toHTML() { return '<BR>'; }
toMarkdown() { return ' \n'; } toMarkdown() { return ' \n'; }
} }
@@ -210,50 +244,40 @@ exports.HardBreak = HardBreak;
* converter.toHTML('```js\nlet x = 1;\n```') * converter.toHTML('```js\nlet x = 1;\n```')
* // <pre><code class="language-js">let x = 1;</code></pre> * // <pre><code class="language-js">let x = 1;</code></pre>
*/ */
class FencedCode extends BlockTag { class FencedCode extends Tag {
opener = '```'; opener = ['```'];
closer = '```'; closer = '';
pattern = /(?<captured>^(?<fence>```)(?<lang>\w*)\s*\n(?<code>(.+\n)+)\k<fence>)/m;
isBlock = true;
element = ['PRE']; element = ['PRE'];
button = { template = '```\n10 PRINT "HELLO"\n20 GOTO 10\n```';
show: true,
label: 'Code Block',
shortcut: 'Ctrl+Shift+E',
};
template = '```\ncode\n```';
replaceSelection = true; 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) { toHTML(token, callback) {
const langAttr = token.meta?.lang const fence = token.groups.fence;
? ` class="language-${(0, hopdown_1.escapeHtml)(token.meta.lang)}"` 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) { toMarkdown(element, callback) {
const lang = element.className.match(/language-(\S+)/)?.[1] || ''; const lang = element.className.match(/language-(\S+)/)?.[1] || '';
const content = element?.textContent || ''; 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; exports.FencedCode = FencedCode;
@@ -262,93 +286,85 @@ exports.FencedCode = FencedCode;
* *
* converter.toHTML('---') // '<hr>' * converter.toHTML('---') // '<hr>'
*/ */
class HorizontalRule extends BlockTag { class HorizontalRule extends Tag {
element = ['HR']; element = ['HR'];
button = { template = '***';
show: true, opener = ['***', '___', '---'];
label: 'Divider', isBlock = true;
shortcut: 'Ctrl+Shift+-',
};
template = '---';
replaceSelection = false; replaceSelection = false;
pattern = /^(\*{3,}|-{3,}|_{3,})\s*$/; pattern = /^(?<captured>(\*{3,}|-{3,}|_{3,}))\s*$/;
match(context) { precedence = 20;
const matched = context.lines[context.index].match(this.pattern);
if (!matched) {
return null;
}
return {
opener: '',
closer: '',
content: '',
raw: '',
consumed: 1,
};
}
toHTML(token, callback) { toHTML(token, callback) {
return `<${this.element[0].toUpperCase()}>`; return `<${this.element[0].toUpperCase()}>`;
} }
toMarkdown(element, callback) { toMarkdown(element, callback) {
return '\n***\n'; return '\n' + this.opener[0] + '\n';
}
toEditorNode(token, callback) {
return document.createElement('hr');
} }
} }
exports.HorizontalRule = HorizontalRule; exports.HorizontalRule = HorizontalRule;
class Heading extends BlockTag { class Heading1 extends Tag {
element = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']; element = ['H1'];
match(context) { pattern = /^(?<captured>(?<hashes>\#{1,6})\s+(?<content>.*?$))/m;
// ATX heading: # through ######, optional closing #s escapePattern = /[.*+?^${}()|[\]\\]/g;
const atxPattern = /^(?<hashes>#{1,6})\s+(?<content>.*?)(?:\s+#+\s*)?$/; opener = ['#'];
const matched = context.lines[context.index].match(atxPattern); closer = '';
if (matched?.groups) { isBlock = true;
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) { toHTML(token, callback) {
const level = token.meta.level; const level = token.groups.hashes.length;
const id = this.anchorId(token.content); const id = this.anchorId(token.groups.content);
return `<h${level} id='${id}'>${callback(token.content)}</h${level}>`; return `<h${level} id='${id}'>${callback(token.groups.content)}</h${level}>`;
} }
toMarkdown(element, callback) { toMarkdown(element, callback) {
const inner = childNodesToMarkdown(element, callback); const inner = childNodesToMarkdown(element, callback);
const level = parseInt(element.nodeName[1]); const level = parseInt(element.nodeName[1]);
return '\n\n' + '#'.repeat(level) + ' ' + inner + '\n\n'; return '\n\n' + '#'.repeat(level) + ' ' + inner + '\n\n';
} }
/** toEditorNode(token, callback) {
* Generate a PascalCase anchor ID from heading text so that const content = token.groups.content;
* in-page links like #MyHeading work without manual IDs. const id = this.anchorId(content);
*/ const heading = document.createElement(this.element[0]);
heading.innerHTML = content;
heading.setAttribute('id', id);
return heading;
}
anchorId(text) { 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 * Lines prefixed with > become blockquotes. Consecutive > lines
* are merged into a single blockquote. * are merged into a single blockquote.
@@ -356,55 +372,26 @@ exports.Heading = Heading;
* converter.toHTML('> hello\n> world') * converter.toHTML('> hello\n> world')
* // <blockquote><p>hello\nworld</p></blockquote> * // <blockquote><p>hello\nworld</p></blockquote>
*/ */
class Blockquote extends BlockTag { class Blockquote extends Tag {
element = ['BLOCKQUOTE']; element = ['BLOCKQUOTE'];
hidden = false; opener = ['> '];
closer = '';
isBlock = true;
template = '> Quote\n> continues here'; template = '> Quote\n> continues here';
match(context) { pattern = /(?<captured>(?:\>\s+.*?(\n|$))+)$/m;
const quotePrefix = /^>\s?/; childToEditorNode(node, callback) {
if (!quotePrefix.test(context.lines[context.index])) { var nodes = [];
return null; for (const child of node.childNodes) {
} if (child.nodeType == 11) {
const lines = []; nodes.concat(this.childToEditorNode(child, callback));
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 { else if (child.textContent) {
const text = callback(child).trim(); nodes.push(child);
if (text) {
paragraphs.push('> ' + text);
}
} }
} }
return '\n\n' + paragraphs.join('\n') + '\n\n'; return nodes;
} }
;
/** /**
* Split a paragraph's content into lines at <br> elements. * Split a paragraph's content into lines at <br> elements.
*/ */
@@ -425,6 +412,63 @@ class Blockquote extends BlockTag {
} }
return lines; 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; exports.Blockquote = Blockquote;
/** /**
@@ -433,40 +477,40 @@ exports.Blockquote = Blockquote;
* *
* converter.toHTML('- one\n- two\n 1. nested') * converter.toHTML('- one\n- two\n 1. nested')
*/ */
class UnorderedList extends BlockTag { class List extends Tag {
element = ['UL']; pattern = /(?<captured>(?<lines>^(?:\s{2,})?(?:[-+*]|\d+\.)\s.+?(?:\n|$))+)/m;
hidden = false; orderedOpenerPattern = /^\d+\.\s/;
match(context) { isBlock = true;
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) { toHTML(token, callback) {
const lines = JSON.parse(token.meta.lines); const result = this.parseBlock(token.groups.captured.split("\n"), 0, 0, callback);
const result = this.parseBlock(lines, 0, 0, callback);
return result.html; return result.html;
} }
toMarkdown(element, callback) { toMarkdown(element, callback) {
return this.nodeToMarkdown(element, 0, 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) { countListLines(lines, start) {
const indentedUnordered = /^(\s*)[*\-+]\s/; const indentedUnordered = /^(\s*)[*\-+]\s/;
const indentedOrdered = /^(\s*)\d+\.\s/; const indentedOrdered = /^(\s*)\d+\.\s/;
@@ -487,13 +531,13 @@ class UnorderedList extends BlockTag {
* Recursively parse markdown list lines into HTML, handling nested * Recursively parse markdown list lines into HTML, handling nested
* sublists at arbitrary depth and mixed list types (ul/ol). * 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('^' const prefix = new RegExp('^'
+ ' '.repeat(indent) // indentation for this nesting level + ' '.repeat(indent) // indentation for this nesting level
+ '([\\*\\-+]|\\d+\\.)' // bullet or number marker + '([\\*\\-+]|\\d+\\.)' // bullet or number marker
+ '\\s' // space after 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 tag = isOl ? 'ol' : 'ul';
const items = []; const items = [];
let lineIndex = start; let lineIndex = start;
@@ -522,9 +566,12 @@ class UnorderedList extends BlockTag {
}); });
lineIndex++; lineIndex++;
} }
const html = `<${tag}>` var html = `<${tag}>`;
+ items.map(item => '<li>' + callback(item.text) + item.sub + '</li>').join('') for (var item of items) {
+ `</${tag}>`; const res = callback(item.text);
html += '<li>' + (res.firstElementChild?.innerHTML || res) + item.sub + '</li>';
}
html += `</${tag}>`;
return { return {
html, html,
end: lineIndex, 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; exports.UnorderedList = UnorderedList;
class OrderedList extends UnorderedList { class OrderedList extends List {
element = ['OL']; element = ['OL'];
opener = ['1.'];
template = '1. list\n1. items \n1. here';
} }
exports.OrderedList = OrderedList; exports.OrderedList = OrderedList;
/** /**
@@ -578,55 +632,59 @@ exports.OrderedList = OrderedList;
* *
* converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |') * converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
*/ */
class Table extends BlockTag { class Table extends Tag {
opener = ['|'];
element = ['TABLE']; element = ['TABLE'];
hidden = false;
template = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |'; template = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
replaceSelection = false; replaceSelection = false;
match(context) { pattern = /(?<captured>(?:\|.+?\|\s*(?:\n|$))+)/m;
const { lines, index } = context; tableSeparatorPattern = new RegExp('^'
if (lines[index].indexOf('|') === -1 + '\\|?' // optional leading pipe
|| index + 1 >= lines.length) { + '\\s*:?-+:?\\s*' // first column separator (optional alignment colons)
return null; + '('
} + '\\|' // 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. |---|---|) // Second line must be the separator row (e.g. |---|---|)
if (!tableSeparator.test(lines[index + 1])) { if (!this.tableSeparatorPattern.test(lines[1])) {
return null; return null;
} }
const headers = this.parseRow(lines[index]); const headers = this.parseRow(lines[0]);
const aligns = this.parseAligns(lines[index + 1]); const aligns = this.parseAligns(lines[1]);
const rows = []; const rows = [];
let lineIndex = index + 2; let lineIndex = 2;
while (lineIndex < lines.length && lines[lineIndex].indexOf('|') !== -1 && !blankLine.test(lines[lineIndex])) { while (lineIndex < lines.length && lines[lineIndex].indexOf('|') !== -1 && !blankLine.test(lines[lineIndex])) {
rows.push(this.parseRow(lines[lineIndex++])); rows.push(this.parseRow(lines[lineIndex++]));
} }
return { return {
content: '', headers: headers,
opener: '', aligns: aligns,
closer: '', rows: rows
raw: '',
consumed: lineIndex - index,
meta: {
headers: JSON.stringify(headers),
aligns: JSON.stringify(aligns),
rows: JSON.stringify(rows),
},
}; };
} }
toHTML(token, callback) { toHTML(token, callback) {
const headers = JSON.parse(token.meta.headers); const block = this.parseBlock(token);
const aligns = JSON.parse(token.meta.aligns); if (!block) {
const rows = JSON.parse(token.meta.rows); throw new Error(`Could not parse table token! ${token.value}`);
}
const cell = (tag, text, index) => { const cell = (tag, text, index) => {
const align = aligns[index] const align = block.aligns[index]
? ` class="align-${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>' 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>'; + '</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>`; return `<table>${head}<tbody>${body}</tbody></table>`;
} }
toMarkdown(element, callback) { toMarkdown(element, callback) {
@@ -674,44 +732,3 @@ class Table extends BlockTag {
} }
} }
exports.Table = Table; 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 { class Tokenizer {
tags; tags;
constructor(tags) { constructor(tags) {
this.tags = tags.ordered; this.tags = tags;
} }
/** /**
* Tokenize a markdown string into an inline token stream. * Tokenize a markdown string into an inline token stream.
@@ -58,12 +58,12 @@ class Tokenizer {
*/ */
tokenize(source) { tokenize(source) {
const tokens = []; const tokens = [];
let position = 0; var position = 0;
let textBuffer = ''; var textBuffer = '';
const flushText = () => { const flushText = () => {
if (textBuffer.length > 0) { if (textBuffer.length > 0) {
tokens.push({ tokens.push({
role: 'text', role: 'block',
value: textBuffer, value: textBuffer,
}); });
textBuffer = ''; textBuffer = '';
@@ -85,7 +85,7 @@ class Tokenizer {
const spaceMatch = remaining.match(/^(?<spaces> {2,})\n/); const spaceMatch = remaining.match(/^(?<spaces> {2,})\n/);
if (spaceMatch?.groups) { if (spaceMatch?.groups) {
flushText(); flushText();
tokens.push({ role: 'break', value: '<br>' }); tokens.push({ role: 'block', value: '<br>' });
position += spaceMatch[0].length; position += spaceMatch[0].length;
continue; continue;
} }
@@ -99,66 +99,19 @@ class Tokenizer {
continue; continue;
} }
} }
// Link: [text](url) or [text](url "title") var match = this.matchTag(source, position);
if (source[position] === '[') { if (match && match.value?.length) {
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(); flushText();
tokens.push(match); 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; continue;
} }
// Plain character // Plain character
@@ -298,24 +251,32 @@ class Tokenizer {
return null; return null;
} }
sequence = sequence.repeat(runLength); sequence = sequence.repeat(runLength);
// get the tag that matches this sequence var matches = this.tags.map.get(sequence);
for (const tag of this.tags) { if (!matches && runLength == 1) {
const delim = tag.isBlock ? tag.opener : tag.delimiter; sequence = source.slice(position, 3);
if (delim !== sequence) { for (var i = 0; i < 2; i++) {
continue; 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 charBefore = position > 0 ? source[position - 1] : '\n';
const charAfter = source[position + sequence.length]; const charAfter = source[position + sequence.length];
const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n') const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0'; && charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0';
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0' const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n'); && (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
return { const role = leftFlanking ? 'open' : rightFlanking ? 'close' : 'block';
role: leftFlanking ? 'open' : rightFlanking ? 'close' : 'block', var token = tag.tokenize(role, sequence, source);
sequence: sequence, if (token) {
value: sequence, return token;
tag: tag, }
};
} }
; ;
return null; 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.
+8 -6
View File
@@ -2,14 +2,16 @@
"name": "hopdown", "name": "hopdown",
"version": "1.0.0", "version": "1.0.0",
"description": "Configurable HTML <=> Markdown converter for WYSIWYG editors.", "description": "Configurable HTML <=> Markdown converter for WYSIWYG editors.",
"main": "dist/js/index.js",
"module": "commonjs", "main": "dist/index.js",
"files": [
"dist/lib/"
],
"types": "src/ts/index.d.ts", "types": "src/ts/index.d.ts",
"files": [
"dist/*"
],
"scripts": { "scripts": {
"tsc": "tsc -p tsconfig.json && tsc -p tsconfig-cjs.json", "tsc": "tsc -p tsconfig.json",
"build": "npm run tsc", "build": "npm run tsc",
"test": "npm run build && vitest" "test": "npm run build && vitest"
}, },
+6 -2
View File
@@ -11,10 +11,14 @@ export const defaultTags = new tags.TagCollection([
new tags.HardBreak(), new tags.HardBreak(),
new tags.FencedCode(), new tags.FencedCode(),
new tags.HorizontalRule(), 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.Blockquote(),
new tags.OrderedList(), new tags.OrderedList(),
new tags.UnorderedList(), new tags.UnorderedList(),
new tags.Table(), new tags.Table(),
new tags.Paragraph()
]); ]);
+20 -208
View File
@@ -5,7 +5,7 @@
import { defaultTags } from "./defaults"; import { defaultTags } from "./defaults";
import { Tokenizer } from "./tokenizer"; import { Tokenizer } from "./tokenizer";
import type { Token, SourceToken, MatchContext, TagType, TagCollectionType } from './types'; import type { Token, TagType, TagCollectionType } from './types';
export function escapeHtml(source: string): string { export function escapeHtml(source: string): string {
@@ -17,16 +17,7 @@ export function escapeHtml(source: string): string {
} }
/** /**
* The preprocessor interface. see HopDown.preprocessLines. * A configurable markdown <=> HTML converter.
*/
interface preprocessedLines {
lines:string[],
referenceLinks: Map<string, { url: string; title?: string }>
}
/**
* A configurable markdown <=> HTML converter.
* *
* Examples: * Examples:
* *
@@ -40,19 +31,18 @@ interface preprocessedLines {
* *
* const node = hopdown.toEditorNode('**bold**'); * const node = hopdown.toEditorNode('**bold**');
* (node as HTMLElement).outerHTML === ( * (node as HTMLElement).outerHTML === (
* "<div>" + * "<div>" +
* "<span class='delim strong'>**</span>" + * "<span class='delim strong'>**</span>" +
* "bold" + * "bold" +
* "<span class='delim strong'>" + * "<span class='delim strong'>" +
* "</div>" * "</div>"
* ); * );
*/ */
export class HopDown { export class HopDown {
public tags: TagCollectionType; public tags: TagCollectionType;
private referenceLinks: Map<string, { url: string; title?: string }> = new Map();
private tokenizer: Tokenizer; private tokenizer: Tokenizer;
constructor(tags: TagCollectionType | null) { constructor(tags?: TagCollectionType | null) {
this.tags = tags || defaultTags; this.tags = tags || defaultTags;
this.tokenizer = new Tokenizer(this.tags); this.tokenizer = new Tokenizer(this.tags);
} }
@@ -61,15 +51,7 @@ export class HopDown {
* Convert a markdown string to tokens. * Convert a markdown string to tokens.
*/ */
public tokenize(source: string): Token[] { public tokenize(source: string): Token[] {
let text = source; return this.tokenizer.tokenize(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;
} }
/** /**
@@ -84,64 +66,8 @@ export class HopDown {
} else { } else {
md = this.nodeToMarkdown(html); md = this.nodeToMarkdown(html);
} }
return md.replace(/\n{3,}/g, '\n\n').trim(); md = md.replace(/^\n+/, '').replace(/\n+$/, '');
} return md;
/**
* Convert a markdown string to an HTML string.
*/
public toHTML = (markdown: string): string => {
const output: string[] = [];
const preprocessed: preprocessedLines = this.preprocessMarkdown(markdown);
if (!preprocessed.lines) {
return '';
}
const blockTags = this.tags.ordered.filter(t => {
return t.isBlock && t.constructor.name !== 'Paragraph'
})
const pTag = this.tags.getByElementName('p');
if (pTag === undefined) {
throw new Error("Cannot parse without a paragraph tag!");
}
let lineIndex = 0;
while (lineIndex < preprocessed.lines.length) {
let context: MatchContext;
let matched = false;
let token: SourceToken | null;
for (const tag of blockTags) {
context = {
lines: preprocessed.lines,
index: lineIndex,
text: '',
offset: 0,
};
token = tag.match!(context, this.tags);
if (!token) {
continue;
}
output.push(tag.toHTML(token, this.inlineToHTML));
lineIndex += token.consumed;
matched = true;
break;
}
if (matched) {
continue;
}
/* always check the paragraph tag last */
token = pTag.match!(context!, this.tags);
if (token) {
output.push(pTag.toHTML(token, this.inlineToHTML));
lineIndex += token.consumed;
continue;
}
lineIndex++;
}
return output.join('\n');
} }
/** /**
@@ -152,13 +78,16 @@ export class HopDown {
return this.tokensToEditorNodes(tokens); return this.tokensToEditorNodes(tokens);
} }
public toHTML = (markdown: string): string => {
return '<p>' + this.inlineToHTML(markdown) + '</p>';
}
/** /**
* Recursive entrypoint for converting markdown to html. * Recursive entrypoint for converting markdown to html.
*/ */
private inlineToHTML = (markdown: string): string => { public inlineToHTML = (markdown: string): string => {
const tokens = this.tokenize(markdown); const tokens = this.tokenize(markdown);
return this.tokensToHTML(tokens); return this.tokensToHTML(tokens)
} }
/** /**
@@ -169,41 +98,6 @@ export class HopDown {
return this.tokensToEditorNodes(tokens); return this.tokensToEditorNodes(tokens);
}; };
/**
* Process a markdown string by splitting it into lines, stripping empty lines, and capturing reference links.
*/
private preprocessMarkdown(markdown: string): preprocessedLines {
const blankLine = /^\s*$/;
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
const referenceLinks = new Map<string, { url: string; title?: string }>;
const output: string[] = [];
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
for (const line of lines) {
const match = line.match(refDefinition);
if (match?.groups) {
this.referenceLinks.set(
match.groups.label.toLowerCase(),
{
url: match.groups.url,
title: match.groups.title,
},
);
continue;
}
if (!blankLine.test(line)) {
output.push(line)
}
}
return {
lines: output,
referenceLinks: referenceLinks
}
}
/** /**
* Convert an HTML node to a markdown string. Used by toMarkdown(). * Convert an HTML node to a markdown string. Used by toMarkdown().
*/ */
@@ -235,59 +129,6 @@ export class HopDown {
return ret; return ret;
} }
/**
* Replace [text][ref] and [text][] with [text](url) using the
* reference definitions collected during block parsing.
*/
private resolveReferenceLinks(text: string): string {
if (this.referenceLinks.size === 0) {
return text;
}
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
return text.replace(refLink, (...args) => {
const groups = args[args.length - 1] as Record<string, string>;
const label = (groups.label || groups.text).toLowerCase();
const ref = this.referenceLinks.get(label);
if (!ref) {
return args[0];
}
const titlePart = ref.title ? ` "${ref.title}"` : '';
return `[${groups.text}](${ref.url}${titlePart})`;
});
}
/**
* Normalize flanking underscore runs to asterisks so the tokenizer
* only needs to handle * delimiters for emphasis.
*/
private normalizeUnderscores(text: string): string {
// Protect backslash-escaped underscores from normalization
const escapePlaceholder = '\x00U\x00';
const safeText = text.replace(/\\_/g, escapePlaceholder);
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
const openRun = new RegExp(
`(?<=^|${punctuation})` + // preceded by start, space, or punctuation
`(_+)` + // one or more underscores
`(?=\\S)`, // followed by non-whitespace
'g'
);
const closeRun = new RegExp(
`(?<=\\S)` + // preceded by non-whitespace
`(_+)` + // one or more underscores
`(?=$|${punctuation})`, // followed by end, space, or punctuation
'g'
);
const toAsterisks = (_: string, run: string) => '*'.repeat(run.length);
const normalized = safeText
.replace(openRun, toAsterisks)
.replace(closeRun, toAsterisks);
return normalized.replace(/\x00U\x00/g, '\\_');
}
/** /**
* Convert a token stream to editor nodes. * Convert a token stream to editor nodes.
*/ */
@@ -310,15 +151,6 @@ export class HopDown {
}; };
for (const token of paired) { 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) { if (token.tag) {
const thisNode = token.tag.toEditorNode(token, this.inlineToEditorNodes); const thisNode = token.tag.toEditorNode(token, this.inlineToEditorNodes);
if (! thisNode) { if (! thisNode) {
@@ -327,7 +159,7 @@ export class HopDown {
if (token.role == 'open') { if (token.role == 'open') {
if (grouping.length) { if (grouping.length) {
fragment.append(groupAsNode()); fragment.appendChild(groupAsNode());
} }
grouping = [thisNode]; grouping = [thisNode];
continue; continue;
@@ -335,7 +167,7 @@ export class HopDown {
if (token.role == 'close') { if (token.role == 'close') {
grouping.push(thisNode); grouping.push(thisNode);
fragment.append(groupAsNode()); fragment.appendChild(groupAsNode());
grouping = []; grouping = [];
continue; continue;
} }
@@ -351,13 +183,14 @@ export class HopDown {
continue; continue;
} }
if (grouping.length) { if (grouping.length) {
fragment.appendChild(groupAsNode()); fragment.appendChild(groupAsNode());
} }
return fragment; return fragment;
} }
/** /**
* Convert a token stream to HTML. Matches open/close delimiter * Convert a token stream to HTML. Matches open/close delimiter
* pairs and wraps their content in the appropriate HTML tags. * pairs and wraps their content in the appropriate HTML tags.
@@ -371,34 +204,17 @@ export class HopDown {
// Second pass: build HTML from paired tokens // Second pass: build HTML from paired tokens
let html = ''; let html = '';
for (const token of paired) { 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) { if (token.tag) {
html += token.tag.toHTML(token, this.inlineToHTML); html += token.tag.toHTML(token, this.inlineToHTML);
continue; continue;
} }
html += escapeHtml(token.value); html += escapeHtml(token.value);
} }
return html; 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 * openers/closers are converted to text tokens so they render
* as literal characters. * as literal characters.
*/ */
@@ -413,15 +229,11 @@ export class HopDown {
for (let index = 0; index < result.length; index++) { for (let index = 0; index < result.length; index++) {
const token = result[index]; const token = result[index];
if (token.role === 'block') {
token.role = openDelimiters.has(token.sequence!) ? 'close' : 'open';
}
if (token.role === 'open') { if (token.role === 'open') {
// Don't open a delimiter that's already open (prevents nesting) // Don't open a delimiter that's already open (prevents nesting)
if (openDelimiters.has(token.sequence!)) { if (openDelimiters.has(token.sequence!)) {
result[index] = { result[index] = {
role: 'text', role: 'block',
value: token.value, value: token.value,
}; };
continue; continue;
@@ -441,7 +253,7 @@ export class HopDown {
} }
if (!matched) { if (!matched) {
result[index] = { result[index] = {
role: 'text', role: 'block',
value: token.value, value: token.value,
}; };
} }
-784
View File
@@ -1,784 +0,0 @@
import type { Token, TagType, TagCollectionType, MatchContext, SourceToken, ListItem, ListResult} from './types';
import { escapeHtml } from "./hopdown";
/**
* Patterns shared across multiple tag classes. Defined once here
* so the regexes aren't duplicated and can be referenced by name.
*/
const blankLine = /^\s*$/;
const unorderedMarker = /^[*\-]\s/;
const orderedMarker = /^\d+\.\s/;
const escapeRegex = /[.*+?^${}()|[\]\\]/g;
const tableSeparator = new RegExp(
'^'
+ '\\|?' // optional leading pipe
+ '\\s*:?-+:?\\s*' // first column separator (optional alignment colons)
+ '('
+ '\\|' // pipe between columns
+ '\\s*:?-+:?\\s*' // subsequent column separator
+ ')*' // zero or more additional columns
+ '\\|?' // optional trailing pipe
+ '\\s*$' // end of line
);
export function childNodesToMarkdown(element: HTMLElement, callback: Function): string {
return Array.from(element.childNodes)
.map(child => callback(child))
.join('');
}
export class Tag implements TagType {
element: string[] = [''];
opener: string = '';
closer: string = '';
delimiter: string | null = null;
precedence: number = 50;
isBlock: boolean = false;
toHTML(token: any, callback: Function): string {
if (token.role == 'open') {
return this.element.map(el => { return `<${el.toUpperCase()}>` }).join('');
}
if (token.role == 'close') {
const rev = this.element;
rev.reverse();
return rev.map(el => { return `</${el.toUpperCase()}>` }).join('');
}
return token.value;
};
toMarkdown(element: HTMLElement, callback: Function): string {
const children = childNodesToMarkdown(element, callback);
return `${this.delimiter}${children || element.textContent || ''}${this.delimiter}`;
};
toEditorNode(token: Token, callback: Function): Node {
if (token.role == 'open' || token.role == 'close') {
const span = document.createElement('span');
span.classList.add('delim');
for (const el of this.element) {
span.classList.add(el.toLowerCase());
};
if (this.isBlock) {
span.textContent = token.role == 'open' ? this.opener : this.closer;
} else {
span.textContent = this.delimiter;
}
return span;
}
return document.createTextNode(token.value);
};
}
export class TagCollection implements TagCollectionType {
tags: Record<string, TagType> = {};
ordered: TagType[];
openers: RegExp[];
constructor(tags: TagType[]) {
for (const tag of tags) {
if (tag.constructor.name in this.tags) {
throw new Error(`Duplicate tag instances found for class ${tag.constructor.name}! Do you need to subclass it?`);
}
this.tags[tag.constructor.name] = tag;
}
this.ordered = tags.sort((a, b) => {
const p1 = a.precedence - (a.isBlock ? 100 : 0);
const p2 = b.precedence - (b.isBlock ? 100 : 0);
return p1 < p2 ? -1 : 1;
});
this.validate();
const escapeRegex = /[.*+?^${}()|[\]\\]/g;
this.openers = this.ordered.filter(t => { return t.delimiter !== null }).map(tag => {
const escaped = tag.delimiter!.replace(escapeRegex, '\\$&');
const escapedChar = tag.delimiter![0].replace(escapeRegex, '\\$&');
return new RegExp(
`(?<!${escapedChar})` +
`${escaped}` +
`(?!${escapedChar})` +
`([^\\x01\\x02]+)$`
);
});
}
private validate(): void {
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: string): TagType | undefined {
const el = element.toUpperCase();
return this.ordered.filter(t => {
return [...t.element, ...t.aliases ?? []].includes(el);
})[0] || undefined;
}
}
export class Bold extends Tag {
element = ['STRONG'];
aliases = ['B'];
delimiter = '**';
shortcut = 'Ctrl+B';
precedence = 40;
}
export class Italic extends Tag {
element = ['EM'];
aliases = ['I'];
delimiter = '*';
shortcut = 'Ctrl+I';
precedence = 30;
}
export class BoldItalic extends Tag {
element = ['STRONG', 'EM'];
delimiter = '***';
precedence = 50;
}
export class Strikethrough extends Tag {
element = ['DEL'];
aliases = ['S', 'STRIKE'];
delimiter = '~~';
}
export class Code extends Tag {
element = ['CODE'];
delimiter = '`';
}
export class Anchor extends Tag {
element = ['A'];
toHTML(token: Token, callback: Function) {
const titleAttr = token.title
? ` TITLE="${escapeHtml(token.title)}"`
: '';
return `<A HREF="${escapeHtml(token.href || '')}"${titleAttr}>${escapeHtml(token.value)}</A>`;
}
toMarkdown(element: HTMLElement, callback: Function) {
const href = element.getAttribute('href') || '';
const title = element.getAttribute('title');
const titlePart = title ? ` "${title}"` : '';
return '[' + element.innerText + '](' + href + titlePart + ')';
}
toEditorNode(token: Token, callback: Function) {
return document.createElement('A');
}
}
class BlockTag extends Tag {
isBlock: boolean = true;
delimiter = null;
toMarkdown(element: HTMLElement, callback: Function): string {
const children = childNodesToMarkdown(element, callback);
const ret = `${this.opener || ''}${children || element.textContent || ''}${this.closer || ''}`;
return ret;
};
match(context: MatchContext, tags?: TagCollectionType): SourceToken | null {
return null;
}
}
export class HardBreak extends BlockTag {
match(context: MatchContext) {
return null;
}
toHTML(): string { return '<BR>' }
toMarkdown(): string { 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: MatchContext): SourceToken | null {
// 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: string[] = [];
let lineIndex = context.index + 1;
while (lineIndex < context.lines.length && !context.lines[lineIndex].startsWith(fence)) {
code.push(context.lines[lineIndex++]);
}
return {
opener: fence + lang,
closer: '',
content: code.join('\n'),
raw: '',
consumed: lineIndex + 1 - context.index,
meta: { lang },
};
}
toHTML(token: SourceToken, callback: Function): string {
const langAttr = token.meta?.lang
? ` class="language-${escapeHtml(token.meta.lang)}"`
: '';
return `<pre${langAttr}>${escapeHtml(token.content)}</pre>`;
}
toMarkdown(element: HTMLElement, callback: Function): string {
const lang = element.className.match(/language-(\S+)/)?.[1] || '';
const content = element?.textContent || '';
return '\n\n' + this.opener + `${lang}\n${content}\n` + this.closer + '\n\n' ;
}
}
/**
* Three or more *, -, or _ on a line become <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: MatchContext): SourceToken | null {
const matched = context.lines[context.index].match(this.pattern);
if (!matched) {
return null;
}
return {
opener: '',
closer: '',
content: '',
raw: '',
consumed: 1,
};
}
toHTML(token: SourceToken, callback: Function): string {
return `<${this.element[0].toUpperCase()}>`;
}
toMarkdown(element: HTMLElement, callback: Function): string {
return '\n***\n';
}
}
export class Heading extends BlockTag {
element = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
match(context: MatchContext): SourceToken | null {
// ATX heading: # through ######, optional closing #s
const atxPattern = /^(?<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: SourceToken, callback: Function): string {
const level = token.meta!.level;
const id = this.anchorId(token.content);
return `<h${level} id='${id}'>${callback(token.content)}</h${level}>`;
}
toMarkdown(element: HTMLElement, callback: Function): string {
const inner = childNodesToMarkdown(element, callback)
const level = parseInt(element.nodeName[1]);
return '\n\n' + '#'.repeat(level) + ' ' + inner + '\n\n';
}
/**
* Generate a PascalCase anchor ID from heading text so that
* in-page links like #MyHeading work without manual IDs.
*/
private anchorId(text: string): string {
return text.replace(escapeRegex, ' ').trim().split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join('_');
}
}
/**
* Lines prefixed with > become blockquotes. Consecutive > lines
* are merged into a single blockquote.
*
* converter.toHTML('> hello\n> world')
* // <blockquote><p>hello\nworld</p></blockquote>
*/
export class Blockquote extends BlockTag {
element = ['BLOCKQUOTE'];
hidden = false;
template = '> Quote\n> continues here';
match(context: MatchContext): SourceToken | null {
const quotePrefix = /^>\s?/;
if (!quotePrefix.test(context.lines[context.index])) {
return null;
}
const lines: string[] = [];
let lineIndex = context.index;
while (lineIndex < context.lines.length && quotePrefix.test(context.lines[lineIndex])) {
lines.push(context.lines[lineIndex++].replace(quotePrefix, ''));
}
return {
opener: '> ',
closer: '',
content: lines.join('\n'),
raw: '',
consumed: lineIndex - context.index,
};
}
toHTML(token: SourceToken, callback: Function): string {
const inner = callback(token.content);
const breaks = inner.replace(/([^\n])\n(?!\n)/g, '$1<br>')
.replace(/\n\n(.+?)(\n\n|$)/g, '<p>$1</p>');
return '<blockquote>' + breaks + '</blockquote>';
}
toMarkdown(element: HTMLElement, callback: Function): string {
// 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: string[] = [];
for (const child of Array.from(element.childNodes)) {
const el = child as HTMLElement;
if (child.nodeType === 1 && el.tagName === 'P') {
const lines = this.paragraphToLines(el, callback);
paragraphs.push(">");
paragraphs.push(lines.map(line => '> ' + line).join('\n'));
} else {
const text = callback(child).trim();
if (text) {
paragraphs.push('> ' + text);
}
}
}
return '\n\n' + paragraphs.join('\n') + '\n\n';
}
/**
* Split a paragraph's content into lines at <br> elements.
*/
private paragraphToLines(paragraph: HTMLElement, callback: Function): string[] {
const lines: string[] = [];
let currentLine = '';
for (const child of Array.from(paragraph.childNodes)) {
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
lines.push(currentLine);
currentLine = '';
} else {
currentLine += callback(child);
}
}
if (currentLine.trim()) {
lines.push(currentLine);
}
return lines;
}
}
/**
* Ordered and unordered lists with arbitrary nesting. Indentation
* determines depth; mixed list types (ul inside ol) are supported.
*
* converter.toHTML('- one\n- two\n 1. nested')
*/
export class UnorderedList extends BlockTag {
element = ['UL'];
hidden = false;
match(context: MatchContext): SourceToken | null {
const line = context.lines[context.index];
const prefixPattern = /^((?:[*\-]|\d+\.)\s)/;
const matched = line.match(prefixPattern);
if (!matched) {
return null;
}
// Count how many lines this list consumes so HopDown can
// advance past them. The actual parsing happens in toHTML
// where we have access to the inline converter.
const consumed = this.countListLines(context.lines, context.index);
return {
opener: matched[1],
closer: '',
content: '',
raw: '',
consumed,
meta: {
startIndex: String(context.index),
lines: JSON.stringify(context.lines.slice(context.index, context.index + consumed)),
},
};
}
toHTML(token: SourceToken, callback: Function): string {
const lines = JSON.parse(token.meta!.lines) as string[];
const result = this.parseBlock(lines, 0, 0, callback);
return result.html;
}
toMarkdown(element: HTMLElement, callback: Function): string {
return this.nodeToMarkdown(element, 0, callback);
}
private countListLines(lines: string[], start: number): number {
const indentedUnordered = /^(\s*)[*\-+]\s/;
const indentedOrdered = /^(\s*)\d+\.\s/;
let lineIndex = start;
while (lineIndex < lines.length) {
const line = lines[lineIndex];
if (blankLine.test(line)) {
break;
}
if (!indentedUnordered.test(line) && !indentedOrdered.test(line)) {
break;
}
lineIndex++;
}
return lineIndex - start;
}
/**
* Recursively parse markdown list lines into HTML, handling nested
* sublists at arbitrary depth and mixed list types (ul/ol).
*/
private parseBlock(lines: string[], start: number, indent: number, callback: Function): ListResult {
const prefix = new RegExp(
'^'
+ ' '.repeat(indent) // indentation for this nesting level
+ '([\\*\\-+]|\\d+\\.)' // bullet or number marker
+ '\\s' // space after marker
);
const isOl = orderedMarker.test(lines[start].trim());
const tag = isOl ? 'ol' : 'ul';
const items: ListItem[] = [];
let lineIndex = start;
while (lineIndex < lines.length) {
const line = lines[lineIndex];
if (blankLine.test(line)) {
break;
}
const leadingWhitespace = /^(\s*)/;
const lineIndent = line.match(leadingWhitespace)![1].length;
if (lineIndent < indent) {
break;
}
if (lineIndent > indent) {
const sub = this.parseBlock(lines, lineIndex, lineIndent, callback);
items[items.length - 1].sub = sub.html;
lineIndex = sub.end;
continue;
}
if (!prefix.test(line)) {
break;
}
items.push({
text: line.replace(prefix, ''),
sub: '',
});
lineIndex++;
}
const html = `<${tag}>`
+ items.map(item => '<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.
*/
private nodeToMarkdown(node: HTMLElement, depth: number, callback: Function): string {
const isOl = node.nodeName === 'OL';
const indent = ' '.repeat(depth);
const lines: string[] = [];
Array.from(node.children).forEach((listItem, index) => {
const marker = isOl ? (index + 1) + '. ' : '* ';
const { text, sublist } = this.partitionListItem(listItem, depth, callback);
lines.push(indent + marker + text.trim());
if (sublist) {
lines.push(sublist);
}
});
const result = lines.join('\n');
return depth === 0
? '\n\n' + result + '\n\n'
: result;
}
private partitionListItem(listItem: Element, depth: number, callback: Function): { text: string; sublist: string } {
let text = '';
let sublist = '';
for (const child of Array.from(listItem.childNodes)) {
if (child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')) {
sublist += this.nodeToMarkdown(child as HTMLElement, depth + 1, callback);
} else {
text += callback(child);
}
}
return {
text,
sublist,
};
}
}
export class OrderedList extends UnorderedList {
element = ['OL'];
}
/**
* Pipe-delimited tables with optional column alignment.
*
* converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
*/
export class Table extends BlockTag {
element = ['TABLE'];
hidden = false;
template = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
replaceSelection = false;
match(context: MatchContext): SourceToken | null {
const { lines, index } = context;
if (lines[index].indexOf('|') === -1
|| index + 1 >= lines.length) {
return null;
}
// Second line must be the separator row (e.g. |---|---|)
if (!tableSeparator.test(lines[index + 1])) {
return null;
}
const headers = this.parseRow(lines[index]);
const aligns = this.parseAligns(lines[index + 1]);
const rows: string[][] = [];
let lineIndex = index + 2;
while (lineIndex < lines.length && lines[lineIndex].indexOf('|') !== -1 && !blankLine.test(lines[lineIndex])) {
rows.push(this.parseRow(lines[lineIndex++]));
}
return {
content: '',
opener: '',
closer: '',
raw: '',
consumed: lineIndex - index,
meta: {
headers: JSON.stringify(headers),
aligns: JSON.stringify(aligns),
rows: JSON.stringify(rows),
},
};
}
toHTML(token: SourceToken, callback: Function): string {
const headers: string[] = JSON.parse(token.meta!.headers);
const aligns: (string | null)[] = JSON.parse(token.meta!.aligns);
const rows: string[][] = JSON.parse(token.meta!.rows);
const cell = (tag: string, text: string, index: number): string => {
const align = aligns[index]
? ` class="align-${aligns[index]}"`
: '';
return `<${tag}${align}>${callback(text)}</${tag}>`;
};
const head = '<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: HTMLElement, callback: Function): string {
const rows = Array.from(element.querySelectorAll('tr'));
if (!rows.length) {
return '';
}
const headers = Array.from(rows[0].querySelectorAll('th,td'))
.map(cell => callback(cell).trim());
const separator = Array.from(element.querySelectorAll('th')).map( h => {
return h.className == 'align-center' ? ':---:' :
h.className == 'align-right' ? '---:' :
'---';
});
const output = [
'| ' + headers.join(' | ') + ' |',
'| ' + separator.join(' | ') + ' |',
];
for (const row of rows.slice(1)) {
const cells = Array.from(row.querySelectorAll('td,th'))
.map(cell => callback(cell).trim());
output.push('| ' + cells.join(' | ') + ' |');
}
return '\n\n' + output.join('\n') + '\n\n';
}
private parseRow(line: string): string[] {
return line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map(cell => cell.trim());
}
private parseAligns(line: string): (string | null)[] {
const centerAlign = /^:-+:$/;
const rightAlign = /^-+:$/;
return this.parseRow(line).map(cell => {
if (centerAlign.test(cell)) {
return 'center';
}
if (rightAlign.test(cell)) {
return 'right';
}
return null;
});
}
}
export class Paragraph extends BlockTag {
element = ['P'];
opener = '';
closer = '';
precedence = -1;
match(context: MatchContext, tags?: TagCollectionType): SourceToken | null {
const collected: string[] = [];
let lineIndex = context.index;
while (lineIndex < context.lines.length) {
const line = context.lines[lineIndex];
const nextLine = lineIndex < context.lines.length ? context.lines[lineIndex + 1]: '';
for (const pattern of [...tags!.openers, blankLine]) {
if (pattern.test(line)) {
break;
}
}
// Table: current line has pipes and next line is a separator row
if (line.indexOf('|') !== -1) {
if (nextLine && tableSeparator.test(nextLine)) {
break;
}
}
collected.push(context.lines[lineIndex++]);
}
if (!collected.length) {
return null;
}
const ret = {
opener: '',
closer: '',
content: collected.join('\n'),
raw: '',
consumed: lineIndex - context.index,
};
return ret;
}
toHTML(token: SourceToken, callback: Function): string {
return '<p>' + callback(token.content) + '</p>';
}
}
+46
View File
@@ -0,0 +1,46 @@
import { Tag } from "./base";
import { escapeHtml } from "../hopdown";
import type { Token, TokenRole } from '../types';
export class Anchor extends Tag {
opener = ['['];
element = ['A'];
pattern = /(?<captured>\[(?<text>[^\[\]]+)\]\((?<url>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\))/;
autolink_pattern = /^(?<captured>(?<url>[A-Za-z]+:\/\/[^\s>]+))/;
showDelimitersInEditorNodes = false;
tokenize(role: TokenRole, sequence: string, source: string): Token | null {
const match = (
this.pattern.exec(source) ||
this.autolink_pattern.exec(source)
);
if (match?.groups) {
const token: Token = {
'role': 'block',
'sequence': sequence,
'value': match.groups.captured,
'groups': match.groups,
'tag': this
}
return token;
}
return null;
}
toHTML(token: Token, callback: Function) {
const title = escapeHtml(token.groups!.title || '');
const url = escapeHtml(token.groups!.url || '');
const text = escapeHtml(token.groups!.text || '') || url;
return `<A HREF="${url}" TITLE="${title}">${text}</A>`;
}
toMarkdown(element: HTMLElement, callback: Function) {
const href = element.getAttribute('href') || '';
const title = element.getAttribute('title');
const titlePart = title ? ` "${title}"` : '';
return '[' + element.innerText + '](' + href + titlePart + ')';
}
}
+139
View File
@@ -0,0 +1,139 @@
import type { Token, TokenRole, TagType, TagCollectionType } from '../types';
export class Tag implements TagType {
element: string[] = [''];
opener: string[] = [''];
closer: string = '';
pattern: RegExp | null = null;
precedence: number = 50;
isBlock: boolean = false;
showDelimitersInEditorNodes: boolean = true;
childNodesToMarkdown(element: HTMLElement, callback: Function): string {
return Array.from(element.childNodes)
.map(child => callback(child))
.join('');
}
tokenize(role: TokenRole, sequence: string, source: string): Token | null {
if (this.pattern) {
const match = this.pattern!.exec(source);
if (!match?.groups) {
return null;
}
return {
'role': 'block',
'sequence': sequence,
'value': match.groups.captured,
'groups': match.groups,
'tag': this
}
}
return {
role: role,
sequence: sequence,
value: sequence,
tag: this,
}
}
toHTML(token: Token, callback: Function): string {
if (token.role == 'open') {
return this.element.map(el => { return `<${el.toUpperCase()}>` }).join('');
}
if (token.role == 'close') {
const rev = this.element;
rev.reverse();
return rev.map(el => { return `</${el.toUpperCase()}>` }).join('');
}
if (token.role == 'block') {
const res = callback(token.value);
return res.firstElementChild?.innerHTML || res;
}
return token.value;
};
toMarkdown(element: HTMLElement, callback: Function): string {
const children = this.childNodesToMarkdown(element, callback);
return `${this.opener[0]}${children || element.textContent || ''}${this.closer || this.opener[0]}`;
};
delimNodes(): [Node, Node] {
const opener = document.createElement('span');
opener.classList.add('delim');
for (const el of this.element) {
opener.classList.add(el.toLowerCase());
};
opener.textContent = this.opener[0];
const closer = opener.cloneNode(true);
closer.textContent = this.closer || this.opener[0];
return [opener, closer]
}
toEditorNode(token: Token, callback: Function): Node {
console.log(token);
if (['open', 'close', 'block'].indexOf(token.role) == -1) {
return document.createTextNode(token.value);
}
const [opener, closer] = this.delimNodes();
if (token.role == 'block') {
const div: HTMLElement = document.createElement('div');
div.innerHTML = this.toHTML(token, callback);
const el = div.firstChild!;
if (token.tag!.showDelimitersInEditorNodes) {
if (this.opener || this.closer) {
el.insertBefore(opener, el.firstChild);
el.appendChild(closer);
}
}
return el as Node;
}
if (token.role == 'close') {
return closer;
}
return opener;
};
}
export class TagCollection implements TagCollectionType {
tags: Record<string, TagType> = {};
ordered: TagType[];
map: Map<string, TagType[]>;
//openers: RegExp[];
constructor(tags: TagType[]) {
for (const tag of tags) {
if (tag.constructor.name in this.tags) {
throw new Error(`Duplicate tag instances found for class ${tag.constructor.name}! Do you need to subclass it?`);
}
this.tags[tag.constructor.name] = tag;
}
this.ordered = tags.sort((a, b) => {
const p1 = a.precedence - (a.isBlock ? 100 : 0);
const p2 = b.precedence - (b.isBlock ? 100 : 0);
return p1 < p2 ? -1 : 1;
});
this.map = new Map();
for (const tag of this.ordered) {
for (const key of tag.opener) {
if (!this.map.has(key)) {
this.map.set(key, []);
}
this.map.get(key)!.push(tag);
}
}
}
getByElementName(element: string): TagType | undefined {
const el = element.toUpperCase();
return this.ordered.filter(t => {
return [...t.element, ...t.aliases ?? []].includes(el);
})[0] || undefined;
}
}
+112
View File
@@ -0,0 +1,112 @@
import { Tag } from "./base";
import type { Token, TokenRole } from '../types';
/**
* Lines prefixed with > become blockquotes. Consecutive > lines
* are merged into a single blockquote.
*
* converter.toHTML('> hello\n> world')
* // <blockquote><p>hello\nworld</p></blockquote>
*/
export class Blockquote extends Tag {
element = ['BLOCKQUOTE'];
opener = ['> '];
closer = '';
isBlock = true;
template = '> Quote\n> continues here';
pattern = /(?<captured>(?:\>\s+.*?(\n|$))+)$/m;
private childToEditorNode(node: Node, callback:Function): Node[] {
var nodes: Node[] = [];
for (const child of node.childNodes) {
if (child.nodeType == 11) {
nodes.concat(this.childToEditorNode(child, callback));
} else if (child.textContent) {
nodes.push(child);
}
}
return nodes;
};
/**
* Split a paragraph's content into lines at <br> elements.
*/
private paragraphToLines(paragraph: HTMLElement, callback: Function): string[] {
const lines: string[] = [];
let currentLine = '';
for (const child of Array.from(paragraph.childNodes)) {
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
lines.push(currentLine);
currentLine = '';
} else {
currentLine += callback(child);
}
}
if (currentLine.trim()) {
lines.push(currentLine);
}
return lines;
}
toHTML(token: Token, callback: Function): string {
var html = `<${this.element[0].toLowerCase()}>`;
var inner = [];
var pFlag = false;
for (var line of token.value.split('\n')) {
var text = line.replace(/^>\s*/, '');
if (text) {
html += callback(text) + "<br>";
continue;
}
html += pFlag ? "</p>" : "<p>";
pFlag = !pFlag;
}
html = html.replace(/\<br\>$/g, pFlag ? '</p>' : '');
html += `</${this.element[0].toLowerCase()}>`;
return html;
}
toMarkdown(element: HTMLElement, callback: Function): string {
// 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: string[] = [];
for (const child of Array.from(element.childNodes)) {
const el = child as HTMLElement;
if (child.nodeType === 1 && el.tagName === 'P') {
const lines = this.paragraphToLines(el, callback);
paragraphs.push(this.opener[0]);
paragraphs.push(lines.map(line => '> ' + line).join('\n'));
} else {
const text = callback(child).trim();
if (text) {
paragraphs.push('> ' + text);
}
}
}
return '\n\n' + paragraphs.join('\n') + '\n\n';
}
toEditorNode(token: Token, callback: Function): Node {
const [opener, closer] = this.delimNodes();
opener.textContent = opener.textContent!.replaceAll(' ', '');
const bq: Node = document.createElement('blockquote');
for (const line of (token.value).split('\n')) {
if (line === '') {
continue;
}
const inner = callback(line.replace(/^>\s?/, ''));
const children: Node[] = this.childToEditorNode(inner, callback);
const p = document.createElement('P');
for (const c of children) {
p.innerHTML += (c as HTMLElement).innerHTML;
}
p.insertBefore(opener.cloneNode(true), p.firstChild);
bq.appendChild(p);
}
return bq;
}
}
+54
View File
@@ -0,0 +1,54 @@
import { Tag } from "./base";
import { escapeHtml } from "../hopdown";
import type { Token, TokenRole } from '../types';
/**
* Fenced code blocks: lines between ``` delimiters become <pre><code>.
*
* converter.toHTML('```js\nlet x = 1;\n```')
* // <pre><code class="language-js">let x = 1;</code></pre>
*/
export class FencedCode extends 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: Token, callback: Function): string {
const fence = token.groups!.fence;
const lang = token.groups!.lang.trim();
var code = token.groups!.code.replace(/^\n|\n$/g, '');
code = `\n${code}\n`;
const langAttr = lang
? ` class="language-${escapeHtml(lang)}"`
: '';
const val = token.value.slice(
token.groups!.fence.length + token.groups!.lang.length,
token.value.length - token.groups!.fence.length
);
return `<pre${langAttr}>${escapeHtml(val)}</pre>`;
}
toMarkdown(element: HTMLElement, callback: Function): string {
const lang = element.className.match(/language-(\S+)/)?.[1] || '';
const content = element?.textContent || '';
return '\n\n' + this.opener[0] + lang + content + this.opener[0] + '\n\n';
}
toEditorNode(token: Token, callback: Function): Node {
const div: HTMLElement = document.createElement('div');
div.innerHTML = this.toHTML(token, callback);
const [opener, closer] = this.delimNodes();
const el = div.firstChild;
if (el) {
el.insertBefore(opener, el.firstChild);
el.appendChild(closer);
}
return el as Node;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Tag } from "./base";
export class HardBreak extends Tag {
element = ['BR'];
template = ' \n';
precedence = 20;
pattern = /^\s*$/;
isBlock = true;
toHTML(): string { return '<BR>' }
toMarkdown(): string { return ' \n' }
}
+47
View File
@@ -0,0 +1,47 @@
import { Tag } from "./base";
import { escapeHtml } from "../hopdown";
import type { Token, TokenRole } from '../types';
export class Heading1 extends Tag {
element = ['H1'];
pattern = /^(?<captured>(?<hashes>\#{1,6})\s+(?<content>.*?$))/m;
escapePattern = /[.*+?^${}()|[\]\\]/g;
opener = ['#'];
closer = '';
isBlock = true;
toHTML(token: Token, callback: Function): string {
const level = token.groups!.hashes.length;
const id = this.anchorId(token.groups!.content);
return `<h${level} id='${id}'>${callback(token.groups!.content)}</h${level}>`;
}
toMarkdown(element: HTMLElement, callback: Function): string {
const inner = this.childNodesToMarkdown(element, callback)
const level = parseInt(element.nodeName[1]);
return '\n\n' + '#'.repeat(level) + ' ' + inner + '\n\n';
}
toEditorNode(token: Token, callback: Function): Node {
const content = token.groups!.content;
const id = this.anchorId(content);
const heading = document.createElement(this.element[0]);
heading.innerHTML = content;
heading.setAttribute('id', id);
return heading;
}
private anchorId(text: string): string {
return text.replace(this.escapePattern, ' ').trim().split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join('_');
}
}
export class Heading2 extends Heading1 { element = ['H2']; opener = ['##'] };
export class Heading3 extends Heading1 { element = ['H3']; opener = ['###'] };
export class Heading4 extends Heading1 { element = ['H4']; opener = ['####'] };
export class Heading5 extends Heading1 { element = ['H5']; opener = ['#####'] };
export class Heading6 extends Heading1 { element = ['H6']; opener = ['######'] };
+29
View File
@@ -0,0 +1,29 @@
import { Tag } from "./base";
import type { Token, TokenRole } from '../types';
/**
* Three or more *, -, or _ on a line become <hr>.
*
* converter.toHTML('---') // '<hr>'
*/
export class HorizontalRule extends Tag {
element = ['HR'];
template = '***';
opener = ['***', '___', '---'];
isBlock = true;
replaceSelection = false;
pattern = /^(?<captured>(\*{3,}|-{3,}|_{3,}))\s*$/;
precedence = 20;
toHTML(token: Token, callback: Function): string {
return `<${this.element[0].toUpperCase()}>`;
}
toMarkdown(element: HTMLElement, callback: Function): string {
return '\n' + this.opener[0] + '\n';
}
toEditorNode(token: Token, callback: Function): Node {
return document.createElement('hr');
}
}
+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";
+32
View File
@@ -0,0 +1,32 @@
import { Tag } from "./base";
export class Bold extends Tag {
element = ['STRONG'];
aliases = ['B'];
opener = ['**', '__'];
precedence = 40;
}
export class Italic extends Tag {
element = ['EM'];
aliases = ['I'];
opener = ['*', '_'];
precedence = 30;
}
export class BoldItalic extends Tag {
element = ['STRONG', 'EM'];
opener = ['***'];
precedence = 50;
}
export class Strikethrough extends Tag {
element = ['DEL'];
aliases = ['S', 'STRIKE'];
opener = ['~~'];
}
export class Code extends Tag {
element = ['CODE'];
opener = ['`'];
}
+193
View File
@@ -0,0 +1,193 @@
import { Tag } from "./base";
import type { Token } from '../types';
/**
* A single item in a parsed list, with optional nested sublist HTML.
*/
export interface ListItem {
text: string;
sub: string;
}
/**
* Result of parsing a list block: the generated HTML and the line
* index where the list ends (so the caller can advance past it).
*/
export interface ListResult {
html: string;
end: number;
}
const blankLine = /^\s*$/;
/**
* Ordered and unordered lists with arbitrary nesting. Indentation
* determines depth; mixed list types (ul inside ol) are supported.
*
* converter.toHTML('- one\n- two\n 1. nested')
*/
class List extends Tag {
pattern = /(?<captured>(?<lines>^(?:\s{2,})?(?:[-+*]|\d+\.)\s.+?(?:\n|$))+)/m;
orderedOpenerPattern = /^\d+\.\s/;
isBlock = true;
toHTML(token: Token, callback: Function): string {
const result = this.parseBlock(token.groups!.captured.split("\n"), 0, 0, callback);
return result.html;
}
toMarkdown(element: HTMLElement, callback: Function): string {
return this.nodeToMarkdown(element, 0, callback);
}
toEditorNode(token: Token, callback: Function): Node {
const matched = token.value!.match(this.pattern);
if (matched === null) {
throw new Error(`Token value does not match pattern ${this.pattern}: '${token.value}'`);
}
const parsed = this.parseBlock(token.value.split('\n'), 0, 0, callback);
const div: HTMLElement = document.createElement('div');
div.innerHTML = parsed.html;
return div.firstChild as Node;
}
private childToEditorNode(node: Node, callback:Function): Node[] {
var nodes: Node[] = [];
for (const child of node.childNodes) {
if (child.nodeType == 11) {
nodes.concat(this.childToEditorNode(child, callback));
} else if (child.textContent) {
nodes.push(child);
}
}
return nodes;
};
private countListLines(lines: string[], start: number): number {
const indentedUnordered = /^(\s*)[*\-+]\s/;
const indentedOrdered = /^(\s*)\d+\.\s/;
let lineIndex = start;
while (lineIndex < lines.length) {
const line = lines[lineIndex];
if (blankLine.test(line)) {
break;
}
if (!indentedUnordered.test(line) && !indentedOrdered.test(line)) {
break;
}
lineIndex++;
}
return lineIndex - start;
}
/**
* Recursively parse markdown list lines into HTML, handling nested
* sublists at arbitrary depth and mixed list types (ul/ol).
*/
private parseBlock(lines: string[], start: number = 0, indent: number = 0, callback: Function): ListResult {
const prefix = new RegExp(
'^'
+ ' '.repeat(indent) // indentation for this nesting level
+ '([\\*\\-+]|\\d+\\.)' // bullet or number marker
+ '\\s' // space after marker
);
const isOl = this.orderedOpenerPattern.test(lines[start].trim());
const tag = isOl ? 'ol' : 'ul';
const items: ListItem[] = [];
let lineIndex = start;
while (lineIndex < lines.length) {
const line = lines[lineIndex];
if (blankLine.test(line)) {
break;
}
const leadingWhitespace = /^(\s*)/;
const lineIndent = line.match(leadingWhitespace)![1].length;
if (lineIndent < indent) {
break;
}
if (lineIndent > indent) {
const sub = this.parseBlock(lines, lineIndex, lineIndent, callback);
items[items.length - 1].sub = sub.html;
lineIndex = sub.end;
continue;
}
if (!prefix.test(line)) {
break;
}
items.push({
text: line.replace(prefix, ''),
sub: '',
});
lineIndex++;
}
var html = `<${tag}>`;
for (var item of items) {
const res = callback(item.text);
html += '<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.
*/
private nodeToMarkdown(node: HTMLElement, depth: number, callback: Function): string {
const isOl = node.nodeName === 'OL';
const indent = ' '.repeat(depth);
const lines: string[] = [];
Array.from(node.children).forEach((listItem, index) => {
const marker = isOl ? (index + 1) + '. ' : '* ';
const { text, sublist } = this.partitionListItem(listItem, depth, callback);
lines.push(indent + marker + text.trim());
if (sublist) {
lines.push(sublist);
}
});
const result = lines.join('\n');
return depth === 0
? '\n\n' + result + '\n\n'
: result;
}
private partitionListItem(listItem: Element, depth: number, callback: Function): { text: string; sublist: string } {
let text = '';
let sublist = '';
for (const child of Array.from(listItem.childNodes)) {
if (child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')) {
sublist += this.nodeToMarkdown(child as HTMLElement, depth + 1, callback);
} else {
text += callback(child);
}
}
return {
text,
sublist,
};
}
}
export class UnorderedList extends List {
element = ['UL'];
opener = ['*', '-'];
template = '* list\n* items \n* here';
}
export class OrderedList extends List {
element = ['OL'];
opener = ['1.'];
template = '1. list\n1. items \n1. here';
}
+126
View File
@@ -0,0 +1,126 @@
import { Tag } from "./base";
import { escapeHtml } from "../hopdown";
import type { Token, TokenRole } from '../types';
const blankLine = /^\s*$/;
/**
* Pipe-delimited tables with optional column alignment.
*
* converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
*/
export class Table extends Tag {
opener = ['|'];
element = ['TABLE'];
template = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
replaceSelection = false;
pattern = /(?<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: Token): Record<string, any> | null {
const lines = token.groups!.captured.split("\n");
// Second line must be the separator row (e.g. |---|---|)
if (!this.tableSeparatorPattern.test(lines[1])) {
return null;
}
const headers = this.parseRow(lines[0]);
const aligns = this.parseAligns(lines[1]);
const rows: string[][] = [];
let lineIndex = 2;
while (lineIndex < lines.length && lines[lineIndex].indexOf('|') !== -1 && !blankLine.test(lines[lineIndex])) {
rows.push(this.parseRow(lines[lineIndex++]));
}
return {
headers: headers,
aligns: aligns,
rows: rows
};
}
toHTML(token: Token, callback: Function): string {
const block = this.parseBlock(token);
if (!block) {
throw new Error(`Could not parse table token! ${token.value}`);
}
const cell = (tag: string, text: string, index: number): string => {
const align = block.aligns[index]
? ` class="align-${block.aligns[index]}"`
: '';
const res = callback(text);
return `<${tag}${align}>` + (res.firstElementChild?.innerHTML || res) + `</${tag}>`;
};
const head = '<thead><tr>'
+ block.headers.map((text: string, column: number) => cell('th', text, column)).join('')
+ '</tr></thead>';
const body = block.rows.map((row: string[]) =>
'<tr>' + row.map((text: string, column: number) => cell('td', text, column)).join('') + '</tr>'
).join('');
return `<table>${head}<tbody>${body}</tbody></table>`;
}
toMarkdown(element: HTMLElement, callback: Function): string {
const rows = Array.from(element.querySelectorAll('tr'));
if (!rows.length) {
return '';
}
const headers = Array.from(rows[0].querySelectorAll('th,td'))
.map(cell => callback(cell).trim());
const separator = Array.from(element.querySelectorAll('th')).map( h => {
return h.className == 'align-center' ? ':---:' :
h.className == 'align-right' ? '---:' :
'---';
});
const output = [
'| ' + headers.join(' | ') + ' |',
'| ' + separator.join(' | ') + ' |',
];
for (const row of rows.slice(1)) {
const cells = Array.from(row.querySelectorAll('td,th'))
.map(cell => callback(cell).trim());
output.push('| ' + cells.join(' | ') + ' |');
}
return '\n\n' + output.join('\n') + '\n\n';
}
private parseRow(line: string): string[] {
return line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map(cell => cell.trim());
}
private parseAligns(line: string): (string | null)[] {
const centerAlign = /^:-+:$/;
const rightAlign = /^-+:$/;
return this.parseRow(line).map(cell => {
if (centerAlign.test(cell)) {
return 'center';
}
if (rightAlign.test(cell)) {
return 'right';
}
return null;
});
}
}
+38 -76
View File
@@ -16,7 +16,7 @@
* distinguishes structural markers from literal content, which * distinguishes structural markers from literal content, which
* is the key insight that makes round-trip escaping correct. * is the key insight that makes round-trip escaping correct.
*/ */
import type { Token, TagType, TagCollectionType } from "./types"; import type { Token, TokenRole, TagType, TagCollectionType } from "./types";
/** /**
* Characters that count as punctuation for flanking delimiter rules. * Characters that count as punctuation for flanking delimiter rules.
@@ -58,10 +58,10 @@ const NAMED_ENTITIES: Record<string, string> = {
* const tokens = tokenizer.tokenize('**bold**'); * const tokens = tokenizer.tokenize('**bold**');
*/ */
export class Tokenizer { export class Tokenizer {
private tags: TagType[]; private tags: TagCollectionType;
constructor(tags: TagCollectionType) { constructor(tags: TagCollectionType) {
this.tags = tags.ordered; this.tags = tags;
} }
/** /**
@@ -72,13 +72,13 @@ export class Tokenizer {
*/ */
tokenize(source: string): Token[] { tokenize(source: string): Token[] {
const tokens: Token[] = []; const tokens: Token[] = [];
let position = 0; var position: number = 0;
let textBuffer = ''; var textBuffer: string = '';
const flushText = () => { const flushText = () => {
if (textBuffer.length > 0) { if (textBuffer.length > 0) {
tokens.push({ tokens.push({
role: 'text', role: 'block',
value: textBuffer, value: textBuffer,
}); });
textBuffer = ''; textBuffer = '';
@@ -103,7 +103,7 @@ export class Tokenizer {
const spaceMatch = remaining.match(/^(?<spaces> {2,})\n/); const spaceMatch = remaining.match(/^(?<spaces> {2,})\n/);
if (spaceMatch?.groups) { if (spaceMatch?.groups) {
flushText(); flushText();
tokens.push({ role: 'break', value: '<br>' }); tokens.push({ role: 'block', value: '<br>' });
position += spaceMatch[0].length; position += spaceMatch[0].length;
continue; continue;
} }
@@ -119,69 +119,20 @@ export class Tokenizer {
} }
} }
// Link: [text](url) or [text](url "title") var match = this.matchTag(source, position);
if (source[position] === '[') { if (match && match.value?.length) {
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(); flushText();
tokens.push({ tokens.push(match);
role: 'link', position += match.value?.length;
value: bareUrl.url,
href: bareUrl.url,
tag: this.tags.filter(t => { return t.constructor.name == 'Anchor' })[0]
});
position += bareUrl.length;
continue; continue;
} }
// Delimiter: check each registered delimiter const anchor = this.tags.getByElementName('A');
const match = this.matchTag(source, position); match = anchor!.tokenize('block', remaining[0], remaining);
if (match && match.sequence?.length) { if (match && match.value?.length) {
flushText(); flushText();
tokens.push(match); tokens.push(match);
position += match.sequence?.length; position += match.value?.length;
continue; continue;
} }
@@ -341,14 +292,24 @@ export class Tokenizer {
return null; return null;
} }
sequence = sequence.repeat(runLength); sequence = sequence.repeat(runLength);
var matches = this.tags.map.get(sequence);
// get the tag that matches this sequence if (!matches && runLength == 1) {
for (const tag of this.tags) { sequence = source.slice(position, 3);
const delim = tag.isBlock ? tag.opener : tag.delimiter; for (var i=0; i<2; i++) {
if (delim !== sequence) { sequence = sequence.slice(0, sequence.length - 1);
continue; 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 charBefore = position > 0 ? source[position - 1] : '\n';
const charAfter = source[position + sequence.length]; const charAfter = source[position + sequence.length];
@@ -357,13 +318,14 @@ export class Tokenizer {
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0' const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n'); && (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
return { const role: TokenRole = leftFlanking ? 'open' : rightFlanking ? 'close' : 'block';
role: leftFlanking ? 'open' : rightFlanking ? 'close' : 'block',
sequence: sequence, var token: Token | null = tag.tokenize(role, sequence, source);
value: sequence,
tag: tag, if (token) {
}; return token;
}
}; };
return null; return null;
} }
+18 -59
View File
@@ -1,16 +1,20 @@
export type TokenRole = 'open' | 'close' | 'block';
export interface Token { export interface Token {
role: 'text' | 'open' | 'close' | 'link' | 'html' | 'break' | 'block'; role: TokenRole;
value: string; value: string;
// for tokens corresponding to tags;;
tag?: TagType;
sequence?: string; sequence?: string;
tag?: TagType;
groups?: { [key: string]: string; };
/** For link tokens: the href and optional title. */ /** For link tokens: the href and optional title. */
href?: string; href?: string;
title?: string; title?: string;
}
meta?: Record<string, string>;
}
/** /**
* A Tag is the core abstraction: it knows how to match markdown syntax, * A Tag is the core abstraction: it knows how to match markdown syntax,
@@ -20,70 +24,25 @@ export interface Token {
*/ */
export interface TagType { export interface TagType {
element: string[]; element: string[];
opener: string[];
closer: string | null;
precedence: number;
isBlock: boolean;
template?: string;
pattern?: RegExp | null;
showDelimitersInEditorNodes?: boolean;
aliases?: string[]; aliases?: string[];
delimiter: string | null; tokenize(role: TokenRole, sequence: string, value: string): Token | null;
opener: string | null; toHTML: (token: Token, callback: Function) => string;
closer: string | null;
match?(context: MatchContext, tags?: TagCollectionType): SourceToken | null;
precedence: number;
toHTML: (token: any, callback: Function) => string;
toMarkdown: (element: HTMLElement, callback: Function) => string; toMarkdown: (element: HTMLElement, callback: Function) => string;
toEditorNode: (token: Token, callback: Function) => Node | null; toEditorNode: (token: Token, callback: Function) => Node | null;
isBlock: boolean;
hidden?: boolean;
template?: string;
shortcut?: string;
} }
export interface TagCollectionType { export interface TagCollectionType {
tags: Record<string, TagType>; tags: Record<string, TagType>;
ordered: TagType[]; ordered: TagType[];
map: Map<string, TagType[]>;
getByElementName(element: string): TagType | undefined; getByElementName(element: string): TagType | undefined;
openers: RegExp[];
}
/**
* Context passed to Tag.match() during block-level scanning.
* `lines` and `index` are for block matching; `text` and `offset`
* are for inline matching within a single line.
*/
export interface MatchContext {
lines: string[];
index: number;
text: string;
offset: number;
}
export interface SourceToken {
opener: string,
content: string;
closer: string,
raw: string;
consumed: number;
meta?: Record<string, string>;
}
/**
* 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;
} }
+164 -101
View File
@@ -1,6 +1,6 @@
import { expect, test, suite, describe } from 'vitest' import { expect, test, suite, describe } from 'vitest'
import { HopDown } from '../dist/lib'; import { HopDown } from '../dist';
const hopdown = new HopDown(); const hopdown = new HopDown();
@@ -8,7 +8,7 @@ const hopdown = new HopDown();
suite('Markdown to HTML Round-Trips', () => { suite('Markdown to HTML Round-Trips', () => {
function assert(name: string, input: string, output: string, expected: string | undefined = undefined) { function assert(name: string, input: string, output: string, expected: string | undefined = undefined) {
return test(name, () => { return test(name, () => {
const html = hopdown.toHTML(input); const html = hopdown.toHTML(input);
expect(html.toLowerCase()).toBe(output.toLowerCase()); expect(html.toLowerCase()).toBe(output.toLowerCase());
expect(hopdown.toMarkdown(html)).toBe(expected !== undefined ? expected : input); expect(hopdown.toMarkdown(html)).toBe(expected !== undefined ? expected : input);
@@ -23,77 +23,52 @@ suite('Markdown to HTML Round-Trips', () => {
assert('strikethrough', '~~strike~~', '<p><del>strike</del></p>'); assert('strikethrough', '~~strike~~', '<p><del>strike</del></p>');
assert('inline code', '`code`', '<p><code>code</code></p>'); assert('inline code', '`code`', '<p><code>code</code></p>');
assert('bold+italic', '***bi***', '<p><strong><em>bi</em></strong></p>'); assert('bold+italic', '***bi***', '<p><strong><em>bi</em></strong></p>');
assert('link', '[t](http://x)', '<p><a href="http://x">t</a></p>'); assert('link', '[t](http://x)', '<p><a href="http://x" title="">t</a></p>');
assert('mixed', 'a **b** *c* `d`', '<p>a <strong>b</strong> <em>c</em> <code>d</code></p>'); assert('mixed', 'a **b** *c* `d`', '<p>a <strong>b</strong> <em>c</em> <code>d</code></p>');
assert('code then bold', '`a` **b**', '<p><code>a</code> <strong>b</strong></p>'); assert('code then bold', '`a` **b**', '<p><code>a</code> <strong>b</strong></p>');
assert('nested, ummatched italic', '**i* b**', '<p><strong>i* b</strong></p>'); assert('nested, ummatched italic', '**i* b**', '<p><strong>i* b</strong></p>');
assert('nested, ummatched bold', '*b** i*', '<p><em>b** i</em></p>'); assert('nested, ummatched bold', '*b** i*', '<p><em>b** i</em></p>');
}); });
describe('block', () => { describe('block', () => {
assert('HR ***', '***', '<hr>'); assert('HR ***', '***', '<p><hr></p>');
assert('HR ******', '******', '<hr>', '***'); assert('HR ---', '---', '<p><hr></p>', '***');
assert('HR ---', '---', '<hr>', '***'); assert('HR ___', '___', '<p><hr></p>', '***');
assert('HR ___', '___', '<hr>', '***'); //assert('HR ******', '******', '<p><hr></p>', '***');
assert('H1', '# h', "<h1 id='h'>h</h1>"); assert('H1', '# h', "<p><h1 id='h'>h</h1></p>");
assert('H2', '## h', "<h2 id='h'>h</h2>"); assert('H2', '## h', "<p><h2 id='h'>h</h2></p>");
assert('H3', '### h', "<h3 id='h'>h</h3>"); assert('H3', '### h', "<p><h3 id='h'>h</h3></p>");
assert('H4', '#### h', "<h4 id='h'>h</h4>"); assert('H4', '#### h', "<p><h4 id='h'>h</h4></p>");
assert('H5', '##### h', "<h5 id='h'>h</h5>"); assert('H5', '##### h', "<p><h5 id='h'>h</h5></p>");
assert('H6', '###### h', "<h6 id='h'>h</h6>"); assert('H6', '###### h', "<p><h6 id='h'>h</h6></p>");
assert('H1 with nested', '# h **bold**', "<h1 id='h_bold'>h <strong>bold</strong></h1>"); assert('H1 with nested', '# h **bold**', "<p><h1 id='h_bold'>h <strong>bold</strong></h1></p>");
assert('Blockquote', '> foo\n> bar', "<blockquote>foo<br>bar</blockquote>"); assert('Blockquote+break', '> foo\n> \n> bar', "<p><blockquote>foo<br><p>bar</p></blockquote></p>");
assert('Blockquote+break', '> foo\n>\n> bar', "<blockquote>foo<p>bar</p></blockquote>"); assert('Blockquote', '> one\n> two', "<p><blockquote>one<br>two</blockquote></p>");
assert('Blockquote+Bold`', '> **foo**\n> bar', "<blockquote><strong>foo</strong><br>bar</blockquote>"); assert('Blockquote+Bold`', '> **foo**\n> bar', "<p><blockquote><strong>foo</strong><br>bar</blockquote></p>");
assert('fenced code', '```\none\ntwo\n```', '<pre>one\ntwo</pre>'); assert('fenced code', '```\none\ntwo\n```', '<p><pre>\none\ntwo\n</pre></p>');
assert('fenced code+lang', '```ts\nconst foo = "";\n```', '<pre class="language-ts">const foo = &quot;&quot;;</pre>'); assert('fenced code+lang', '```ts\nconst foo = "";\n```', '<p><pre class="language-ts">\nconst foo = &quot;&quot;;\n</pre></p>');
assert('fenced code ~~~', '~~~\none\ntwo\n~~~', '<pre>one\ntwo</pre>', '```\none\ntwo\n```'); assert('UL *', '* foo\n* bar', '<p><ul><li>foo</li><li>bar</li></ul></p>');
assert('UL *', '* foo\n* bar', '<ul><li>foo</li><li>bar</li></ul>'); assert('UL -', '- foo\n- **bar**', '<p><ul><li>foo</li><li><strong>bar</strong></li></ul></p>', '* foo\n* **bar**');
assert('UL -', '- foo\n- bar', '<ul><li>foo</li><li>bar</li></ul>', '* foo\n* bar'); assert('OL 1. 2.', '1. foo\n2. bar', '<p><ol><li>foo</li><li>bar</li></ol></p>');
assert('OL 1. 2.', '1. foo\n2. bar', '<ol><li>foo</li><li>bar</li></ol>'); assert('OL 1. 1.', '1. foo\n1. bar', '<p><ol><li>foo</li><li>bar</li></ol></p>', '1. foo\n2. bar');
assert('OL 1. 1.', '1. foo\n1. bar', '<ol><li>foo</li><li>bar</li></ol>', '1. foo\n2. bar'); assert('UL Nested', '* foo\n * bar', '<p><ul><li>foo<ul><li>bar</li></ul></li></ul></p>');
assert('UL Nested', '* foo\n * bar', '<ul><li>foo<ul><li>bar</li></ul></li></ul>'); assert('OL in UL', '* foo\n 1. bar\n 2. baz', '<p><ul><li>foo<ol><li>bar</li><li>baz</li></ol></li></ul></p>');
assert('OL in UL', '* foo\n 1. bar\n 2. baz', '<ul><li>foo<ol><li>bar</li><li>baz</li></ol></li></ul>');
}); });
describe('links and anchors', () => { describe('links and anchors', () => {
assert('link', '[foo](http://x)', '<p><a href="http://x">foo</a></p>'); assert('link', '[foo](http://x)', '<p><a href="http://x" title="">foo</a></p>');
assert('link with title', '[foo](http://x "bar")', '<p><a href="http://x" title="bar">foo</a></p>'); assert('link with title', '[foo](http://x "bar")', '<p><a href="http://x" title="bar">foo</a></p>');
assert('anchor', '[foo](#anchor)', '<p><a href="#anchor">foo</a></p>'); assert('anchor', '[foo](#anchor)', '<p><a href="#anchor" title="">foo</a></p>');
var reflinks = (
'\n\n' +
'[foo]: http://foo\n' +
'[bar]: http://bar "Bar"\n'
);
assert(
'reference link',
'[text][foo]' + reflinks,
'<p><a href="http://foo">text</a></p>',
'[text](http://foo)'
);
assert(
'shortcut link',
'[foo][]\n\n' + reflinks,
'<p><a href="http://foo">foo</a></p>',
'[foo](http://foo)'
);
assert(
'reference with title',
'[text][bar]' + reflinks,
'<p><a href="http://bar" title="Bar">text</a></p>',
'[text](http://bar "Bar")'
);
assert( assert(
'autolink', 'autolink',
'foo http://x bar', 'foo http://x bar',
'<p>foo <a href="http://x">http://x</a> bar</p>', '<p>foo <a href="http://x" title="">http://x</a> bar</p>',
'foo [http://x](http://x) bar', 'foo [http://x](http://x) bar',
); );
assert( assert(
'anchor wrapped with bold', 'anchor wrapped with bold',
'**[bold](http://x)**', '**[bold](http://x)**',
'<p><strong><a href="http://x">bold</a></strong></p>', '<p><strong><a href="http://x" title="">bold</a></strong></p>',
); );
}); });
@@ -101,68 +76,71 @@ suite('Markdown to HTML Round-Trips', () => {
// basic table // basic table
var md = ( var md = (
'| Header 1 | Header 2 |\n' + '| Header 1 | Header 2 |\n' +
'| --- | --- |\n' + '| --- | --- |\n' +
'| Cell 1 | Cell 2 |' '| Cell 1 | Cell 2 |'
); );
var html = ( var html = (
'<table><thead>' + '<p><table><thead>' +
'<tr><th>Header 1</th><th>Header 2</th></tr>' + '<tr><th>Header 1</th><th>Header 2</th></tr>' +
'</thead><tbody>' + '</thead><tbody>' +
'<tr><td>Cell 1</td><td>Cell 2</td></tr>' + '<tr><td>Cell 1</td><td>Cell 2</td></tr>' +
'</tbody></table>' '</tbody></table></p>'
); );
assert('Table', md, html); assert('Table', md, html);
// table with inline formatting // table with inline formatting
var md = ( var md = (
'| Header 1 | Header 2 |\n' + '| Header 1 | Header 2 |\n' +
'| --- | --- |\n' + '| --- | --- |\n' +
'| *italic* | **bold** |\n' + '| *italic* | **bold** |\n' +
'| `code` | |' '| `code` | |'
); );
var html = ( var html = (
'<table><thead>' + '<p><table><thead>' +
'<tr><th>Header 1</th><th>Header 2</th></tr>' + '<tr><th>Header 1</th><th>Header 2</th></tr>' +
'</thead><tbody>' + '</thead><tbody>' +
'<tr><td><em>italic</em></td><td><strong>bold</strong></td></tr>' + '<tr><td><em>italic</em></td><td><strong>bold</strong></td></tr>' +
'<tr><td><code>code</code></td><td></td></tr>' + '<tr><td><code>code</code></td><td></td></tr>' +
'</tbody></table>' '</tbody></table></p>'
); );
assert('Table + inline', md, html); assert('Table + inline', md, html);
// normalized markdown and cell alignment // normalized markdown and cell alignment
var expected = ( var expected = (
'| Header 1 | Header 2 | Header 3 |\n' + '| Header 1 | Header 2 | Header 3 |\n' +
'| :---: | --- | ---: |\n' + '| :---: | --- | ---: |\n' +
'| Cell 1 | Cell 2 | Cell 3 |' '| Cell 1 | Cell 2 | Cell 3 |'
); );
var md = ( var md = (
'| Header 1 | Header 2 |Header 3 |\n' + '| Header 1 | Header 2 |Header 3 |\n' +
'| :---: | :--- | ----------: |\n' + '| :---: | :--- | ----------: |\n' +
'| Cell 1 | Cell 2 |Cell 3 |' '| Cell 1 | Cell 2 |Cell 3 |'
) )
var html = ( var html = (
'<table><thead><tr>' + '<p><table><thead><tr>' +
'<th class="align-center">Header 1</th>' + '<th class="align-center">Header 1</th>' +
'<th>Header 2</th>' + '<th>Header 2</th>' +
'<th class="align-right">Header 3</th>' + '<th class="align-right">Header 3</th>' +
'</tr></thead><tbody><tr>' + '</tr></thead><tbody><tr>' +
'<td class="align-center">Cell 1</td>' + '<td class="align-center">Cell 1</td>' +
'<td>Cell 2</td>' + '<td>Cell 2</td>' +
'<td class="align-right">Cell 3</td>' + '<td class="align-right">Cell 3</td>' +
'</tr></tbody></table>' '</tr></tbody></table></p>'
); );
assert('Table alignment', md, html, expected); assert('Table alignment', md, html, expected);
}); });
describe('Edge Cases', () => { describe('Edge Cases', () => {
var html = 'a <span>SPAN</span>'; assert('HTML is escaped',
assert('HTML is stripped', html, '<p>' + html + '</p>', 'a SPAN') 'some <span>html</span>',
'<p>some &lt;span&gt;html&lt;/span&gt;</p>',
'some <span>html</span>'
);
assert('Bare brackets', '< foo', '<p>&lt; foo</p>', '< foo'); assert('Bare brackets', '< foo', '<p>&lt; foo</p>', '< foo');
assert( assert(
'HTML Entities', 'HTML Entities',
@@ -172,11 +150,12 @@ suite('Markdown to HTML Round-Trips', () => {
); );
assert('Escape sequence \\*', '\\*not italic\\*', '<p>\\*not italic\\*</p>'); assert('Escape sequence \\*', '\\*not italic\\*', '<p>\\*not italic\\*</p>');
assert('Escape sequence \\`', '\\`not code\\`', '<p>\\`not code\\`</p>'); assert('Escape sequence \\`', '\\`not code\\`', '<p>\\`not code\\`</p>');
assert('trailing space', '*i* ', '<p><em>i</em> </p>');
}); });
}); });
suite('Markdown to Editor Nodes Round-Trips', () => { suite('Markdown to Editor Nodes Round-Trips', () => {
function span(name: string, delim: string) { function span(name: string, delim: string) {
@@ -186,20 +165,14 @@ suite('Markdown to Editor Nodes Round-Trips', () => {
return s; return s;
} }
function assertInline(name: string, delim: string) { function assertInline(name: string, markdown: string, expected: string) {
return test(name, () => { return test(name, () => {
const node = hopdown.toEditorNode(markdown);
const input = delim + name + delim; var html = '';
for (const child of Array.from(node.children)) {
const expected = document.createDocumentFragment(); html += child.outerHTML;
expected.appendChild(document.createElement('div'));; }
expected.childNodes[0].appendChild(span(name, delim)); expect(html).toStrictEqual(expected);
expected.childNodes[0].appendChild(document.createTextNode(name));
expected.childNodes[0].appendChild(span(name, delim));
const node = hopdown.toEditorNode(input);
expect(node).toStrictEqual(expected);
expect(hopdown.toMarkdown(node)).toBe(input);
}); });
} }
@@ -211,23 +184,113 @@ suite('Markdown to Editor Nodes Round-Trips', () => {
}); });
} }
describe('inline', () => { describe('inline', () => {
assertInline('strong', '**'); assertInline('trailing text', '*i* fnord', '<div><span class="delim em">*</span>i<span class="delim em">*</span></div><div> fnord</div>');
assertInline('em', '*');
}); });
describe('block', () => { describe('block', () => {
assertBlock('hr ***', '***', '<hr>');
assertBlock('hr ---', '---', '<hr>');
assertBlock('hr ___', '___', '<hr>');
assertBlock('h1', '# one', '<h1 id="One">one</h1>');
assertBlock('h2', '## two', '<h2 id="Two">two</h2>');
assertBlock('h3', '### three', '<h3 id="Three">three</h3>');
assertBlock('h4', '#### four', '<h4 id="Four">four</h4>');
assertBlock('h5', '##### five', '<h5 id="Five">five</h5>');
assertBlock('h6', '###### six', '<h6 id="Six">six</h6>');
assertBlock('h1 with inline markup', '# *italic* one\nfoo', '<h1 id="Italic_One">*italic* one</h1><div>\nfoo</div>');
assertBlock( assertBlock(
'Fenced Code', 'Fenced Code',
'```\nfoo\n```', '```\nfoo\n```',
( (
'<div>' + '<pre>' +
'<span class="delim pre">```</span>\n' + '<span class="delim pre">```</span>\n' +
'foo\n' + 'foo\n' +
'<span class="delim pre">```</span>' + '<span class="delim pre">```</span>' +
'</div>' '</pre>'
) )
); );
assertBlock(
'Blockquote',
'> foo\n>\n> ***bar*** buz\nbaz',
(
'<blockquote>' +
'<p><span class="delim blockquote">&gt;</span>foo</p>' +
'<p><span class="delim blockquote">&gt;</span></p>' +
'<p><span class="delim blockquote">&gt;</span>' +
'<span class="delim em strong">***</span>' +
'bar' +
'<span class="delim em strong">***</span> buz</p>' +
'</blockquote><div>\nbaz</div>'
)
);
assertBlock(
'Unordered List',
'- foo\n - bar\n- **baz**\n',
(
'<ul>' +
'<li>foo<ul><li>bar</li></ul></li>' +
'<li><span class="delim strong">**</span>baz' +
'<span class="delim strong">**</span></li>' +
'</ul>'
)
);
assertBlock(
'Ordered List with Nesting',
'1. foo\n * ***bar***\n1. baz',
(
'<ol>' +
'<li>foo<ul><li>' +
'<span class="delim em strong">***</span>' +
'bar' +
'<span class="delim em strong">***</span>' +
'</li></ul></li>' +
'<li>baz</li>' +
'</ol>'
)
);
describe('tables', () => {
var md = (
'| Header 1 | Header 2 |\n' +
'| --- | ---: |\n' +
'| **Cell 1** | Cell 2 |'
);
var html = (
'<table>' +
'<thead>' +
'<tr>' +
'<th>Header 1</th>' +
'<th class="align-right">Header 2</th>' +
'</tr>' +
'</thead>' +
'<tbody>' +
'<tr>' +
'<td>' +
'<span class="delim strong">**</span>' +
'Cell 1' +
'<span class="delim strong">**</span>' +
'</td>' +
'<td class="align-right">Cell 2</td>' +
'</tr>' +
'</tbody>' +
'</table>'
);
assertBlock('Table', md, html);
});
}); });
describe('link', () => {
assertInline('link', '[label](url)', '<a href="url" title="">label</a>');
assertInline('link', '[label](url "title")', '<a href="url" title="title">label</a>');
});
}); });
+3 -5
View File
@@ -1,12 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"declaration": true,
"target": "esnext", "target": "esnext",
"module": "esnext", "module": "commonjs",
"esModuleInterop": true, "outDir": "./dist",
"types": ["node"],
"forceConsistentCasingInFileNames": true,
"outDir": "dist/lib",
"rootDir": "src/ts" "rootDir": "src/ts"
}, },
"include": ["src/ts/**/*.ts"], "include": ["src/ts/**/*.ts"],