2026-04-28 16:59:30 +00:00
|
|
|
/*
|
|
|
|
|
* hopdown.ts — configurable markdown↔HTML converter.
|
|
|
|
|
*
|
2026-04-29 22:48:36 +00:00
|
|
|
* 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.
|
2026-04-28 16:59:30 +00:00
|
|
|
*/
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
import type { Converter, MatchContext, Tag, DelimiterMatch } from './types';
|
|
|
|
|
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml } from './tags';
|
2026-04-29 03:03:58 +00:00
|
|
|
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros';
|
2026-04-29 22:48:36 +00:00
|
|
|
import { InlineTokenizer, type InlineToken, type DelimiterDef } from './tokenizer';
|
|
|
|
|
import { MarkdownSerializer, type SerializerTagDef } from './serializer';
|
2026-04-28 16:59:30 +00:00
|
|
|
|
|
|
|
|
export type TagMap = Record<string, Tag>;
|
|
|
|
|
|
|
|
|
|
export interface HopDownOptions {
|
|
|
|
|
tags?: TagMap;
|
|
|
|
|
exclude?: string[];
|
2026-04-29 03:03:58 +00:00
|
|
|
macros?: MacroDef[];
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 22:48:36 +00:00
|
|
|
* Configurable markdown↔HTML converter. Uses a tokenizer for inline
|
|
|
|
|
* parsing (markdown→HTML) and a serializer for HTML→markdown. Block
|
|
|
|
|
* parsing delegates to Tag definitions.
|
2026-04-28 16:59:30 +00:00
|
|
|
*
|
2026-04-29 22:48:36 +00:00
|
|
|
* const converter = new HopDown();
|
|
|
|
|
* converter.toHTML('**bold**');
|
|
|
|
|
* converter.toMarkdown('<strong>bold</strong>');
|
2026-04-28 16:59:30 +00:00
|
|
|
*/
|
|
|
|
|
export class HopDown {
|
|
|
|
|
private blockTags: Tag[];
|
|
|
|
|
private inlineTags: Tag[];
|
|
|
|
|
private tags: Map<string, Tag>;
|
2026-04-29 03:03:58 +00:00
|
|
|
private macroMap: Map<string, MacroDef>;
|
2026-04-29 22:48:36 +00:00
|
|
|
private referenceLinks: Map<string, { url: string; title?: string }>;
|
|
|
|
|
private tokenizer: InlineTokenizer;
|
|
|
|
|
private serializer: MarkdownSerializer;
|
|
|
|
|
private cachedConverter: Converter;
|
|
|
|
|
private delimiterRegexes: { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[];
|
|
|
|
|
private editableSelectorCache: string;
|
2026-04-28 16:59:30 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 03:03:58 +00:00
|
|
|
this.macroMap = new Map();
|
2026-04-29 22:48:36 +00:00
|
|
|
this.referenceLinks = new Map();
|
2026-04-29 03:03:58 +00:00
|
|
|
if (options.macros && options.macros.length > 0) {
|
2026-04-29 05:16:28 +00:00
|
|
|
const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros);
|
2026-04-29 03:03:58 +00:00
|
|
|
this.macroMap = macroMap;
|
2026-04-29 05:16:28 +00:00
|
|
|
tagMap['[data-macro]'] = selectorTag;
|
2026-04-29 03:03:58 +00:00
|
|
|
tagMap['_macro'] = blockTag;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:59:30 +00:00
|
|
|
const allTags = Object.values(tagMap);
|
2026-04-29 22:48:36 +00:00
|
|
|
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(tag => tag.name));
|
|
|
|
|
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(tag => tag.name));
|
2026-04-28 16:59:30 +00:00
|
|
|
|
|
|
|
|
this.blockTags = allTags.filter(tag =>
|
2026-04-29 03:03:58 +00:00
|
|
|
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
|
2026-04-29 03:18:19 +00:00
|
|
|
(!defaultInlineNames.has(tag.name) && !tag.pattern)
|
2026-04-28 16:59:30 +00:00
|
|
|
);
|
2026-04-29 03:03:58 +00:00
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
// Macro block tag must run after fencedCode (so code blocks aren't
|
|
|
|
|
// parsed as macros) but before paragraph (the catch-all)
|
2026-04-29 03:03:58 +00:00
|
|
|
this.blockTags.sort((a, b) => {
|
2026-04-29 22:48:36 +00:00
|
|
|
const order = (tag: Tag) => {
|
|
|
|
|
if (tag.name === 'fencedCode') {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
if (tag.name === 'macro') {
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
if (tag.name === 'paragraph') {
|
|
|
|
|
return 99;
|
|
|
|
|
}
|
2026-04-29 03:03:58 +00:00
|
|
|
return 50;
|
|
|
|
|
};
|
|
|
|
|
return order(a) - order(b);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-28 16:59:30 +00:00
|
|
|
this.inlineTags = allTags.filter(tag =>
|
2026-04-29 03:18:19 +00:00
|
|
|
defaultInlineNames.has(tag.name) || tag.pattern
|
2026-04-28 16:59:30 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.tags = new Map();
|
2026-04-29 22:48:36 +00:00
|
|
|
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 {
|
2026-04-28 16:59:30 +00:00
|
|
|
for (const [selector, tag] of Object.entries(tagMap)) {
|
2026-04-29 22:48:36 +00:00
|
|
|
const parts = selector.split(',').map(part => part.trim()).filter(Boolean);
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
if (part.startsWith('_')) {
|
2026-04-28 16:59:30 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-29 22:48:36 +00:00
|
|
|
const existing = this.tags.get(part);
|
2026-04-28 16:59:30 +00:00
|
|
|
if (existing && existing !== tag) {
|
|
|
|
|
throw new Error(
|
2026-04-29 22:48:36 +00:00
|
|
|
`HTML tag "${part}" is claimed by both "${existing.name}" and "${tag.name}". ` +
|
2026-04-28 16:59:30 +00:00
|
|
|
`Use the exclude option to remove one before adding the other.`
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-29 22:48:36 +00:00
|
|
|
this.tags.set(part, tag);
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private validateInlineTags(): void {
|
|
|
|
|
const withDelimiters = this.inlineTags
|
2026-04-29 03:18:19 +00:00
|
|
|
.filter(tag => tag.delimiter)
|
2026-04-28 16:59:30 +00:00
|
|
|
.map(tag => ({
|
|
|
|
|
name: tag.name,
|
2026-04-29 03:18:19 +00:00
|
|
|
delimiter: tag.delimiter as string,
|
|
|
|
|
precedence: tag.precedence as number ?? 50,
|
2026-04-28 16:59:30 +00:00
|
|
|
}));
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
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) {
|
2026-04-28 16:59:30 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-29 22:48:36 +00:00
|
|
|
const longer = first.delimiter.length > second.delimiter.length ? first : second;
|
|
|
|
|
const shorter = first.delimiter.length > second.delimiter.length ? second : first;
|
2026-04-28 16:59:30 +00:00
|
|
|
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.
|
2026-04-29 22:48:36 +00:00
|
|
|
*
|
|
|
|
|
* converter.toHTML('# Hello\n\n**bold** text')
|
2026-04-28 16:59:30 +00:00
|
|
|
*/
|
2026-04-29 22:48:36 +00:00
|
|
|
toHTML(markdown: string): string {
|
|
|
|
|
return this.processBlocks(markdown);
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 22:48:36 +00:00
|
|
|
* Convert an HTML string back to markdown. Uses the serializer
|
|
|
|
|
* which produces correctly-escaped output via typed tokens.
|
|
|
|
|
*
|
|
|
|
|
* converter.toMarkdown('<h1>Hello</h1><p><strong>bold</strong> text</p>')
|
2026-04-28 16:59:30 +00:00
|
|
|
*/
|
|
|
|
|
toMarkdown(html: string): string {
|
|
|
|
|
const container = document.createElement('div');
|
|
|
|
|
container.innerHTML = html;
|
2026-04-29 22:48:36 +00:00
|
|
|
return this.serializeNode(container).replace(/\n{3,}/g, '\n\n').trim();
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 03:18:19 +00:00
|
|
|
/**
|
2026-04-29 22:48:36 +00:00
|
|
|
* 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))
|
2026-04-29 03:18:19 +00:00
|
|
|
*/
|
|
|
|
|
getBlockTags(): Tag[] {
|
|
|
|
|
return this.blockTags;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 22:48:36 +00:00
|
|
|
* The registered inline tags. Used by the WYSIWYG editor to
|
|
|
|
|
* build delimiter regexes for speculative rendering.
|
|
|
|
|
*
|
|
|
|
|
* converter.getInlineTags().filter(tag => tag.delimiter)
|
2026-04-29 03:18:19 +00:00
|
|
|
*/
|
|
|
|
|
getInlineTags(): Tag[] {
|
|
|
|
|
return this.inlineTags;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
/**
|
|
|
|
|
* 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');
|
2026-04-28 16:59:30 +00:00
|
|
|
const output: string[] = [];
|
2026-04-29 22:48:36 +00:00
|
|
|
const blankLine = /^\s*$/;
|
|
|
|
|
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
|
|
|
|
|
let lineIndex = 0;
|
|
|
|
|
|
|
|
|
|
// Collect reference link definitions
|
|
|
|
|
this.referenceLinks = new Map();
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-28 16:59:30 +00:00
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
while (lineIndex < lines.length) {
|
|
|
|
|
if (blankLine.test(lines[lineIndex]) || refDefinition.test(lines[lineIndex])) {
|
|
|
|
|
lineIndex++;
|
2026-04-28 16:59:30 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let matched = false;
|
|
|
|
|
for (const tag of this.blockTags) {
|
|
|
|
|
const context: MatchContext = {
|
|
|
|
|
lines,
|
2026-04-29 22:48:36 +00:00
|
|
|
index: lineIndex,
|
2026-04-28 16:59:30 +00:00
|
|
|
text: '',
|
|
|
|
|
offset: 0,
|
|
|
|
|
};
|
|
|
|
|
const token = tag.match(context);
|
2026-04-29 22:48:36 +00:00
|
|
|
if (!token) {
|
|
|
|
|
continue;
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
2026-04-29 22:48:36 +00:00
|
|
|
output.push(tag.toHTML(token, this.cachedConverter));
|
|
|
|
|
lineIndex += token.consumed;
|
2026-04-28 16:59:30 +00:00
|
|
|
matched = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!matched) {
|
2026-04-29 22:48:36 +00:00
|
|
|
lineIndex++;
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return output.join('\n');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
/**
|
|
|
|
|
* Convert inline markdown to HTML using the tokenizer.
|
|
|
|
|
* Tokenizes the source, then walks the token stream to build HTML.
|
|
|
|
|
* Open/close delimiter pairs are matched using a stack.
|
|
|
|
|
*/
|
2026-04-28 16:59:30 +00:00
|
|
|
private processInline(source: string): string {
|
|
|
|
|
let text = source;
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
// Process inline macros before tokenizing — they produce HTML
|
|
|
|
|
// that should pass through without further parsing
|
2026-04-29 03:03:58 +00:00
|
|
|
if (this.macroMap.size > 0) {
|
2026-04-29 22:48:36 +00:00
|
|
|
const placeholders: string[] = [];
|
|
|
|
|
text = processInlineMacros(text, this.macroMap, this.cachedConverter, placeholders);
|
|
|
|
|
// Restore placeholders to their HTML content
|
|
|
|
|
const placeholderPattern = /\x00P(?<index>\d+)\x00/g;
|
|
|
|
|
text = text.replace(placeholderPattern, (_, index: string) =>
|
|
|
|
|
placeholders[parseInt(index)]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve reference links before tokenizing
|
|
|
|
|
text = this.resolveReferenceLinks(text);
|
|
|
|
|
// Normalize _ emphasis to *
|
|
|
|
|
text = this.normalizeUnderscores(text);
|
|
|
|
|
const tokens = this.tokenizer.tokenize(text);
|
|
|
|
|
return this.tokensToHTML(tokens);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Replace [text][ref] and [text][] with [text](url) using the
|
|
|
|
|
* reference definitions collected during block parsing.
|
|
|
|
|
*/
|
|
|
|
|
private resolveReferenceLinks(text: string): string {
|
|
|
|
|
if (this.referenceLinks.size === 0) {
|
|
|
|
|
return text;
|
2026-04-29 03:03:58 +00:00
|
|
|
}
|
2026-04-29 22:48:36 +00:00
|
|
|
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
|
|
|
|
|
return text.replace(refLink, (...args) => {
|
|
|
|
|
const groups = args[args.length - 1] as Record<string, string>;
|
|
|
|
|
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.
|
|
|
|
|
*/
|
|
|
|
|
private normalizeUnderscores(text: string): string {
|
|
|
|
|
// 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 = (_: string, run: string) => '*'.repeat(run.length);
|
|
|
|
|
const normalized = safeText
|
|
|
|
|
.replace(openRun, toAsterisks)
|
|
|
|
|
.replace(closeRun, toAsterisks);
|
2026-04-29 03:03:58 +00:00
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
return normalized.replace(/\x00U\x00/g, '\\_');
|
|
|
|
|
}
|
2026-04-28 16:59:30 +00:00
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
private tokensToHTML(tokens: InlineToken[]): string {
|
|
|
|
|
// Build a map from delimiter string to tag info
|
|
|
|
|
const delimiterToTag = new Map<string, { htmlTag: string; name: string }>();
|
|
|
|
|
for (const tag of this.inlineTags) {
|
|
|
|
|
if (tag.delimiter) {
|
|
|
|
|
const htmlTag = tag.name === 'boldItalic'
|
|
|
|
|
? 'em'
|
|
|
|
|
: (tag.selector as string).split(',')[0].toLowerCase();
|
|
|
|
|
delimiterToTag.set(tag.delimiter, {
|
|
|
|
|
htmlTag,
|
|
|
|
|
name: tag.name,
|
2026-04-28 16:59:30 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
// 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) {
|
|
|
|
|
switch (token.role) {
|
|
|
|
|
case 'text':
|
|
|
|
|
html += escapeHtml(token.value);
|
|
|
|
|
break;
|
|
|
|
|
case 'open': {
|
|
|
|
|
const info = delimiterToTag.get(token.delimiter!);
|
|
|
|
|
if (info) {
|
|
|
|
|
if (info.name === 'boldItalic') {
|
|
|
|
|
html += '<em><strong>';
|
|
|
|
|
} else {
|
|
|
|
|
html += `<${info.htmlTag}>`;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
html += escapeHtml(token.value);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'close': {
|
|
|
|
|
const info = delimiterToTag.get(token.delimiter!);
|
|
|
|
|
if (info) {
|
|
|
|
|
if (info.name === 'boldItalic') {
|
|
|
|
|
html += '</strong></em>';
|
|
|
|
|
} else {
|
|
|
|
|
html += `</${info.htmlTag}>`;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
html += escapeHtml(token.value);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'code':
|
|
|
|
|
html += `<code>${escapeHtml(token.content || '')}</code>`;
|
|
|
|
|
break;
|
|
|
|
|
case 'link': {
|
|
|
|
|
const titleAttr = token.title
|
|
|
|
|
? ` title="${escapeHtml(token.title)}"`
|
|
|
|
|
: '';
|
|
|
|
|
// Process link text for nested inline formatting
|
|
|
|
|
const innerTokens = this.tokenizer.tokenize(token.value);
|
|
|
|
|
const innerHtml = this.tokensToHTML(innerTokens);
|
|
|
|
|
// Strip any nested <a> tags (links can't contain links)
|
|
|
|
|
const nestedLink = /<a[^>]*>|<\/a>/g;
|
|
|
|
|
const cleanInner = innerHtml.replace(nestedLink, '');
|
|
|
|
|
html += `<a href="${escapeHtml(token.href!)}"${titleAttr}>${cleanInner}</a>`;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'autolink':
|
|
|
|
|
html += `<a href="${escapeHtml(token.href!)}">${escapeHtml(token.value)}</a>`;
|
|
|
|
|
break;
|
|
|
|
|
case 'html':
|
|
|
|
|
html += token.value;
|
|
|
|
|
break;
|
|
|
|
|
case 'break':
|
|
|
|
|
html += '<br>';
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
html += escapeHtml(token.value);
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
2026-04-29 22:48:36 +00:00
|
|
|
}
|
|
|
|
|
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.
|
|
|
|
|
*/
|
|
|
|
|
private pairDelimiters(tokens: InlineToken[]): InlineToken[] {
|
|
|
|
|
const openStack: number[] = [];
|
|
|
|
|
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<string>();
|
|
|
|
|
|
|
|
|
|
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.delimiter!)) {
|
|
|
|
|
result[index] = {
|
|
|
|
|
role: 'text',
|
|
|
|
|
value: token.value,
|
|
|
|
|
};
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
openStack.push(index);
|
|
|
|
|
openDelimiters.add(token.delimiter!);
|
|
|
|
|
} else if (token.role === 'close') {
|
|
|
|
|
let matched = false;
|
|
|
|
|
for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex--) {
|
|
|
|
|
const openerIndex = openStack[stackIndex];
|
|
|
|
|
if (result[openerIndex].delimiter === token.delimiter) {
|
|
|
|
|
openStack.splice(stackIndex, 1);
|
|
|
|
|
openDelimiters.delete(token.delimiter!);
|
|
|
|
|
matched = true;
|
|
|
|
|
break;
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
2026-04-29 22:48:36 +00:00
|
|
|
}
|
|
|
|
|
if (!matched) {
|
|
|
|
|
result[index] = {
|
|
|
|
|
role: 'text',
|
|
|
|
|
value: token.value,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
// Any remaining unmatched openers become literal text
|
|
|
|
|
for (const openerIndex of openStack) {
|
|
|
|
|
result[openerIndex] = {
|
|
|
|
|
role: 'text',
|
|
|
|
|
value: result[openerIndex].value,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
/**
|
|
|
|
|
* Serialize a DOM node to markdown using the serializer for inline
|
|
|
|
|
* content and custom logic for block-level elements.
|
|
|
|
|
*/
|
|
|
|
|
private serializeNode(node: Node): string {
|
2026-04-28 16:59:30 +00:00
|
|
|
if (node.nodeType === 3) {
|
2026-04-29 22:48:36 +00:00
|
|
|
return this.serializer.serialize(node);
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
if (node.nodeType !== 1) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
const element = node as HTMLElement;
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
// CSS selectors (e.g. [data-macro]) are more specific
|
|
|
|
|
const cssSelectorMatch = this.matchCssSelector(element);
|
|
|
|
|
if (cssSelectorMatch) {
|
|
|
|
|
return cssSelectorMatch.toMarkdown(element, this.cachedConverter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inline elements: use the serializer which handles escaping
|
|
|
|
|
// via typed tokens (text vs delimiter separation)
|
|
|
|
|
const inlineTag = this.tags.get(element.nodeName);
|
|
|
|
|
if (inlineTag && (inlineTag.delimiter || inlineTag.name === 'link'
|
|
|
|
|
|| inlineTag.name === 'code' || inlineTag.name === 'hardBreak')) {
|
|
|
|
|
return this.serializer.serialize(element);
|
2026-04-29 03:03:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
// Block elements: use the tag's toMarkdown
|
2026-04-28 16:59:30 +00:00
|
|
|
const tag = this.tags.get(element.nodeName);
|
|
|
|
|
if (tag) {
|
2026-04-29 22:48:36 +00:00
|
|
|
return tag.toMarkdown(element, this.cachedConverter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.serializeChildren(node);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private matchCssSelector(element: HTMLElement): Tag | null {
|
|
|
|
|
for (const [selector, tag] of this.tags.entries()) {
|
|
|
|
|
if (!selector.includes('[') && !selector.includes('.') && !selector.includes('#')) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const uppercaseTagName = /^[A-Z]+/;
|
|
|
|
|
const normalized = selector.replace(uppercaseTagName, part => part.toLowerCase());
|
|
|
|
|
try {
|
|
|
|
|
if (element.matches(normalized)) {
|
|
|
|
|
return tag;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Invalid selector — skip
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private serializeChildren(node: Node): string {
|
|
|
|
|
return Array.from(node.childNodes)
|
|
|
|
|
.map(child => this.serializeNode(child))
|
|
|
|
|
.join('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build the inline tokenizer from registered delimiter-based tags.
|
|
|
|
|
*/
|
|
|
|
|
private buildTokenizer(): InlineTokenizer {
|
|
|
|
|
const hasCodeTag = this.inlineTags.some(tag => tag.name === 'code');
|
|
|
|
|
const delimiterDefs: DelimiterDef[] = this.inlineTags
|
|
|
|
|
.filter(tag => tag.delimiter && tag.name !== 'code')
|
|
|
|
|
.map(tag => ({
|
|
|
|
|
delimiter: tag.delimiter!,
|
|
|
|
|
htmlTag: tag.name === 'boldItalic'
|
|
|
|
|
? 'em'
|
|
|
|
|
: (tag.selector as string).split(',')[0].toLowerCase(),
|
|
|
|
|
recursive: tag.recursive !== false,
|
|
|
|
|
precedence: tag.precedence ?? 50,
|
|
|
|
|
}));
|
|
|
|
|
return new InlineTokenizer(delimiterDefs, { codeSpans: hasCodeTag });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build the markdown serializer from registered tags. Maps HTML
|
|
|
|
|
* element names to their serialization strategy (delimiter wrap
|
|
|
|
|
* or custom function).
|
|
|
|
|
*/
|
|
|
|
|
private buildSerializer(): MarkdownSerializer {
|
|
|
|
|
const tagMap = new Map<string, SerializerTagDef>();
|
|
|
|
|
const delimiterChars = new Set<string>();
|
|
|
|
|
|
|
|
|
|
for (const [selector, tag] of this.tags.entries()) {
|
|
|
|
|
if (tag.delimiter) {
|
|
|
|
|
delimiterChars.add(tag.delimiter[0]);
|
|
|
|
|
// Delimiter-based tags: emit delimiter + children + delimiter
|
|
|
|
|
for (const part of selector.split(',').map(part => part.trim())) {
|
|
|
|
|
tagMap.set(part, { delimiter: tag.delimiter });
|
|
|
|
|
}
|
|
|
|
|
} else if (tag.name === 'link') {
|
|
|
|
|
tagMap.set('A', {
|
|
|
|
|
serialize: (element, children) => {
|
|
|
|
|
const href = element.getAttribute('href') || '';
|
|
|
|
|
const title = element.getAttribute('title');
|
|
|
|
|
const titlePart = title ? ` "${title}"` : '';
|
|
|
|
|
return '[' + children() + '](' + href + titlePart + ')';
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} else if (tag.name === 'hardBreak') {
|
|
|
|
|
tagMap.set('BR', {
|
|
|
|
|
serialize: () => ' \n',
|
|
|
|
|
});
|
|
|
|
|
} else if (tag.name === 'fencedCode') {
|
|
|
|
|
tagMap.set('PRE', {
|
|
|
|
|
serialize: (element) => {
|
|
|
|
|
const code = element.querySelector('code');
|
|
|
|
|
const langMatch = (code?.getAttribute('class') || '').match(/language-(\S+)/);
|
|
|
|
|
const lang = langMatch ? langMatch[1] : '';
|
|
|
|
|
const content = code?.textContent || element.textContent || '';
|
|
|
|
|
return '\n\n```' + lang + '\n' + content + '\n```\n\n';
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
// CODE gets a custom serializer because its content is literal
|
|
|
|
|
tagMap.set('CODE', {
|
|
|
|
|
serialize: (element) => {
|
|
|
|
|
// Code inside <pre> is handled by the PRE serializer
|
|
|
|
|
if (element.parentNode?.nodeName === 'PRE') {
|
|
|
|
|
return element.textContent || '';
|
|
|
|
|
}
|
|
|
|
|
return '`' + (element.textContent || '') + '`';
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return new MarkdownSerializer(tagMap, delimiterChars);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildDelimiterRegexes(): { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[] {
|
|
|
|
|
const escapeRegex = /[.*+?^${}()|[\]\\]/g;
|
|
|
|
|
const sorted = this.inlineTags
|
|
|
|
|
.filter(tag => tag.delimiter)
|
|
|
|
|
.sort((first, second) => (first.precedence ?? 50) - (second.precedence ?? 50));
|
|
|
|
|
|
|
|
|
|
return sorted.map(tag => {
|
|
|
|
|
const delimiter = tag.delimiter!;
|
|
|
|
|
const escaped = delimiter.replace(escapeRegex, '\\$&');
|
|
|
|
|
const escapedChar = delimiter[0].replace(escapeRegex, '\\$&');
|
|
|
|
|
const htmlTag = tag.name === 'boldItalic'
|
|
|
|
|
? 'em'
|
|
|
|
|
: (tag.selector as string).split(',')[0].toLowerCase();
|
|
|
|
|
return {
|
|
|
|
|
tag,
|
|
|
|
|
htmlTag,
|
|
|
|
|
complete: new RegExp(
|
|
|
|
|
`(?<!${escapedChar})` +
|
|
|
|
|
`${escaped}` +
|
|
|
|
|
`(?!${escapedChar})` +
|
|
|
|
|
`([^\\x01\\x02]+?)` +
|
|
|
|
|
`(?<!${escapedChar})` +
|
|
|
|
|
`${escaped}`
|
|
|
|
|
),
|
|
|
|
|
open: new RegExp(
|
|
|
|
|
`(?<!${escapedChar})` +
|
|
|
|
|
`${escaped}` +
|
|
|
|
|
`(?!${escapedChar})` +
|
|
|
|
|
`([^\\x01\\x02]+)$`
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:48:36 +00:00
|
|
|
private buildEditableSelector(): string {
|
|
|
|
|
return [
|
|
|
|
|
...this.inlineTags,
|
|
|
|
|
...this.blockTags,
|
|
|
|
|
].filter(tag => typeof tag.selector === 'string')
|
|
|
|
|
.map(tag => (tag.selector as string).toLowerCase())
|
|
|
|
|
.join(', ');
|
2026-04-28 16:59:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private makeConverter(): Converter {
|
|
|
|
|
return {
|
|
|
|
|
inline: (source) => this.processInline(source),
|
2026-04-29 22:48:36 +00:00
|
|
|
block: (markdown) => this.processBlocks(markdown),
|
|
|
|
|
children: (node) => this.serializeChildren(node),
|
|
|
|
|
node: (node) => this.serializeNode(node),
|
2026-04-28 16:59:30 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|