Files
hopdown/dist/hopdown.js
T
evilchili 965fc09d99 v1 checkpoint
This commit is the initial checkpoint for a functioning converter with
all tests passing.
2026-10-16 13:00:11 -07:00

236 lines
7.5 KiB
JavaScript

"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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* A configurable markdown <=> HTML converter.
*
* Examples:
*
* const hopdown = new HopDown();
*
* const html = hopdown.toHTML('**bold**');
* html === '<p><strong>bold</strong></p>';
*
* const markdown = hopdown.toMarkdown(html);
* markdown === '**bold**';
*
* const node = hopdown.toEditorNode('**bold**');
* (node as HTMLElement).outerHTML === (
* "<div>" +
* "<span class='delim strong'>**</span>" +
* "bold" +
* "<span class='delim strong'>" +
* "</div>"
* );
*/
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;