Add themes support
Usage:
const editor = new RibbitEditor({
themes: [
{ name: 'dark', features: { sourceMode: false } },
{ name: 'minimal', tags: minimalTags },
],
currentTheme: 'dark',
});
The built-in theme is 'ribbit-default' and is always available.
Additional themes from the themes array are registered on top.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
export type TagMap = Record<string, Tag>;
|
||||
|
||||
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<string, Tag>;
|
||||
|
||||
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('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
|
||||
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 '<em><strong>' + restored + '</strong></em>';
|
||||
}
|
||||
return `<${htmlTag}>${restored}</${htmlTag}>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
Reference in New Issue
Block a user