diff --git a/README.md b/README.md
index d5a8232..8a4894a 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,97 @@
# 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/ribbit.js` — Base viewer class (`Ribbit`), plugin base class (`RibbitPlugin`), utilities
-- `src/ribbit-editor.js` — Editor class (`RibbitEditor`) with VIEW/EDIT/WYSIWYG modes
-- `src/ribbit.css` — Editor and content styles
+- `src/ts/` — TypeScript source files
+ - `types.ts` — shared interfaces (Tag, SourceToken, Converter, etc.)
+ - `tags.ts` — tag definitions and `inlineTag()` factory
+ - `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
```html
-
+
your markdown here
-
-
-
+
```
+## 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) =>
+ 'Spoiler
' + convert.block(token.content) + ' ',
+ 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
Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists,
blockquotes, fenced code blocks with language, horizontal rules, GFM tables with
-column alignment, and paragraphs. Arbitrary nesting of all inline formatting.
-
-## Tests
-
-Open `test/test_ribbit-down.html` in a browser.
+column alignment, paragraphs, and macros (@name syntax).
diff --git a/package.json b/package.json
index 20dce8e..16af4d2 100644
--- a/package.json
+++ b/package.json
@@ -2,11 +2,9 @@
"name": "ribbit",
"version": "1.0.0",
"description": "Zero-dependency WYSIWYG markdown editor for the browser",
- "main": "dist/ribbit.js",
- "types": "dist/ribbit.d.ts",
+ "main": "dist/ribbit/ribbit.js",
"files": [
- "dist/",
- "src/"
+ "dist/ribbit/"
],
"scripts": {
"build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css",
diff --git a/src/static/ribbit-core.css b/src/static/ribbit-core.css
index f352fce..9b7a11a 100644
--- a/src/static/ribbit-core.css
+++ b/src/static/ribbit-core.css
@@ -20,3 +20,38 @@
#ribbit.wysiwyg .md {
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: "> ";
+}
diff --git a/src/ts/hopdown.ts b/src/ts/hopdown.ts
index 1fe9b66..ee8c521 100644
--- a/src/ts/hopdown.ts
+++ b/src/ts/hopdown.ts
@@ -68,7 +68,7 @@ export class HopDown {
this.blockTags = allTags.filter(tag =>
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
@@ -83,7 +83,7 @@ export class HopDown {
});
this.inlineTags = allTags.filter(tag =>
- defaultInlineNames.has(tag.name) || (tag as any).pattern
+ defaultInlineNames.has(tag.name) || tag.pattern
);
this.tags = new Map();
@@ -113,11 +113,11 @@ export class HopDown {
*/
private validateInlineTags(): void {
const withDelimiters = this.inlineTags
- .filter(tag => (tag as any).delimiter)
+ .filter(tag => tag.delimiter)
.map(tag => ({
name: tag.name,
- delimiter: (tag as any).delimiter as string,
- precedence: (tag as any).precedence as number ?? 50,
+ delimiter: tag.delimiter as string,
+ precedence: tag.precedence as number ?? 50,
}));
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 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 {
const lines = md.replace(/\r\n/g, '\n').split('\n');
const output: string[] = [];
@@ -186,6 +200,7 @@ export class HopDown {
output.push(result.html);
index = result.end;
} else {
+
output.push(tag.toHTML(token, this.makeConverter()));
index += token.consumed;
}
@@ -216,13 +231,11 @@ export class HopDown {
// Pass 1: extract links and non-recursive tags into placeholders before escaping
for (const tag of sorted) {
- const recursive = (tag as any).recursive ?? true;
+ const recursive = tag.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)]);
@@ -232,9 +245,10 @@ export class HopDown {
placeholders.push('' + inner + '');
return '\x00P' + (placeholders.length - 1) + '\x00';
});
- } else if (!recursive && (tag as any).pattern) {
- const globalPattern = (tag as any).pattern as RegExp;
+ } else if (!recursive && tag.pattern) {
+ const globalPattern = tag.pattern as RegExp;
globalPattern.lastIndex = 0;
+
text = text.replace(globalPattern, (_, content: string) => {
placeholders.push(tag.toHTML(
{ content, raw: '', consumed: 0 },
@@ -247,21 +261,20 @@ export class HopDown {
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.
+ // 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().
for (const tag of sorted) {
- const recursive = (tag as any).recursive ?? true;
+ const recursive = tag.recursive ?? true;
if (tag.name === 'link' || !recursive) {
continue;
}
- const globalPattern = (tag as any).pattern as RegExp | undefined;
+ const globalPattern = tag.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'
+ const htmlTag = tag.name === 'boldItalic'
? null
: ((tag.selector as string) || '').split(',')[0].toLowerCase();
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)]);
return text;
}
diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts
index 7b38e26..d9940e1 100644
--- a/src/ts/ribbit-editor.ts
+++ b/src/ts/ribbit-editor.ts
@@ -6,6 +6,7 @@ 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';
+import { type MacroDef } from './macros';
/**
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
@@ -38,18 +39,156 @@ export class RibbitEditor extends Ribbit {
}
#bindEvents(): void {
+ let debounceTimer: number | undefined;
+ let lastThrottle = 0;
+
this.element.addEventListener('input', () => {
- if (this.state !== this.states.VIEW) {
- this.notifyChange();
+ if (this.state === this.states.VIEW) {
+ 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 {
return this.converter.toMarkdown(html || this.element.innerHTML);
}
getMarkdown(): string {
+ if (this.cachedMarkdown !== null) {
+ return this.cachedMarkdown;
+ }
if (this.getState() === this.states.EDIT) {
let html = this.element.innerHTML;
html = html.replace(/<(?:div|br)>/ig, '');
@@ -57,8 +196,7 @@ export class RibbitEditor extends Ribbit {
this.cachedMarkdown = decodeHtmlEntities(html);
} else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
- }
- if (!this.cachedMarkdown) {
+ } else {
this.cachedMarkdown = this.element.textContent || '';
}
return this.cachedMarkdown;
@@ -66,7 +204,6 @@ export class RibbitEditor extends Ribbit {
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 => {
@@ -84,7 +221,6 @@ export class RibbitEditor extends Ribbit {
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);
@@ -102,8 +238,6 @@ export class RibbitEditor extends Ribbit {
}
}
-import { type MacroDef } from './macros';
-
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
export { RibbitEditor as Editor };
export { Ribbit as Viewer };
diff --git a/src/ts/ribbit.ts b/src/ts/ribbit.ts
index 57f98b0..c9c0d71 100644
--- a/src/ts/ribbit.ts
+++ b/src/ts/ribbit.ts
@@ -6,7 +6,7 @@ import { HopDown } from './hopdown';
import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager';
import { RibbitEmitter, type RibbitEventMap } from './events';
-import { buildMacroTags, type MacroDef } from './macros';
+import { type MacroDef } from './macros';
import type { RibbitTheme } from './types';
export interface RibbitSettings {
@@ -174,14 +174,11 @@ export class Ribbit {
setState(newState: string): void {
const previous = this.state;
+ if (previous) {
+ this.element.classList.remove(previous);
+ }
this.state = newState;
- Object.values(this.states).forEach(state => {
- if (state === newState) {
- this.element.classList.add(state);
- } else {
- this.element.classList.remove(state);
- }
- });
+ this.element.classList.add(newState);
this.emitter.emit('modeChange', {
current: newState,
previous,
@@ -193,14 +190,14 @@ export class Ribbit {
}
getHTML(): string {
- if (this.changed || !this.cachedHTML) {
+ if (this.cachedHTML === null) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
}
return this.cachedHTML;
}
getMarkdown(): string {
- if (!this.cachedMarkdown) {
+ if (this.cachedMarkdown === null) {
this.cachedMarkdown = this.element.textContent || '';
}
return this.cachedMarkdown;
@@ -226,12 +223,21 @@ export class Ribbit {
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
* on input events. Fires the 'change' event with current content.
*/
notifyChange(): void {
- this.changed = true;
this.emitter.emit('change', {
markdown: this.getMarkdown(),
html: this.getHTML(),
diff --git a/src/ts/tags.ts b/src/ts/tags.ts
index 5402fb1..68e1331 100644
--- a/src/ts/tags.ts
+++ b/src/ts/tags.ts
@@ -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: '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 matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
+ const openPattern = new RegExp(escaped + '(.+)$');
const upperTag = def.htmlTag.toUpperCase();
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
const recursive = def.recursive !== false;
@@ -32,6 +33,7 @@ export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recurs
precedence: def.precedence ?? 50,
recursive,
pattern: globalPattern,
+ openPattern,
delimiter: def.delimiter,
match: (context) => {
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.
*/
-export function camelId(text: string): string {
+function camelId(text: string): string {
return text.trim().split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).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
* 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 indent = ' '.repeat(depth);
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
* paragraph boundaries).
*/
-export function isBlockStart(lines: string[], index: number): boolean {
+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;
diff --git a/src/ts/theme-manager.ts b/src/ts/theme-manager.ts
index acdcd76..a90ad67 100644
--- a/src/ts/theme-manager.ts
+++ b/src/ts/theme-manager.ts
@@ -3,7 +3,6 @@
*/
import type { RibbitTheme } from './types';
-import { HopDown } from './hopdown';
export class ThemeManager {
private registered: Map;
diff --git a/src/ts/types.ts b/src/ts/types.ts
index 8a28f4b..cbcd4d0 100644
--- a/src/ts/types.ts
+++ b/src/ts/types.ts
@@ -29,6 +29,22 @@ export interface Tag {
toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean);
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 {
diff --git a/test/test_hopdown.js b/test/test_hopdown.js
index cfaf87b..9903680 100644
--- a/test/test_hopdown.js
+++ b/test/test_hopdown.js
@@ -64,7 +64,7 @@ function not(name, actual, sub) {
}
}
-function section(n) { /* silent */ }
+function section(n) { console.log(' ' + n); }
// ── 1. Inline formatting ────────────────────────────────
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: 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), 'world');
+has('speculate: unclosed italic',
+ specPatch('hello *world', 0, 12), 'world');
+has('speculate: unclosed code',
+ specPatch('hello `world', 0, 12), 'world');
+has('speculate: unclosed bold+italic',
+ specPatch('hello ***world', 0, 14), 'world');
+
+// Already closed — no double closing
+eq('speculate: closed bold unchanged',
+ specPatch('hello **world**', 0, 15), 'hello world
');
+eq('speculate: closed italic unchanged',
+ specPatch('hello *world*', 0, 13), 'hello world
');
+
+// Only cursor line patched
+has('speculate: multiline patches cursor only',
+ specPatch('normal\nhello **world', 1, 13), 'world');
+not('speculate: other line untouched',
+ specPatch('normal\nhello **world', 1, 13), 'normal');
+
+// No unclosed delimiter — no change
+eq('speculate: no delimiter no-op',
+ specPatch('hello world', 0, 11), 'hello world
');
+
+// ** wins over * (precedence)
+has('speculate: ** wins over *',
+ specPatch('hello **world', 0, 13), '');
+not('speculate: ** not italic',
+ specPatch('hello **world', 0, 13), 'world');
+
+// Delimiter with no content — speculation appends but nothing to format
+eq('speculate: bare delimiter no content',
+ specPatch('hello **', 0, 8), 'hello **
');
+
+// Even count — all closed
+eq('speculate: even count no-op',
+ specPatch('**a** **b**', 0, 11), 'a b
');
+
+// Block tags need no speculation
+eq('speculate: list works as-is',
+ H('- '), '');
+has('speculate: blockquote works as-is',
+ H('> '), '');
+
// ── Results ─────────────────────────────────────────────
const total = passed + failed;
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);