Compare commits

..

2 Commits

Author SHA1 Message Date
gsb
bfc20f56bf Add dev server with livereload
npm run dev starts a dev server on port 8080 serving the test
page and ribbit dist files. Watches src/ and test/integration/
for changes, rebuilds automatically, and notifies connected
browsers to reload via EventSource on port 8081.

The test page includes a livereload script that auto-reloads
when the dev server signals a rebuild.
2026-04-30 22:47:26 +00:00
gsb
24560a4d21 bug fixes and wysiwyg behaviour improvements 2026-04-30 22:47:26 +00:00
10 changed files with 1126 additions and 25 deletions

View File

@ -14,6 +14,7 @@
"build:core": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit-core.js",
"build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js",
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
"dev": "npm run build && node test/integration/dev-server.js",
"test": "npm run build && jest --verbose",
"test:integration": "npm run build && node test/integration/test.js && node test/integration/test_wysiwyg.js",
"test:coverage": "npm run build && jest --coverage"

View File

@ -90,9 +90,7 @@ export class RibbitEditor extends Ribbit {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (event.key === 'Enter') {
this.handleEnter(event);
}
this.dispatchKeydown(event);
});
this.element.addEventListener('keyup', (event: KeyboardEvent) => {
@ -212,7 +210,9 @@ export class RibbitEditor extends Ribbit {
while (node && node !== this.element) {
if (node.nodeType === 1) {
const element = node as HTMLElement;
if (element.tagName === 'LI' || element.parentNode === this.element) {
if (element.tagName === 'LI'
|| (element.tagName === 'P' && element.parentElement?.tagName === 'BLOCKQUOTE')
|| element.parentNode === this.element) {
return element;
}
}
@ -235,6 +235,17 @@ export class RibbitEditor extends Ribbit {
// Normalize   → space so patterns like "- " and "> " match
const text = (block.textContent || '').replace(/\u00A0/g, ' ');
// If the block contains <br> elements, check the current line
// (text after the last <br> before the cursor). Block-level
// patterns at the start of a line after <br> should split the
// block and transform the new portion.
const currentLineText = this.getCurrentLineText(block);
if (currentLineText !== null && currentLineText !== text) {
if (this.tryBlockTransformOnCurrentLine(block, currentLineText)) {
return;
}
}
const headingMatch = text.match(/^(#{1,6})\s/);
if (headingMatch) {
const level = headingMatch[1].length;
@ -315,6 +326,12 @@ export class RibbitEditor extends Ribbit {
return '';
}
const element = node as HTMLElement;
// Preserve <br> as a newline so blockquote line breaks survive
if (element.tagName === 'BR') {
return '\n';
}
const specDelim = element.getAttribute('data-speculative');
if (specDelim) {
@ -358,6 +375,167 @@ export class RibbitEditor extends Ribbit {
* Sentinel markers (\x01...\x02) prevent the regex from matching
* delimiters that belong to already-transformed elements.
*/
/**
* Get the text of the current line within a block the text
* after the last <br> before the cursor. Returns null if there
* are no <br> elements (single-line block).
*/
private getCurrentLineText(block: HTMLElement): string | null {
const hasBr = block.querySelector('br');
if (!hasBr) {
return null;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
}
// Collect text from the cursor's text node backward to the
// nearest <br> or start of block
let node: Node | null = selection.anchorNode;
if (!node || !block.contains(node)) {
return null;
}
// Get text from cursor position to start of current text node
let lineText = '';
if (node.nodeType === 3) {
lineText = (node.textContent || '').slice(0, selection.anchorOffset);
}
// Walk backward through siblings collecting text until we hit a <br>
let sibling: Node | null = node.nodeType === 3
? node.previousSibling
: null;
while (sibling) {
if (sibling.nodeType === 1 && (sibling as HTMLElement).tagName === 'BR') {
break;
}
lineText = (sibling.textContent || '') + lineText;
sibling = sibling.previousSibling;
}
// Only return if we actually found a <br> (meaning this is a
// subsequent line, not the first line of the block)
if (!sibling) {
return null;
}
return lineText.replace(/\u00A0/g, ' ').replace(/\u200B/g, '');
}
/**
* Check if a block-level pattern matches on the current line text.
* If so, split the block at the <br> before this line and transform
* the new block.
*/
private tryBlockTransformOnCurrentLine(block: HTMLElement, lineText: string): boolean {
let newTag: string | null = null;
let prefixLength = 0;
const headingMatch = lineText.match(/^(#{1,6})\s/);
if (headingMatch) {
newTag = 'H' + headingMatch[1].length;
prefixLength = headingMatch[0].length;
} else if (lineText.startsWith('> ')) {
newTag = 'BLOCKQUOTE';
prefixLength = 2;
} else if (/^[-*+]\s/.test(lineText)) {
return this.splitAndTransformList(block, 'ul', lineText);
} else if (/^\d+\.\s/.test(lineText)) {
return this.splitAndTransformList(block, 'ol', lineText);
} else if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(lineText)) {
this.splitAtCurrentLine(block);
// The split created a new <p> with the hr text — transform it
const newBlock = block.nextElementSibling as HTMLElement;
if (newBlock) {
const hr = document.createElement('hr');
const paragraph = document.createElement('p');
paragraph.innerHTML = '<br>';
newBlock.replaceWith(hr, paragraph);
const range = document.createRange();
range.setStart(paragraph, 0);
range.collapse(true);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
return true;
}
if (!newTag) {
return false;
}
this.splitAtCurrentLine(block);
const newBlock = block.nextElementSibling as HTMLElement;
if (newBlock) {
this.replaceBlock(newBlock, newTag, prefixLength);
}
return true;
}
/**
* Split a block at the <br> before the current line. Everything
* after the <br> becomes a new <p> element after the original block.
*/
private splitAtCurrentLine(block: HTMLElement): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
let node: Node | null = selection.anchorNode;
// Find the <br> before the current line
let brNode: Node | null = null;
if (node?.nodeType === 3) {
let sibling: Node | null = node.previousSibling;
while (sibling) {
if (sibling.nodeType === 1 && (sibling as HTMLElement).tagName === 'BR') {
brNode = sibling;
break;
}
sibling = sibling.previousSibling;
}
}
if (!brNode) {
return;
}
// Collect all nodes after the <br> into a new <p>
const newParagraph = document.createElement('p');
let nextNode: Node | null = brNode.nextSibling;
while (nextNode) {
const following = nextNode.nextSibling;
newParagraph.appendChild(nextNode);
nextNode = following;
}
// Strip leading ZWS from the first text node — it was a cursor
// anchor from the Enter handler, not real content
const firstChild = newParagraph.firstChild;
if (firstChild && firstChild.nodeType === 3) {
firstChild.textContent = (firstChild.textContent || '').replace(/^\u200B+/, '');
}
brNode.parentNode?.removeChild(brNode);
// Remove trailing empty text nodes and <br> from the original block
while (block.lastChild
&& ((block.lastChild.nodeType === 3
&& (block.lastChild.textContent || '').replace(/\u200B/g, '').trim() === '')
|| (block.lastChild.nodeType === 1
&& (block.lastChild as HTMLElement).tagName === 'BR'))) {
block.removeChild(block.lastChild);
}
block.after(newParagraph);
}
private splitAndTransformList(block: HTMLElement, listTag: string, lineText: string): boolean {
const prefixLength = lineText.indexOf(' ') + 1;
this.splitAtCurrentLine(block);
const newBlock = block.nextElementSibling as HTMLElement;
if (newBlock) {
this.replaceBlockWithList(newBlock, listTag, prefixLength);
}
return true;
}
private transformInline(block: HTMLElement): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
@ -369,6 +547,11 @@ export class RibbitEditor extends Ribbit {
return;
}
// Normalize flanking underscores to asterisks so _ triggers
// the same live preview as *. The CSS pseudo-elements will
// show * even though the user typed _.
markdown = this.normalizeUnderscores(markdown);
// Nesting rules: which elements must not appear inside which
const forbiddenChildren = RibbitEditor.forbiddenNesting;
@ -413,6 +596,38 @@ export class RibbitEditor extends Ribbit {
* If an unclosed opener was found, wrap the trailing content in
* a speculative element; otherwise set innerHTML directly.
*/
/**
* Convert flanking underscore runs to asterisks so the delimiter
* matching treats _ the same as *. Non-flanking underscores (like
* foo_bar) are left alone. Backslash-escaped underscores (\_) are
* protected from normalization.
*/
private normalizeUnderscores(text: string): string {
const escapePlaceholder = '\x00U\x00';
const safeText = text.replace(/\\_/g, escapePlaceholder);
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|*\`]`;
const openRun = new RegExp(
`(?<=^|${punctuation})` +
`(_+)` +
`(?=\\S)`,
'g'
);
const closeRun = new RegExp(
`(?<=\\S)` +
`(_+)` +
`(?=$|${punctuation})`,
'g'
);
const toAsterisks = (_match: string, run: string) =>
'*'.repeat(run.length);
const normalized = safeText
.replace(openRun, toAsterisks)
.replace(closeRun, toAsterisks);
return normalized.replace(/\x00U\x00/g, '\\_');
}
private rebuildBlock(
block: HTMLElement,
markdown: string,
@ -420,7 +635,7 @@ export class RibbitEditor extends Ribbit {
forbiddenChildren: Record<string, string[]>,
): void {
if (!opener) {
block.innerHTML = markdown;
block.innerHTML = markdown.replace(/\n/g, '<br>\u200B');
this.sanitizeNesting(block);
this.appendZwsIfNeeded(block);
this.placeCursorAtEnd(block);
@ -434,7 +649,7 @@ export class RibbitEditor extends Ribbit {
const probe = document.createElement('div');
probe.innerHTML = inside;
if (banned && banned.some(tag => probe.querySelector(tag))) {
block.innerHTML = markdown;
block.innerHTML = markdown.replace(/\n/g, '<br>\u200B');
this.sanitizeNesting(block);
this.appendZwsIfNeeded(block);
this.placeCursorAtEnd(block);
@ -501,6 +716,30 @@ export class RibbitEditor extends Ribbit {
private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
const newEl = document.createElement(newTag);
const content = (block.textContent || '').slice(prefixLength);
// Blockquotes need inner <p> elements so Enter can create
// sibling paragraphs within the quote
if (newTag === 'BLOCKQUOTE') {
const paragraph = document.createElement('p');
if (content) {
paragraph.textContent = content;
} else {
paragraph.innerHTML = '<br>';
}
newEl.appendChild(paragraph);
block.replaceWith(newEl);
newEl.classList.add('ribbit-editing');
const range = document.createRange();
if (paragraph.firstChild && paragraph.firstChild.nodeType === 3) {
range.setStart(paragraph.firstChild, 0);
} else {
range.setStart(paragraph, 0);
}
range.collapse(true);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
return;
}
if (content) {
newEl.textContent = content;
} else {
@ -554,11 +793,56 @@ export class RibbitEditor extends Ribbit {
* On Enter, strip editing decorations from the current block so
* the browser's default newline behavior creates a clean element.
*/
private handleEnter(_event: KeyboardEvent): void {
const prev = this.element.querySelector('.ribbit-editing');
if (prev) {
prev.classList.remove('ribbit-editing');
prev.removeAttribute('data-speculative');
/**
* Dispatch a keydown event to the tag that contains the cursor.
* Walks up from the cursor to find a tag with handleKeydown,
* which routes to named handlers in the tag's eventHandlers map.
* If no tag handles the event, the browser's default runs.
*/
private dispatchKeydown(event: KeyboardEvent): void {
// Strip editing decorations on Enter regardless of tag handling
if (event.key === 'Enter') {
const prev = this.element.querySelector('.ribbit-editing');
if (prev) {
prev.classList.remove('ribbit-editing');
prev.removeAttribute('data-speculative');
}
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
// Walk up from the cursor to find a tag with handleKeydown.
// Skip <p> elements inside container blocks (blockquote, li)
// so the container's handler runs instead.
const containerTags = new Set(['BLOCKQUOTE', 'LI']);
let node: Node | null = selection.anchorNode;
while (node && node !== this.element) {
if (node.nodeType === 1) {
const element = node as HTMLElement;
// Skip <p> inside containers — let the container handle it
if (element.tagName === 'P' && element.parentElement
&& containerTags.has(element.parentElement.tagName)) {
node = node.parentNode;
continue;
}
const tag = this.converter.getBlockTags().find(
blockTag => typeof blockTag.selector === 'string'
&& blockTag.selector.split(',').some(
selector => element.tagName === selector.trim()
)
);
if (tag?.handleKeydown) {
const handled = tag.handleKeydown(element, event, selection, this);
if (handled) {
event.preventDefault();
return;
}
}
}
node = node.parentNode;
}
}
@ -705,7 +989,11 @@ export class RibbitEditor extends Ribbit {
return decodeHtmlEntities(html);
}
if (this.getState() === this.states.WYSIWYG || this.getState() === this.states.VIEW) {
return this.htmlToMarkdown(this.element.innerHTML);
// Normalize non-breaking spaces and strip zero-width spaces
// that the WYSIWYG transform inserts for cursor positioning
return this.htmlToMarkdown(this.element.innerHTML)
.replace(/\u00A0/g, ' ')
.replace(/\u200B/g, '');
}
// Before run() — element has raw markdown as text
return this.element.textContent || '';
@ -720,6 +1008,8 @@ export class RibbitEditor extends Ribbit {
wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return;
const wasEditing = this.getState() === this.states.EDIT;
// Invalidate cache so getHTML() re-converts from current content
this.invalidateCache();
this.vim?.detach();
this.collaboration?.connect();
if (wasEditing && this.collaboration?.isPaused()) {

View File

@ -271,6 +271,7 @@ export class Ribbit {
*/
view(): void {
if (this.getState() === this.states.VIEW) return;
this.invalidateCache();
this.collaboration?.disconnect();
this.element.innerHTML = this.getHTML();
this.setState(this.states.VIEW);

View File

@ -132,13 +132,219 @@ export function inlineTag(def: InlineTagDef): Tag {
};
}
/**
* Base class for block-level tags. Provides keyboard event dispatch
* for WYSIWYG mode: subclasses populate `eventHandlers` with named
* handlers (e.g. 'onEnter', 'onBackspace'), and `handleKeydown`
* routes events to the matching handler.
*
* class MyTag extends BaseTag implements Tag {
* eventHandlers = { 'onEnter': this.onEnter };
* private onEnter(element: HTMLElement, selection: Selection): boolean { ... }
* }
*/
class BaseTag {
eventHandlers: Record<string, (element: HTMLElement, selection: Selection, editor: any) => boolean> = {};
handleKeydown(element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any): boolean {
const handlerName = 'on' + event.key;
const handler = this.eventHandlers[handlerName];
if (handler) {
return handler.call(this, element, selection, editor);
}
// Default Enter behavior: insert <br> for single Enter,
// exit block for double Enter (empty line after <br>)
if (event.key === 'Enter') {
return this.defaultOnEnter(element, selection, editor);
}
if (event.key === 'Backspace') {
return this.defaultOnBackspace(element, selection);
}
return false;
}
/**
* Default Enter handler for all block tags. Inserts a <br> for
* line continuation. If the cursor is on an empty line (right
* after a <br> with no content following), removes the trailing
* <br> and creates a new <p> after the current block.
*/
/**
* Default Backspace handler. When the cursor is at the start of
* a line after a <br> (i.e. on the ZWS cursor anchor), remove
* the <br> and ZWS to join the lines. Otherwise let the browser
* handle it.
*/
private defaultOnBackspace(element: HTMLElement, selection: Selection): boolean {
const range = selection.getRangeAt(0);
const container = range.startContainer;
const offset = range.startOffset;
if (container.nodeType !== 3) {
return false;
}
const zeroWidthSpace = /\u200B/g;
const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '');
// Only intercept if cursor is at the start of the line
// (nothing but ZWS before the cursor in this text node)
if (textBefore !== '') {
return false;
}
// Walk back past empty text nodes to find a <br>
let previous: Node | null = container.previousSibling;
while (previous && previous.nodeType === 3
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
previous = previous.previousSibling;
}
if (!previous || previous.nodeType !== 1 || (previous as HTMLElement).tagName !== 'BR') {
return false;
}
// Remove the <br> and any ZWS/empty text nodes between it
// and the cursor's text node
let nodeToRemove: Node | null = container.previousSibling;
while (nodeToRemove && nodeToRemove !== previous) {
const prev = nodeToRemove.previousSibling;
nodeToRemove.parentNode?.removeChild(nodeToRemove);
nodeToRemove = prev;
}
previous.parentNode?.removeChild(previous);
// Remove the ZWS from the cursor's text node
if (container.textContent?.replace(zeroWidthSpace, '') === '') {
// Text node is only ZWS — remove it entirely
const nextSibling = container.nextSibling;
const parentNode = container.parentNode;
container.parentNode?.removeChild(container);
// Place cursor at end of previous text node
const prevText = parentNode?.lastChild;
if (prevText && prevText.nodeType === 3) {
const newRange = document.createRange();
newRange.setStart(prevText, prevText.textContent?.length || 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
}
return true;
}
private defaultOnEnter(element: HTMLElement, selection: Selection, _editor: any): boolean {
const range = selection.getRangeAt(0);
const container = range.startContainer;
const offset = range.startOffset;
// Detect empty line: cursor is at a position where the
// previous sibling is a <br> and there's no text after it
const zeroWidthSpace = /\u200B/g;
let lineIsEmpty = false;
if (container.nodeType === 3) {
const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim();
const textAfter = (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim();
if (textBefore === '' && textAfter === '') {
// Walk back past empty text nodes to find a <br>
let previous: Node | null = container.previousSibling;
while (previous && previous.nodeType === 3
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
previous = previous.previousSibling;
}
if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
lineIsEmpty = true;
}
}
} else if (container.nodeType === 1) {
const childAtCursor = (container as HTMLElement).childNodes[offset - 1];
if (childAtCursor && childAtCursor.nodeType === 1 && (childAtCursor as HTMLElement).tagName === 'BR') {
// Only treat as empty line if there's real content before
// the <br> — an empty paragraph's placeholder <br> doesn't count
const textBefore = Array.from((container as HTMLElement).childNodes)
.slice(0, offset - 1)
.map(node => node.textContent || '')
.join('')
.replace(zeroWidthSpace, '')
.trim();
const textAfter = Array.from((container as HTMLElement).childNodes)
.slice(offset)
.map(node => node.textContent || '')
.join('')
.replace(zeroWidthSpace, '')
.trim();
if (textAfter === '' && textBefore !== '') {
lineIsEmpty = true;
}
}
}
if (lineIsEmpty) {
// Double Enter: remove the trailing <br>, any empty text
// nodes after it, and the cursor's text node
let nodeToRemove: Node | null = container.nodeType === 3
? container.previousSibling
: (container as HTMLElement).childNodes[offset - 1];
// Walk past empty text nodes to find the <br>
while (nodeToRemove && nodeToRemove.nodeType === 3
&& (nodeToRemove.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
const previous = nodeToRemove.previousSibling;
nodeToRemove.parentNode?.removeChild(nodeToRemove);
nodeToRemove = previous;
}
// Remove the <br> itself
if (nodeToRemove && nodeToRemove.nodeType === 1
&& (nodeToRemove as HTMLElement).tagName === 'BR') {
nodeToRemove.parentNode?.removeChild(nodeToRemove);
}
// Remove the cursor's empty text node
if (container.nodeType === 3
&& container.textContent?.replace(zeroWidthSpace, '').trim() === '') {
container.parentNode?.removeChild(container);
}
const newParagraph = document.createElement('p');
newParagraph.innerHTML = '<br>';
// Find the top-level block to insert after
let block: Node = element;
while (block.parentNode && block.parentNode !== _editor.element) {
block = block.parentNode;
}
(block as HTMLElement).after(newParagraph);
const newRange = document.createRange();
newRange.setStart(newParagraph, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
} else {
// Single Enter: insert <br> followed by a zero-width space.
// The ZWS gives the browser a text node to place the cursor
// in — without it, Firefox removes the <br> when the user
// types the next character.
const brElement = document.createElement('br');
const cursorAnchor = document.createTextNode('\u200B');
range.deleteContents();
range.insertNode(cursorAnchor);
range.insertNode(brElement);
const newRange = document.createRange();
newRange.setStart(cursorAnchor, 1);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
return true;
}
}
/**
* Fenced code blocks: lines between ``` delimiters become <pre><code>.
*
* converter.toHTML('```js\nlet x = 1;\n```')
* // <pre><code class="language-js">let x = 1;</code></pre>
*/
class FencedCodeTag implements Tag {
class FencedCodeTag extends BaseTag implements Tag {
name = 'fencedCode';
selector = 'PRE';
button = {
@ -192,7 +398,7 @@ class FencedCodeTag implements Tag {
*
* converter.toHTML('---') // '<hr>'
*/
class HorizontalRuleTag implements Tag {
class HorizontalRuleTag extends BaseTag implements Tag {
name = 'hr';
selector = 'HR';
button = {
@ -229,9 +435,12 @@ class HorizontalRuleTag implements Tag {
*
* converter.toHTML('## Hello') // <h2 id='Hello'>Hello</h2>
*/
class HeadingTag implements Tag {
class HeadingTag extends BaseTag implements Tag {
name = 'heading';
selector = 'H1,H2,H3,H4,H5,H6';
eventHandlers = {
'onEnter': this.onEnter,
};
button = {
show: false,
label: 'Heading',
@ -287,6 +496,22 @@ class HeadingTag implements Tag {
* Generate a PascalCase anchor ID from heading text so that
* in-page links like #MyHeading work without manual IDs.
*/
/**
* Headings always exit on Enter you don't continue a heading
* across multiple lines.
*/
private onEnter(heading: HTMLElement, selection: Selection, _editor: any): boolean {
const newParagraph = document.createElement('p');
newParagraph.innerHTML = '<br>';
heading.after(newParagraph);
const range = document.createRange();
range.setStart(newParagraph, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
private anchorId(text: string): string {
return text.trim().split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
@ -301,7 +526,7 @@ class HeadingTag implements Tag {
* converter.toHTML('> hello\n> world')
* // <blockquote><p>hello\nworld</p></blockquote>
*/
class BlockquoteTag implements Tag {
class BlockquoteTag extends BaseTag implements Tag {
name = 'blockquote';
selector = 'BLOCKQUOTE';
button = {
@ -311,6 +536,9 @@ class BlockquoteTag implements Tag {
};
template = '> Quote\n> continues here';
replaceSelection = true;
eventHandlers = {
'onEnter': this.onEnter,
};
match(context: MatchContext): SourceToken | null {
const quotePrefix = /^>\s?/;
@ -330,12 +558,153 @@ class BlockquoteTag implements Tag {
}
toHTML(token: SourceToken, convert: Converter): string {
return '<blockquote>' + convert.block(token.content) + '</blockquote>';
// Within a blockquote, consecutive lines without a blank line
// between them should produce <br> line breaks (not merge into
// one paragraph). Insert hard break markers before block parsing
// so the inline processor creates <br> elements.
const withHardBreaks = token.content.replace(
/([^\n])\n(?!\n)/g, // \n not followed by another \n
'$1 \n' // trailing two spaces = hard break
);
return '<blockquote>' + convert.block(withHardBreaks) + '</blockquote>';
}
toMarkdown(element: HTMLElement, convert: Converter): string {
const lines = convert.children(element).trim().split('\n');
return '\n\n' + lines.map(line => '> ' + line).join('\n') + '\n\n';
// Each <p> inside the blockquote is a paragraph group.
// Within a <p>, <br> elements create line breaks.
// Separate paragraphs get a blank > line between them.
const paragraphs: string[] = [];
for (const child of Array.from(element.childNodes)) {
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'P') {
const lines = this.paragraphToLines(child as HTMLElement, convert);
paragraphs.push(lines.map(line => '> ' + line).join('\n'));
} else {
const text = convert.node(child).trim();
if (text) {
paragraphs.push('> ' + text);
}
}
}
return '\n\n' + paragraphs.join('\n>\n') + '\n\n';
}
/**
* Split a paragraph's content into lines at <br> elements.
*/
private paragraphToLines(paragraph: HTMLElement, convert: Converter): string[] {
const lines: string[] = [];
let currentLine = '';
for (const child of Array.from(paragraph.childNodes)) {
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
lines.push(currentLine);
currentLine = '';
} else {
currentLine += convert.node(child);
}
}
if (currentLine.trim()) {
lines.push(currentLine);
}
return lines;
}
/**
* Enter inside a blockquote adds a new paragraph within the quote.
* Double-Enter (empty line) exits the blockquote and creates a
* new paragraph after it matching the behavior users expect
* from list editing.
*/
private onEnter(blockquote: HTMLElement, selection: Selection, _editor: any): boolean {
// Find the text content after the cursor to determine if
// the current line is empty (for double-Enter exit detection)
const range = selection.getRangeAt(0);
const container = range.startContainer;
const offset = range.startOffset;
// Check if the cursor is at an empty line: either the entire
// paragraph is empty, or the cursor is right after a <br> with
// nothing after it
let lineIsEmpty = false;
const paragraph = container.nodeType === 1
? container as HTMLElement
: container.parentElement;
if (paragraph) {
const zeroWidthSpace = /\u200B/g;
const textAfterCursor = container.nodeType === 3
? (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim()
: '';
const textBeforeCursor = container.nodeType === 3
? (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim()
: '';
// Empty if: whole paragraph is empty, or cursor is at the
// start of an empty text node after a <br>
const fullText = paragraph.textContent?.replace(zeroWidthSpace, '').trim() || '';
if (fullText === '') {
lineIsEmpty = true;
} else if (textBeforeCursor === '' && textAfterCursor === '') {
// Walk back past empty text nodes to find a <br>
let previous: Node | null = container.nodeType === 3
? container.previousSibling
: (container as HTMLElement).childNodes[offset - 1];
while (previous && previous.nodeType === 3
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
previous = previous.previousSibling;
}
if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
lineIsEmpty = true;
}
}
}
if (lineIsEmpty) {
// Double-Enter: clean up the trailing <br> and empty nodes,
// then exit the blockquote
const lastParagraph = blockquote.querySelector('p:last-child');
if (lastParagraph) {
// Remove trailing <br> and empty text nodes
const zwsPattern = /\u200B/g;
while (lastParagraph.lastChild) {
const child = lastParagraph.lastChild;
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
child.remove();
break;
}
if (child.nodeType === 3
&& (child.textContent || '').replace(zwsPattern, '').trim() === '') {
child.remove();
continue;
}
break;
}
// If the paragraph is now empty, remove it entirely
if (lastParagraph.textContent?.replace(zwsPattern, '').trim() === '') {
lastParagraph.remove();
}
}
const newParagraph = document.createElement('p');
newParagraph.innerHTML = '<br>';
blockquote.after(newParagraph);
const newRange = document.createRange();
newRange.setStart(newParagraph, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
} else {
// Single Enter: insert <br> + ZWS cursor anchor
const brElement = document.createElement('br');
const cursorAnchor = document.createTextNode('\u200B');
range.deleteContents();
range.insertNode(cursorAnchor);
range.insertNode(brElement);
const newRange = document.createRange();
newRange.setStart(cursorAnchor, 1);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
return true;
}
}
@ -345,9 +714,12 @@ class BlockquoteTag implements Tag {
*
* converter.toHTML('- one\n- two\n 1. nested')
*/
class ListTag implements Tag {
class ListTag extends BaseTag implements Tag {
name = 'list';
selector = 'UL,OL';
eventHandlers = {
'onEnter': this.onEnter,
};
button = {
show: false,
label: 'List',
@ -387,6 +759,129 @@ class ListTag implements Tag {
* Count how many consecutive lines belong to this list, including
* nested sublists (detected by increased indentation).
*/
/**
* List Enter behavior has three tiers:
* 1. Single Enter <br> within current <li> (line continuation)
* 2. Double Enter in non-empty <li> new <li> sibling
* 3. Double Enter in empty <li> exit list, create <p> after it
*/
private onEnter(listElement: HTMLElement, selection: Selection, _editor: any): boolean {
// Find the <li> containing the cursor
let listItem: HTMLElement | null = null;
let node: Node | null = selection.anchorNode;
while (node && node !== listElement) {
if (node.nodeType === 1 && (node as HTMLElement).tagName === 'LI') {
listItem = node as HTMLElement;
break;
}
node = node.parentNode;
}
if (!listItem) {
return false;
}
const zeroWidthSpace = /\u200B/g;
const itemText = (listItem.textContent || '').replace(zeroWidthSpace, '').trim();
const range = selection.getRangeAt(0);
const container = range.startContainer;
const offset = range.startOffset;
// Detect empty line after <br> (same logic as defaultOnEnter)
let lineIsEmpty = false;
if (container.nodeType === 3) {
const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim();
const textAfter = (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim();
if (textBefore === '' && textAfter === '') {
let previous: Node | null = container.previousSibling;
while (previous && previous.nodeType === 3
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
previous = previous.previousSibling;
}
if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
lineIsEmpty = true;
}
}
} else if (container.nodeType === 1) {
const childAtCursor = (container as HTMLElement).childNodes[offset - 1];
if (childAtCursor && childAtCursor.nodeType === 1 && (childAtCursor as HTMLElement).tagName === 'BR') {
const textBefore = Array.from((container as HTMLElement).childNodes)
.slice(0, offset - 1)
.map(child => child.textContent || '')
.join('')
.replace(zeroWidthSpace, '')
.trim();
const textAfter = Array.from((container as HTMLElement).childNodes)
.slice(offset)
.map(child => child.textContent || '')
.join('')
.replace(zeroWidthSpace, '')
.trim();
if (textAfter === '' && textBefore !== '') {
lineIsEmpty = true;
}
}
}
if (lineIsEmpty && itemText === '') {
// Tier 3: empty <li> — exit the list
listItem.remove();
const newParagraph = document.createElement('p');
newParagraph.innerHTML = '<br>';
listElement.after(newParagraph);
const newRange = document.createRange();
newRange.setStart(newParagraph, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
return true;
}
if (lineIsEmpty) {
// Tier 2: non-empty <li> with empty line — create new <li>
// Remove the trailing <br> and empty nodes
let nodeToRemove: Node | null = container.nodeType === 3
? container.previousSibling
: (container as HTMLElement).childNodes[offset - 1];
while (nodeToRemove && nodeToRemove.nodeType === 3
&& (nodeToRemove.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
const previous = nodeToRemove.previousSibling;
nodeToRemove.parentNode?.removeChild(nodeToRemove);
nodeToRemove = previous;
}
if (nodeToRemove && nodeToRemove.nodeType === 1
&& (nodeToRemove as HTMLElement).tagName === 'BR') {
nodeToRemove.parentNode?.removeChild(nodeToRemove);
}
if (container.nodeType === 3
&& container.textContent?.replace(zeroWidthSpace, '').trim() === '') {
container.parentNode?.removeChild(container);
}
const newItem = document.createElement('li');
newItem.innerHTML = '<br>';
listItem.after(newItem);
const newRange = document.createRange();
newRange.setStart(newItem, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
return true;
}
// Tier 1: single Enter — insert <br> + ZWS cursor anchor
const brElement = document.createElement('br');
const cursorAnchor = document.createTextNode('\u200B');
range.deleteContents();
range.insertNode(cursorAnchor);
range.insertNode(brElement);
const newRange = document.createRange();
newRange.setStart(cursorAnchor, 1);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
return true;
}
private countListLines(lines: string[], start: number): number {
const indentedUnordered = /^(\s*)[*\-+]\s/;
const indentedOrdered = /^(\s*)\d+\.\s/;
@ -501,7 +996,7 @@ class ListTag implements Tag {
*
* converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
*/
class TableTag implements Tag {
class TableTag extends BaseTag implements Tag {
name = 'table';
selector = 'TABLE';
button = {
@ -611,7 +1106,7 @@ class TableTag implements Tag {
*
* converter.toHTML('hello world') // '<p>hello world</p>'
*/
class ParagraphTag implements Tag {
class ParagraphTag extends BaseTag implements Tag {
name = 'paragraph';
selector = 'P';

View File

@ -50,7 +50,7 @@ export interface DelimiterDef {
* and followed by non-whitespace. Right-flanking is the reverse.
*/
const PUNCTUATION = new Set(
' \t\n.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('')
' \t\n\u00A0.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('')
);
/**
@ -416,9 +416,9 @@ export class InlineTokenizer {
const charAfter = source[position + delimiter.length];
const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t';
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0';
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t'
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
if (leftFlanking) {

View File

@ -70,6 +70,13 @@ export interface Tag {
template?: string;
replaceSelection?: boolean;
button?: ToolbarButton;
/** Keyboard event handlers for WYSIWYG mode. Keys are event names
* like 'onEnter', 'onBackspace', 'onTab'. The handler receives
* the tag's element, the current selection, and the editor instance. */
eventHandlers?: Record<string, (element: HTMLElement, selection: Selection, editor: any) => boolean>;
/** Dispatch a keydown event to the appropriate handler in
* eventHandlers. Provided by BaseTag; override for custom logic. */
handleKeydown?: (element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any) => boolean;
}
/**

View File

@ -534,3 +534,18 @@ describe('Backslash-escaped HTML tags', () => {
expect(rehtml).toBe(rehtml2);
});
});
describe('Table cell round-trip', () => {
it('inline formatting in cells survives round-trip', () => {
const html = '<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td><strong>bold</strong></td><td><em>italic</em></td></tr></tbody></table>';
expect(H(M(html))).toBe(html);
});
it('code in cells survives round-trip', () => {
const html = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td><code>x</code></td></tr></tbody></table>';
expect(H(M(html))).toBe(html);
});
it('literal * in cells survives round-trip', () => {
const html = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>2 * 3</td></tr></tbody></table>';
expect(H(M(html))).toBe(html);
});
});

View File

@ -0,0 +1,106 @@
/**
* Development server with livereload.
*
* Serves the test page and ribbit dist files. Watches src/ for
* changes, rebuilds automatically, and notifies connected browsers
* to reload via a simple EventSource stream.
*
* Run: npm run dev
*/
const { createServer } = require('./server');
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const PORT = 8080;
const WATCH_DIRS = [
path.join(__dirname, '..', '..', 'src'),
path.join(__dirname, '..', '..', 'test', 'integration'),
];
const DEBOUNCE_MS = 300;
const server = createServer(PORT);
const reloadClients = [];
// Patch the server to add the livereload endpoint
const originalServer = require('http').createServer;
const httpServer = server._server || (() => {
// Access the internal server by starting and intercepting
let captured = null;
const origListen = require('http').Server.prototype.listen;
require('http').Server.prototype.listen = function (...args) {
captured = this;
return origListen.apply(this, args);
};
server.start();
require('http').Server.prototype.listen = origListen;
return captured;
})();
// Simpler approach: create a standalone livereload server
const reloadServer = require('http').createServer((request, response) => {
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
reloadClients.push(response);
request.on('close', () => {
const index = reloadClients.indexOf(response);
if (index >= 0) {
reloadClients.splice(index, 1);
}
});
});
function notifyReload() {
for (const client of reloadClients) {
client.write('data: reload\n\n');
}
}
function rebuild() {
try {
console.log('\n🔨 Rebuilding...');
execSync('npm run build:js && npm run build:css', {
cwd: path.join(__dirname, '..', '..'),
stdio: 'pipe',
});
console.log('✅ Build complete');
notifyReload();
} catch (error) {
console.error('❌ Build failed:', error.stderr?.toString().slice(0, 500));
}
}
let debounceTimer = null;
function onFileChange(filename) {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
console.log(`📝 Changed: ${filename}`);
debounceTimer = setTimeout(rebuild, DEBOUNCE_MS);
}
// Watch source directories
for (const directory of WATCH_DIRS) {
if (fs.existsSync(directory)) {
fs.watch(directory, { recursive: true }, (eventType, filename) => {
if (filename && !filename.includes('node_modules')) {
onFileChange(filename);
}
});
}
}
server.start().then(() => {
reloadServer.listen(PORT + 1, () => {
console.log(`\n🐸 Ribbit dev server`);
console.log(` Editor: http://localhost:${PORT}`);
console.log(` Livereload: http://localhost:${PORT + 1} (EventSource)`);
console.log(` Watching: src/, test/integration/`);
console.log(`\n Add this to the page to enable livereload:`);
console.log(` <script>new EventSource('http://localhost:${PORT + 1}').onmessage = () => location.reload()</script>\n`);
});
});

View File

@ -42,5 +42,10 @@
editor.run();
window.__ribbitEditor = editor;
</script>
<script>
// Livereload — connects to dev server's EventSource endpoint.
// Silently fails if the dev server isn't running.
try { new EventSource('http://localhost:8081').onmessage = () => location.reload(); } catch(e) {}
</script>
</body>
</html>

View File

@ -318,6 +318,67 @@ async function runTests() {
assert(html.includes('<blockquote'), `No blockquote after "> ": ${html}`);
});
await test('enter inside blockquote adds new line', async () => {
await resetEditor();
await typeString('> first line');
let html = await getHTML();
assert(html.includes('<blockquote'), `No blockquote: ${html}`);
await typeChar(Key.ENTER);
await typeString('second line');
html = await getHTML();
// Both lines must be inside the same blockquote
const blockquoteCount = (html.match(/<blockquote/g) || []).length;
assert(blockquoteCount === 1, `Expected 1 blockquote, got ${blockquoteCount}: ${html}`);
assert(html.includes('first line'), `Missing first line: ${html}`);
assert(html.includes('second line'), `Missing second line: ${html}`);
// The two lines must be separate — not merged into one string
assert(!html.includes('first linesecond'), `Lines merged without break: ${html}`);
// Markdown should be "> foo\n> bar" — continuation, no blank lines
const markdown = await getMarkdown();
assert(markdown.includes('> first line'), `Missing > first line in markdown: ${markdown}`);
assert(markdown.includes('> second line'), `Missing > second line in markdown: ${markdown}`);
assert(!markdown.includes('>\n>'), `Unwanted blank > line in markdown: ${markdown}`);
});
await test('blockquote paragraphs survive mode round-trip', async () => {
await resetEditor();
await typeString('> foo');
await typeChar(Key.ENTER);
await typeString('bar');
await driver.sleep(50);
// Switch to source and back to wysiwyg twice
await driver.executeScript('window.__ribbitEditor.edit()');
await driver.sleep(50);
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await driver.sleep(50);
await driver.executeScript('window.__ribbitEditor.edit()');
await driver.sleep(50);
const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent');
assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`);
assert(markdown.includes('> bar'), `Missing > bar — second line lost its prefix: ${markdown}`);
});
await test('double enter exits blockquote', async () => {
await resetEditor();
await typeString('> quoted');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('after');
const html = await getHTML();
assert(html.includes('<blockquote'), `No blockquote: ${html}`);
assert(html.includes('quoted'), `Missing quoted text: ${html}`);
assert(html.includes('after'), `Missing text after blockquote: ${html}`);
// "after" should NOT be inside the blockquote
const afterBlockquote = html.indexOf('</blockquote');
const afterText = html.indexOf('after');
assert(afterText > afterBlockquote, `"after" is inside blockquote: ${html}`);
});
// ── Horizontal rule ──
console.log(' Horizontal rule:');
@ -412,6 +473,99 @@ async function runTests() {
assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
});
console.log(' Enter behavior:');
await test('block pattern after Enter splits and transforms', async () => {
await resetEditor();
await typeString('foo');
await typeChar(Key.ENTER);
await typeString('> bar');
await driver.sleep(50);
const html = await getHTML();
assert(html.includes('<blockquote'), `> after Enter did not create blockquote: ${html}`);
assert(html.includes('foo'), `Lost content before split: ${html}`);
assert(html.includes('bar'), `Lost content after split: ${html}`);
});
await test('single Enter in paragraph inserts line break', async () => {
await resetEditor();
await typeString('line one');
await typeChar(Key.ENTER);
await typeString('line two');
const markdown = await getMarkdown();
// Single Enter = one \n, not \n\n
assert(markdown.includes('line one'), `Missing line one: ${markdown}`);
assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`);
});
await test('double Enter in paragraph creates new block', async () => {
await resetEditor();
await typeString('first paragraph');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('second paragraph');
const html = await getHTML();
// Double Enter = new <p>, so two separate paragraphs
const paragraphCount = (html.match(/<p[\s>]/g) || []).length;
assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`);
});
await test('backspace at start of line after Enter joins lines', async () => {
await resetEditor();
await typeString('foo');
await typeChar(Key.ENTER);
await typeChar(Key.BACK_SPACE);
await driver.sleep(50);
const html = await getHTML();
// The <br> should be removed, cursor at end of "foo"
assert(!html.includes('<br'), `<br> not removed: ${html}`);
assert(html.includes('foo'), `Content lost: ${html}`);
});
await test('single Enter in list item inserts line break', async () => {
await resetEditor();
await typeString('- line one');
await typeChar(Key.ENTER);
await typeString('line two');
const markdown = await getMarkdown();
// Both lines in the same list item
assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`);
assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
// Should NOT create a second list item
const markerCount = (markdown.match(/^- /gm) || []).length;
assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`);
});
await test('double Enter in list item creates new item', async () => {
await resetEditor();
await typeString('- first');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('second');
const markdown = await getMarkdown();
const markerCount = (markdown.match(/^- /gm) || []).length;
assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`);
assert(markdown.includes('- first'), `Missing first item: ${markdown}`);
assert(markdown.includes('- second'), `Missing second item: ${markdown}`);
});
await test('double Enter on empty list item exits list', async () => {
await resetEditor();
await typeString('- item');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('after list');
const html = await getHTML();
assert(html.includes('after list'), `Missing text after list: ${html}`);
// "after list" should NOT be inside the <ul>
const ulEnd = html.indexOf('</ul>');
const afterPos = html.indexOf('after list');
assert(afterPos > ulEnd, `"after list" is inside the list: ${html}`);
});
// ── Complex document ──
console.log(' Complex document:');
@ -422,6 +576,7 @@ async function runTests() {
await typeChar(Key.ENTER);
await typeString('Some **bold** text.');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('## Section');
await typeChar(Key.ENTER);
await typeString('- item one');
@ -474,6 +629,32 @@ async function runTests() {
assert(html.includes('<ul') || html.includes('<li'), `No list after "+ ": ${html}`);
});
console.log(' Underscore emphasis:');
await test('_text_ transforms to italic (shows * delimiters)', async () => {
await resetEditor();
await typeString('_hello_');
const html = await getHTML();
assert(html.includes('<em'), `No <em> after _hello_: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
});
await test('_text shows speculative italic', async () => {
await resetEditor();
await typeString('_hel');
const html = await getHTML();
assert(html.includes('<em'), `No <em> after _hel: ${html}`);
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
});
await test('__text__ transforms to bold', async () => {
await resetEditor();
await typeString('__hello__');
const html = await getHTML();
assert(html.includes('<strong'), `No <strong> after __hello__: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
});
console.log(' Backslash escapes:');
await test('backslash is just a character in WYSIWYG', async () => {