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