/* * hopdown.ts — configurable markdown↔HTML converter. * * Usage: * const converter = new HopDown(); * const converter = new HopDown({ exclude: ['table'] }); * const converter = new HopDown({ tags: { ...defaultTags, 'DEL,S,STRIKE': strikethrough } }); * * converter.toHTML('**bold**'); * converter.toMarkdown('bold'); */ import type { Converter, MatchContext, Tag } from './types'; import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags'; export type TagMap = Record; export interface HopDownOptions { tags?: TagMap; exclude?: string[]; } /** * A configurable markdown↔HTML converter. * * By default includes all standard tags. Pass options to customize: * - tags: a mapping of HTML selectors to Tag definitions * - exclude: remove specific tags by name from the defaults */ export class HopDown { private blockTags: Tag[]; private inlineTags: Tag[]; private tags: Map; constructor(options: HopDownOptions = {}) { let tagMap: TagMap; if (options.tags) { tagMap = options.tags; } else if (options.exclude) { const excluded = new Set(options.exclude); tagMap = Object.fromEntries( Object.entries(defaultTags).filter(([, tag]) => !excluded.has(tag.name)) ); } else { tagMap = defaultTags; } const allTags = Object.values(tagMap); const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name)); const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name)); this.blockTags = allTags.filter(tag => defaultBlockNames.has(tag.name) || (!defaultInlineNames.has(tag.name) && !(tag as any).pattern) ); this.inlineTags = allTags.filter(tag => defaultInlineNames.has(tag.name) || (tag as any).pattern ); this.tags = new Map(); for (const [selector, tag] of Object.entries(tagMap)) { for (const sel of selector.split(',').map(s => s.trim()).filter(Boolean)) { if (sel.startsWith('_')) { continue; } const existing = this.tags.get(sel); if (existing && existing !== tag) { throw new Error( `HTML tag "${sel}" is claimed by both "${existing.name}" and "${tag.name}". ` + `Use the exclude option to remove one before adding the other.` ); } this.tags.set(sel, tag); } } this.validateInlineTags(); } /** * Verify that no two inline tags have colliding delimiters without * correct precedence ordering. If delimiter A is a prefix of delimiter B, * B must have lower (earlier) precedence so the longer match wins. */ private validateInlineTags(): void { const withDelimiters = this.inlineTags .filter(tag => (tag as any).delimiter) .map(tag => ({ name: tag.name, delimiter: (tag as any).delimiter as string, precedence: (tag as any).precedence as number ?? 50, })); for (let i = 0; i < withDelimiters.length; i++) { for (let j = i + 1; j < withDelimiters.length; j++) { const a = withDelimiters[i]; const b = withDelimiters[j]; const aPrefix = b.delimiter.startsWith(a.delimiter); const bPrefix = a.delimiter.startsWith(b.delimiter); if (!aPrefix && !bPrefix) { continue; } const longer = a.delimiter.length > b.delimiter.length ? a : b; const shorter = a.delimiter.length > b.delimiter.length ? b : a; if (longer.precedence >= shorter.precedence) { throw new Error( `Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` + `lower precedence than "${shorter.name}" (delimiter "${shorter.delimiter}") ` + `because its delimiter is a prefix match. ` + `Got ${longer.name}=${longer.precedence}, ${shorter.name}=${shorter.precedence}.` ); } } } } /** * Convert a markdown string to HTML. */ toHTML(md: string): string { return this.processBlocks(md); } /** * Convert an HTML string back to markdown. */ toMarkdown(html: string): string { const container = document.createElement('div'); container.innerHTML = html; return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim(); } private processBlocks(md: string): string { const lines = md.replace(/\r\n/g, '\n').split('\n'); const output: string[] = []; let index = 0; while (index < lines.length) { if (/^\s*$/.test(lines[index])) { index++; continue; } let matched = false; for (const tag of this.blockTags) { const context: MatchContext = { lines, index, text: '', offset: 0, }; const token = tag.match(context); if (!token) continue; if (tag.name === 'list') { const result = parseListBlock(lines, index, 0, (source) => this.processInline(source)); output.push(result.html); index = result.end; } else { output.push(tag.toHTML(token, this.makeConverter())); index += token.consumed; } matched = true; break; } if (!matched) { index++; } } return output.join('\n'); } private processInline(source: string): string { const sorted = [...this.inlineTags].sort((a, b) => ((a as any).precedence ?? 50) - ((b as any).precedence ?? 50) ); const placeholders: string[] = []; let text = source; // Pass 1: extract links and non-recursive tags into placeholders before escaping for (const tag of sorted) { const recursive = (tag as any).recursive ?? true; if (tag.name === 'link') { text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => { // Process link text: restore earlier placeholders, then run inline on any remaining markdown let inner = linkText; // Check if link text contains placeholders (already-processed content) const hasPlaceholders = /\x00P\d+\x00/.test(inner); if (hasPlaceholders) { inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]); } else { inner = this.processInline(inner); } placeholders.push('' + inner + ''); return '\x00P' + (placeholders.length - 1) + '\x00'; }); } else if (!recursive && (tag as any).pattern) { const globalPattern = (tag as any).pattern as RegExp; globalPattern.lastIndex = 0; text = text.replace(globalPattern, (_, content: string) => { placeholders.push(tag.toHTML( { content, raw: '', consumed: 0 }, this.makeConverter(), )); return '\x00P' + (placeholders.length - 1) + '\x00'; }); } } text = escapeHtml(text); // Pass 2: apply recursive tags in precedence order (longest delimiter first). // Content matched here is already HTML-escaped and has had earlier // passes applied, so we wrap directly without re-processing. for (const tag of sorted) { const recursive = (tag as any).recursive ?? true; if (tag.name === 'link' || !recursive) { continue; } const globalPattern = (tag as any).pattern as RegExp | undefined; if (globalPattern) { globalPattern.lastIndex = 0; text = text.replace(globalPattern, (_, content: string) => { // Restore any placeholders in the captured content const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]); const htmlTag = (tag as any).name === 'boldItalic' ? null : ((tag.selector as string) || '').split(',')[0].toLowerCase(); if (tag.name === 'boldItalic') { return '' + restored + ''; } return `<${htmlTag}>${restored}`; }); } } // Restore placeholders text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]); return text; } private nodeToMd(node: Node): string { if (node.nodeType === 3) { return node.textContent || ''; } if (node.nodeType !== 1) { return ''; } const element = node as HTMLElement; const tag = this.tags.get(element.nodeName); if (tag) { return tag.toMarkdown(element, this.makeConverter()); } return this.childrenToMd(node); } private childrenToMd(node: Node): string { return Array.from(node.childNodes).map(child => this.nodeToMd(child)).join(''); } private makeConverter(): Converter { return { inline: (source) => this.processInline(source), block: (md) => this.processBlocks(md), children: (node) => this.childrenToMd(node), node: (node) => this.nodeToMd(node), }; } } /** * A default HopDown instance with all standard tags enabled. * Use this for simple cases where no configuration is needed. */ const hopdown = new HopDown(); export function toHTML(md: string): string { return hopdown.toHTML(md); } export function toMarkdown(html: string): string { return hopdown.toMarkdown(html); } export default hopdown;