diff --git a/TOKENIZER_DESIGN.md b/TOKENIZER_DESIGN.md new file mode 100644 index 0000000..876ac82 --- /dev/null +++ b/TOKENIZER_DESIGN.md @@ -0,0 +1,118 @@ +# HopDown Tokenizer Design + +## Problem + +The regex-based inline parser and serializer can't reliably distinguish +structural delimiters from literal text characters. This causes: +- `toMarkdown` escaping bugs (over-escaping inside inline tags, under-escaping + in text nodes) +- Round-trip failures (`toHTML(toMarkdown(html)) !== html`) +- Fragile interactions between features (underscore normalization + strikethrough, + HTML passthrough + escaping) + +## Invariants + +1. `toHTML` satisfies GFM spec rules 1-15 +2. `toMarkdown` always emits the canonical form +3. `toHTML(toMarkdown(html)) === html` (single-pass round-trip) + +## Architecture + +### Token types + +``` +text — literal characters, will be escaped during serialization +delimiter — structural marker (**, *, ~~, `, etc.) +html — raw HTML tag passthrough +break — hard line break (
) +``` + +### Inline tokenizer (markdown → tokens) + +Scans left-to-right, character by character. Maintains a stack of open +delimiters. Produces a flat token stream: + +``` +Input: "hello **bold *nested*** end" +Tokens: [text "hello "] [open **] [text "bold "] [open *] [text "nested"] [close *] [close **] [text " end"] +``` + +The tokenizer handles: +- Backslash escapes: `\*` → text token containing `*` +- Entity resolution: `&` → text token containing `&` +- Flanking rules: only emit delimiter tokens when flanking conditions are met +- Code spans: `` ` `` opens a code span that consumes everything until the matching `` ` `` +- Links: `[text](url)` parsed as a unit +- Autolinks: `` and bare URLs +- Hard line breaks: trailing spaces or `\` before newline +- HTML tags: `` etc. passed through as html tokens + +### Inline parser (tokens → HTML) + +Walks the token stream and matches open/close delimiter pairs using a +stack. Produces HTML string. Handles: +- Delimiter pairing with precedence (*** before ** before *) +- Multiple-of-3 rule +- Nesting validation (no em inside em, no links inside links) + +### Serializer (DOM → tokens → markdown) + +Walks the DOM tree. For each node: +- Text nodes → text tokens (the serializer knows these need escaping) +- Element nodes → look up the tag, emit delimiter tokens + recurse into children +- Unknown elements → recurse into children + +Then the token stream is serialized to a string: +- Delimiter tokens → emitted verbatim (they're structural) +- Text tokens → characters that would be misinterpreted as delimiters are + backslash-escaped. The serializer knows exactly which characters are + dangerous because it knows what delimiters exist. +- HTML tokens → emitted verbatim + +### Why this solves the round-trip problem + +The key insight: delimiter tokens and text tokens are different types. +When serializing `hello *world*`, the output is: + +``` +[delim **] [text "hello "] [delim *] [text "world"] [delim *] [delim **] +``` + +The `*` around "world" are delimiter tokens (from the nested ``). +If instead the text contained a literal `*`: + +``` +hello * world +``` + +The output would be: + +``` +[delim **] [text "hello * world"] [delim **] +``` + +The `*` is a text token. During serialization, the text token scanner +sees `*` and escapes it to `\*` because `*` is a known delimiter character. +The delimiter tokens are never escaped. No ambiguity. + +## Files + +- `types.ts` — Token type, updated Tag interface +- `tokenizer.ts` — Inline tokenizer (markdown → tokens) +- `serializer.ts` — DOM → tokens → markdown string +- `hopdown.ts` — Orchestrator (block parsing, delegates inline to tokenizer) +- `tags.ts` — Tag definitions (simplified: no more regex patterns) + +## Migration + +The Tag interface changes: +- `pattern` field removed (tokenizer handles delimiter matching) +- `toMarkdown` returns Token[] instead of string +- `match` stays the same (block-level matching is already clean) +- `toHTML` stays the same + +The HopDown public API stays the same: +- `toHTML(markdown)` — unchanged +- `toMarkdown(html)` — unchanged +- `findCompletePair`, `findUnmatchedOpener` — reimplemented on tokenizer +- `getTagForElement`, `getEditableSelector` — unchanged diff --git a/jest.config.js b/jest.config.js index 7eec04f..0c5dc19 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,7 +11,7 @@ module.exports = { '^.+\\.tsx?$': ['ts-jest', { tsconfig: { strict: true, - target: 'ES2017', + target: 'ES2018', module: 'CommonJS', moduleResolution: 'node', esModuleInterop: true, diff --git a/src/ts/collaboration.ts b/src/ts/collaboration.ts index e05095b..7fc60d9 100644 --- a/src/ts/collaboration.ts +++ b/src/ts/collaboration.ts @@ -11,6 +11,38 @@ import type { CollaborationSettings, RevisionProvider, Revision, RevisionMetadata, } from './types'; +/** Milliseconds to buffer rapid remote updates before applying the latest. */ +const THROTTLE_DELAY_MS = 150; + +/** Default milliseconds before a peer is considered idle. */ +const DEFAULT_IDLE_TIMEOUT_MS = 30000; + +/** Peer status values used in presence tracking. */ +const PEER_STATUS = { + ACTIVE: 'active' as const, + EDITING: 'editing' as const, + IDLE: 'idle' as const, +}; + +/** Auto-revision metadata when saving remote state before source mode merge. */ +const AUTO_REVISION_AUTHOR = 'auto'; +const AUTO_REVISION_SUMMARY = 'Auto-saved before source mode merge'; + +/** + * Manages real-time collaboration for a ribbit editor instance. + * + * Handles document sync, peer presence, document locking, and + * revision management through consumer-provided transport interfaces. + * + * @example + * const collab = new CollaborationManager(settings, { + * onRemoteUpdate: (content) => editor.setContent(content), + * onPeersChange: (peers) => updateUserList(peers), + * onLockChange: (holder) => updateLockUI(holder), + * onRemoteActivity: (count) => showBadge(count), + * }); + * collab.connect(); + */ export class CollaborationManager { private transport: DocumentTransport; private presence?: PresenceChannel; @@ -21,9 +53,7 @@ export class CollaborationManager { private paused: boolean; private remoteChangeCount: number; private latestRemoteContent: string | null; - private baseContent: string | null; private idleTimeout: number; - private idleTimer?: number; private lockHolder: PeerInfo | null; private onRemoteUpdate: (content: string) => void; private onPeersChange: (peers: PeerInfo[]) => void; @@ -50,8 +80,7 @@ export class CollaborationManager { this.paused = false; this.remoteChangeCount = 0; this.latestRemoteContent = null; - this.baseContent = null; - this.idleTimeout = settings.idleTimeout ?? 30000; + this.idleTimeout = settings.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS; this.lockHolder = null; this.onRemoteUpdate = callbacks.onRemoteUpdate; this.onPeersChange = callbacks.onPeersChange; @@ -78,16 +107,32 @@ export class CollaborationManager { } } + /** + * Open the transport connection and begin receiving updates. + * + * @example + * collab.connect(); + */ connect(): void { - if (this.connected) return; + if (this.connected) { + return; + } this.transport.connect(); this.connected = true; this.remoteChangeCount = 0; this.latestRemoteContent = null; } + /** + * Close the transport connection and clear peer state. + * + * @example + * collab.disconnect(); + */ disconnect(): void { - if (!this.connected) return; + if (!this.connected) { + return; + } this.transport.disconnect(); this.connected = false; this.peers = []; @@ -95,103 +140,200 @@ export class CollaborationManager { } /** - * Pause applying remote updates (entering source mode). - * Updates are still received and counted. + * Pause applying remote updates (e.g. when entering source mode). + * Updates are still received and counted so the UI can show a badge. + * + * @example + * collab.pause(editor.getMarkdown()); */ pause(currentContent: string): void { this.paused = true; - this.baseContent = currentContent; this.remoteChangeCount = 0; this.latestRemoteContent = null; } /** - * Resume applying remote updates (leaving source mode). - * If there were remote changes, creates a revision of the remote - * version before applying the local version (last-write-wins). + * Resume applying remote updates (e.g. when leaving source mode). + * If remote changes arrived while paused, creates a revision of + * the remote version before applying local content (last-write-wins). + * + * @example + * await collab.resume(editor.getMarkdown()); */ async resume(localContent: string): Promise { if (this.paused && this.latestRemoteContent && this.revisions) { await this.revisions.create(this.latestRemoteContent, { - author: 'auto', - summary: 'Auto-saved before source mode merge', + author: AUTO_REVISION_AUTHOR, + summary: AUTO_REVISION_SUMMARY, }); } this.paused = false; - this.baseContent = null; this.remoteChangeCount = 0; this.latestRemoteContent = null; this.sendUpdate(localContent); } + /** + * Broadcast local content to connected peers. + * + * @example + * collab.sendUpdate(editor.getMarkdown()); + */ sendUpdate(markdown: string): void { - if (!this.connected || this.paused) return; + if (!this.connected || this.paused) { + return; + } const encoded = new TextEncoder().encode(markdown); this.transport.send(encoded); } + /** + * Broadcast cursor position to connected peers. + * + * @example + * collab.sendCursor(selection.anchorOffset); + */ sendCursor(position: number): void { - if (!this.connected || !this.presence) return; + if (!this.connected || !this.presence) { + return; + } this.presence.send({ ...this.user, - status: this.paused ? 'editing' : 'active', + status: this.paused ? PEER_STATUS.EDITING : PEER_STATUS.ACTIVE, lastActive: Date.now(), cursor: position, }); } + /** + * Request an exclusive document lock. + * + * @example + * const acquired = await collab.lock(); + */ async lock(): Promise { - if (!this.transport.lock) return false; + if (!this.transport.lock) { + return false; + } return this.transport.lock(); } + /** + * Release the document lock. + * + * @example + * collab.unlock(); + */ unlock(): void { this.transport.unlock?.(); } + /** + * Force-acquire the lock, overriding any existing holder. + * + * @example + * const acquired = await collab.forceLock(); + */ async forceLock(): Promise { - if (!this.transport.forceLock) return false; + if (!this.transport.forceLock) { + return false; + } return this.transport.forceLock(); } + /** + * Return the peer currently holding the document lock, or null. + * + * @example + * const holder = collab.getLockHolder(); + */ getLockHolder(): PeerInfo | null { return this.lockHolder; } + /** + * Return the list of currently connected peers. + * + * @example + * const peers = collab.getPeers(); + */ getPeers(): PeerInfo[] { return this.peers; } + /** + * Return the number of remote changes received while paused. + * + * @example + * const count = collab.getRemoteChangeCount(); + */ getRemoteChangeCount(): number { return this.remoteChangeCount; } + /** + * Whether the transport connection is open. + * + * @example + * if (collab.isConnected()) { ... } + */ isConnected(): boolean { return this.connected; } + /** + * Whether remote updates are currently paused. + * + * @example + * if (collab.isPaused()) { ... } + */ isPaused(): boolean { return this.paused; } /** - * Revision access — delegates to the consumer's RevisionProvider. + * List all stored revisions via the consumer's RevisionProvider. + * + * @example + * const revisions = await collab.listRevisions(); */ async listRevisions(): Promise { - if (!this.revisions) return []; + if (!this.revisions) { + return []; + } return this.revisions.list(); } + /** + * Retrieve a specific revision by ID. + * + * @example + * const revision = await collab.getRevision('abc123'); + */ async getRevision(id: string): Promise<(Revision & { content: string }) | null> { - if (!this.revisions) return null; + if (!this.revisions) { + return null; + } return this.revisions.get(id); } + /** + * Create a new revision with the given content and metadata. + * + * @example + * await collab.createRevision(markdown, { author: 'user1', summary: 'Draft' }); + */ async createRevision(content: string, metadata?: RevisionMetadata): Promise { - if (!this.revisions) return null; + if (!this.revisions) { + return null; + } return this.revisions.create(content, metadata); } + /** + * Buffers rapid remote updates and applies only the latest after + * a throttle delay. When paused, counts changes without applying. + */ private handleRemoteUpdate(update: Uint8Array): void { const content = new TextDecoder().decode(update); @@ -203,23 +345,29 @@ export class CollaborationManager { } this.receiveBuffer.push(update); - if (this.throttleTimer !== undefined) return; + if (this.throttleTimer !== undefined) { + return; + } this.throttleTimer = window.setTimeout(() => { this.throttleTimer = undefined; - if (this.receiveBuffer.length === 0) return; + if (this.receiveBuffer.length === 0) { + return; + } const latest = this.receiveBuffer[this.receiveBuffer.length - 1]; this.receiveBuffer = []; this.onRemoteUpdate(new TextDecoder().decode(latest)); - }, 150); + }, THROTTLE_DELAY_MS); } + /** Marks peers as idle when their lastActive exceeds the timeout. */ private applyIdleStatus(peers: PeerInfo[]): PeerInfo[] { const now = Date.now(); return peers.map(peer => ({ ...peer, - status: peer.status === 'editing' ? 'editing' - : (now - peer.lastActive > this.idleTimeout ? 'idle' : 'active'), + status: peer.status === PEER_STATUS.EDITING + ? PEER_STATUS.EDITING + : (now - peer.lastActive > this.idleTimeout ? PEER_STATUS.IDLE : PEER_STATUS.ACTIVE), })); } } diff --git a/src/ts/default-theme.ts b/src/ts/default-theme.ts index e7c26f0..f72aeb1 100644 --- a/src/ts/default-theme.ts +++ b/src/ts/default-theme.ts @@ -7,8 +7,18 @@ import type { RibbitTheme } from './types'; import { defaultTags } from './tags'; +/** Theme name used as the built-in default across ribbit. */ +const DEFAULT_THEME_NAME = 'ribbit-default'; + +/** + * The built-in ribbit theme. Enables all default tags and source mode. + * + * @example + * import { defaultTheme } from './default-theme'; + * const editor = new RibbitEditor({ theme: defaultTheme }); + */ export const defaultTheme: RibbitTheme = { - name: 'ribbit-default', + name: DEFAULT_THEME_NAME, tags: defaultTags, features: { sourceMode: true, diff --git a/src/ts/events.ts b/src/ts/events.ts index e234fbb..6729cfe 100644 --- a/src/ts/events.ts +++ b/src/ts/events.ts @@ -113,6 +113,14 @@ export interface RibbitEventMap { type EventName = keyof RibbitEventMap; +/** + * Typed event emitter for ribbit editor lifecycle and collaboration events. + * + * @example + * const emitter = new RibbitEmitter(); + * emitter.on('change', ({ markdown }) => console.log(markdown)); + * emitter.emit('change', { markdown: '# Hello', html: '

Hello

' }); + */ export class RibbitEmitter { private listeners: Map>; @@ -122,6 +130,9 @@ export class RibbitEmitter { /** * Register a callback for an event. + * + * @example + * emitter.on('save', ({ markdown }) => saveDraft(markdown)); */ on(event: K, callback: RibbitEventMap[K]): void { if (!this.listeners.has(event)) { @@ -132,6 +143,9 @@ export class RibbitEmitter { /** * Remove a previously registered callback. + * + * @example + * emitter.off('save', savedCallback); */ off(event: K, callback: RibbitEventMap[K]): void { this.listeners.get(event)?.delete(callback); @@ -139,6 +153,9 @@ export class RibbitEmitter { /** * Emit an event, calling all registered callbacks with the payload. + * + * @example + * emitter.emit('change', { markdown: '# Title', html: '

Title

' }); */ emit(event: K, ...args: Parameters): void { for (const callback of this.listeners.get(event) || []) { diff --git a/src/ts/hopdown.ts b/src/ts/hopdown.ts index d087933..a5fe811 100644 --- a/src/ts/hopdown.ts +++ b/src/ts/hopdown.ts @@ -1,18 +1,18 @@ /* * 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('bold'); + * HopDown orchestrates markdown↔HTML conversion using a tokenizer for + * inline parsing and a serializer for HTML→markdown. Block-level parsing + * uses Tag definitions directly. The tokenizer/serializer architecture + * ensures correct round-trips by separating structural delimiters from + * literal text at the type level. */ -import type { Converter, MatchContext, Tag } from './types'; -import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags'; +import type { Converter, MatchContext, Tag, DelimiterMatch } from './types'; +import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml } from './tags'; import { buildMacroTags, processInlineMacros, type MacroDef } from './macros'; +import { InlineTokenizer, type InlineToken, type DelimiterDef } from './tokenizer'; +import { MarkdownSerializer, type SerializerTagDef } from './serializer'; export type TagMap = Record; @@ -23,17 +23,25 @@ export interface HopDownOptions { } /** - * A configurable markdown↔HTML converter. + * Configurable markdown↔HTML converter. Uses a tokenizer for inline + * parsing (markdown→HTML) and a serializer for HTML→markdown. Block + * parsing delegates to Tag definitions. * - * 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 + * const converter = new HopDown(); + * converter.toHTML('**bold**'); + * converter.toMarkdown('bold'); */ export class HopDown { private blockTags: Tag[]; private inlineTags: Tag[]; private tags: Map; private macroMap: Map; + private referenceLinks: Map; + private tokenizer: InlineTokenizer; + private serializer: MarkdownSerializer; + private cachedConverter: Converter; + private delimiterRegexes: { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[]; + private editableSelectorCache: string; constructor(options: HopDownOptions = {}) { let tagMap: TagMap; @@ -49,8 +57,8 @@ export class HopDown { tagMap = defaultTags; } - // Build macro tags if macros are provided this.macroMap = new Map(); + this.referenceLinks = new Map(); if (options.macros && options.macros.length > 0) { const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros); this.macroMap = macroMap; @@ -59,20 +67,27 @@ export class HopDown { } 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)); + const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(tag => tag.name)); + const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(tag => tag.name)); this.blockTags = allTags.filter(tag => defaultBlockNames.has(tag.name) || tag.name === 'macro' || (!defaultInlineNames.has(tag.name) && !tag.pattern) ); - // Ensure macro block tag runs after fencedCode but before everything else + // Macro block tag must run after fencedCode (so code blocks aren't + // parsed as macros) but before paragraph (the catch-all) this.blockTags.sort((a, b) => { - const order = (t: Tag) => { - if (t.name === 'fencedCode') return 0; - if (t.name === 'macro') return 1; - if (t.name === 'paragraph') return 99; + const order = (tag: Tag) => { + if (tag.name === 'fencedCode') { + return 0; + } + if (tag.name === 'macro') { + return 1; + } + if (tag.name === 'paragraph') { + return 99; + } return 50; }; return order(a) - order(b); @@ -83,30 +98,35 @@ export class HopDown { ); this.tags = new Map(); + this.registerSelectors(tagMap); + this.validateInlineTags(); + + this.tokenizer = this.buildTokenizer(); + this.serializer = this.buildSerializer(); + this.cachedConverter = this.makeConverter(); + this.delimiterRegexes = this.buildDelimiterRegexes(); + this.editableSelectorCache = this.buildEditableSelector(); + } + + private registerSelectors(tagMap: TagMap): void { for (const [selector, tag] of Object.entries(tagMap)) { - for (const sel of selector.split(',').map(s => s.trim()).filter(Boolean)) { - if (sel.startsWith('_')) { + const parts = selector.split(',').map(part => part.trim()).filter(Boolean); + for (const part of parts) { + if (part.startsWith('_')) { continue; } - const existing = this.tags.get(sel); + const existing = this.tags.get(part); if (existing && existing !== tag) { throw new Error( - `HTML tag "${sel}" is claimed by both "${existing.name}" and "${tag.name}". ` + + `HTML tag "${part}" 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.tags.set(part, 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.delimiter) @@ -116,17 +136,17 @@ export class HopDown { precedence: tag.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) { + for (let outer = 0; outer < withDelimiters.length; outer++) { + for (let inner = outer + 1; inner < withDelimiters.length; inner++) { + const first = withDelimiters[outer]; + const second = withDelimiters[inner]; + const firstIsPrefix = second.delimiter.startsWith(first.delimiter); + const secondIsPrefix = first.delimiter.startsWith(second.delimiter); + if (!firstIsPrefix && !secondIsPrefix) { continue; } - const longer = a.delimiter.length > b.delimiter.length ? a : b; - const shorter = a.delimiter.length > b.delimiter.length ? b : a; + const longer = first.delimiter.length > second.delimiter.length ? first : second; + const shorter = first.delimiter.length > second.delimiter.length ? second : first; if (longer.precedence >= shorter.precedence) { throw new Error( `Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` + @@ -141,42 +161,145 @@ export class HopDown { /** * Convert a markdown string to HTML. + * + * converter.toHTML('# Hello\n\n**bold** text') */ - toHTML(md: string): string { - return this.processBlocks(md); + toHTML(markdown: string): string { + return this.processBlocks(markdown); } /** - * Convert an HTML string back to markdown. + * Convert an HTML string back to markdown. Uses the serializer + * which produces correctly-escaped output via typed tokens. + * + * converter.toMarkdown('

Hello

bold text

') */ toMarkdown(html: string): string { const container = document.createElement('div'); container.innerHTML = html; - return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim(); + return this.serializeNode(container).replace(/\n{3,}/g, '\n\n').trim(); } /** - * Return the block tags for external iteration (e.g. speculative rendering). + * The registered block-level tags. Used by the WYSIWYG editor + * to detect block syntax patterns during live editing. + * + * converter.getBlockTags().forEach(tag => console.log(tag.name)) */ getBlockTags(): Tag[] { return this.blockTags; } /** - * Return the inline tags for external iteration (e.g. speculative rendering). + * The registered inline tags. Used by the WYSIWYG editor to + * build delimiter regexes for speculative rendering. + * + * converter.getInlineTags().filter(tag => tag.delimiter) */ getInlineTags(): Tag[] { return this.inlineTags; } - private processBlocks(md: string): string { - const lines = md.replace(/\r\n/g, '\n').split('\n'); - const output: string[] = []; - let index = 0; + /** + * Find the first complete delimiter pair in the text. + * + * converter.findCompletePair('hello **world** end') + */ + findCompletePair(text: string): DelimiterMatch | null { + for (const entry of this.delimiterRegexes) { + const match = text.match(entry.complete); + if (match && match.index !== undefined) { + return { + tag: entry.tag, + htmlTag: entry.htmlTag, + content: match[1], + index: match.index, + length: match[0].length, + delimiter: entry.tag.delimiter!, + }; + } + } + return null; + } - while (index < lines.length) { - if (/^\s*$/.test(lines[index])) { - index++; + /** + * Find the first unclosed delimiter opener in the text. + * + * converter.findUnmatchedOpener('hello **world') + */ + findUnmatchedOpener(text: string): DelimiterMatch | null { + for (const entry of this.delimiterRegexes) { + const match = text.match(entry.open); + if (match && match.index !== undefined) { + const before = text.slice(0, match.index); + if (before.endsWith('<') || before.endsWith('/')) { + continue; + } + return { + tag: entry.tag, + htmlTag: entry.htmlTag, + content: match[1], + index: match.index, + length: match[0].length, + delimiter: entry.tag.delimiter!, + }; + } + } + return null; + } + + /** + * Look up the Tag definition for an HTML element by its tag name. + * + * converter.getTagForElement(strongElement) + */ + getTagForElement(element: HTMLElement): Tag | null { + const tag = this.tags.get(element.tagName); + if (tag && tag.delimiter) { + return tag; + } + return null; + } + + /** + * CSS selector string matching all elements that should show + * editing context. + * + * element.matches(converter.getEditableSelector()) + */ + getEditableSelector(): string { + return this.editableSelectorCache; + } + + /** + * Split markdown into lines, match each against block tags in + * priority order, and concatenate the resulting HTML. + */ + private processBlocks(markdown: string): string { + const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const output: string[] = []; + const blankLine = /^\s*$/; + const refDefinition = /^\[(?