Add support for wysiwyg markdown preview

This commit is contained in:
gsb 2026-04-29 03:18:19 +00:00
parent 86d59877f1
commit 4237a3f6a2
10 changed files with 430 additions and 72 deletions

103
README.md
View File

@ -1,44 +1,97 @@
# ribbit # ribbit
Zero-dependency WYSIWYG markdown editor Zero-dependency WYSIWYG markdown editor for the browser.
## Files ## Source Layout
- `src/hopdown.js` — Markdown ↔ HTML converter (`HopDown.toHTML()`, `HopDown.toMarkdown()`) - `src/ts/` — TypeScript source files
- `src/ribbit.js` — Base viewer class (`Ribbit`), plugin base class (`RibbitPlugin`), utilities - `types.ts` — shared interfaces (Tag, SourceToken, Converter, etc.)
- `src/ribbit-editor.js` — Editor class (`RibbitEditor`) with VIEW/EDIT/WYSIWYG modes - `tags.ts` — tag definitions and `inlineTag()` factory
- `src/ribbit.css` — Editor and content styles - `hopdown.ts` — configurable markdown↔HTML converter (HopDown class)
- `macros.ts` — macro parsing and Tag generation
- `ribbit.ts` — Ribbit viewer, RibbitPlugin, utilities
- `ribbit-editor.ts` — RibbitEditor with WYSIWYG support, public API exports
- `default-theme.ts` — built-in theme definition
- `theme-manager.ts` — theme registration and switching
- `events.ts` — typed event emitter
- `src/static/` — CSS and static assets
- `ribbit-core.css` — functional editor styles (always load)
- `themes/ribbit-default/theme.css` — default theme
## Build Output
```
dist/ribbit/
├── ribbit.js # readable IIFE bundle + source map
├── ribbit.min.js # minified bundle
├── ribbit-core.css # functional styles
└── themes/
└── ribbit-default/
└── theme.css # default theme (imports ribbit-core.css)
```
## Usage ## Usage
```html ```html
<link rel="stylesheet" href="ribbit/src/ribbit.css"> <link rel="stylesheet" href="ribbit/themes/ribbit-default/theme.css">
<article id="ribbit">your markdown here</article> <article id="ribbit">your markdown here</article>
<script src="ribbit/src/hopdown.js"></script> <script src="ribbit/ribbit.js"></script>
<script src="ribbit/src/ribbit.js"></script>
<script src="ribbit/src/ribbit-editor.js"></script>
<script> <script>
const editor = new RibbitEditor({ plugins: [] }); const editor = new ribbit.Editor({
on: {
save: ({ markdown }) => {
fetch('/api/save', { method: 'POST', body: markdown });
},
},
macros: [
{
name: 'npc',
toHTML: ({ keywords }) => {
const name = keywords.join(' ');
return `<a href="/NPC/${name}">${name}</a>`;
},
},
],
});
editor.run(); editor.run();
editor.wysiwyg();
// Switch modes
editor.wysiwyg(); // WYSIWYG editing
editor.edit(); // Source editing
editor.view(); // Read-only view
// Get content
editor.getMarkdown();
editor.getHTML();
</script> </script>
``` ```
## Custom Block Tags
```javascript
const spoiler = {
name: 'spoiler',
match: (context) => {
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
const content = [];
let i = context.index + 1;
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i]))
content.push(context.lines[i++]);
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
},
toHTML: (token, convert) =>
'<details><summary>Spoiler</summary>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (element, convert) =>
'\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
};
const converter = new ribbit.HopDown({
tags: { ...ribbit.defaultTags, 'DETAILS': spoiler },
});
```
## Tests
```
npm test
```
## Supported Markdown ## Supported Markdown
Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists, Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists,
blockquotes, fenced code blocks with language, horizontal rules, GFM tables with blockquotes, fenced code blocks with language, horizontal rules, GFM tables with
column alignment, and paragraphs. Arbitrary nesting of all inline formatting. column alignment, paragraphs, and macros (@name syntax).
## Tests
Open `test/test_ribbit-down.html` in a browser.

View File

@ -2,11 +2,9 @@
"name": "ribbit", "name": "ribbit",
"version": "1.0.0", "version": "1.0.0",
"description": "Zero-dependency WYSIWYG markdown editor for the browser", "description": "Zero-dependency WYSIWYG markdown editor for the browser",
"main": "dist/ribbit.js", "main": "dist/ribbit/ribbit.js",
"types": "dist/ribbit.d.ts",
"files": [ "files": [
"dist/", "dist/ribbit/"
"src/"
], ],
"scripts": { "scripts": {
"build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css", "build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css",

View File

@ -20,3 +20,38 @@
#ribbit.wysiwyg .md { #ribbit.wysiwyg .md {
opacity: 0.5; opacity: 0.5;
} }
.ribbit-editing::before,
.ribbit-editing::after {
opacity: 0.3;
font-weight: normal;
font-style: normal;
font-family: monospace;
font-size: 0.85em;
}
#ribbit.wysiwyg strong.ribbit-editing::before,
#ribbit.wysiwyg strong.ribbit-editing::after {
content: "**";
}
#ribbit.wysiwyg em.ribbit-editing::before,
#ribbit.wysiwyg em.ribbit-editing::after {
content: "*";
}
#ribbit.wysiwyg code.ribbit-editing::before,
#ribbit.wysiwyg code.ribbit-editing::after {
content: "\`";
}
#ribbit.wysiwyg h1.ribbit-editing::before { content: "# "; font-size: 0.5em; }
#ribbit.wysiwyg h2.ribbit-editing::before { content: "## "; font-size: 0.5em; }
#ribbit.wysiwyg h3.ribbit-editing::before { content: "### "; font-size: 0.5em; }
#ribbit.wysiwyg h4.ribbit-editing::before { content: "#### "; font-size: 0.5em; }
#ribbit.wysiwyg h5.ribbit-editing::before { content: "##### "; font-size: 0.5em; }
#ribbit.wysiwyg h6.ribbit-editing::before { content: "###### "; font-size: 0.5em; }
#ribbit.wysiwyg blockquote.ribbit-editing::before {
content: "> ";
}

View File

@ -68,7 +68,7 @@ export class HopDown {
this.blockTags = allTags.filter(tag => this.blockTags = allTags.filter(tag =>
defaultBlockNames.has(tag.name) || tag.name === 'macro' || defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern) (!defaultInlineNames.has(tag.name) && !tag.pattern)
); );
// Ensure macro block tag runs after fencedCode but before everything else // Ensure macro block tag runs after fencedCode but before everything else
@ -83,7 +83,7 @@ export class HopDown {
}); });
this.inlineTags = allTags.filter(tag => this.inlineTags = allTags.filter(tag =>
defaultInlineNames.has(tag.name) || (tag as any).pattern defaultInlineNames.has(tag.name) || tag.pattern
); );
this.tags = new Map(); this.tags = new Map();
@ -113,11 +113,11 @@ export class HopDown {
*/ */
private validateInlineTags(): void { private validateInlineTags(): void {
const withDelimiters = this.inlineTags const withDelimiters = this.inlineTags
.filter(tag => (tag as any).delimiter) .filter(tag => tag.delimiter)
.map(tag => ({ .map(tag => ({
name: tag.name, name: tag.name,
delimiter: (tag as any).delimiter as string, delimiter: tag.delimiter as string,
precedence: (tag as any).precedence as number ?? 50, precedence: tag.precedence as number ?? 50,
})); }));
for (let i = 0; i < withDelimiters.length; i++) { for (let i = 0; i < withDelimiters.length; i++) {
@ -159,6 +159,20 @@ export class HopDown {
return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim(); return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
} }
/**
* 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;
}
private processBlocks(md: string): string { private processBlocks(md: string): string {
const lines = md.replace(/\r\n/g, '\n').split('\n'); const lines = md.replace(/\r\n/g, '\n').split('\n');
const output: string[] = []; const output: string[] = [];
@ -186,6 +200,7 @@ export class HopDown {
output.push(result.html); output.push(result.html);
index = result.end; index = result.end;
} else { } else {
output.push(tag.toHTML(token, this.makeConverter())); output.push(tag.toHTML(token, this.makeConverter()));
index += token.consumed; index += token.consumed;
} }
@ -216,13 +231,11 @@ export class HopDown {
// Pass 1: extract links and non-recursive tags into placeholders before escaping // Pass 1: extract links and non-recursive tags into placeholders before escaping
for (const tag of sorted) { for (const tag of sorted) {
const recursive = (tag as any).recursive ?? true; const recursive = tag.recursive ?? true;
if (tag.name === 'link') { if (tag.name === 'link') {
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => { text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
// Process link text: restore earlier placeholders, then run inline on any remaining markdown
let inner = linkText; let inner = linkText;
// Check if link text contains placeholders (already-processed content)
const hasPlaceholders = /\x00P\d+\x00/.test(inner); const hasPlaceholders = /\x00P\d+\x00/.test(inner);
if (hasPlaceholders) { if (hasPlaceholders) {
inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]); inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
@ -232,9 +245,10 @@ export class HopDown {
placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>'); placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
return '\x00P' + (placeholders.length - 1) + '\x00'; return '\x00P' + (placeholders.length - 1) + '\x00';
}); });
} else if (!recursive && (tag as any).pattern) { } else if (!recursive && tag.pattern) {
const globalPattern = (tag as any).pattern as RegExp; const globalPattern = tag.pattern as RegExp;
globalPattern.lastIndex = 0; globalPattern.lastIndex = 0;
text = text.replace(globalPattern, (_, content: string) => { text = text.replace(globalPattern, (_, content: string) => {
placeholders.push(tag.toHTML( placeholders.push(tag.toHTML(
{ content, raw: '', consumed: 0 }, { content, raw: '', consumed: 0 },
@ -247,21 +261,20 @@ export class HopDown {
text = escapeHtml(text); text = escapeHtml(text);
// Pass 2: apply recursive tags in precedence order (longest delimiter first). // Pass 2: apply recursive tags in precedence order.
// Content matched here is already HTML-escaped and has had earlier // Content is already HTML-escaped from pass 1, so we wrap directly
// passes applied, so we wrap directly without re-processing. // without re-processing through convert.inline().
for (const tag of sorted) { for (const tag of sorted) {
const recursive = (tag as any).recursive ?? true; const recursive = tag.recursive ?? true;
if (tag.name === 'link' || !recursive) { if (tag.name === 'link' || !recursive) {
continue; continue;
} }
const globalPattern = (tag as any).pattern as RegExp | undefined; const globalPattern = tag.pattern as RegExp | undefined;
if (globalPattern) { if (globalPattern) {
globalPattern.lastIndex = 0; globalPattern.lastIndex = 0;
text = text.replace(globalPattern, (_, content: string) => { 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 restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
const htmlTag = (tag as any).name === 'boldItalic' const htmlTag = tag.name === 'boldItalic'
? null ? null
: ((tag.selector as string) || '').split(',')[0].toLowerCase(); : ((tag.selector as string) || '').split(',')[0].toLowerCase();
if (tag.name === 'boldItalic') { if (tag.name === 'boldItalic') {
@ -272,7 +285,6 @@ export class HopDown {
} }
} }
// Restore placeholders
text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]); text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
return text; return text;
} }

View File

@ -6,6 +6,7 @@ import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags'; import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme'; import { defaultTheme } from './default-theme';
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit'; import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { type MacroDef } from './macros';
/** /**
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes. * WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
@ -38,18 +39,156 @@ export class RibbitEditor extends Ribbit {
} }
#bindEvents(): void { #bindEvents(): void {
let debounceTimer: number | undefined;
let lastThrottle = 0;
this.element.addEventListener('input', () => { this.element.addEventListener('input', () => {
if (this.state !== this.states.VIEW) { if (this.state === this.states.VIEW) {
this.notifyChange(); return;
} }
this.invalidateCache();
const now = Date.now();
if (now - lastThrottle >= 150) {
lastThrottle = now;
this.refreshPreview();
}
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
this.refreshPreview();
this.notifyChange();
}, 150);
}); });
} }
/**
* Re-render the WYSIWYG preview from the current content.
* Applies speculative rendering for unclosed inline delimiters
* at the cursor position, and uses toHtmlPreview for visible syntax.
*/
refreshPreview(): void {
if (this.state !== this.states.WYSIWYG) {
return;
}
const cursorInfo = this.getCursorInfo();
const text = this.element.textContent || '';
const lines = text.split('\n');
// Speculatively close unclosed delimiters on the cursor line
if (cursorInfo) {
const inlineTags = this.converter.getInlineTags();
const sorted = [...inlineTags].sort((a, b) =>
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
);
for (const tag of sorted) {
if (tag.openPattern && tag.delimiter) {
const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset);
const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, 'g');
const count = (before.match(re) || []).length;
if (count % 2 === 1) {
lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter;
break;
}
}
}
}
const html = this.converter.toHTML(lines.join('\n'));
this.updatePreview(html, cursorInfo);
}
/**
* Track which formatting element contains the cursor and toggle
* the .ribbit-editing class so CSS ::before/::after show delimiters.
*/
private updateEditingContext(): void {
const prev = this.element.querySelector('.ribbit-editing');
if (prev) {
prev.classList.remove('ribbit-editing');
}
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return;
}
let node: Node | null = sel.anchorNode;
while (node && node !== this.element) {
if (node.nodeType === 1) {
const el = node as HTMLElement;
if (el.matches('strong, b, em, i, code, h1, h2, h3, h4, h5, h6, blockquote')) {
el.classList.add('ribbit-editing');
return;
}
}
node = node.parentNode;
}
}
/**
* Get the cursor's line index and offset within that line.
*/
private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return null;
}
const range = sel.getRangeAt(0);
const preRange = document.createRange();
preRange.selectNodeContents(this.element);
preRange.setEnd(range.startContainer, range.startOffset);
const absoluteOffset = preRange.toString().length;
const text = this.element.textContent || '';
const beforeCursor = text.slice(0, absoluteOffset);
const lineIndex = beforeCursor.split('\n').length - 1;
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
const offset = absoluteOffset - lineStart;
return { lineIndex, offset, absoluteOffset };
}
/**
* Replace the editor's HTML and restore the cursor to its
* previous text offset position.
*/
private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void {
this.element.innerHTML = html;
if (!cursorInfo) {
return;
}
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
let remaining = cursorInfo.absoluteOffset;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (remaining <= node.length) {
const sel = window.getSelection()!;
const range = document.createRange();
range.setStart(node, remaining);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
break;
}
remaining -= node.length;
}
this.updateEditingContext();
}
htmlToMarkdown(html?: string): string { htmlToMarkdown(html?: string): string {
return this.converter.toMarkdown(html || this.element.innerHTML); return this.converter.toMarkdown(html || this.element.innerHTML);
} }
getMarkdown(): string { getMarkdown(): string {
if (this.cachedMarkdown !== null) {
return this.cachedMarkdown;
}
if (this.getState() === this.states.EDIT) { if (this.getState() === this.states.EDIT) {
let html = this.element.innerHTML; let html = this.element.innerHTML;
html = html.replace(/<(?:div|br)>/ig, ''); html = html.replace(/<(?:div|br)>/ig, '');
@ -57,8 +196,7 @@ export class RibbitEditor extends Ribbit {
this.cachedMarkdown = decodeHtmlEntities(html); this.cachedMarkdown = decodeHtmlEntities(html);
} else if (this.getState() === this.states.WYSIWYG) { } else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML); this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
} } else {
if (!this.cachedMarkdown) {
this.cachedMarkdown = this.element.textContent || ''; this.cachedMarkdown = this.element.textContent || '';
} }
return this.cachedMarkdown; return this.cachedMarkdown;
@ -66,7 +204,6 @@ export class RibbitEditor extends Ribbit {
wysiwyg(): void { wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return; if (this.getState() === this.states.WYSIWYG) return;
this.changed = false;
this.element.contentEditable = 'true'; this.element.contentEditable = 'true';
this.element.innerHTML = this.getHTML(); this.element.innerHTML = this.getHTML();
Array.from(this.element.querySelectorAll('.macro')).forEach(el => { Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
@ -84,7 +221,6 @@ export class RibbitEditor extends Ribbit {
return; return;
} }
if (this.state === this.states.EDIT) return; if (this.state === this.states.EDIT) return;
this.changed = false;
this.element.contentEditable = 'true'; this.element.contentEditable = 'true';
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown()); this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
this.setState(this.states.EDIT); this.setState(this.states.EDIT);
@ -102,8 +238,6 @@ export class RibbitEditor extends Ribbit {
} }
} }
import { type MacroDef } from './macros';
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc. // Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
export { RibbitEditor as Editor }; export { RibbitEditor as Editor };
export { Ribbit as Viewer }; export { Ribbit as Viewer };

View File

@ -6,7 +6,7 @@ import { HopDown } from './hopdown';
import { defaultTheme } from './default-theme'; import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager'; import { ThemeManager } from './theme-manager';
import { RibbitEmitter, type RibbitEventMap } from './events'; import { RibbitEmitter, type RibbitEventMap } from './events';
import { buildMacroTags, type MacroDef } from './macros'; import { type MacroDef } from './macros';
import type { RibbitTheme } from './types'; import type { RibbitTheme } from './types';
export interface RibbitSettings { export interface RibbitSettings {
@ -174,14 +174,11 @@ export class Ribbit {
setState(newState: string): void { setState(newState: string): void {
const previous = this.state; const previous = this.state;
this.state = newState; if (previous) {
Object.values(this.states).forEach(state => { this.element.classList.remove(previous);
if (state === newState) {
this.element.classList.add(state);
} else {
this.element.classList.remove(state);
} }
}); this.state = newState;
this.element.classList.add(newState);
this.emitter.emit('modeChange', { this.emitter.emit('modeChange', {
current: newState, current: newState,
previous, previous,
@ -193,14 +190,14 @@ export class Ribbit {
} }
getHTML(): string { getHTML(): string {
if (this.changed || !this.cachedHTML) { if (this.cachedHTML === null) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown()); this.cachedHTML = this.markdownToHTML(this.getMarkdown());
} }
return this.cachedHTML; return this.cachedHTML;
} }
getMarkdown(): string { getMarkdown(): string {
if (!this.cachedMarkdown) { if (this.cachedMarkdown === null) {
this.cachedMarkdown = this.element.textContent || ''; this.cachedMarkdown = this.element.textContent || '';
} }
return this.cachedMarkdown; return this.cachedMarkdown;
@ -226,12 +223,21 @@ export class Ribbit {
this.element.contentEditable = 'false'; this.element.contentEditable = 'false';
} }
/**
* Invalidate cached markdown and HTML. Called when content changes.
* The next call to getMarkdown() or getHTML() will recompute.
*/
invalidateCache(): void {
this.changed = true;
this.cachedMarkdown = null;
this.cachedHTML = null;
}
/** /**
* Notify that content has changed. Called internally by the editor * Notify that content has changed. Called internally by the editor
* on input events. Fires the 'change' event with current content. * on input events. Fires the 'change' event with current content.
*/ */
notifyChange(): void { notifyChange(): void {
this.changed = true;
this.emitter.emit('change', { this.emitter.emit('change', {
markdown: this.getMarkdown(), markdown: this.getMarkdown(),
html: this.getHTML(), html: this.getHTML(),

View File

@ -19,10 +19,11 @@ import type { Tag, MatchContext, SourceToken, Converter, ListItem, ListResult, I
* inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 }) * inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 })
* inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' }) * inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' })
*/ */
export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recursive: boolean; pattern: RegExp; delimiter: string } { export function inlineTag(def: InlineTagDef): Tag {
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped); const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g'); const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
const openPattern = new RegExp(escaped + '(.+)$');
const upperTag = def.htmlTag.toUpperCase(); const upperTag = def.htmlTag.toUpperCase();
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(','); const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
const recursive = def.recursive !== false; const recursive = def.recursive !== false;
@ -32,6 +33,7 @@ export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recurs
precedence: def.precedence ?? 50, precedence: def.precedence ?? 50,
recursive, recursive,
pattern: globalPattern, pattern: globalPattern,
openPattern,
delimiter: def.delimiter, delimiter: def.delimiter,
match: (context) => { match: (context) => {
const matched = context.text.slice(context.offset).match(matchPattern); const matched = context.text.slice(context.offset).match(matchPattern);
@ -69,7 +71,7 @@ export function escapeHtml(source: string): string {
/** /**
* Generate a camelCase ID from heading text, for use as an anchor. * Generate a camelCase ID from heading text, for use as an anchor.
*/ */
export function camelId(text: string): string { function camelId(text: string): string {
return text.trim().split(/\s+/).map(word => return text.trim().split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(''); ).join('');
@ -115,7 +117,7 @@ export function parseListBlock(lines: string[], start: number, indent: number, i
* Convert an HTML list element back to markdown, recursing into * Convert an HTML list element back to markdown, recursing into
* nested sublists with 2-space indentation per depth level. * nested sublists with 2-space indentation per depth level.
*/ */
export function listToMd(node: HTMLElement, depth: number, convert: Converter): string { function listToMd(node: HTMLElement, depth: number, convert: Converter): string {
const isOl = node.nodeName === 'OL'; const isOl = node.nodeName === 'OL';
const indent = ' '.repeat(depth); const indent = ' '.repeat(depth);
const lines: string[] = []; const lines: string[] = [];
@ -141,7 +143,7 @@ export function listToMd(node: HTMLElement, depth: number, convert: Converter):
* Test whether a line begins a block-level element (used to detect * Test whether a line begins a block-level element (used to detect
* paragraph boundaries). * paragraph boundaries).
*/ */
export function isBlockStart(lines: string[], index: number): boolean { function isBlockStart(lines: string[], index: number): boolean {
const line = lines[index]; const line = lines[index];
if (/^(`{3,})/.test(line)) return true; if (/^(`{3,})/.test(line)) return true;
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true; if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true;

View File

@ -3,7 +3,6 @@
*/ */
import type { RibbitTheme } from './types'; import type { RibbitTheme } from './types';
import { HopDown } from './hopdown';
export class ThemeManager { export class ThemeManager {
private registered: Map<string, RibbitTheme>; private registered: Map<string, RibbitTheme>;

View File

@ -29,6 +29,22 @@ export interface Tag {
toHTML: (token: SourceToken, convert: Converter) => string; toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean); selector: string | ((element: HTMLElement) => boolean);
toMarkdown: (element: HTMLElement, convert: Converter) => string; toMarkdown: (element: HTMLElement, convert: Converter) => string;
/**
* The regex pattern that matches an unclosed opening delimiter.
* Used by the live preview to speculatively close incomplete syntax.
* Auto-generated by inlineTag().
*/
openPattern?: RegExp;
/**
* The markdown delimiter string. Auto-generated by inlineTag().
*/
delimiter?: string;
/** Lower runs first in inline processing. Default 50. Auto-generated by inlineTag(). */
precedence?: number;
/** Whether inner content is processed for nested markdown. Auto-generated by inlineTag(). */
recursive?: boolean;
/** Global regex for matching this tag's delimiter pair. Auto-generated by inlineTag(). */
pattern?: RegExp;
} }
export interface ListItem { export interface ListItem {

View File

@ -64,7 +64,7 @@ function not(name, actual, sub) {
} }
} }
function section(n) { /* silent */ } function section(n) { console.log(' ' + n); }
// ── 1. Inline formatting ──────────────────────────────── // ── 1. Inline formatting ────────────────────────────────
section('1. Inline Formatting → HTML'); section('1. Inline Formatting → HTML');
@ -497,6 +497,109 @@ has('macro: unknown block renders error', MH('@bogus(args\ncontent\n)'), 'ribbit
eq('macro: npc round-trip', mrt('@npc(Goblin King)'), '@npc(Goblin King)'); eq('macro: npc round-trip', mrt('@npc(Goblin King)'), '@npc(Goblin King)');
eq('macro: user round-trip', mrt('hello @user world'), 'hello @user world'); eq('macro: user round-trip', mrt('hello @user world'), 'hello @user world');
// ── 25. Preview CSS (via .ribbit-editing pseudo-elements) ───
// Preview styling is handled by CSS ::before/::after on .ribbit-editing,
// not by JS. We verify the converter output is clean HTML without syntax spans.
not('preview: no syntax spans in toHTML',
H('**bold**'), 'ribbit-syntax');
not('preview: no syntax spans in heading',
H('## Title'), 'ribbit-syntax');
// ── 26. openPattern — unclosed delimiter detection ──────
var inlineTags = hopdown.getInlineTags();
function findTag(name) {
return inlineTags.find(function(t) { return t.name === name; });
}
var boldTag = findTag('bold');
var italicTag = findTag('italic');
var codeTag = findTag('code');
var boldItalicTag = findTag('boldItalic');
eq('openPattern: bold has pattern', String(!!boldTag.openPattern), 'true');
eq('openPattern: italic has pattern', String(!!italicTag.openPattern), 'true');
eq('openPattern: code has pattern', String(!!codeTag.openPattern), 'true');
// Unclosed bold matches
eq('openPattern: unclosed ** odd count',
String((('hello **world').match(/\*\*/g) || []).length % 2 === 1), 'true');
// Closed bold — even count
eq('openPattern: closed ** even count',
String((('hello **world**').match(/\*\*/g) || []).length % 2 === 1), 'false');
// Unclosed italic
eq('openPattern: unclosed * odd count',
String((('hello *world').match(/\*/g) || []).length % 2 === 1), 'true');
// Unclosed code
eq('openPattern: unclosed ` odd count',
String((('hello `world').match(/`/g) || []).length % 2 === 1), 'true');
// ── 27. Speculative patching ────────────────────────────
function specPatch(md, cursorLine, cursorOffset) {
var lines = md.split('\n');
var sorted = inlineTags.slice().sort(function(a, b) {
return ((a).precedence || 50) - ((b).precedence || 50);
});
for (var i = 0; i < sorted.length; i++) {
var tag = sorted[i];
if (tag.openPattern && tag.delimiter) {
var before = lines[cursorLine].slice(0, cursorOffset);
var escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
var re = new RegExp(escaped, 'g');
var count = (before.match(re) || []).length;
if (count % 2 === 1) {
lines[cursorLine] = lines[cursorLine] + tag.delimiter;
break;
}
}
}
return hopdown.toHTML(lines.join('\n'));
}
has('speculate: unclosed bold',
specPatch('hello **world', 0, 13), '<strong>world</strong>');
has('speculate: unclosed italic',
specPatch('hello *world', 0, 12), '<em>world</em>');
has('speculate: unclosed code',
specPatch('hello `world', 0, 12), '<code>world</code>');
has('speculate: unclosed bold+italic',
specPatch('hello ***world', 0, 14), '<em><strong>world</strong></em>');
// Already closed — no double closing
eq('speculate: closed bold unchanged',
specPatch('hello **world**', 0, 15), '<p>hello <strong>world</strong></p>');
eq('speculate: closed italic unchanged',
specPatch('hello *world*', 0, 13), '<p>hello <em>world</em></p>');
// Only cursor line patched
has('speculate: multiline patches cursor only',
specPatch('normal\nhello **world', 1, 13), '<strong>world</strong>');
not('speculate: other line untouched',
specPatch('normal\nhello **world', 1, 13), '<strong>normal</strong>');
// No unclosed delimiter — no change
eq('speculate: no delimiter no-op',
specPatch('hello world', 0, 11), '<p>hello world</p>');
// ** wins over * (precedence)
has('speculate: ** wins over *',
specPatch('hello **world', 0, 13), '<strong>');
not('speculate: ** not italic',
specPatch('hello **world', 0, 13), '<em>world</em>');
// Delimiter with no content — speculation appends but nothing to format
eq('speculate: bare delimiter no content',
specPatch('hello **', 0, 8), '<p>hello <em>*</em>*</p>');
// Even count — all closed
eq('speculate: even count no-op',
specPatch('**a** **b**', 0, 11), '<p><strong>a</strong> <strong>b</strong></p>');
// Block tags need no speculation
eq('speculate: list works as-is',
H('- '), '<ul><li></li></ul>');
has('speculate: blockquote works as-is',
H('> '), '<blockquote>');
// ── Results ───────────────────────────────────────────── // ── Results ─────────────────────────────────────────────
const total = passed + failed; const total = passed + failed;
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`); console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);