Files
ribbit/src/ts/hopdown.ts
T

354 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-28 16:59:30 +00:00
/*
* 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('<strong>bold</strong>');
*/
import type { Converter, MatchContext, Tag } from './types';
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags';
2026-04-29 03:03:58 +00:00
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros';
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
}
/**
* 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<string, Tag>;
2026-04-29 03:03:58 +00:00
private macroMap: Map<string, MacroDef>;
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
// Build macro tags if macros are provided
this.macroMap = new Map();
if (options.macros && options.macros.length > 0) {
const { blockTag, selectorEntries, macroMap } = buildMacroTags(options.macros);
this.macroMap = macroMap;
tagMap = {
...tagMap,
...selectorEntries,
};
// Insert macro block tag — will be placed after fencedCode below
tagMap['_macro'] = blockTag;
}
2026-04-28 16:59:30 +00:00
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 =>
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
// Ensure macro block tag runs after fencedCode but before everything else
this.blockTags.sort((a, b) => {
const order = (t: Tag) => {
if (t.name === 'fencedCode') return 0;
if (t.name === 'macro') return 1;
if (t.name === 'paragraph') return 99;
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();
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
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
}));
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();
}
2026-04-29 03:18:19 +00:00
/**
* Return the block tags for external iteration (e.g. speculative rendering).
*/
getBlockTags(): Tag[] {
return this.blockTags;
}
/**
* Return the inline tags for external iteration (e.g. speculative rendering).
*/
getInlineTags(): Tag[] {
return this.inlineTags;
}
2026-04-28 16:59:30 +00:00
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 {
2026-04-29 03:18:19 +00:00
2026-04-28 16:59:30 +00:00
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;
2026-04-29 03:03:58 +00:00
// Extract inline macros before other processing
if (this.macroMap.size > 0) {
text = processInlineMacros(text, this.macroMap, this.makeConverter(), placeholders);
}
2026-04-28 16:59:30 +00:00
// Pass 1: extract links and non-recursive tags into placeholders before escaping
for (const tag of sorted) {
2026-04-29 03:18:19 +00:00
const recursive = tag.recursive ?? true;
2026-04-28 16:59:30 +00:00
if (tag.name === 'link') {
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
let inner = linkText;
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('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
return '\x00P' + (placeholders.length - 1) + '\x00';
});
2026-04-29 03:18:19 +00:00
} else if (!recursive && tag.pattern) {
const globalPattern = tag.pattern as RegExp;
2026-04-28 16:59:30 +00:00
globalPattern.lastIndex = 0;
2026-04-29 03:18:19 +00:00
2026-04-28 16:59:30 +00:00
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);
2026-04-29 03:18:19 +00:00
// Pass 2: apply recursive tags in precedence order.
// Content is already HTML-escaped from pass 1, so we wrap directly
// without re-processing through convert.inline().
2026-04-28 16:59:30 +00:00
for (const tag of sorted) {
2026-04-29 03:18:19 +00:00
const recursive = tag.recursive ?? true;
2026-04-28 16:59:30 +00:00
if (tag.name === 'link' || !recursive) {
continue;
}
2026-04-29 03:18:19 +00:00
const globalPattern = tag.pattern as RegExp | undefined;
2026-04-28 16:59:30 +00:00
if (globalPattern) {
globalPattern.lastIndex = 0;
text = text.replace(globalPattern, (_, content: string) => {
const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
2026-04-29 03:18:19 +00:00
const htmlTag = tag.name === 'boldItalic'
2026-04-28 16:59:30 +00:00
? null
: ((tag.selector as string) || '').split(',')[0].toLowerCase();
if (tag.name === 'boldItalic') {
return '<em><strong>' + restored + '</strong></em>';
}
return `<${htmlTag}>${restored}</${htmlTag}>`;
});
}
}
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;
2026-04-29 03:03:58 +00:00
// Check CSS selectors first (macro selectors are more specific)
for (const [selector, selectorTag] of this.tags.entries()) {
if (selector.includes('[') || selector.includes('.') || selector.includes('#')) {
// Lowercase only the tag name portion for case-insensitive matching
const normalized = selector.replace(/^[A-Z]+/, s => s.toLowerCase());
try {
if (element.matches(normalized)) {
return selectorTag.toMarkdown(element, this.makeConverter());
}
} catch {
// invalid selector, skip
}
}
}
// Then check by element name
2026-04-28 16:59:30 +00:00
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;