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:
gsb
2026-04-29 01:20:55 +00:00
parent 5983ce50fd
commit ac7a698c4f
13 changed files with 264 additions and 70 deletions
+16
View File
@@ -0,0 +1,16 @@
/*
* default-theme.ts — the default ribbit theme.
*
* Enables all default tags and all editor features.
*/
import type { RibbitTheme } from './types';
import { defaultTags } from './tags';
export const defaultTheme: RibbitTheme = {
name: 'ribbit-default',
tags: defaultTags,
features: {
sourceMode: true,
},
};
+292
View File
@@ -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;
+117
View File
@@ -0,0 +1,117 @@
/*
* ribbit-editor.ts — WYSIWYG editing extension for Ribbit.
*/
import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme';
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
/**
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
*
* Extends Ribbit with contentEditable support and bidirectional
* markdown↔HTML conversion on mode switches.
*
* Usage:
* const editor = new RibbitEditor({ editorId: 'my-element' });
* editor.run();
* editor.wysiwyg(); // switch to WYSIWYG mode
* editor.edit(); // switch to source editing mode
* editor.view(); // switch to read-only view
*/
export class RibbitEditor extends Ribbit {
run(): void {
this.states = {
VIEW: 'view',
EDIT: 'edit',
WYSIWYG: 'wysiwyg'
};
this.#bindEvents();
this.plugins().forEach(plugin => {
plugin.setEditable();
});
this.element.classList.add('loaded');
this.view();
}
#bindEvents(): void {
this.element.addEventListener('input', () => {
if (this.state !== this.states.VIEW) {
this.changed = true;
}
});
}
htmlToMarkdown(html?: string): string {
return this.converter.toMarkdown(html || this.element.innerHTML);
}
getMarkdown(): string {
if (this.getState() === this.states.EDIT) {
let html = this.element.innerHTML;
html = html.replace(/<(?:div|br)>/ig, '');
html = html.replace(/<\/div>/ig, '\n');
this.cachedMarkdown = decodeHtmlEntities(html);
} else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
}
if (!this.cachedMarkdown) {
this.cachedMarkdown = this.element.textContent || '';
}
return this.cachedMarkdown;
}
wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return;
this.changed = false;
this.element.contentEditable = 'true';
this.element.innerHTML = this.getHTML();
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
const macroEl = el as HTMLElement;
if (macroEl.dataset.editable === 'false') {
macroEl.contentEditable = 'false';
macroEl.style.opacity = '0.5';
}
});
this.setState(this.states.WYSIWYG);
}
edit(): void {
if (!this.theme.features?.sourceMode) {
return;
}
if (this.state === this.states.EDIT) return;
this.changed = false;
this.element.contentEditable = 'true';
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
this.setState(this.states.EDIT);
}
insertAtCursor(node: Node): void {
const sel = window.getSelection()!;
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(node);
range.setStartAfter(node);
this.element.focus();
sel.removeAllRanges();
sel.addRange(range);
}
}
// Attach public API to window for <script> tag usage.
(window as any).HopDown = HopDown;
(window as any).inlineTag = inlineTag;
(window as any).defaultTags = defaultTags;
(window as any).defaultBlockTags = defaultBlockTags;
(window as any).defaultInlineTags = defaultInlineTags;
(window as any).defaultTheme = defaultTheme;
(window as any).Ribbit = Ribbit;
(window as any).RibbitEditor = RibbitEditor;
(window as any).RibbitPlugin = RibbitPlugin;
(window as any).camelCase = camelCase;
(window as any).decodeHtmlEntities = decodeHtmlEntities;
(window as any).encodeHtmlEntities = encodeHtmlEntities;
+186
View File
@@ -0,0 +1,186 @@
/*
* ribbit.ts — core editor classes for the ribbit WYSIWYG markdown editor.
*/
import { HopDown } from './hopdown';
import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager';
import type { RibbitTheme } from './types';
export interface RibbitSettings {
api?: unknown;
editorId?: string;
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
currentTheme?: string;
themes?: RibbitTheme[];
themesPath?: string;
}
/**
* Base class for editor plugins. Subclass and override toHTML/toMarkdown
* to add custom processing hooks.
*/
export class RibbitPlugin {
name: string;
wiki: Ribbit;
precedence: number;
constructor(settings: { name: string; wiki: Ribbit }) {
this.name = settings.name;
this.wiki = settings.wiki;
this.precedence = 50;
}
setEditable(): void {
}
toMarkdown(html: string): string {
return html;
}
toHTML(md: string): string {
return md;
}
}
/**
* Read-only markdown viewer. Renders markdown content into an HTML element.
*
* Usage:
* const viewer = new Ribbit({ editorId: 'my-element' });
* viewer.run();
*/
export class Ribbit {
api: unknown;
element: HTMLElement;
states: Record<string, string>;
cachedHTML: string | null;
cachedMarkdown: string | null;
state: string | null;
changed: boolean;
enabledPlugins: Record<string, RibbitPlugin>;
theme: RibbitTheme;
themes: ThemeManager;
converter: HopDown;
themesPath: string;
constructor(settings: RibbitSettings) {
this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!;
this.themesPath = settings.themesPath || './themes';
this.states = {
VIEW: 'view',
};
this.cachedHTML = null;
this.cachedMarkdown = null;
this.state = null;
this.changed = false;
this.enabledPlugins = {};
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme) => {
this.theme = theme;
this.converter = theme.tags
? new HopDown({ tags: theme.tags })
: new HopDown();
this.cachedHTML = null;
if (this.getState() === this.states.VIEW) {
this.state = null;
this.view();
}
});
(settings.themes || []).forEach(theme => {
this.themes.add(theme);
});
const activeName = settings.currentTheme || defaultTheme.name;
this.themes.set(activeName);
this.theme = this.themes.current();
this.converter = this.theme.tags
? new HopDown({ tags: this.theme.tags })
: new HopDown();
(settings.plugins || []).forEach(plugin => {
this.enabledPlugins[plugin.name] = new plugin({
name: plugin.name,
wiki: this,
});
});
}
run(): void {
this.element.classList.add('loaded');
this.view();
}
plugins(): RibbitPlugin[] {
return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence);
}
getState(): string | null {
return this.state;
}
setState(newState: string): void {
this.state = newState;
Object.values(this.states).forEach(state => {
if (state === newState) {
this.element.classList.add(state);
} else {
this.element.classList.remove(state);
}
});
}
markdownToHTML(md: string): string {
return this.converter.toHTML(md);
}
getHTML(): string {
if (this.changed || !this.cachedHTML) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
}
return this.cachedHTML;
}
getMarkdown(): string {
if (!this.cachedMarkdown) {
this.cachedMarkdown = this.element.textContent || '';
}
return this.cachedMarkdown;
}
view(): void {
if (this.getState() === this.states.VIEW) return;
this.element.innerHTML = this.getHTML();
this.setState(this.states.VIEW);
this.element.contentEditable = 'false';
}
}
/**
* Convert a string to title case, splitting on whitespace.
* Returns an array of capitalized words.
*/
export function camelCase(words: string): string[] {
return words.trim().split(/\s+/g).map(word => {
const lc = word.toLowerCase();
return lc.charAt(0).toUpperCase() + lc.slice(1);
});
}
/**
* Decode HTML entities in a string using a textarea element.
*/
export function decodeHtmlEntities(html: string): string {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
}
/**
* Encode HTML-significant characters as numeric entities.
*/
export function encodeHtmlEntities(str: string): string {
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
}
+479
View File
@@ -0,0 +1,479 @@
/*
* tags.ts — tag definitions for the hopdown converter.
*
* Each Tag is a self-contained definition of a markdown element,
* with rules for matching, converting to HTML, and converting back.
*/
import type { Tag, MatchContext, SourceToken, Converter, ListItem, ListResult, InlineTagDef } from './types';
/**
* Create a Tag from a shorthand inline definition.
*
* Most inline markdown elements follow the same pattern: a delimiter wraps
* content, it maps to an HTML element, and the reverse is just unwrapping.
* This factory builds a full Tag from that pattern.
*
* Usage:
* inlineTag({ name: 'bold', delimiter: '**', htmlTag: 'strong', aliases: 'B' })
* inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 })
* inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' })
*/
export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recursive: boolean; pattern: RegExp; delimiter: string } {
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
const upperTag = def.htmlTag.toUpperCase();
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
const recursive = def.recursive !== false;
return {
name: def.name,
precedence: def.precedence ?? 50,
recursive,
pattern: globalPattern,
delimiter: def.delimiter,
match: (context) => {
const matched = context.text.slice(context.offset).match(matchPattern);
if (!matched) {
return null;
}
return {
content: matched[1],
raw: matched[0],
consumed: matched[0].length,
};
},
toHTML: (token, convert) => {
const inner = recursive ? convert.inline(token.content) : escapeHtml(token.content);
return `<${def.htmlTag}>${inner}</${def.htmlTag}>`;
},
selector,
toMarkdown: (element, convert) => {
if (!recursive && element.parentNode?.nodeName === 'PRE') {
return convert.children(element);
}
return def.delimiter + convert.children(element) + def.delimiter;
},
};
}
/**
* Escape HTML special characters in a string.
*/
export function escapeHtml(source: string): string {
return source.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Generate a camelCase ID from heading text, for use as an anchor.
*/
export function camelId(text: string): string {
return text.trim().split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join('');
}
/**
* Recursively parse a markdown list into HTML, handling nested sublists
* at arbitrary depth and mixed list types (ul/ol).
*/
export function parseListBlock(lines: string[], start: number, indent: number, inlineConvert: (s: string) => string): ListResult {
const prefix = new RegExp('^' + ' '.repeat(indent) + '([\\*\\-]|\\d+\\.)\\s');
const isOl = /^\d+\./.test(lines[start].trim());
const tag = isOl ? 'ol' : 'ul';
const items: ListItem[] = [];
let i = start;
while (i < lines.length) {
const line = lines[i];
if (/^\s*$/.test(line)) break;
const lineIndent = line.match(/^(\s*)/)![1].length;
if (lineIndent < indent) break;
if (lineIndent > indent) {
const sub = parseListBlock(lines, i, lineIndent, inlineConvert);
items[items.length - 1].sub = sub.html;
i = sub.end;
continue;
}
if (!prefix.test(line)) break;
items.push({
text: line.replace(prefix, ''),
sub: '',
});
i++;
}
const html = '<' + tag + '>' + items.map(item =>
'<li>' + inlineConvert(item.text) + item.sub + '</li>'
).join('') + '</' + tag + '>';
return { html, end: i };
}
/**
* Convert an HTML list element back to markdown, recursing into
* nested sublists with 2-space indentation per depth level.
*/
export function listToMd(node: HTMLElement, depth: number, convert: Converter): string {
const isOl = node.nodeName === 'OL';
const indent = ' '.repeat(depth);
const lines: string[] = [];
Array.from(node.children).forEach((listItem, index) => {
const marker = isOl ? (index + 1) + '. ' : '- ';
let text = '';
let sublist = '';
Array.from(listItem.childNodes).forEach(child => {
if (child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')) {
sublist += listToMd(child as HTMLElement, depth + 1, convert);
} else {
text += convert.node(child);
}
});
lines.push(indent + marker + text.trim());
if (sublist) lines.push(sublist);
});
const result = lines.join('\n');
return depth === 0 ? '\n\n' + result + '\n\n' : result;
}
/**
* Test whether a line begins a block-level element (used to detect
* paragraph boundaries).
*/
export function isBlockStart(lines: string[], index: number): boolean {
const line = lines[index];
if (/^(`{3,})/.test(line)) return true;
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true;
if (/^#{1,6}\s/.test(line)) return true;
if (/^>\s?/.test(line)) return true;
if (/^[*\-]\s/.test(line) || /^\d+\.\s/.test(line)) return true;
if (line.indexOf('|') !== -1 && index + 1 < lines.length &&
/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(lines[index + 1])) return true;
return false;
}
function parseTableRow(line: string): string[] {
return line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim());
}
function parseAligns(line: string): (string | null)[] {
return parseTableRow(line).map(cell => {
if (/^:-+:$/.test(cell)) return 'center';
if (/^-+:$/.test(cell)) return 'right';
return null;
});
}
/**
* The default set of block-level tags, matched in order.
* Paragraph is the catch-all and must be last.
*/
export const defaultBlockTags: Record<string, Tag> = {
'PRE': {
/*
* ```lang
* code here
* ```
*/
name: 'fencedCode',
match: (context) => {
const matched = context.lines[context.index].match(/^(`{3,})(.*)/);
if (!matched) return null;
const fence = matched[1], lang = matched[2].trim();
const code: string[] = [];
let i = context.index + 1;
while (i < context.lines.length && !context.lines[i].startsWith(fence))
code.push(context.lines[i++]);
return {
content: code.join('\n'),
raw: '',
consumed: i + 1 - context.index,
meta: {
lang,
},
};
},
toHTML: (token) =>
'<pre><code' + (token.meta?.lang ? ` class="language-${escapeHtml(token.meta.lang)}"` : '') +
'>' + escapeHtml(token.content) + '</code></pre>',
selector: 'PRE',
toMarkdown: (element) => {
const code = element.querySelector('code');
const lang = (code?.getAttribute('class') || '').match(/language-(\S+)/)?.[1] || '';
return '\n\n```' + lang + '\n' + (code?.textContent || element.textContent || '') + '\n```\n\n';
},
},
'HR': {
/*
* ***
* ---
* ___
*/
name: 'hr',
match: (context) => {
if (!/^(\*{3,}|-{3,}|_{3,})\s*$/.test(context.lines[context.index])) {
return null;
}
return {
content: '',
raw: '',
consumed: 1,
};
},
toHTML: () => '<hr>',
selector: 'HR',
toMarkdown: () => '\n\n---\n\n',
},
'H1,H2,H3,H4,H5,H6': {
/*
* # Heading 1
* ## Heading 2
* ### Heading 3
*/
name: 'heading',
match: (context) => {
const matched = context.lines[context.index].match(/^(#{1,6})\s+(.*)/);
if (!matched) return null;
return {
content: matched[2].trim(),
raw: '',
consumed: 1,
meta: {
level: String(matched[1].length),
},
};
},
toHTML: (token, convert) =>
`<h${token.meta!.level} id='${camelId(token.content)}'>${convert.inline(token.content)}</h${token.meta!.level}>`,
selector: 'H1,H2,H3,H4,H5,H6',
toMarkdown: (element, convert) =>
'\n\n' + '#'.repeat(parseInt(element.nodeName[1])) + ' ' + convert.children(element) + '\n\n',
},
'BLOCKQUOTE': {
/*
* > quoted text
* > more quoted text
*/
name: 'blockquote',
match: (context) => {
if (!/^>\s?/.test(context.lines[context.index])) return null;
const lines: string[] = [];
let i = context.index;
while (i < context.lines.length && /^>\s?/.test(context.lines[i]))
lines.push(context.lines[i++].replace(/^>\s?/, ''));
return {
content: lines.join('\n'),
raw: '',
consumed: i - context.index,
};
},
toHTML: (token, convert) => '<blockquote>' + convert.block(token.content) + '</blockquote>',
selector: 'BLOCKQUOTE',
toMarkdown: (element, convert) =>
'\n\n' + convert.children(element).trim().split('\n').map(line => '> ' + line).join('\n') + '\n\n',
},
'UL,OL': {
/*
* - unordered item
* - unordered item
* - nested item
*
* 1. ordered item
* 2. ordered item
*/
name: 'list',
match: (context) => {
const line = context.lines[context.index];
if (!/^[*\-]\s/.test(line) && !/^\d+\.\s/.test(line)) return null;
return {
content: '',
raw: '',
consumed: 0,
};
},
toHTML: (token) => token.raw,
selector: 'UL,OL',
toMarkdown: (element, convert) =>
listToMd(element, 0, convert),
},
'TABLE': {
/*
* | head 1 | head 2 |
* |--------|--------|
* | cell 1 | cell 2 |
*/
name: 'table',
match: (context) => {
const { lines, index } = context;
if (lines[index].indexOf('|') === -1 || index + 1 >= lines.length) return null;
if (!/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(lines[index + 1])) return null;
const headers = parseTableRow(lines[index]);
const aligns = parseAligns(lines[index + 1]);
const rows: string[][] = [];
let i = index + 2;
while (i < lines.length && lines[i].indexOf('|') !== -1 && !/^\s*$/.test(lines[i]))
rows.push(parseTableRow(lines[i++]));
return {
content: '',
raw: '',
consumed: i - index,
meta: {
headers: JSON.stringify(headers),
aligns: JSON.stringify(aligns),
rows: JSON.stringify(rows),
},
};
},
toHTML: (token, convert) => {
const headers: string[] = JSON.parse(token.meta!.headers);
const aligns: (string | null)[] = JSON.parse(token.meta!.aligns);
const rows: string[][] = JSON.parse(token.meta!.rows);
function cell(tag: string, text: string, index: number): string {
const align = aligns[index] ? ` style="text-align:${aligns[index]}"` : '';
return `<${tag}${align}>${convert.inline(text)}</${tag}>`;
}
const head = '<thead><tr>' + headers.map((text, i) => cell('th', text, i)).join('') + '</tr></thead>';
const body = rows.map(row =>
'<tr>' + row.map((text, i) => cell('td', text, i)).join('') + '</tr>'
).join('');
return '<table>' + head + '<tbody>' + body + '</tbody></table>';
},
selector: 'TABLE',
toMarkdown: (element, convert) => {
const rows = Array.from(element.querySelectorAll('tr'));
if (!rows.length) return '';
const headers = Array.from(rows[0].querySelectorAll('th,td')).map(cell => convert.children(cell).trim());
const separator = headers.map(() => '---');
const output = [
'| ' + headers.join(' | ') + ' |',
'| ' + separator.join(' | ') + ' |',
];
rows.slice(1).forEach(row => {
const cells = Array.from(row.querySelectorAll('td,th')).map(cell => convert.children(cell).trim());
output.push('| ' + cells.join(' | ') + ' |');
});
return '\n\n' + output.join('\n') + '\n\n';
},
},
'P': {
/*
* Any text that doesn't match another block tag
* becomes a paragraph.
*/
name: 'paragraph',
match: (context) => {
const collected: string[] = [];
let i = context.index;
while (i < context.lines.length && !/^\s*$/.test(context.lines[i]) && !isBlockStart(context.lines, i))
collected.push(context.lines[i++]);
return collected.length
? {
content: collected.join('\n'),
raw: '',
consumed: i - context.index,
}
: null;
},
toHTML: (token, convert) => '<p>' + convert.inline(token.content) + '</p>',
selector: 'P',
toMarkdown: (element, convert) => '\n\n' + convert.children(element) + '\n\n',
},
};
/**
* The default set of inline tags, matched in precedence order.
* Tags created with inlineTag() carry a precedence field; lower values run first.
*/
export const defaultInlineTags: Record<string, Tag> = {
'CODE': inlineTag({
/*
* `inline code`
*/
name: 'code',
delimiter: '`',
htmlTag: 'code',
precedence: 10,
recursive: false,
}),
'A': {
/*
* [link text](http://example.com)
*/
name: 'link',
match: (context) => {
const matched = context.text.slice(context.offset).match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (!matched) {
return null;
}
return {
content: matched[1],
raw: matched[0],
consumed: matched[0].length,
meta: {
href: matched[2],
},
};
},
toHTML: (token, convert) =>
'<a href="' + escapeHtml(token.meta!.href) + '">' + convert.inline(token.content) + '</a>',
selector: 'A',
toMarkdown: (element, convert) =>
'[' + convert.children(element) + '](' + (element.getAttribute('href') || '') + ')',
},
'_boldItalic': {
/*
* ***bold and italic***
*/
...inlineTag({
name: 'boldItalic',
delimiter: '***',
htmlTag: 'em',
precedence: 30,
}),
toHTML: (token, convert) =>
'<em><strong>' + convert.inline(token.content) + '</strong></em>',
selector: ((element: HTMLElement) => false) as string | ((element: HTMLElement) => boolean),
toMarkdown: () => '',
},
'STRONG,B': inlineTag({
/*
* **bold text**
*/
name: 'bold',
delimiter: '**',
htmlTag: 'strong',
aliases: 'B',
precedence: 40,
}),
'EM,I': inlineTag({
/*
* *italic text*
*/
name: 'italic',
delimiter: '*',
htmlTag: 'em',
aliases: 'I',
precedence: 50,
}),
};
/**
* All default tags: block tags merged with inline tags.
*/
export const defaultTags: Record<string, Tag> = {
...defaultBlockTags,
...defaultInlineTags,
};
+114
View File
@@ -0,0 +1,114 @@
/*
* theme-manager.ts — manages theme registration and activation for a Ribbit instance.
*/
import type { RibbitTheme } from './types';
import { HopDown } from './hopdown';
export class ThemeManager {
private registered: Map<string, RibbitTheme>;
private disabled: Set<string>;
private active: RibbitTheme;
private themeLink: HTMLLinkElement | null;
private themesPath: string;
private onSwitch: (theme: RibbitTheme) => void;
constructor(initial: RibbitTheme, themesPath: string, onSwitch: (theme: RibbitTheme) => void) {
this.registered = new Map();
this.disabled = new Set();
this.themeLink = null;
this.themesPath = themesPath;
this.onSwitch = onSwitch;
this.active = initial;
this.add(initial);
}
/**
* Register a theme. Themes must be added before they can be enabled.
*/
add(theme: RibbitTheme): void {
this.registered.set(theme.name, theme);
}
/**
* Unregister a theme by name. Cannot remove the active theme.
*/
remove(name: string): void {
if (this.active.name === name) {
throw new Error(`Cannot remove the active theme "${name}".`);
}
this.registered.delete(name);
}
/**
* Return the names of all registered and enabled themes.
*/
list(): string[] {
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
}
/**
* Get a registered theme by name, or undefined if not found.
*/
get(name: string): RibbitTheme | undefined {
return this.registered.get(name);
}
/**
* Return the currently active theme.
*/
current(): RibbitTheme {
return this.active;
}
/**
* Switch to a registered theme by name. The theme must be
* registered and enabled. Loads the theme's CSS and notifies
* the editor to rebuild its converter.
*/
set(name: string): void {
const theme = this.registered.get(name);
if (!theme) {
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
}
if (this.disabled.has(name)) {
throw new Error(`Theme "${name}" is disabled. Call enable() first.`);
}
this.active = theme;
this.loadCSS(name);
this.onSwitch(theme);
}
/**
* Mark a theme as available for selection via set().
* Themes are enabled by default when added.
*/
enable(name: string): void {
if (!this.registered.has(name)) {
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
}
this.disabled.delete(name);
}
/**
* Mark a theme as unavailable for selection via set().
* Does not affect the current theme if it is already active.
*/
disable(name: string): void {
if (!this.registered.has(name)) {
throw new Error(`Theme "${name}" is not registered.`);
}
this.disabled.add(name);
}
private loadCSS(name: string): void {
if (this.themeLink) {
this.themeLink.remove();
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `${this.themesPath}/${name}/theme.css`;
document.head.appendChild(link);
this.themeLink = link;
}
}
+66
View File
@@ -0,0 +1,66 @@
/*
* types.ts — shared types for the hopdown converter.
*/
export interface SourceToken {
content: string;
raw: string;
consumed: number;
meta?: Record<string, string>;
}
export interface Converter {
inline: (text: string) => string;
block: (md: string) => string;
children: (node: Node) => string;
node: (node: Node) => string;
}
export interface MatchContext {
lines: string[];
index: number;
text: string;
offset: number;
}
export interface Tag {
name: string;
match: (context: MatchContext) => SourceToken | null;
toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean);
toMarkdown: (element: HTMLElement, convert: Converter) => string;
}
export interface ListItem {
text: string;
sub: string;
}
export interface ListResult {
html: string;
end: number;
}
export interface InlineTagDef {
name: string;
/** The markdown delimiter, e.g. '**' or '`' or '~~' */
delimiter: string;
/** The HTML tag to wrap with, e.g. 'strong' or 'code' */
htmlTag: string;
/** Additional HTML selectors for reverse matching, e.g. 'B' for bold */
aliases?: string;
/** Lower runs first. Default 50. */
precedence?: number;
/** Process inner content for nested markdown? Default true. False for code spans. */
recursive?: boolean;
}
export interface RibbitThemeFeatures {
sourceMode?: boolean;
}
export interface RibbitTheme {
name: string;
tags?: Record<string, Tag>;
features?: RibbitThemeFeatures;
}