From 4237a3f6a2f819e3faded0368bbcfb551ec4a0d6 Mon Sep 17 00:00:00 2001 From: gsb Date: Wed, 29 Apr 2026 03:18:19 +0000 Subject: [PATCH] Add support for wysiwyg markdown preview --- README.md | 103 ++++++++++++++++++------- package.json | 6 +- src/static/ribbit-core.css | 35 +++++++++ src/ts/hopdown.ts | 48 +++++++----- src/ts/ribbit-editor.ts | 150 +++++++++++++++++++++++++++++++++++-- src/ts/ribbit.ts | 28 ++++--- src/ts/tags.ts | 10 ++- src/ts/theme-manager.ts | 1 - src/ts/types.ts | 16 ++++ test/test_hopdown.js | 105 +++++++++++++++++++++++++- 10 files changed, 430 insertions(+), 72 deletions(-) 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`);