();
+
+ for (const [selector, tag] of this.tags.entries()) {
+ if (tag.delimiter) {
+ delimiterChars.add(tag.delimiter[0]);
+ // Delimiter-based tags: emit delimiter + children + delimiter
+ for (const part of selector.split(',').map(part => part.trim())) {
+ tagMap.set(part, { delimiter: tag.delimiter });
+ }
+ } else if (tag.name === 'link') {
+ tagMap.set('A', {
+ serialize: (element, children) => {
+ const href = element.getAttribute('href') || '';
+ const title = element.getAttribute('title');
+ const titlePart = title ? ` "${title}"` : '';
+ return '[' + children() + '](' + href + titlePart + ')';
+ },
+ });
+ } else if (tag.name === 'hardBreak') {
+ tagMap.set('BR', {
+ serialize: () => ' \n',
+ });
+ } else if (tag.name === 'fencedCode') {
+ tagMap.set('PRE', {
+ serialize: (element) => {
+ const code = element.querySelector('code');
+ const langMatch = (code?.getAttribute('class') || '').match(/language-(\S+)/);
+ const lang = langMatch ? langMatch[1] : '';
+ const content = code?.textContent || element.textContent || '';
+ return '\n\n```' + lang + '\n' + content + '\n```\n\n';
+ },
+ });
}
}
- // Then check by element name
- const tag = this.tags.get(element.nodeName);
- if (tag) {
- return tag.toMarkdown(element, this.makeConverter());
- }
+ // CODE gets a custom serializer because its content is literal
+ tagMap.set('CODE', {
+ serialize: (element) => {
+ // Code inside is handled by the PRE serializer
+ if (element.parentNode?.nodeName === 'PRE') {
+ return element.textContent || '';
+ }
+ return '`' + (element.textContent || '') + '`';
+ },
+ });
- return this.childrenToMd(node);
+ return new MarkdownSerializer(tagMap, delimiterChars);
}
- private childrenToMd(node: Node): string {
- return Array.from(node.childNodes).map(child => this.nodeToMd(child)).join('');
+ private buildDelimiterRegexes(): { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[] {
+ const escapeRegex = /[.*+?^${}()|[\]\\]/g;
+ const sorted = this.inlineTags
+ .filter(tag => tag.delimiter)
+ .sort((first, second) => (first.precedence ?? 50) - (second.precedence ?? 50));
+
+ return sorted.map(tag => {
+ const delimiter = tag.delimiter!;
+ const escaped = delimiter.replace(escapeRegex, '\\$&');
+ const escapedChar = delimiter[0].replace(escapeRegex, '\\$&');
+ const htmlTag = tag.name === 'boldItalic'
+ ? 'em'
+ : (tag.selector as string).split(',')[0].toLowerCase();
+ return {
+ tag,
+ htmlTag,
+ complete: new RegExp(
+ `(? typeof tag.selector === 'string')
+ .map(tag => (tag.selector as string).toLowerCase())
+ .join(', ');
}
private makeConverter(): Converter {
return {
inline: (source) => this.processInline(source),
- block: (md) => this.processBlocks(md),
- children: (node) => this.childrenToMd(node),
- node: (node) => this.nodeToMd(node),
+ block: (markdown) => this.processBlocks(markdown),
+ children: (node) => this.serializeChildren(node),
+ node: (node) => this.serializeNode(node),
};
}
}
-
-/**
- * A default HopDown instance with all standard tags enabled.
- * Use this for simple cases where no configuration is needed.
- */
-const hopdown = new HopDown();
-
-export function toHTML(md: string): string {
- return hopdown.toHTML(md);
-}
-
-export function toMarkdown(html: string): string {
- return hopdown.toMarkdown(html);
-}
-
-export default hopdown;
diff --git a/src/ts/macros.ts b/src/ts/macros.ts
index 24bdb3d..54c0d36 100644
--- a/src/ts/macros.ts
+++ b/src/ts/macros.ts
@@ -21,6 +21,63 @@
import type { Tag, Converter, ToolbarButton } from './types';
import { escapeHtml } from './tags';
+/* ── Constants ─────────────────────────────────────────────────── */
+
+const VERBATIM_KEYWORD = 'verbatim';
+const VERBATIM_DATA_VALUE = 'true';
+const DATASET_PARAM_PREFIX = 'param';
+const DATASET_PARAM_PREFIX_LENGTH = 5;
+const PLACEHOLDER_SENTINEL = '\x00P';
+const PLACEHOLDER_TERMINATOR = '\x00';
+
+/* Named regex for key="value" pairs inside macro argument strings */
+const PARAM_PATTERN = /(?\w+)="(?[^"]*)"/g;
+
+/* Matches the opening line of a block macro: @name(args with no closing paren */
+const BLOCK_MACRO_OPEN = /^@(?\w+)\((?[^)]*)\s*$/;
+
+/* Matches a line that closes a block macro body */
+const BLOCK_CLOSE_LINE = /^\)\s*$/;
+
+/* Matches a nested block macro opening inside a body */
+const NESTED_BLOCK_OPEN = /^@\w+\([^)]*\s*$/;
+
+/**
+ * Matches inline macros: `@name` or `@name(args)`.
+ * The lookbehind ensures macros only start after whitespace or
+ * markdown punctuation, preventing false matches mid-word.
+ *
+ * Named groups:
+ * inlineName — the macro name after @
+ * inlineArgs — optional parenthesized arguments
+ */
+const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(?\w+)(?:\((?[^)]*)\))?/g;
+
+/* ── Public interfaces ─────────────────────────────────────────── */
+
+/**
+ * Definition for a macro that can be registered with ribbit.
+ *
+ * Each macro provides a name and a `toHTML` renderer. Ribbit handles
+ * wrapping, round-tripping, and toolbar integration automatically.
+ *
+ * @example
+ * ```ts
+ * const userMacro: MacroDef = {
+ * name: 'user',
+ * toHTML: () => 'gsb',
+ * };
+ * ```
+ *
+ * @example
+ * ```ts
+ * const styleMacro: MacroDef = {
+ * name: 'style',
+ * toHTML: ({ keywords, content }) =>
+ * `${content}
`,
+ * };
+ * ```
+ */
export interface MacroDef {
name: string;
/**
@@ -44,34 +101,58 @@ export interface MacroDef {
button?: ToolbarButton | false;
}
+/** Internal representation of a fully parsed macro invocation. */
interface ParsedMacro {
name: string;
keywords: string[];
params: Record;
verbatim: boolean;
content?: string;
+ /** Number of source lines consumed by this macro (for block advancement). */
consumed: number;
}
-const PARAM_PATTERN = /(\w+)="([^"]*)"/g;
+/* ── Module-level helpers ──────────────────────────────────────── */
-function parseArgs(argsStr: string | undefined): {
+/**
+ * Parse the argument string from a macro invocation into keywords,
+ * key="value" params, and a verbatim flag.
+ *
+ * @example
+ * ```ts
+ * parseArgs('box center depth="3"')
+ * // { keywords: ['box', 'center'], params: { depth: '3' }, verbatim: false }
+ * ```
+ */
+function parseArgs(argumentString: string | undefined): {
keywords: string[];
params: Record;
verbatim: boolean;
} {
- if (!argsStr || !argsStr.trim()) {
- return { keywords: [], params: {}, verbatim: false };
+ if (!argumentString || !argumentString.trim()) {
+ return {
+ keywords: [],
+ params: {},
+ verbatim: false,
+ };
}
const params: Record = {};
- const withoutParams = argsStr.replace(new RegExp(PARAM_PATTERN.source, 'g'), (_, key, val) => {
- params[key] = val;
- return '';
- });
+ /* Strip key="value" pairs, collecting them into params */
+ const withoutParams = argumentString.replace(
+ new RegExp(PARAM_PATTERN.source, 'g'),
+ (_match, paramKey, paramValue) => {
+ params[paramKey] = paramValue;
+ return '';
+ },
+ );
const allKeywords = withoutParams.trim().split(/\s+/).filter(Boolean);
- const verbatim = allKeywords.includes('verbatim');
- const keywords = allKeywords.filter(k => k !== 'verbatim');
- return { keywords, params, verbatim };
+ const verbatim = allKeywords.includes(VERBATIM_KEYWORD);
+ const keywords = allKeywords.filter(keyword => keyword !== VERBATIM_KEYWORD);
+ return {
+ keywords,
+ params,
+ verbatim,
+ };
}
function macroError(name: string): string {
@@ -80,7 +161,7 @@ function macroError(name: string): string {
/**
* Wrap a macro's rendered HTML with data- attributes for round-tripping.
- * Block macros (with content) use , inline macros use
.
+ * Block macros (with content) use ``, inline macros use `
`.
*/
function wrapMacro(
name: string,
@@ -95,34 +176,36 @@ function wrapMacro(
if (keywords.length) {
attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`;
}
- for (const [key, val] of Object.entries(params)) {
- attrs += ` data-param-${escapeHtml(key)}="${escapeHtml(val)}"`;
+ for (const [paramKey, paramValue] of Object.entries(params)) {
+ attrs += ` data-param-${escapeHtml(paramKey)}="${escapeHtml(paramValue)}"`;
}
if (verbatim) {
- attrs += ` data-verbatim="true"`;
+ attrs += ` data-verbatim="${VERBATIM_DATA_VALUE}"`;
}
return `<${tag}${attrs}>${innerHtml}${tag}>`;
}
/**
* Reconstruct macro source from a DOM element's data- attributes.
- * This is the generic toMarkdown for all macros.
+ * This is the generic toMarkdown for all macros — it reads the
+ * data- attributes that wrapMacro wrote and rebuilds the @name(...)
+ * syntax so the document can round-trip without per-macro logic.
*/
function macroToMarkdown(element: HTMLElement, convert: Converter): string {
const name = element.dataset.macro || '';
const keywords = element.dataset.keywords || '';
- const verbatim = element.dataset.verbatim === 'true';
+ const verbatim = element.dataset.verbatim === VERBATIM_DATA_VALUE;
const paramParts: string[] = [];
- for (const [key, val] of Object.entries(element.dataset)) {
- if (key.startsWith('param') && key.length > 5) {
- const paramName = key.slice(5).toLowerCase();
- paramParts.push(`${paramName}="${val}"`);
+ for (const [datasetKey, datasetValue] of Object.entries(element.dataset)) {
+ if (datasetKey.startsWith(DATASET_PARAM_PREFIX) && datasetKey.length > DATASET_PARAM_PREFIX_LENGTH) {
+ const paramName = datasetKey.slice(DATASET_PARAM_PREFIX_LENGTH).toLowerCase();
+ paramParts.push(`${paramName}="${datasetValue}"`);
}
}
const allKeywords = verbatim
- ? [keywords, 'verbatim'].filter(Boolean).join(' ')
+ ? [keywords, VERBATIM_KEYWORD].filter(Boolean).join(' ')
: keywords;
const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' ');
@@ -136,32 +219,36 @@ function macroToMarkdown(element: HTMLElement, convert: Converter): string {
/**
* Try to parse a block macro starting at the given line index.
+ * Returns null if the line doesn't start a block macro or the
+ * closing paren is never found (unclosed macro).
*/
-function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
- const line = lines[index];
- const m = line.match(/^@(\w+)\(([^)]*)\s*$/);
- if (!m) {
+function parseBlockMacro(lines: string[], lineIndex: number): ParsedMacro | null {
+ const line = lines[lineIndex];
+ const openMatch = BLOCK_MACRO_OPEN.exec(line);
+ if (!openMatch || !openMatch.groups) {
return null;
}
- const name = m[1];
- const { keywords, params, verbatim } = parseArgs(m[2]);
+ const name = openMatch.groups.macroName;
+ const { keywords, params, verbatim } = parseArgs(openMatch.groups.macroArgs);
+
const contentLines: string[] = [];
- let i = index + 1;
- let depth = 1;
- while (i < lines.length && depth > 0) {
- if (/^\)\s*$/.test(lines[i])) {
- depth--;
- if (depth === 0) {
+ let scanIndex = lineIndex + 1;
+ let nestingDepth = 1;
+ while (scanIndex < lines.length && nestingDepth > 0) {
+ if (BLOCK_CLOSE_LINE.test(lines[scanIndex])) {
+ nestingDepth--;
+ if (nestingDepth === 0) {
break;
}
}
- if (/^@\w+\([^)]*\s*$/.test(lines[i])) {
- depth++;
+ if (NESTED_BLOCK_OPEN.test(lines[scanIndex])) {
+ nestingDepth++;
}
- contentLines.push(lines[i]);
- i++;
+ contentLines.push(lines[scanIndex]);
+ scanIndex++;
}
- if (depth !== 0) {
+ /* Unclosed macro — treat as plain text */
+ if (nestingDepth !== 0) {
return null;
}
return {
@@ -170,14 +257,25 @@ function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
params,
verbatim,
content: contentLines.join('\n'),
- consumed: i + 1 - index,
+ consumed: scanIndex + 1 - lineIndex,
};
}
-const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g;
+/* ── Public API ────────────────────────────────────────────────── */
/**
* Build Tags from an array of macro definitions.
+ *
+ * Returns a block-level Tag for parsing `@name(args\ncontent\n)` syntax,
+ * a selector Tag for HTML→markdown round-tripping, and a lookup map
+ * for inline macro processing.
+ *
+ * @example
+ * ```ts
+ * const { blockTag, selectorTag, macroMap } = buildMacroTags([
+ * { name: 'user', toHTML: () => 'gsb' },
+ * ]);
+ * ```
*/
export function buildMacroTags(
macros: MacroDef[],
@@ -188,11 +286,6 @@ export function buildMacroTags(
}
const blockTag: Tag = {
- /*
- * @name(args
- * content
- * )
- */
name: 'macro',
match: (context) => {
const parsed = parseBlockMacro(context.lines, context.index);
@@ -235,8 +328,10 @@ export function buildMacroTags(
};
/**
- * Generic selector tag that matches any element with data-macro
+ * Generic selector tag — matches any element with data-macro
* and reconstructs the macro source from data- attributes.
+ * Separate from blockTag so the selector-based HTML→markdown
+ * path can find macro elements independently.
*/
const selectorTag: Tag = {
name: 'macro:generic',
@@ -246,11 +341,30 @@ export function buildMacroTags(
toMarkdown: macroToMarkdown,
};
- return { blockTag, selectorTag, macroMap };
+ return {
+ blockTag,
+ selectorTag,
+ macroMap,
+ };
}
/**
* Process inline macros in a text string, replacing them with rendered HTML.
+ *
+ * Inline macros are replaced with placeholder tokens so that subsequent
+ * inline parsing (bold, italic, etc.) doesn't mangle the HTML output.
+ * The caller restores placeholders after all inline processing is done.
+ *
+ * @example
+ * ```ts
+ * const placeholders: string[] = [];
+ * const result = processInlineMacros(
+ * 'Hello @user!',
+ * macroMap,
+ * convert,
+ * placeholders,
+ * );
+ * ```
*/
export function processInlineMacros(
text: string,
@@ -258,20 +372,26 @@ export function processInlineMacros(
convert: Converter,
placeholders: string[],
): string {
- return text.replace(INLINE_MACRO_GLOBAL, (match, nameStr: string, argsStr: string | undefined) => {
- const macro = macroMap.get(nameStr);
- if (!macro) {
- placeholders.push(macroError(nameStr));
- return '\x00P' + (placeholders.length - 1) + '\x00';
- }
- const { keywords, params } = parseArgs(argsStr);
- const innerHtml = macro.toHTML({
- keywords,
- params,
- convert,
- });
- const wrapped = wrapMacro(nameStr, keywords, params, false, false, innerHtml);
- placeholders.push(wrapped);
- return '\x00P' + (placeholders.length - 1) + '\x00';
- });
+ return text.replace(
+ INLINE_MACRO_GLOBAL,
+ (match, ...args) => {
+ /* Named groups are the last non-offset argument from replace() */
+ const groups = args[args.length - 1] as { inlineName: string; inlineArgs?: string };
+ const macroName = groups.inlineName;
+ const macro = macroMap.get(macroName);
+ if (!macro) {
+ placeholders.push(macroError(macroName));
+ return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
+ }
+ const { keywords, params } = parseArgs(groups.inlineArgs);
+ const innerHtml = macro.toHTML({
+ keywords,
+ params,
+ convert,
+ });
+ const wrapped = wrapMacro(macroName, keywords, params, false, false, innerHtml);
+ placeholders.push(wrapped);
+ return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
+ },
+ );
}
diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts
index e72996e..9f56621 100644
--- a/src/ts/ribbit-editor.ts
+++ b/src/ts/ribbit-editor.ts
@@ -7,24 +7,38 @@ import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './t
import { defaultTheme } from './default-theme';
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { VimHandler } from './vim';
+import type { DelimiterMatch } from './types';
import { type MacroDef } from './macros';
/**
- * WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
+ * WYSIWYG markdown editor. Extends Ribbit's read-only viewer with
+ * contentEditable support, live inline transforms (typing `**bold**`
+ * immediately wraps in ``), and source editing mode.
*
- * Extends Ribbit with contentEditable support and bidirectional
- * markdown↔HTML conversion on mode switches.
- *
- * Usage:
* const editor = new RibbitEditor({ editorId: 'my-element' });
* editor.run();
- * editor.wysiwyg(); // switch to WYSIWYG mode
- * editor.edit(); // switch to source editing mode
- * editor.view(); // switch to read-only view
+ * editor.wysiwyg();
*/
export class RibbitEditor extends Ribbit {
private vim?: VimHandler;
+ // Elements that must not be nested inside each other.
+ // Used by transformInline and rebuildBlock to prevent
+ // invalid structures like inside .
+ private static readonly forbiddenNesting: Record = {
+ 'strong': ['strong', 'b'],
+ 'em': ['em', 'i'],
+ 'del': ['del', 's', 'strike'],
+ 'code': ['code', 'strong', 'b', 'em', 'i', 'a', 'del'],
+ };
+
+ /**
+ * Initialize the editor with all three modes (view/edit/wysiwyg),
+ * bind DOM events, and optionally attach vim keybindings.
+ *
+ * const editor = new RibbitEditor({ editorId: 'content' });
+ * editor.run();
+ */
run(): void {
this.states = {
VIEW: 'view',
@@ -72,20 +86,20 @@ export class RibbitEditor extends Ribbit {
}, 300);
});
- this.element.addEventListener('keydown', (e: KeyboardEvent) => {
+ this.element.addEventListener('keydown', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
- if (e.key === 'Enter') {
- this.handleEnter(e);
+ if (event.key === 'Enter') {
+ this.handleEnter(event);
}
});
- this.element.addEventListener('keyup', (e: KeyboardEvent) => {
+ this.element.addEventListener('keyup', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
- if (e.key.startsWith('Arrow')) {
+ if (event.key.startsWith('Arrow')) {
this.closeOrphanedSpeculative();
this.updateEditingContext();
}
@@ -105,11 +119,11 @@ export class RibbitEditor extends Ribbit {
this.closeOrphanedSpeculative();
});
- document.addEventListener('click', (e: MouseEvent) => {
+ document.addEventListener('click', (event: MouseEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
- if (!this.element.contains(e.target as Node)) {
+ if (!this.element.contains(event.target as Node)) {
this.closeAllSpeculative();
}
});
@@ -124,11 +138,9 @@ export class RibbitEditor extends Ribbit {
}
/**
- * Find the block-level element containing the cursor.
- */
- /**
- * Ensure the editor contains valid block structure.
- * Wraps bare
and elements in
tags.
+ * Browsers create bare
and
elements in contentEditable
+ * that aren't valid markdown block containers. Convert them to
+ * so every editor child is a recognized block element.
*/
private ensureBlockStructure(): void {
for (const child of Array.from(this.element.childNodes)) {
@@ -147,9 +159,10 @@ export class RibbitEditor extends Ribbit {
p.innerHTML = '
';
}
element.replaceWith(p);
- // Restore cursor inside the new
- const sel = window.getSelection();
- if (sel && sel.rangeCount > 0) {
+ // Cursor must follow the content into the new
,
+ // otherwise the next keystroke creates another
+ const selection = window.getSelection();
+ if (selection && selection.rangeCount > 0) {
const range = document.createRange();
const target = p.lastChild || p;
if (target.nodeType === 3) {
@@ -158,8 +171,8 @@ export class RibbitEditor extends Ribbit {
range.selectNodeContents(target);
range.collapse(false);
}
- sel.removeAllRanges();
- sel.addRange(range);
+ selection.removeAllRanges();
+ selection.addRange(range);
}
}
}
@@ -169,25 +182,30 @@ export class RibbitEditor extends Ribbit {
}
}
+ /**
+ * Walk up from the cursor to find the nearest block-level ancestor.
+ * Returns
for list items (not the