Files
hopdown/dist/lib/hopdown.js
T
2026-08-09 16:06:10 -07:00

379 lines
13 KiB
JavaScript

/*
* hopdown.ts
* - Configurable markdown <=> HTML converter.
*/
import { defaultTags } from "./defaults";
import { Tokenizer } from "./tokenizer";
export function escapeHtml(source) {
return source
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* A configurable markdown <=> HTML converter.
*
* Examples:
*
* const hopdown = new HopDown();
*
* const html = hopdown.toHTML('**bold**');
* html === '<p><strong>bold</strong></p>';
*
* const markdown = hopdown.toMarkdown(html);
* markdown === '**bold**';
*
* const node = hopdown.toEditorNode('**bold**');
* (node as HTMLElement).outerHTML === (
* "<div>" +
* "<span class='delim strong'>**</span>" +
* "bold" +
* "<span class='delim strong'>" +
* "</div>"
* );
*/
export class HopDown {
tags;
referenceLinks = new Map();
tokenizer;
constructor(tags) {
this.tags = tags || defaultTags;
this.tokenizer = new Tokenizer(this.tags);
}
/**
* Convert a markdown string to tokens.
*/
tokenize(source) {
let text = source;
// Resolve reference links before tokenizing
text = this.resolveReferenceLinks(text);
// Normalize _ emphasis to *
text = this.normalizeUnderscores(text);
const tokens = this.tokenizer.tokenize(text);
return tokens;
}
/**
* Convert an HTML string or a DocumentFragment to markdown.
*/
toMarkdown = (html) => {
var md = '';
if (typeof (html) == 'string') {
const container = document.createElement('div');
container.innerHTML = html;
md = this.nodeToMarkdown(container);
}
else {
md = this.nodeToMarkdown(html);
}
return md.replace(/\n{3,}/g, '\n\n').trim();
};
/**
* Convert a markdown string to an HTML string.
*/
toHTML = (markdown) => {
const output = [];
const preprocessed = this.preprocessMarkdown(markdown);
if (!preprocessed.lines) {
return '';
}
const blockTags = this.tags.ordered.filter(t => {
return t.isBlock && t.constructor.name !== 'Paragraph';
});
const pTag = this.tags.getByElementName('p');
if (pTag === undefined) {
throw new Error("Cannot parse without a paragraph tag!");
}
let lineIndex = 0;
while (lineIndex < preprocessed.lines.length) {
let context;
let matched = false;
let token;
for (const tag of blockTags) {
context = {
lines: preprocessed.lines,
index: lineIndex,
text: '',
offset: 0,
};
token = tag.match(context, this.tags);
if (!token) {
continue;
}
output.push(tag.toHTML(token, this.inlineToHTML));
lineIndex += token.consumed;
matched = true;
break;
}
if (matched) {
continue;
}
/* always check the paragraph tag last */
token = pTag.match(context, this.tags);
if (token) {
output.push(pTag.toHTML(token, this.inlineToHTML));
lineIndex += token.consumed;
continue;
}
lineIndex++;
}
return output.join('\n');
};
/**
* Convert a markdown string to a DocumentFragment consisting of wysiwyg editor nodes.
*/
toEditorNode = (markdown) => {
const tokens = this.tokenize(markdown);
return this.tokensToEditorNodes(tokens);
};
/**
* Recursive entrypoint for converting markdown to html.
*/
inlineToHTML = (markdown) => {
const tokens = this.tokenize(markdown);
return this.tokensToHTML(tokens);
};
/**
* Recursive entrypoint for converting markdown to editor nodes.
*/
inlineToEditorNodes = (markdown) => {
const tokens = this.tokenize(markdown);
return this.tokensToEditorNodes(tokens);
};
/**
* Process a markdown string by splitting it into lines, stripping empty lines, and capturing reference links.
*/
preprocessMarkdown(markdown) {
const blankLine = /^\s*$/;
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
const referenceLinks = new Map;
const output = [];
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
for (const line of lines) {
const match = line.match(refDefinition);
if (match?.groups) {
this.referenceLinks.set(match.groups.label.toLowerCase(), {
url: match.groups.url,
title: match.groups.title,
});
continue;
}
if (!blankLine.test(line)) {
output.push(line);
}
}
return {
lines: output,
referenceLinks: referenceLinks
};
}
/**
* Convert an HTML node to a markdown string. Used by toMarkdown().
*/
nodeToMarkdown = (node) => {
// DocumentFragment
if (node.nodeType == 11) {
return Array.from(node.childNodes).map(child => {
return this.nodeToMarkdown(child);
}).join('');
}
// text
if (node.nodeType == 3) {
return node.textContent || '';
// not a block-level element
}
else if (node.nodeType !== 1) {
return '';
}
const tag = this.tags.getByElementName(node.nodeName);
if (!tag) {
return Array.from(node.childNodes)
.map(child => this.nodeToMarkdown(child))
.join('');
}
const ret = tag.toMarkdown(node, this.nodeToMarkdown);
return ret;
};
/**
* Replace [text][ref] and [text][] with [text](url) using the
* reference definitions collected during block parsing.
*/
resolveReferenceLinks(text) {
if (this.referenceLinks.size === 0) {
return text;
}
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
return text.replace(refLink, (...args) => {
const groups = args[args.length - 1];
const label = (groups.label || groups.text).toLowerCase();
const ref = this.referenceLinks.get(label);
if (!ref) {
return args[0];
}
const titlePart = ref.title ? ` "${ref.title}"` : '';
return `[${groups.text}](${ref.url}${titlePart})`;
});
}
/**
* Normalize flanking underscore runs to asterisks so the tokenizer
* only needs to handle * delimiters for emphasis.
*/
normalizeUnderscores(text) {
// Protect backslash-escaped underscores from normalization
const escapePlaceholder = '\x00U\x00';
const safeText = text.replace(/\\_/g, escapePlaceholder);
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
const openRun = new RegExp(`(?<=^|${punctuation})` + // preceded by start, space, or punctuation
`(_+)` + // one or more underscores
`(?=\\S)`, // followed by non-whitespace
'g');
const closeRun = new RegExp(`(?<=\\S)` + // preceded by non-whitespace
`(_+)` + // one or more underscores
`(?=$|${punctuation})`, // followed by end, space, or punctuation
'g');
const toAsterisks = (_, run) => '*'.repeat(run.length);
const normalized = safeText
.replace(openRun, toAsterisks)
.replace(closeRun, toAsterisks);
return normalized.replace(/\x00U\x00/g, '\\_');
}
/**
* Convert a token stream to editor nodes.
*/
tokensToEditorNodes(tokens) {
// First pass: match open/close pairs using a stack
const paired = this.pairDelimiters(tokens);
// Second pass: build HTML from paired tokens
const fragment = document.createDocumentFragment();
var grouping = [];
const groupAsNode = () => {
const div = document.createElement('div');
for (const node of grouping) {
div.appendChild(node);
}
return div;
};
for (const token of paired) {
if (token.role == 'html') {
const div = document.createElement('div');
div.innerHTML = token.value;
for (const node of Array.from(div.childNodes)) {
grouping.push(node);
}
continue;
}
if (token.tag) {
const thisNode = token.tag.toEditorNode(token, this.inlineToEditorNodes);
if (!thisNode) {
continue;
}
if (token.role == 'open') {
if (grouping.length) {
fragment.append(groupAsNode());
}
grouping = [thisNode];
continue;
}
if (token.role == 'close') {
grouping.push(thisNode);
fragment.append(groupAsNode());
grouping = [];
continue;
}
if (!grouping.length) {
fragment.appendChild(thisNode.cloneNode(true));
}
continue;
}
const text = document.createTextNode(token.value);
grouping.push(text);
continue;
}
if (grouping.length) {
fragment.appendChild(groupAsNode());
}
return fragment;
}
/**
* Convert a token stream to HTML. Matches open/close delimiter
* pairs and wraps their content in the appropriate HTML tags.
* Unmatched delimiters are emitted as literal text.
*/
tokensToHTML(tokens) {
// First pass: match open/close pairs using a stack
const paired = this.pairDelimiters(tokens);
// Second pass: build HTML from paired tokens
let html = '';
for (const token of paired) {
console.log(token);
if (token.role == 'text') {
html += escapeHtml(token.value);
continue;
}
if (token.role == 'html') {
html += token.value;
continue;
}
if (token.role == 'link') {
html += this.tags.getByElementName('A').toHTML(token, this.inlineToHTML) || token.value;
continue;
}
if (token.tag) {
html += token.tag.toHTML(token, this.inlineToHTML);
continue;
}
html += escapeHtml(token.value);
}
return html;
}
/**
* Matchn/close delimiter pairs in a token stream. Unmatched
* openers/closers are converted to text tokens so they render
* as literal characters.
*/
pairDelimiters(tokens) {
const openStack = [];
const result = [...tokens];
// Track which delimiter types are currently open to prevent
// forbidden nesting (e.g. <del> inside <del>, <em> inside <em>)
const openDelimiters = new Set();
for (let index = 0; index < result.length; index++) {
const token = result[index];
if (token.role === 'block') {
token.role = openDelimiters.has(token.sequence) ? 'close' : 'open';
}
if (token.role === 'open') {
// Don't open a delimiter that's already open (prevents nesting)
if (openDelimiters.has(token.sequence)) {
result[index] = {
role: 'text',
value: token.value,
};
continue;
}
openStack.push(index);
openDelimiters.add(token.sequence);
}
else if (token.role === 'close') {
let matched = false;
for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex--) {
const openerIndex = openStack[stackIndex];
if (result[openerIndex].sequence === token.sequence) {
openStack.splice(stackIndex, 1);
openDelimiters.delete(token.sequence);
matched = true;
break;
}
}
if (!matched) {
result[index] = {
role: 'text',
value: token.value,
};
}
}
}
return result;
}
}