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
+2921
-3224
No files matched your search
Vendored
+235
@@ -0,0 +1,235 @@
|
||||
"use strict";
|
||||
/*
|
||||
* hopdown.ts
|
||||
* - Configurable markdown <=> HTML converter.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.HopDown = void 0;
|
||||
exports.escapeHtml = escapeHtml;
|
||||
const defaults_1 = require("./defaults");
|
||||
const tokenizer_1 = require("./tokenizer");
|
||||
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>"
|
||||
* );
|
||||
*/
|
||||
class HopDown {
|
||||
tags;
|
||||
tokenizer;
|
||||
constructor(tags) {
|
||||
this.tags = tags || defaults_1.defaultTags;
|
||||
this.tokenizer = new tokenizer_1.Tokenizer(this.tags);
|
||||
}
|
||||
/**
|
||||
* Convert a markdown string to tokens.
|
||||
*/
|
||||
tokenize(source) {
|
||||
return this.tokenizer.tokenize(source);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
md = md.replace(/^\n+/, '').replace(/\n+$/, '');
|
||||
return md;
|
||||
};
|
||||
/**
|
||||
* Convert a markdown string to a DocumentFragment consisting of wysiwyg editor nodes.
|
||||
*/
|
||||
toEditorNode = (markdown) => {
|
||||
const tokens = this.tokenize(markdown);
|
||||
return this.tokensToEditorNodes(tokens);
|
||||
};
|
||||
toHTML = (markdown) => {
|
||||
return '<p>' + this.inlineToHTML(markdown) + '</p>';
|
||||
};
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
/**
|
||||
* 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.tag) {
|
||||
const thisNode = token.tag.toEditorNode(token, this.inlineToEditorNodes);
|
||||
if (!thisNode) {
|
||||
continue;
|
||||
}
|
||||
if (token.role == 'open') {
|
||||
if (grouping.length) {
|
||||
fragment.appendChild(groupAsNode());
|
||||
}
|
||||
grouping = [thisNode];
|
||||
continue;
|
||||
}
|
||||
if (token.role == 'close') {
|
||||
grouping.push(thisNode);
|
||||
fragment.appendChild(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) {
|
||||
if (token.tag) {
|
||||
html += token.tag.toHTML(token, this.inlineToHTML);
|
||||
continue;
|
||||
}
|
||||
html += escapeHtml(token.value);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
/**
|
||||
* Match open/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 === 'open') {
|
||||
// Don't open a delimiter that's already open (prevents nesting)
|
||||
if (openDelimiters.has(token.sequence)) {
|
||||
result[index] = {
|
||||
role: 'block',
|
||||
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: 'block',
|
||||
value: token.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
exports.HopDown = HopDown;
|
||||
Reference in new issue
Block a user