"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, '"'); } /** * A configurable markdown <=> HTML converter. * * Examples: * * const hopdown = new HopDown(); * * const html = hopdown.toHTML('**bold**'); * html === '

bold

'; * * const markdown = hopdown.toMarkdown(html); * markdown === '**bold**'; * * const node = hopdown.toEditorNode('**bold**'); * (node as HTMLElement).outerHTML === ( * "
" + * "**" + * "bold" + * "" + * "
" * ); */ 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 '

' + this.inlineToHTML(markdown) + '

'; }; /** * 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. inside , inside ) 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;