/* * hopdown.ts — configurable markdown↔HTML converter. * * HopDown orchestrates markdown↔HTML conversion using a tokenizer for * inline parsing and a serializer for HTML→markdown. Block-level parsing * uses Tag definitions directly. The tokenizer/serializer architecture * ensures correct round-trips by separating structural delimiters from * literal text at the type level. */ import type { Converter, MatchContext, Tag, DelimiterMatch } from './types'; import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml } from './tags'; import { buildMacroTags, processInlineMacros, type MacroDef } from './macros'; import { InlineTokenizer, type InlineToken, type DelimiterDef } from './tokenizer'; import { MarkdownSerializer, type SerializerTagDef } from './serializer'; export type TagMap = Record; export interface HopDownOptions { tags?: TagMap; exclude?: string[]; macros?: MacroDef[]; } /** * Configurable markdown↔HTML converter. Uses a tokenizer for inline * parsing (markdown→HTML) and a serializer for HTML→markdown. Block * parsing delegates to Tag definitions. * * const converter = new HopDown(); * converter.toHTML('**bold**'); * converter.toMarkdown('bold'); */ export class HopDown { private blockTags: Tag[]; private inlineTags: Tag[]; private tags: Map; private macroMap: Map; private referenceLinks: Map; private tokenizer: InlineTokenizer; private serializer: MarkdownSerializer; private cachedConverter: Converter; private delimiterRegexes: { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[]; private editableSelectorCache: string; 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; } this.macroMap = new Map(); this.referenceLinks = new Map(); if (options.macros && options.macros.length > 0) { const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros); this.macroMap = macroMap; tagMap['[data-macro]'] = selectorTag; tagMap['_macro'] = blockTag; } const allTags = Object.values(tagMap); const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(tag => tag.name)); const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(tag => tag.name)); this.blockTags = allTags.filter(tag => defaultBlockNames.has(tag.name) || tag.name === 'macro' || (!defaultInlineNames.has(tag.name) && !tag.pattern) ); // Macro block tag must run after fencedCode (so code blocks aren't // parsed as macros) but before paragraph (the catch-all) this.blockTags.sort((a, b) => { const order = (tag: Tag) => { if (tag.name === 'fencedCode') { return 0; } if (tag.name === 'macro') { return 1; } if (tag.name === 'paragraph') { return 99; } return 50; }; return order(a) - order(b); }); this.inlineTags = allTags.filter(tag => defaultInlineNames.has(tag.name) || tag.pattern ); this.tags = new Map(); this.registerSelectors(tagMap); this.validateInlineTags(); this.tokenizer = this.buildTokenizer(); this.serializer = this.buildSerializer(); this.cachedConverter = this.makeConverter(); this.delimiterRegexes = this.buildDelimiterRegexes(); this.editableSelectorCache = this.buildEditableSelector(); } private registerSelectors(tagMap: TagMap): void { for (const [selector, tag] of Object.entries(tagMap)) { const parts = selector.split(',').map(part => part.trim()).filter(Boolean); for (const part of parts) { if (part.startsWith('_')) { continue; } const existing = this.tags.get(part); if (existing && existing !== tag) { throw new Error( `HTML tag "${part}" is claimed by both "${existing.name}" and "${tag.name}". ` + `Use the exclude option to remove one before adding the other.` ); } this.tags.set(part, tag); } } } private validateInlineTags(): void { const withDelimiters = this.inlineTags .filter(tag => tag.delimiter) .map(tag => ({ name: tag.name, delimiter: tag.delimiter as string, precedence: tag.precedence as number ?? 50, })); for (let outer = 0; outer < withDelimiters.length; outer++) { for (let inner = outer + 1; inner < withDelimiters.length; inner++) { const first = withDelimiters[outer]; const second = withDelimiters[inner]; const firstIsPrefix = second.delimiter.startsWith(first.delimiter); const secondIsPrefix = first.delimiter.startsWith(second.delimiter); if (!firstIsPrefix && !secondIsPrefix) { continue; } const longer = first.delimiter.length > second.delimiter.length ? first : second; const shorter = first.delimiter.length > second.delimiter.length ? second : first; 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. * * converter.toHTML('# Hello\n\n**bold** text') */ toHTML(markdown: string): string { return this.processBlocks(markdown); } /** * Convert an HTML string back to markdown. Uses the serializer * which produces correctly-escaped output via typed tokens. * * converter.toMarkdown('

Hello

bold text

') */ toMarkdown(html: string): string { const container = document.createElement('div'); container.innerHTML = html; return this.serializeNode(container).replace(/\n{3,}/g, '\n\n').trim(); } /** * The registered block-level tags. Used by the WYSIWYG editor * to detect block syntax patterns during live editing. * * converter.getBlockTags().forEach(tag => console.log(tag.name)) */ getBlockTags(): Tag[] { return this.blockTags; } /** * The registered inline tags. Used by the WYSIWYG editor to * build delimiter regexes for speculative rendering. * * converter.getInlineTags().filter(tag => tag.delimiter) */ getInlineTags(): Tag[] { return this.inlineTags; } /** * Find the first complete delimiter pair in the text. * * converter.findCompletePair('hello **world** end') */ findCompletePair(text: string): DelimiterMatch | null { for (const entry of this.delimiterRegexes) { const match = text.match(entry.complete); if (match && match.index !== undefined) { return { tag: entry.tag, htmlTag: entry.htmlTag, content: match[1], index: match.index, length: match[0].length, delimiter: entry.tag.delimiter!, }; } } return null; } /** * Find the first unclosed delimiter opener in the text. * * converter.findUnmatchedOpener('hello **world') */ findUnmatchedOpener(text: string): DelimiterMatch | null { for (const entry of this.delimiterRegexes) { const match = text.match(entry.open); if (match && match.index !== undefined) { const before = text.slice(0, match.index); if (before.endsWith('<') || before.endsWith('/')) { continue; } return { tag: entry.tag, htmlTag: entry.htmlTag, content: match[1], index: match.index, length: match[0].length, delimiter: entry.tag.delimiter!, }; } } return null; } /** * Look up the Tag definition for an HTML element by its tag name. * * converter.getTagForElement(strongElement) */ getTagForElement(element: HTMLElement): Tag | null { const tag = this.tags.get(element.tagName); if (tag && tag.delimiter) { return tag; } return null; } /** * CSS selector string matching all elements that should show * editing context. * * element.matches(converter.getEditableSelector()) */ getEditableSelector(): string { return this.editableSelectorCache; } /** * Split markdown into lines, match each against block tags in * priority order, and concatenate the resulting HTML. */ private processBlocks(markdown: string): string { const lines = markdown.replace(/\r\n/g, '\n').split('\n'); const output: string[] = []; const blankLine = /^\s*$/; const refDefinition = /^\[(?