Compare commits

..
17 Commits
Author SHA1 Message Date
evilchili 95f6ffcf86 checkpoint 2026-07-10 16:21:32 -07:00
evilchili c1dc49f0b3 all tests pass but only if md-delim display: inline 2026-07-10 12:39:35 -07:00
gsb 818ee418d5 docs: Styled source editor design plan
Design document for replacing the WYSIWYG innerHTML rebuild
approach with a styled-source model where the editor always
contains markdown text with CSS styling. No mode conversions
during editing, no innerHTML rebuild, no round-trip bugs.
2026-04-30 22:49:09 +00:00
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
gsb 5b2bd94388 Add @block macros to Flask example 2026-04-30 07:09:33 +00:00
gsb d41716c8b2 Reimplement as a tokenizer with GFM parity 2026-04-30 07:09:19 +00:00
gsb 005db2f431 Add integration tests 2026-04-29 22:15:19 +00:00
gsb 3e8d3388f6 Add Selenium integration test framework 2026-04-29 22:15:02 +00:00
gsb 1198791505 Add collaboration support
Real-time collaboration through consumer-provided transport and
presence interfaces. Also includes a sample backend app.
2026-04-29 22:15:02 +00:00
gsb 2e28598243 Two build targets — core and full
dist/ribbit/ribbit-core.js    — editor without extra features
dist/ribbit/ribbit.js         — editor with all features

Added a theme feature flag for vim keybindings
2026-04-29 07:57:40 +00:00
gsb 3368e719fd Vim keybindings for source edit mode
VimHandler activates in source (edit) mode only. Two modes:
- Insert: standard typing, Esc enters normal mode
- Normal: vim navigation and editing, i/a/o/O enter insert

Normal mode commands:
  h/j/k/l: cursor movement
  w/b: word forward/back
  0/$: line start/end
  gg/G: document start/end
  i/a/o/O: enter insert mode
  x: delete char
  dd: delete line
  u: undo
  Ctrl+r: redo
2026-04-29 07:37:09 +00:00
gsb 8bef75e59f Add keyboard shortcuts to all toolbar buttons 2026-04-29 07:23:10 +00:00
gsb 1f523cbc0f Implement Toolbar 2026-04-29 07:11:31 +00:00
gsb 98719ec8cd Use data- attributes to preserve macro configs 2026-04-29 05:19:17 +00:00
gsb 2b88d2c10b Reimplement tests 2026-04-29 05:02:25 +00:00
gsb 4237a3f6a2 Add support for wysiwyg markdown preview 2026-04-29 05:01:51 +00:00
45 changed files with 16027 additions and 2805 deletions
+78 -25
View File
@@ -1,44 +1,97 @@
# ribbit # ribbit
Zero-dependency WYSIWYG markdown editor Zero-dependency WYSIWYG markdown editor for the browser.
## Files ## Source Layout
- `src/hopdown.js` — Markdown ↔ HTML converter (`HopDown.toHTML()`, `HopDown.toMarkdown()`) - `src/ts/` — TypeScript source files
- `src/ribbit.js` — Base viewer class (`Ribbit`), plugin base class (`RibbitPlugin`), utilities - `types.ts` — shared interfaces (Tag, SourceToken, Converter, etc.)
- `src/ribbit-editor.js` — Editor class (`RibbitEditor`) with VIEW/EDIT/WYSIWYG modes - `tags.ts` — tag definitions and `inlineTag()` factory
- `src/ribbit.css` — Editor and content styles - `hopdown.ts` — configurable markdown↔HTML converter (HopDown class)
- `macros.ts` — macro parsing and Tag generation
- `ribbit.ts` — Ribbit viewer, RibbitPlugin, utilities
- `ribbit-editor.ts` — RibbitEditor with WYSIWYG support, public API exports
- `default-theme.ts` — built-in theme definition
- `theme-manager.ts` — theme registration and switching
- `events.ts` — typed event emitter
- `src/static/` — CSS and static assets
- `ribbit-core.css` — functional editor styles (always load)
- `themes/ribbit-default/theme.css` — default theme
## Build Output
```
dist/ribbit/
├── ribbit.js # readable IIFE bundle + source map
├── ribbit.min.js # minified bundle
├── ribbit-core.css # functional styles
└── themes/
└── ribbit-default/
└── theme.css # default theme (imports ribbit-core.css)
```
## Usage ## Usage
```html ```html
<link rel="stylesheet" href="ribbit/src/ribbit.css"> <link rel="stylesheet" href="ribbit/themes/ribbit-default/theme.css">
<article id="ribbit">your markdown here</article> <article id="ribbit">your markdown here</article>
<script src="ribbit/src/hopdown.js"></script> <script src="ribbit/ribbit.js"></script>
<script src="ribbit/src/ribbit.js"></script>
<script src="ribbit/src/ribbit-editor.js"></script>
<script> <script>
const editor = new RibbitEditor({ plugins: [] }); const editor = new ribbit.Editor({
on: {
save: ({ markdown }) => {
fetch('/api/save', { method: 'POST', body: markdown });
},
},
macros: [
{
name: 'npc',
toHTML: ({ keywords }) => {
const name = keywords.join(' ');
return `<a href="/NPC/${name}">${name}</a>`;
},
},
],
});
editor.run(); editor.run();
editor.wysiwyg();
// Switch modes
editor.wysiwyg(); // WYSIWYG editing
editor.edit(); // Source editing
editor.view(); // Read-only view
// Get content
editor.getMarkdown();
editor.getHTML();
</script> </script>
``` ```
## Custom Block Tags
```javascript
const spoiler = {
name: 'spoiler',
match: (context) => {
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
const content = [];
let i = context.index + 1;
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i]))
content.push(context.lines[i++]);
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
},
toHTML: (token, convert) =>
'<details><summary>Spoiler</summary>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (element, convert) =>
'\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
};
const converter = new ribbit.HopDown({
tags: { ...ribbit.defaultTags, 'DETAILS': spoiler },
});
```
## Tests
```
npm test
```
## Supported Markdown ## Supported Markdown
Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists, Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists,
blockquotes, fenced code blocks with language, horizontal rules, GFM tables with blockquotes, fenced code blocks with language, horizontal rules, GFM tables with
column alignment, and paragraphs. Arbitrary nesting of all inline formatting. column alignment, paragraphs, and macros (@name syntax).
## Tests
Open `test/test_ribbit-down.html` in a browser.
+161
View File
@@ -0,0 +1,161 @@
# Styled Source Editor — Design Plan
## Core Concept
The editor is always a markdown text editor. There is no separate "WYSIWYG mode" —
the user edits markdown directly, but the editor applies CSS styling that makes it
look like rendered output. Delimiters (`**`, `*`, `` ` ``, etc.) are hidden when the
cursor is outside the element and revealed when the cursor enters it.
## Two CSS States (not modes)
- **Editing**: `contentEditable="true"`, delimiters revealed on cursor focus
- **Viewing**: `contentEditable="false"`, all delimiters hidden
No content transformation on state switch. The DOM is identical in both states —
only CSS changes. This eliminates all conversion-during-editing bugs.
## DOM Structure
The editor contains markdown text wrapped in styled spans:
```html
<div id="ribbit">
<div class="md-heading" data-level="2">
<span class="md-delim">## </span>Hello World
</div>
<div class="md-paragraph">
Some <span class="md-bold">
<span class="md-delim">**</span>bold<span class="md-delim">**</span>
</span> and <span class="md-italic">
<span class="md-delim">*</span>italic<span class="md-delim">*</span>
</span> text.
</div>
<div class="md-list-item">
<span class="md-delim">- </span>First item
</div>
<div class="md-blockquote">
<span class="md-delim">&gt; </span>Quoted text
</div>
</div>
```
CSS handles all visual rendering:
```css
.md-delim { display: none; color: #999; font-weight: normal; }
.md-bold.editing .md-delim,
.md-italic.editing .md-delim { display: inline; }
.md-bold { font-weight: bold; }
.md-italic { font-style: italic; }
.md-heading[data-level="1"] { font-size: 2em; font-weight: bold; }
.md-list-item { display: list-item; margin-left: 1.5em; }
.md-blockquote { border-left: 3px solid #ccc; padding-left: 1em; }
.md-code { font-family: monospace; background: #f5f5f5; }
```
## Per-Keystroke Pipeline
1. User types a character → browser inserts it into the DOM (contentEditable)
2. `input` event fires
3. Parser scans the **current line only** (the block element containing the cursor)
4. If the span structure needs updating (e.g. user just typed the closing `**`):
- Wrap/unwrap the affected text range using targeted DOM operations
- No innerHTML rebuild, no full-document re-parse
5. If a block pattern is detected (e.g. `# ` at start of line):
- Update the block element's class and data attributes
- Move the delimiter text into a `.md-delim` span
## Key Operations
### Inline formatting detection
When the user types a delimiter character, scan backward in the current
text node for a matching opener. If found, wrap the range:
```
Before: <span class="md-paragraph">hello **world**</span>
After: <span class="md-paragraph">hello <span class="md-bold">
<span class="md-delim">**</span>world<span class="md-delim">**</span>
</span></span>
```
Use `Range` and `surroundContents` for the wrap — no innerHTML.
### Block detection
When the user types a space after `#`, `>`, `-`, `1.`, etc. at the start
of a line, update the block element:
```
Before: <div class="md-paragraph"># Title</div>
After: <div class="md-heading" data-level="1">
<span class="md-delim"># </span>Title
</div>
```
### Cursor focus tracking
On `selectionchange`, find the nearest formatting span and add an
`.editing` class so CSS reveals its delimiters. Remove `.editing`
from the previous span.
## getMarkdown()
Read `textContent` from the editor element. The delimiter spans contain
the actual delimiter characters, so `textContent` produces valid markdown.
No conversion needed.
## getHTML()
Run the existing tokenizer + `toHTML` pipeline on the markdown string
from `getMarkdown()`. This is only called on demand (export, save, API),
never during editing.
## Macros
Macros are rendered as `contentEditable="false"` islands within the
editable text. The macro source (`@user`) is stored in a `data-source`
attribute. The rendered output is displayed inside the island. On focus,
the island could expand to show the source for editing.
For `toMarkdown`, macro islands emit their `data-source` value.
## Initial Load
Markdown → styled source DOM is a one-time conversion on editor init:
1. Parse markdown using the existing tokenizer (produces token stream)
2. Walk the token stream, creating the span structure described above
3. Set the editor's innerHTML once
This replaces the current `toHTML` → innerHTML path.
## What This Eliminates
- `transformInline` and its innerHTML rebuild
- `blockToMarkdown` / `nodeToMarkdown` (DOM → markdown string → DOM)
- The flatten-rebuild pipeline and all its escaping bugs
- The `<br>` + ZWS cursor anchor workarounds
- The sentinel marker system for preserved HTML elements
- Mode switch conversions (WYSIWYG ↔ view ↔ edit)
## What This Keeps
- The tokenizer (for initial load and `getHTML()`)
- The serializer (for `getHTML()` via `toMarkdown` → `toHTML`)
- Tag definitions (for block pattern matching and toolbar buttons)
- The `BaseTag` keyboard dispatch system
- The collaboration transport layer
- The macro system
## Implementation Order
1. Build the markdown → styled DOM renderer (replaces `toHTML` for editor init)
2. Build the per-line parser that updates span structure on keystroke
3. Build the inline delimiter detection (wrap/unwrap via Range)
4. Wire up cursor focus tracking for delimiter reveal
5. Implement `getMarkdown()` as `textContent` read
6. Remove `transformInline`, `blockToMarkdown`, and the rebuild pipeline
7. Update tests
## Branch
Work on the `styled-source` branch, branched from current `main`.
+118
View File
@@ -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 (<br>)
```
### 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: `&amp;` → 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: `<url>` and bare URLs
- Hard line breaks: trailing spaces or `\` before newline
- HTML tags: `<span>` 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 `<strong>hello *world*</strong>`, the output is:
```
[delim **] [text "hello "] [delim *] [text "world"] [delim *] [delim **]
```
The `*` around "world" are delimiter tokens (from the nested `<em>`).
If instead the text contained a literal `*`:
```
<strong>hello * world</strong>
```
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
+46
View File
@@ -0,0 +1,46 @@
# Flask Collaboration Example
A minimal Flask server demonstrating ribbit's collaboration features:
real-time sync, presence, locking, and revisions.
## Setup
```sh
pip install flask flask-sock
```
Copy (or symlink) the ribbit dist into the static directory:
```sh
ln -s /path/to/ribbit/dist/ribbit static/ribbit
```
## Run
```sh
python server.py
```
Open http://localhost:5000 in multiple browser tabs. Edits in one tab
appear in the others in real time.
## What it demonstrates
- **Real-time sync**: WebSocket relays document updates between clients
- **Presence**: colored badges show connected users and their status
- **Revisions**: save button creates named revisions, click to restore
- **Locking**: (available via console: `editor.lockForEditing()`)
- **Source mode**: entering markdown mode pauses sync, shows remote change count
## Architecture
```
Browser A ──┐
├── WebSocket ──→ Flask server ──→ WebSocket ──→ Browser B
Browser C ──┘ │
├── /api/revisions (REST)
└── /api/lock (REST)
```
The server is ~160 lines. In production you'd replace the in-memory
stores with a database and add authentication.
+281
View File
@@ -0,0 +1,281 @@
"""
Flask collaboration server example for ribbit.
Demonstrates: WebSocket relay, presence, revisions, and locking.
Requires: flask, flask-sock
pip install flask flask-sock
python server.py
Then open http://localhost:5000 in multiple browser tabs.
"""
import json
import time
import uuid
from pathlib import Path
from threading import Lock
from flask import Flask, jsonify, render_template, request
from flask_sock import Sock
app = Flask(__name__)
sock = Sock(app)
# In-memory state (replace with a database in production)
document = {"content": """# Ribbit Demo Document
## Inline Formatting
@block(examples
@block(example
### Type this
`**bold**`
### To get this
**bold**
)
@block(example
### Type this
`*italic*`
### To get this
*italic*
)
@block(example
### Type this
`***bold italic***`
### To get this
***bold italic***
)
@block(example
### Type this
`~~strikethrough~~`
### To get this
~~strikethrough~~
)
@block(example
### Type this
`` `inline code` ``
### To get this
`inline code`
)
@block(example
### Type this
`[link](http://example.com)`
### To get this
[link](http://example.com)
)
)
## Block Elements
@block(examples
@block(example
### Type this
```
- apples
- bananas
- cherries
```
### To get this
- apples
- bananas
- cherries
)
@block(example
### Type this
```
1. Step one
2. Step two
3. Step three
```
### To get this
1. Step one
2. Step two
3. Step three
)
@block(example
### Type this
```
> First line
> Second line
> Third line
```
### To get this
> First line
> Second line
> Third line
)
@block(example
### Type this
````
```python
def hello():
print("Hello!")
```
````
### To get this
```python
def hello():
print("Hello!")
```
)
)
## Full Example
Here is a paragraph with **bold**, *italic*, and `code` inline.
A [link](http://example.com) and ~~deleted text~~ too.
> A blockquote with **formatting** inside.
- List with *italic*
- And `code`
***
"""}
revisions = []
lock_holder = None
lock_mutex = Lock()
clients = {} # ws -> user info
# ── Pages ────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html", content=document["content"])
# ── Revisions API ────────────────────────────────────────
@app.route("/api/revisions", methods=["GET"])
def list_revisions():
return jsonify([{k: v for k, v in r.items() if k != "content"} for r in revisions])
@app.route("/api/revisions/<revision_id>", methods=["GET"])
def get_revision(revision_id):
for r in revisions:
if r["id"] == revision_id:
return jsonify(r)
return jsonify({"error": "not found"}), 404
@app.route("/api/revisions", methods=["POST"])
def create_revision():
data = request.json
rev = {
"id": str(uuid.uuid4())[:8],
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
"author": data.get("author", "anonymous"),
"summary": data.get("summary", ""),
"content": data.get("content", document["content"]),
}
revisions.append(rev)
broadcast_json({"type": "revision", "revision": {k: v for k, v in rev.items() if k != "content"}})
return jsonify(rev), 201
# ── Locking API ──────────────────────────────────────────
@app.route("/api/lock", methods=["POST"])
def acquire_lock():
global lock_holder
with lock_mutex:
if lock_holder is None:
lock_holder = request.json
broadcast_json({"type": "lock", "holder": lock_holder})
return jsonify({"ok": True})
return jsonify({"ok": False, "holder": lock_holder}), 409
@app.route("/api/lock", methods=["DELETE"])
def release_lock():
global lock_holder
with lock_mutex:
lock_holder = None
broadcast_json({"type": "lock", "holder": None})
return jsonify({"ok": True})
@app.route("/api/lock/force", methods=["POST"])
def force_lock():
global lock_holder
with lock_mutex:
lock_holder = request.json
broadcast_json({"type": "lock", "holder": lock_holder})
return jsonify({"ok": True})
# ── WebSocket relay ──────────────────────────────────────
@sock.route("/ws")
def websocket(ws):
client_id = str(uuid.uuid4())[:8]
clients[client_id] = {"ws": ws, "user": None}
try:
while True:
data = ws.receive()
if isinstance(data, bytes):
# Binary = document update, relay to all other clients
document["content"] = data.decode("utf-8")
for cid, client in clients.items():
if cid != client_id:
try:
client["ws"].send(data)
except Exception:
pass
elif isinstance(data, str):
msg = json.loads(data)
if msg.get("type") == "join":
clients[client_id]["user"] = msg.get("user")
# Send current document state
ws.send(document["content"].encode("utf-8"))
# Send current lock state
ws.send(json.dumps({"type": "lock", "holder": lock_holder}))
# Broadcast updated peer list
broadcast_peers()
elif msg.get("type") == "presence":
clients[client_id]["user"] = msg
broadcast_peers()
except Exception:
pass
finally:
del clients[client_id]
broadcast_peers()
def broadcast_json(msg):
data = json.dumps(msg)
for client in clients.values():
try:
client["ws"].send(data)
except Exception:
pass
def broadcast_peers():
peers = [c["user"] for c in clients.values() if c["user"]]
broadcast_json({"type": "peers", "peers": peers})
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5000)
+1
View File
@@ -0,0 +1 @@
/tmp/ribbit/dist/ribbit
+178
View File
@@ -0,0 +1,178 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Ribbit Collaboration Example</title>
<link rel="stylesheet" href="/static/ribbit/themes/ribbit-default/theme.css">
<style>
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; }
#peers { padding: 8px; background: #f0f0f0; border-radius: 4px; margin-bottom: 10px; font-size: 14px; }
#peers .peer { display: inline-block; padding: 2px 8px; border-radius: 3px; margin-right: 4px; color: white; }
.examples { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; }
.example { border: 1px solid #ddd; border-radius: 4px; padding: 12px; }
.example h3 { margin: 0 0 8px 0; font-size: 13px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
#status { font-size: 12px; color: #666; margin-bottom: 10px; }
#revisions { margin-top: 20px; }
#revisions button { margin: 2px; }
</style>
</head>
<body>
<h1>Ribbit Collaboration Example</h1>
<div id="peers">No peers connected</div>
<div id="status"></div>
<article id="ribbit">{{ content }}</article>
<div id="revisions">
<h3>Revisions</h3>
<div id="revision-list">Loading...</div>
</div>
<script src="/static/ribbit/ribbit.js"></script>
<script>
const userId = 'user-' + Math.random().toString(36).slice(2, 6);
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#1abc9c'];
const color = colors[Math.floor(Math.random() * colors.length)];
const ws = new WebSocket(`ws://${location.host}/ws`);
const transport = {
connect() {
ws.send(JSON.stringify({
type: 'join',
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
}));
},
disconnect() {},
send(update) { if (ws.readyState === 1) ws.send(update); },
onReceive(callback) {
ws.addEventListener('message', (e) => {
if (e.data instanceof Blob) {
e.data.arrayBuffer().then(buf => callback(new Uint8Array(buf)));
}
});
},
async lock() {
const res = await fetch('/api/lock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, displayName: userId }),
});
return res.ok;
},
unlock() { fetch('/api/lock', { method: 'DELETE' }); },
async forceLock() {
const res = await fetch('/api/lock/force', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, displayName: userId }),
});
return res.ok;
},
onLockChange(callback) {
ws.addEventListener('message', (e) => {
if (typeof e.data === 'string') {
const msg = JSON.parse(e.data);
if (msg.type === 'lock') callback(msg.holder);
}
});
},
};
const presence = {
send(info) {
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'presence', ...info }));
},
onUpdate(callback) {
ws.addEventListener('message', (e) => {
if (typeof e.data === 'string') {
const msg = JSON.parse(e.data);
if (msg.type === 'peers') callback(msg.peers);
}
});
},
};
const revisions = {
async list() {
return (await fetch('/api/revisions')).json();
},
async get(id) {
return (await fetch(`/api/revisions/${id}`)).json();
},
async create(content, metadata) {
const res = await fetch('/api/revisions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, ...metadata }),
});
return res.json();
},
};
const editor = new ribbit.Editor({
macros: [
{
name: 'block',
block: true,
toHTML: ({ keywords, content }) => {
const className = keywords.join(' ');
const classAttr = className ? ' class="' + className + '"' : '';
return '<div' + classAttr + '>' + (content || '') + '</div>';
},
},
],
collaboration: {
transport,
presence,
revisions,
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
},
on: {
peerChange({ peers }) {
const el = document.getElementById('peers');
if (peers.length === 0) {
el.innerHTML = 'No peers connected';
} else {
el.innerHTML = peers.map(p =>
`<span class="peer" style="background:${p.color || '#999'}">${p.displayName} (${p.status})</span>`
).join('');
}
},
lockChange({ holder }) {
const el = document.getElementById('status');
el.textContent = holder ? `🔒 Locked by ${holder.displayName}` : '';
},
remoteActivity({ count }) {
const el = document.getElementById('status');
el.textContent = `${count} remote change${count > 1 ? 's' : ''} while in source mode`;
},
save({ markdown }) {
revisions.create(markdown, { author: userId, summary: 'Manual save' }).then(refreshRevisions);
},
revisionCreated() {
refreshRevisions();
},
},
});
editor.run();
async function refreshRevisions() {
const list = await editor.listRevisions();
const el = document.getElementById('revision-list');
if (list.length === 0) {
el.innerHTML = '<em>No revisions yet. Click Save to create one.</em>';
} else {
el.innerHTML = list.map(r =>
`<button onclick="restore('${r.id}')">${r.timestamp} by ${r.author}${r.summary ? ': ' + r.summary : ''}</button>`
).join('<br>');
}
}
window.restore = async function(id) {
await editor.restoreRevision(id);
};
refreshRevisions();
</script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testPathIgnorePatterns: ['/node_modules/', '/test/integration/'],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
strict: true,
target: 'ES2018',
module: 'CommonJS',
moduleResolution: 'node',
esModuleInterop: true,
lib: ['ES2019', 'DOM'],
types: ['jest'],
},
}],
},
};
+6429 -1462
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -2,26 +2,38 @@
"name": "ribbit", "name": "ribbit",
"version": "1.0.0", "version": "1.0.0",
"description": "Zero-dependency WYSIWYG markdown editor for the browser", "description": "Zero-dependency WYSIWYG markdown editor for the browser",
"main": "dist/ribbit.js", "main": "dist/ribbit/ribbit.js",
"types": "dist/ribbit.d.ts",
"files": [ "files": [
"dist/", "dist/ribbit/"
"src/"
], ],
"scripts": { "scripts": {
"build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css", "build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:core && npm run build:core-min && npm run build:css",
"build:check": "tsc --noEmit", "build:check": "tsc --noEmit",
"build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit.js", "build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit.js",
"build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit.min.js", "build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit.min.js",
"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/", "build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
"test": "npm run build && node test/test_hopdown.js" "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"
}, },
"license": "MIT", "license": "MIT",
"author": "evilchili", "author": "evilchili",
"devDependencies": { "devDependencies": {
"@types/jsdom": "^28.0.1", "@types/jest": "^29.5.14",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"jsdom": "^20.0.3", "happy-dom": "^20.9.0",
"jest": "^29.7.0",
"live-server": "^1.2.0",
"node-watch": "^0.7.4",
"playwright": "^1.60.0",
"selenium-webdriver": "^4.43.0",
"ts-jest": "^29.4.9",
"typescript": "^6.0.3" "typescript": "^6.0.3"
},
"dependencies": {
"bootstrap-icons": "^1.13.1"
} }
} }
+1
View File
@@ -0,0 +1 @@
export * from './ts';
+137 -5
View File
@@ -1,9 +1,18 @@
/* /*
* ribbit-core.css — functional editor styles. Always load this. * ribbit-core.css — functional editor styles. Always load this.
* These styles control editor state visibility and behavior. *
* They should not be overridden by themes. * These styles control editor state visibility and the styled-source
* rendering. They should not be overridden by themes.
*
* Two CSS states (not modes):
* .wysiwyg — contentEditable, delimiters revealed on cursor focus
* .view — read-only, all delimiters hidden, full block styling
*
* The DOM is identical in both states; only CSS changes.
*/ */
/* ── Visibility ─────────────────────────────────────────────────────────────── */
#ribbit { #ribbit {
display: none; display: none;
} }
@@ -12,11 +21,134 @@
display: block; display: block;
} }
#ribbit.edit { /* ── Delimiter visibility ───────────────────────────────────────────────────── */
/*
* Delimiters are always present in the DOM as text nodes inside
* .md-delim spans. In view state they are hidden; in wysiwyg state
* they are hidden by default and revealed only for the span the
* cursor is currently inside (.ribbit-editing).
*
* This means getMarkdown() = element.textContent at all times —
* no conversion is needed.
*/
.md-delim {
display:inline;
opacity: 0.3;
font-size: 0.85em;
font-weight: normal;
font-style: normal;
font-family: monospace;
}
.ribbit-editing {
background: #EEE;
}
.ribbit-editing > .md-delim {
display: inline;
opacity: 0.8;
}
/* List prefixes use a separate class so CSS can replace them with
real list bullets in view state while keeping them in textContent */
.md-list-prefix {
display: inline;
opacity: 0.8;
/*
font-family: monospace;
font-size: 0.85em;
*/
}
#ribbit.view .md-list-prefix {
display: none;
}
/* ── Inline formatting ──────────────────────────────────────────────────────── */
.md-bold,
.md-bold-italic {
font-weight: bold;
}
.md-italic,
.md-bold-italic {
font-style: italic;
}
.md-strikethrough {
text-decoration: line-through;
}
.md-code {
font-family: monospace;
}
.md-link {
cursor: pointer;
}
.md-link-text {
text-decoration: underline;
}
/* ── Block-level styling ────────────────────────────────────────────────────── */
/*
* Block divs use .md-{name} classes. In view state they render as
* their visual equivalents. In wysiwyg state they use monospace so
* the user can see the raw markdown while the formatting is applied.
*/
#ribbit.wysiwyg {
/* white-space: pre-wrap; */
}
.md-h1 { font-size: 2em; font-weight: bold; }
.md-h2 { font-size: 1.5em; font-weight: bold; }
.md-h3 { font-size: 1.17em; font-weight: bold; }
.md-h4 { font-size: 1em; font-weight: bold; }
.md-h5 { font-size: 0.83em; font-weight: bold; }
.md-h6 { font-size: 0.67em; font-weight: bold; }
.md-blockquote {
border-left: 3px solid currentColor;
opacity: 0.7;
padding-left: 1em;
}
/*
* List items: in wysiwyg state the .md-list-prefix span shows the
* raw markdown marker ("- " or "1. "). In view state we hide the
* prefix and use display:list-item to get a real browser bullet.
*/
#ribbit.view .md-list-item {
display: list-item;
margin-left: 1.5em;
list-style-type: disc;
}
#ribbit.view .md-ol-list-item {
display: list-item;
margin-left: 1.5em;
list-style-type: decimal;
}
.md-pre {
font-family: monospace; font-family: monospace;
white-space: pre; white-space: pre;
} }
#ribbit.wysiwyg .md { /* ── Vim mode indicators ────────────────────────────────────────────────────── */
opacity: 0.5;
#ribbit.vim-normal {
cursor: default;
caret-color: transparent;
border-left: 3px solid #4af;
}
#ribbit.vim-insert {
border-left: 3px solid #4f4;
} }
+1
View File
@@ -0,0 +1 @@
../../../../node_modules/bootstrap-icons/icons
+83 -1
View File
@@ -4,7 +4,12 @@
* Replace this file with your own theme to customize the look. * Replace this file with your own theme to customize the look.
*/ */
@import "../ribbit-core.css"; @import "../../ribbit-core.css";
body { font-family: sans-serif; margin: 20px; }
main { max-width: 960px; margin: auto }
#ribbit { border: 1px solid #ccc; border-radius: 4px; padding: 20px; min-height: 200px; }
a { a {
text-decoration: none; text-decoration: none;
@@ -50,3 +55,80 @@ code {
background: #EEE; background: #EEE;
margin: 3px; margin: 3px;
} }
.ribbit-toolbar {
background: #f5f5f5;
border: 1px solid #ccc; border-radius: 4px; padding: 4px; margin-bottom: 8px;
}
.ribbit-toolbar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 2px;
align-items: center;
}
.ribbit-toolbar button {
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
background-repeat: no-repeat;
background-position: center;
background-size: 1rem 1rem;
width: 2rem;
height: 2rem;
}
.ribbit-toolbar button:hover {
background-color: #DDD;
background-blend-mode: darken;
}
.ribbit-toolbar button.disabled {
opacity: 0.3;
cursor: default;
}
.ribbit-btn-fencedCode { background-image: url("icons/code-square.svg"); }
.ribbit-btn-blockquote { background-image: url("icons/blockquote-left.svg"); }
.ribbit-btn-hr { background-image: url("icons/hr.svg"); }
.ribbit-btn-table { background-image: url("icons/table.svg"); }
.ribbit-btn-code { background-image: url("icons/code.svg"); }
.ribbit-btn-link { background-image: url("icons/link.svg"); }
.ribbit-btn-boldItalic { background-image: url("icons/type-bold.svg"); }
.ribbit-btn-bold { background-image: url("icons/type-bold.svg"); }
.ribbit-btn-italic { background-image: url("icons/type-italic.svg"); }
.ribbit-btn-strikethrough { background-image: url("icons/type-strikethrough.svg"); }
.ribbit-btn-h1 { background-image: url("icons/type-h1.svg"); }
.ribbit-btn-h2 { background-image: url("icons/type-h2.svg"); }
.ribbit-btn-h3 { background-image: url("icons/type-h3.svg"); }
.ribbit-btn-h4 { background-image: url("icons/type-h4.svg"); }
.ribbit-btn-h5 { background-image: url("icons/type-h5.svg"); }
.ribbit-btn-h6 { background-image: url("icons/type-h6.svg"); }
.ribbit-btn-ul { background-image: url("icons/list-ul.svg"); }
.ribbit-btn-ol { background-image: url("icons/list-ol.svg"); }
.ribbit-btn-edit { background-image: url("icons/pen.svg"); }
.ribbit-btn-save { background-image: url("icons/floppy.svg"); }
.ribbit-btn-toggle { background-image: url("icons/toggle-off.svg"); }
.ribbit-toolbar .spacer {
width: 12px;
}
.ribbit-dropdown {
position: absolute;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px;
z-index: 10;
}
.ribbit-dropdown button {
display: block;
width: 100%;
text-align: left;
margin: 1px 0;
}
+373
View File
@@ -0,0 +1,373 @@
/*
* collaboration.ts — real-time collaboration manager for ribbit.
*
* Manages document sync, presence, locking, and revision creation
* through consumer-provided interfaces. Ribbit never makes network
* calls — the consumer owns the network layer.
*/
import type {
DocumentTransport, PresenceChannel, PeerInfo,
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;
private revisions?: RevisionProvider;
private user: PeerInfo;
private peers: PeerInfo[];
private connected: boolean;
private paused: boolean;
private remoteChangeCount: number;
private latestRemoteContent: string | null;
private idleTimeout: number;
private lockHolder: PeerInfo | null;
private onRemoteUpdate: (content: string) => void;
private onPeersChange: (peers: PeerInfo[]) => void;
private onLockChange: (holder: PeerInfo | null) => void;
private onRemoteActivity: (count: number) => void;
private receiveBuffer: Uint8Array[];
private throttleTimer?: number;
constructor(
settings: CollaborationSettings,
callbacks: {
onRemoteUpdate: (content: string) => void;
onPeersChange: (peers: PeerInfo[]) => void;
onLockChange: (holder: PeerInfo | null) => void;
onRemoteActivity: (count: number) => void;
},
) {
this.transport = settings.transport;
this.presence = settings.presence;
this.revisions = settings.revisions;
this.user = settings.user;
this.peers = [];
this.connected = false;
this.paused = false;
this.remoteChangeCount = 0;
this.latestRemoteContent = null;
this.idleTimeout = settings.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS;
this.lockHolder = null;
this.onRemoteUpdate = callbacks.onRemoteUpdate;
this.onPeersChange = callbacks.onPeersChange;
this.onLockChange = callbacks.onLockChange;
this.onRemoteActivity = callbacks.onRemoteActivity;
this.receiveBuffer = [];
this.transport.onReceive((update) => {
this.handleRemoteUpdate(update);
});
if (this.presence) {
this.presence.onUpdate((peers) => {
this.peers = this.applyIdleStatus(peers);
this.onPeersChange(this.peers);
});
}
if (this.transport.onLockChange) {
this.transport.onLockChange((holder) => {
this.lockHolder = holder;
this.onLockChange(holder);
});
}
}
/**
* Open the transport connection and begin receiving updates.
*
* @example
* collab.connect();
*/
connect(): void {
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;
}
this.transport.disconnect();
this.connected = false;
this.peers = [];
this.paused = false;
}
/**
* 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.remoteChangeCount = 0;
this.latestRemoteContent = null;
}
/**
* 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<void> {
if (this.paused && this.latestRemoteContent && this.revisions) {
await this.revisions.create(this.latestRemoteContent, {
author: AUTO_REVISION_AUTHOR,
summary: AUTO_REVISION_SUMMARY,
});
}
this.paused = false;
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;
}
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;
}
this.presence.send({
...this.user,
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<boolean> {
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<boolean> {
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;
}
/**
* List all stored revisions via the consumer's RevisionProvider.
*
* @example
* const revisions = await collab.listRevisions();
*/
async listRevisions(): Promise<Revision[]> {
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;
}
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<Revision | 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);
if (this.paused) {
this.remoteChangeCount++;
this.latestRemoteContent = content;
this.onRemoteActivity(this.remoteChangeCount);
return;
}
this.receiveBuffer.push(update);
if (this.throttleTimer !== undefined) {
return;
}
this.throttleTimer = window.setTimeout(() => {
this.throttleTimer = undefined;
if (this.receiveBuffer.length === 0) {
return;
}
const latest = this.receiveBuffer[this.receiveBuffer.length - 1];
this.receiveBuffer = [];
this.onRemoteUpdate(new TextDecoder().decode(latest));
}, 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 === PEER_STATUS.EDITING
? PEER_STATUS.EDITING
: (now - peer.lastActive > this.idleTimeout ? PEER_STATUS.IDLE : PEER_STATUS.ACTIVE),
}));
}
}
+11 -1
View File
@@ -7,8 +7,18 @@
import type { RibbitTheme } from './types'; import type { RibbitTheme } from './types';
import { defaultTags } from './tags'; 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 = { export const defaultTheme: RibbitTheme = {
name: 'ribbit-default', name: DEFAULT_THEME_NAME,
tags: defaultTags, tags: defaultTags,
features: { features: {
sourceMode: true, sourceMode: true,
+55 -1
View File
@@ -2,7 +2,7 @@
* events.ts — typed event emitter for the ribbit editor. * events.ts — typed event emitter for the ribbit editor.
*/ */
import type { RibbitTheme } from './types'; import type { RibbitTheme, PeerInfo, Revision } from './types';
export interface ContentPayload { export interface ContentPayload {
markdown: string; markdown: string;
@@ -72,10 +72,55 @@ export interface RibbitEventMap {
* }); * });
*/ */
ready: (payload: ReadyPayload) => void; ready: (payload: ReadyPayload) => void;
/*
* Remote users connected, disconnected, or moved their cursors.
*
* editor.on('peerChange', ({ peers }) => {
* updateUserList(peers);
* });
*/
peerChange: (payload: { peers: PeerInfo[] }) => void;
/*
* Document lock acquired or released.
*
* editor.on('lockChange', ({ holder }) => {
* if (holder) showBanner(`Locked by ${holder.displayName}`);
* else hideBanner();
* });
*/
lockChange: (payload: { holder: PeerInfo | null }) => void;
/*
* Remote changes received while in source mode.
*
* editor.on('remoteActivity', ({ count }) => {
* statusBar.textContent = `${count} remote changes`;
* });
*/
remoteActivity: (payload: { count: number }) => void;
/*
* A revision was created.
*
* editor.on('revisionCreated', ({ revision }) => {
* console.log(`Revision ${revision.id} saved`);
* });
*/
revisionCreated: (payload: { revision: Revision }) => void;
} }
type EventName = keyof 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: '<h1>Hello</h1>' });
*/
export class RibbitEmitter { export class RibbitEmitter {
private listeners: Map<string, Set<Function>>; private listeners: Map<string, Set<Function>>;
@@ -85,6 +130,9 @@ export class RibbitEmitter {
/** /**
* Register a callback for an event. * Register a callback for an event.
*
* @example
* emitter.on('save', ({ markdown }) => saveDraft(markdown));
*/ */
on<K extends EventName>(event: K, callback: RibbitEventMap[K]): void { on<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
if (!this.listeners.has(event)) { if (!this.listeners.has(event)) {
@@ -95,6 +143,9 @@ export class RibbitEmitter {
/** /**
* Remove a previously registered callback. * Remove a previously registered callback.
*
* @example
* emitter.off('save', savedCallback);
*/ */
off<K extends EventName>(event: K, callback: RibbitEventMap[K]): void { off<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
this.listeners.get(event)?.delete(callback); this.listeners.get(event)?.delete(callback);
@@ -102,6 +153,9 @@ export class RibbitEmitter {
/** /**
* Emit an event, calling all registered callbacks with the payload. * Emit an event, calling all registered callbacks with the payload.
*
* @example
* emitter.emit('change', { markdown: '# Title', html: '<h1>Title</h1>' });
*/ */
emit<K extends EventName>(event: K, ...args: Parameters<RibbitEventMap[K]>): void { emit<K extends EventName>(event: K, ...args: Parameters<RibbitEventMap[K]>): void {
for (const callback of this.listeners.get(event) || []) { for (const callback of this.listeners.get(event) || []) {
+580 -180
View File
@@ -1,18 +1,18 @@
/* /*
* hopdown.ts — configurable markdown↔HTML converter. * hopdown.ts — configurable markdown↔HTML converter.
* *
* Usage: * HopDown orchestrates markdown↔HTML conversion using a tokenizer for
* const converter = new HopDown(); * inline parsing and a serializer for HTML→markdown. Block-level parsing
* const converter = new HopDown({ exclude: ['table'] }); * uses Tag definitions directly. The tokenizer/serializer architecture
* const converter = new HopDown({ tags: { ...defaultTags, 'DEL,S,STRIKE': strikethrough } }); * ensures correct round-trips by separating structural delimiters from
* * literal text at the type level.
* converter.toHTML('**bold**');
* converter.toMarkdown('<strong>bold</strong>');
*/ */
import type { Converter, MatchContext, Tag } from './types'; import type { Converter, MatchContext, Tag, DelimiterMatch } from './types';
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags'; import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml } from './tags';
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros'; 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<string, Tag>; export type TagMap = Record<string, Tag>;
@@ -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: * const converter = new HopDown();
* - tags: a mapping of HTML selectors to Tag definitions * converter.toHTML('**bold**');
* - exclude: remove specific tags by name from the defaults * converter.toMarkdown('<strong>bold</strong>');
*/ */
export class HopDown { export class HopDown {
private blockTags: Tag[]; private blockTags: Tag[];
private inlineTags: Tag[]; private inlineTags: Tag[];
private tags: Map<string, Tag>; private tags: Map<string, Tag>;
private macroMap: Map<string, MacroDef>; private macroMap: Map<string, MacroDef>;
private referenceLinks: Map<string, { url: string; title?: string }>;
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 = {}) { constructor(options: HopDownOptions = {}) {
let tagMap: TagMap; let tagMap: TagMap;
@@ -49,88 +57,96 @@ export class HopDown {
tagMap = defaultTags; tagMap = defaultTags;
} }
// Build macro tags if macros are provided
this.macroMap = new Map(); this.macroMap = new Map();
this.referenceLinks = new Map();
if (options.macros && options.macros.length > 0) { if (options.macros && options.macros.length > 0) {
const { blockTag, selectorEntries, macroMap } = buildMacroTags(options.macros); const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros);
this.macroMap = macroMap; this.macroMap = macroMap;
tagMap = { tagMap['[data-macro]'] = selectorTag;
...tagMap,
...selectorEntries,
};
// Insert macro block tag — will be placed after fencedCode below
tagMap['_macro'] = blockTag; tagMap['_macro'] = blockTag;
} }
const allTags = Object.values(tagMap); const allTags = Object.values(tagMap);
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name)); const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(tag => tag.name));
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name)); const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(tag => tag.name));
this.blockTags = allTags.filter(tag => this.blockTags = allTags.filter(tag =>
defaultBlockNames.has(tag.name) || tag.name === 'macro' || defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern) (!defaultInlineNames.has(tag.name) && !tag.pattern)
); );
// Ensure macro block tag runs after fencedCode but before everything else // 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) => { this.blockTags.sort((a, b) => {
const order = (t: Tag) => { const order = (tag: Tag) => {
if (t.name === 'fencedCode') return 0; if (tag.name === 'fencedCode') {
if (t.name === 'macro') return 1; return 0;
if (t.name === 'paragraph') return 99; }
if (tag.name === 'macro') {
return 1;
}
if (tag.name === 'paragraph') {
return 99;
}
return 50; return 50;
}; };
return order(a) - order(b); return order(a) - order(b);
}); });
this.inlineTags = allTags.filter(tag => this.inlineTags = allTags.filter(tag =>
defaultInlineNames.has(tag.name) || (tag as any).pattern defaultInlineNames.has(tag.name) || tag.pattern
); );
this.tags = new Map(); 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 [selector, tag] of Object.entries(tagMap)) {
for (const sel of selector.split(',').map(s => s.trim()).filter(Boolean)) { const parts = selector.split(',').map(part => part.trim()).filter(Boolean);
if (sel.startsWith('_')) { for (const part of parts) {
if (part.startsWith('_')) {
continue; continue;
} }
const existing = this.tags.get(sel); const existing = this.tags.get(part);
if (existing && existing !== tag) { if (existing && existing !== tag) {
throw new Error( 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.` `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 { private validateInlineTags(): void {
const withDelimiters = this.inlineTags const withDelimiters = this.inlineTags
.filter(tag => (tag as any).delimiter) .filter(tag => tag.delimiter)
.map(tag => ({ .map(tag => ({
name: tag.name, name: tag.name,
delimiter: (tag as any).delimiter as string, delimiter: tag.delimiter as string,
precedence: (tag as any).precedence as number ?? 50, precedence: tag.precedence as number ?? 50,
})); }));
for (let i = 0; i < withDelimiters.length; i++) { for (let outer = 0; outer < withDelimiters.length; outer++) {
for (let j = i + 1; j < withDelimiters.length; j++) { for (let inner = outer + 1; inner < withDelimiters.length; inner++) {
const a = withDelimiters[i]; const first = withDelimiters[outer];
const b = withDelimiters[j]; const second = withDelimiters[inner];
const aPrefix = b.delimiter.startsWith(a.delimiter); const firstIsPrefix = second.delimiter.startsWith(first.delimiter);
const bPrefix = a.delimiter.startsWith(b.delimiter); const secondIsPrefix = first.delimiter.startsWith(second.delimiter);
if (!aPrefix && !bPrefix) { if (!firstIsPrefix && !secondIsPrefix) {
continue; continue;
} }
const longer = a.delimiter.length > b.delimiter.length ? a : b; const longer = first.delimiter.length > second.delimiter.length ? first : second;
const shorter = a.delimiter.length > b.delimiter.length ? b : a; const shorter = first.delimiter.length > second.delimiter.length ? second : first;
if (longer.precedence >= shorter.precedence) { if (longer.precedence >= shorter.precedence) {
throw new Error( throw new Error(
`Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` + `Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` +
@@ -145,28 +161,145 @@ export class HopDown {
/** /**
* Convert a markdown string to HTML. * Convert a markdown string to HTML.
*
* converter.toHTML('# Hello\n\n**bold** text')
*/ */
toHTML(md: string): string { toHTML(markdown: string): string {
return this.processBlocks(md); 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('<h1>Hello</h1><p><strong>bold</strong> text</p>')
*/ */
toMarkdown(html: string): string { toMarkdown(html: string): string {
const container = document.createElement('div'); const container = document.createElement('div');
container.innerHTML = html; 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();
} }
private processBlocks(md: string): string { /**
const lines = md.replace(/\r\n/g, '\n').split('\n'); * The registered block-level tags. Used by the WYSIWYG editor
const output: string[] = []; * to detect block syntax patterns during live editing.
let index = 0; *
* converter.getBlockTags().forEach(tag => console.log(tag.name))
*/
getBlockTags(): Tag[] {
return this.blockTags;
}
while (index < lines.length) { /**
if (/^\s*$/.test(lines[index])) { * The registered inline tags. Used by the WYSIWYG editor to
index++; * build delimiter regexes for speculative rendering.
*
* converter.getInlineTags().filter(tag => tag.delimiter)
*/
getInlineTags(): Tag[] {
return this.inlineTags;
}
/**
* 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;
}
/**
* 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 = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
let lineIndex = 0;
// Collect reference link definitions
this.referenceLinks = new Map();
for (const line of lines) {
const match = line.match(refDefinition);
if (match?.groups) {
this.referenceLinks.set(
match.groups.label.toLowerCase(),
{
url: match.groups.url,
title: match.groups.title,
},
);
}
}
while (lineIndex < lines.length) {
if (blankLine.test(lines[lineIndex]) || refDefinition.test(lines[lineIndex])) {
lineIndex++;
continue; continue;
} }
@@ -174,168 +307,435 @@ export class HopDown {
for (const tag of this.blockTags) { for (const tag of this.blockTags) {
const context: MatchContext = { const context: MatchContext = {
lines, lines,
index, index: lineIndex,
text: '', text: '',
offset: 0, offset: 0,
}; };
const token = tag.match(context); const token = tag.match(context);
if (!token) continue; if (!token) {
continue;
if (tag.name === 'list') {
const result = parseListBlock(lines, index, 0, (source) => this.processInline(source));
output.push(result.html);
index = result.end;
} else {
output.push(tag.toHTML(token, this.makeConverter()));
index += token.consumed;
} }
output.push(tag.toHTML(token, this.cachedConverter));
lineIndex += token.consumed;
matched = true; matched = true;
break; break;
} }
if (!matched) { if (!matched) {
index++; lineIndex++;
} }
} }
return output.join('\n'); return output.join('\n');
} }
/**
* Convert inline markdown to HTML using the tokenizer.
* Tokenizes the source, then walks the token stream to build HTML.
* Open/close delimiter pairs are matched using a stack.
*/
private processInline(source: string): string { private processInline(source: string): string {
const sorted = [...this.inlineTags].sort((a, b) =>
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
);
const placeholders: string[] = [];
let text = source; let text = source;
// Extract inline macros before other processing // Process inline macros before tokenizing — they produce HTML
// that should pass through without further parsing
if (this.macroMap.size > 0) { if (this.macroMap.size > 0) {
text = processInlineMacros(text, this.macroMap, this.makeConverter(), placeholders); const placeholders: string[] = [];
text = processInlineMacros(text, this.macroMap, this.cachedConverter, placeholders);
// Restore placeholders to their HTML content
const placeholderPattern = /\x00P(?<index>\d+)\x00/g;
text = text.replace(placeholderPattern, (_, index: string) =>
placeholders[parseInt(index)]
);
} }
// Pass 1: extract links and non-recursive tags into placeholders before escaping // Resolve reference links before tokenizing
for (const tag of sorted) { text = this.resolveReferenceLinks(text);
const recursive = (tag as any).recursive ?? true; // Normalize _ emphasis to *
text = this.normalizeUnderscores(text);
if (tag.name === 'link') { const tokens = this.tokenizer.tokenize(text);
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => { return this.tokensToHTML(tokens);
// Process link text: restore earlier placeholders, then run inline on any remaining markdown
let inner = linkText;
// Check if link text contains placeholders (already-processed content)
const hasPlaceholders = /\x00P\d+\x00/.test(inner);
if (hasPlaceholders) {
inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
} else {
inner = this.processInline(inner);
}
placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
return '\x00P' + (placeholders.length - 1) + '\x00';
});
} else if (!recursive && (tag as any).pattern) {
const globalPattern = (tag as any).pattern as RegExp;
globalPattern.lastIndex = 0;
text = text.replace(globalPattern, (_, content: string) => {
placeholders.push(tag.toHTML(
{ content, raw: '', consumed: 0 },
this.makeConverter(),
));
return '\x00P' + (placeholders.length - 1) + '\x00';
});
}
} }
text = escapeHtml(text); /**
* Replace [text][ref] and [text][] with [text](url) using the
// Pass 2: apply recursive tags in precedence order (longest delimiter first). * reference definitions collected during block parsing.
// Content matched here is already HTML-escaped and has had earlier */
// passes applied, so we wrap directly without re-processing. private resolveReferenceLinks(text: string): string {
for (const tag of sorted) { if (this.referenceLinks.size === 0) {
const recursive = (tag as any).recursive ?? true;
if (tag.name === 'link' || !recursive) {
continue;
}
const globalPattern = (tag as any).pattern as RegExp | undefined;
if (globalPattern) {
globalPattern.lastIndex = 0;
text = text.replace(globalPattern, (_, content: string) => {
// Restore any placeholders in the captured content
const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
const htmlTag = (tag as any).name === 'boldItalic'
? null
: ((tag.selector as string) || '').split(',')[0].toLowerCase();
if (tag.name === 'boldItalic') {
return '<em><strong>' + restored + '</strong></em>';
}
return `<${htmlTag}>${restored}</${htmlTag}>`;
});
}
}
// Restore placeholders
text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
return text; return text;
} }
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
return text.replace(refLink, (...args) => {
const groups = args[args.length - 1] as Record<string, string>;
const label = (groups.label || groups.text).toLowerCase();
const ref = this.referenceLinks.get(label);
if (!ref) {
return args[0];
}
const titlePart = ref.title ? ` "${ref.title}"` : '';
return `[${groups.text}](${ref.url}${titlePart})`;
});
}
private nodeToMd(node: Node): string { /**
* Normalize flanking underscore runs to asterisks so the tokenizer
* only needs to handle * delimiters for emphasis.
*/
private normalizeUnderscores(text: string): string {
// Protect backslash-escaped underscores from normalization
const escapePlaceholder = '\x00U\x00';
const safeText = text.replace(/\\_/g, escapePlaceholder);
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
const openRun = new RegExp(
`(?<=^|${punctuation})` + // preceded by start, space, or punctuation
`(_+)` + // one or more underscores
`(?=\\S)`, // followed by non-whitespace
'g'
);
const closeRun = new RegExp(
`(?<=\\S)` + // preceded by non-whitespace
`(_+)` + // one or more underscores
`(?=$|${punctuation})`, // followed by end, space, or punctuation
'g'
);
const toAsterisks = (_: string, run: string) => '*'.repeat(run.length);
const normalized = safeText
.replace(openRun, toAsterisks)
.replace(closeRun, toAsterisks);
return normalized.replace(/\x00U\x00/g, '\\_');
}
/**
* Convert a token stream to HTML. Matches open/close delimiter
* pairs and wraps their content in the appropriate HTML tags.
* Unmatched delimiters are emitted as literal text.
*/
private tokensToHTML(tokens: InlineToken[]): string {
// Build a map from delimiter string to tag info
const delimiterToTag = new Map<string, { htmlTag: string; name: string }>();
for (const tag of this.inlineTags) {
if (tag.delimiter) {
const htmlTag = tag.name === 'boldItalic'
? 'em'
: (tag.selector as string).split(',')[0].toLowerCase();
delimiterToTag.set(tag.delimiter, {
htmlTag,
name: tag.name,
});
}
}
// First pass: match open/close pairs using a stack
const paired = this.pairDelimiters(tokens);
// Second pass: build HTML from paired tokens
let html = '';
for (const token of paired) {
switch (token.role) {
case 'text':
html += escapeHtml(token.value);
break;
case 'open': {
const info = delimiterToTag.get(token.delimiter!);
if (info) {
if (info.name === 'boldItalic') {
html += '<em><strong>';
} else {
html += `<${info.htmlTag}>`;
}
} else {
html += escapeHtml(token.value);
}
break;
}
case 'close': {
const info = delimiterToTag.get(token.delimiter!);
if (info) {
if (info.name === 'boldItalic') {
html += '</strong></em>';
} else {
html += `</${info.htmlTag}>`;
}
} else {
html += escapeHtml(token.value);
}
break;
}
case 'code':
html += `<code>${escapeHtml(token.content || '')}</code>`;
break;
case 'link': {
const titleAttr = token.title
? ` title="${escapeHtml(token.title)}"`
: '';
// Process link text for nested inline formatting
const innerTokens = this.tokenizer.tokenize(token.value);
const innerHtml = this.tokensToHTML(innerTokens);
// Strip any nested <a> tags (links can't contain links)
const nestedLink = /<a[^>]*>|<\/a>/g;
const cleanInner = innerHtml.replace(nestedLink, '');
html += `<a href="${escapeHtml(token.href!)}"${titleAttr}>${cleanInner}</a>`;
break;
}
case 'autolink':
html += `<a href="${escapeHtml(token.href!)}">${escapeHtml(token.value)}</a>`;
break;
case 'html':
html += token.value;
break;
case 'break':
html += '<br>';
break;
default:
html += escapeHtml(token.value);
}
}
return html;
}
/**
* Match open/close delimiter pairs in a token stream. Unmatched
* openers/closers are converted to text tokens so they render
* as literal characters.
*/
private pairDelimiters(tokens: InlineToken[]): InlineToken[] {
const openStack: number[] = [];
const result = [...tokens];
// Track which delimiter types are currently open to prevent
// forbidden nesting (e.g. <del> inside <del>, <em> inside <em>)
const openDelimiters = new Set<string>();
for (let index = 0; index < result.length; index++) {
const token = result[index];
if (token.role === 'open') {
// Don't open a delimiter that's already open (prevents nesting)
if (openDelimiters.has(token.delimiter!)) {
result[index] = {
role: 'text',
value: token.value,
};
continue;
}
openStack.push(index);
openDelimiters.add(token.delimiter!);
} else if (token.role === 'close') {
let matched = false;
for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex--) {
const openerIndex = openStack[stackIndex];
if (result[openerIndex].delimiter === token.delimiter) {
openStack.splice(stackIndex, 1);
openDelimiters.delete(token.delimiter!);
matched = true;
break;
}
}
if (!matched) {
result[index] = {
role: 'text',
value: token.value,
};
}
}
}
// Any remaining unmatched openers become literal text
for (const openerIndex of openStack) {
result[openerIndex] = {
role: 'text',
value: result[openerIndex].value,
};
}
return result;
}
/**
* Serialize a DOM node to markdown using the serializer for inline
* content and custom logic for block-level elements.
*/
private serializeNode(node: Node): string {
if (node.nodeType === 3) { if (node.nodeType === 3) {
return node.textContent || ''; return this.serializer.serialize(node);
} }
if (node.nodeType !== 1) { if (node.nodeType !== 1) {
return ''; return '';
} }
const element = node as HTMLElement; const element = node as HTMLElement;
// Check CSS selectors first (macro selectors are more specific) // CSS selectors (e.g. [data-macro]) are more specific
for (const [selector, selectorTag] of this.tags.entries()) { const cssSelectorMatch = this.matchCssSelector(element);
if (selector.includes('[') || selector.includes('.') || selector.includes('#')) { if (cssSelectorMatch) {
// Lowercase only the tag name portion for case-insensitive matching return cssSelectorMatch.toMarkdown(element, this.cachedConverter);
const normalized = selector.replace(/^[A-Z]+/, s => s.toLowerCase());
try {
if (element.matches(normalized)) {
return selectorTag.toMarkdown(element, this.makeConverter());
}
} catch {
// invalid selector, skip
}
}
} }
// Then check by element name // Inline elements: use the serializer which handles escaping
// via typed tokens (text vs delimiter separation)
const inlineTag = this.tags.get(element.nodeName);
if (inlineTag && (inlineTag.delimiter || inlineTag.name === 'link'
|| inlineTag.name === 'code' || inlineTag.name === 'hardBreak')) {
return this.serializer.serialize(element);
}
// Block elements: use the tag's toMarkdown
const tag = this.tags.get(element.nodeName); const tag = this.tags.get(element.nodeName);
if (tag) { if (tag) {
return tag.toMarkdown(element, this.makeConverter()); return tag.toMarkdown(element, this.cachedConverter);
} }
return this.childrenToMd(node); return this.serializeChildren(node);
} }
private childrenToMd(node: Node): string { private matchCssSelector(element: HTMLElement): Tag | null {
return Array.from(node.childNodes).map(child => this.nodeToMd(child)).join(''); for (const [selector, tag] of this.tags.entries()) {
if (!selector.includes('[') && !selector.includes('.') && !selector.includes('#')) {
continue;
}
const uppercaseTagName = /^[A-Z]+/;
const normalized = selector.replace(uppercaseTagName, part => part.toLowerCase());
try {
if (element.matches(normalized)) {
return tag;
}
} catch {
// Invalid selector — skip
}
}
return null;
}
private serializeChildren(node: Node): string {
return Array.from(node.childNodes)
.map(child => this.serializeNode(child))
.join('');
}
/**
* Build the inline tokenizer from registered delimiter-based tags.
*/
private buildTokenizer(): InlineTokenizer {
const hasCodeTag = this.inlineTags.some(tag => tag.name === 'code');
const delimiterDefs: DelimiterDef[] = this.inlineTags
.filter(tag => tag.delimiter && tag.name !== 'code')
.map(tag => ({
delimiter: tag.delimiter!,
htmlTag: tag.name === 'boldItalic'
? 'em'
: (tag.selector as string).split(',')[0].toLowerCase(),
recursive: tag.recursive !== false,
precedence: tag.precedence ?? 50,
}));
return new InlineTokenizer(delimiterDefs, { codeSpans: hasCodeTag });
}
/**
* Build the markdown serializer from registered tags. Maps HTML
* element names to their serialization strategy (delimiter wrap
* or custom function).
*/
private buildSerializer(): MarkdownSerializer {
const tagMap = new Map<string, SerializerTagDef>();
const delimiterChars = new Set<string>();
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';
},
});
}
}
// CODE gets a custom serializer because its content is literal
tagMap.set('CODE', {
serialize: (element) => {
// Code inside <pre> is handled by the PRE serializer
if (element.parentNode?.nodeName === 'PRE') {
return element.textContent || '';
}
return '`' + (element.textContent || '') + '`';
},
});
return new MarkdownSerializer(tagMap, delimiterChars);
}
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(
`(?<!${escapedChar})` +
`${escaped}` +
`(?!${escapedChar})` +
`([^\\x01\\x02]+?)` +
`(?<!${escapedChar})` +
`${escaped}`
),
open: new RegExp(
`(?<!${escapedChar})` +
`${escaped}` +
`(?!${escapedChar})` +
`([^\\x01\\x02]+)$`
),
};
});
}
private buildEditableSelector(): string {
return [
...this.inlineTags,
...this.blockTags,
].filter(tag => typeof tag.selector === 'string')
.map(tag => (tag.selector as string).toLowerCase())
.join(', ');
} }
private makeConverter(): Converter { private makeConverter(): Converter {
return { return {
inline: (source) => this.processInline(source), inline: (source) => this.processInline(source),
block: (md) => this.processBlocks(md), block: (markdown) => this.processBlocks(markdown),
children: (node) => this.childrenToMd(node), children: (node) => this.serializeChildren(node),
node: (node) => this.nodeToMd(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;
+2
View File
@@ -0,0 +1,2 @@
export * from "./ribbit";
export * from "./hopdown";
+248 -82
View File
@@ -1,9 +1,9 @@
/* /*
* macros.ts — macro parsing and Tag generation for ribbit. * macros.ts — macro parsing and Tag generation for ribbit.
* *
* Macros use @name(...) syntax. Everything lives inside the parens: * Macros use @name(...) syntax. Ribbit automatically wraps macro output
* args on the first line, content on subsequent lines. The closing ) * in an element with data- attributes that preserve the original source.
* on its own line ends a block macro. * Round-tripping is handled generically — consumers only write toHTML.
* *
* Syntax: * Syntax:
* @user — bare, no args * @user — bare, no args
@@ -18,18 +18,75 @@
* ) * )
*/ */
import type { Tag, SourceToken, Converter, MatchContext } from './types'; import type { Tag, Converter, ToolbarButton } from './types';
import { escapeHtml } from './tags'; 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 = /(?<paramKey>\w+)="(?<paramValue>[^"]*)"/g;
/* Matches the opening line of a block macro: @name(args with no closing paren */
const BLOCK_MACRO_OPEN = /^@(?<macroName>\w+)\((?<macroArgs>[^)]*)\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*_(>|]))@(?<inlineName>\w+)(?:\((?<inlineArgs>[^)]*)\))?/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: () => '<a href="/User/gsb">gsb</a>',
* };
* ```
*
* @example
* ```ts
* const styleMacro: MacroDef = {
* name: 'style',
* toHTML: ({ keywords, content }) =>
* `<div class="${keywords.join(' ')}">${content}</div>`,
* };
* ```
*/
export interface MacroDef { export interface MacroDef {
name: string; name: string;
/** /**
* Render the macro to HTML. * Render the macro's inner HTML. Ribbit wraps the result in an
* element with data- attributes for round-tripping.
* *
* { name: 'npc', toHTML: ({ keywords }) => { * { name: 'user', toHTML: () => '<a href="/User/gsb">gsb</a>' }
* const name = keywords.join(' '); * { name: 'style', toHTML: ({ keywords, content }) =>
* return `<a href="/NPC/${name}">${name}</a>`; * `<div class="${keywords.join(' ')}">${content}</div>` }
* }}
*/ */
toHTML: (context: { toHTML: (context: {
keywords: string[]; keywords: string[];
@@ -38,46 +95,64 @@ export interface MacroDef {
convert: Converter; convert: Converter;
}) => string; }) => string;
/** /**
* CSS selector for the HTML this macro produces. * Toolbar button. Set to false to hide from the macros dropdown.
* Required for HTML→markdown round-tripping. * Default: auto-generated from the macro name.
*/ */
selector?: string; button?: ToolbarButton | false;
/**
* Convert the macro's HTML back to macro syntax.
*
* toMarkdown: (el) => `@npc(${el.textContent})`
*/
toMarkdown?: (element: HTMLElement, convert: Converter) => string;
} }
/** Internal representation of a fully parsed macro invocation. */
interface ParsedMacro { interface ParsedMacro {
name: string; name: string;
keywords: string[]; keywords: string[];
params: Record<string, string>; params: Record<string, string>;
verbatim: boolean; verbatim: boolean;
content?: string; content?: string;
/** Number of source lines consumed by this macro (for block advancement). */
consumed: number; 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[]; keywords: string[];
params: Record<string, string>; params: Record<string, string>;
verbatim: boolean; verbatim: boolean;
} { } {
if (!argsStr || !argsStr.trim()) { if (!argumentString || !argumentString.trim()) {
return { keywords: [], params: {}, verbatim: false }; return {
keywords: [],
params: {},
verbatim: false,
};
} }
const params: Record<string, string> = {}; const params: Record<string, string> = {};
const withoutParams = argsStr.replace(new RegExp(PARAM_PATTERN.source, 'g'), (_, key, val) => { /* Strip key="value" pairs, collecting them into params */
params[key] = val; const withoutParams = argumentString.replace(
new RegExp(PARAM_PATTERN.source, 'g'),
(_match, paramKey, paramValue) => {
params[paramKey] = paramValue;
return ''; return '';
}); },
);
const allKeywords = withoutParams.trim().split(/\s+/).filter(Boolean); const allKeywords = withoutParams.trim().split(/\s+/).filter(Boolean);
const verbatim = allKeywords.includes('verbatim'); const verbatim = allKeywords.includes(VERBATIM_KEYWORD);
const keywords = allKeywords.filter(k => k !== 'verbatim'); const keywords = allKeywords.filter(keyword => keyword !== VERBATIM_KEYWORD);
return { keywords, params, verbatim }; return {
keywords,
params,
verbatim,
};
} }
function macroError(name: string): string { function macroError(name: string): string {
@@ -85,35 +160,95 @@ function macroError(name: string): string {
} }
/** /**
* Try to parse a block macro starting at the given line index. * Wrap a macro's rendered HTML with data- attributes for round-tripping.
* Matches: @name(args at end of line (no closing paren), * Block macros (with content) use `<div>`, inline macros use `<span>`.
* with content until a line containing only )
*/ */
function parseBlockMacro(lines: string[], index: number): ParsedMacro | null { function wrapMacro(
const line = lines[index]; name: string,
const m = line.match(/^@(\w+)\(([^)]*)\s*$/); keywords: string[],
if (!m) { params: Record<string, string>,
verbatim: boolean,
hasContent: boolean,
innerHtml: string,
): string {
const tag = hasContent ? 'div' : 'span';
let attrs = ` data-macro="${escapeHtml(name)}"`;
if (keywords.length) {
attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`;
}
for (const [paramKey, paramValue] of Object.entries(params)) {
attrs += ` data-param-${escapeHtml(paramKey)}="${escapeHtml(paramValue)}"`;
}
if (verbatim) {
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 — 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 === VERBATIM_DATA_VALUE;
const paramParts: string[] = [];
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_KEYWORD].filter(Boolean).join(' ')
: keywords;
const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' ');
const isBlock = element.tagName === 'DIV';
if (isBlock) {
const content = convert.children(element);
return `\n\n@${name}(${args}\n${content}\n)\n\n`;
}
return args ? `@${name}(${args})` : `@${name}`;
}
/**
* 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[], lineIndex: number): ParsedMacro | null {
const line = lines[lineIndex];
const openMatch = BLOCK_MACRO_OPEN.exec(line);
if (!openMatch || !openMatch.groups) {
return null; return null;
} }
const name = m[1]; const name = openMatch.groups.macroName;
const { keywords, params, verbatim } = parseArgs(m[2]); const { keywords, params, verbatim } = parseArgs(openMatch.groups.macroArgs);
const contentLines: string[] = []; const contentLines: string[] = [];
let i = index + 1; let scanIndex = lineIndex + 1;
let depth = 1; let nestingDepth = 1;
while (i < lines.length && depth > 0) { while (scanIndex < lines.length && nestingDepth > 0) {
if (/^\)\s*$/.test(lines[i])) { if (BLOCK_CLOSE_LINE.test(lines[scanIndex])) {
depth--; nestingDepth--;
if (depth === 0) { if (nestingDepth === 0) {
break; break;
} }
} }
if (/^@\w+\([^)]*\s*$/.test(lines[i])) { if (NESTED_BLOCK_OPEN.test(lines[scanIndex])) {
depth++; nestingDepth++;
} }
contentLines.push(lines[i]); contentLines.push(lines[scanIndex]);
i++; scanIndex++;
} }
if (depth !== 0) { /* Unclosed macro — treat as plain text */
if (nestingDepth !== 0) {
return null; return null;
} }
return { return {
@@ -122,33 +257,35 @@ function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
params, params,
verbatim, verbatim,
content: contentLines.join('\n'), content: contentLines.join('\n'),
consumed: i + 1 - index, consumed: scanIndex + 1 - lineIndex,
}; };
} }
/** /* ── Public API ────────────────────────────────────────────────── */
* Inline macro pattern. Matches @name, @name(), or @name(args).
* The @ must be preceded by whitespace, start of string, or markdown delimiters.
*/
const INLINE_MACRO_GLOBAL = /(?:^|(?<=[\s*_(>|]))@(\w+)(?:\(([^)]*)\))?/g;
/** /**
* Build Tags from an array of macro definitions. * 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: () => '<a href="/User/gsb">gsb</a>' },
* ]);
* ```
*/ */
export function buildMacroTags( export function buildMacroTags(
macros: MacroDef[], macros: MacroDef[],
): { blockTag: Tag; selectorEntries: Record<string, Tag>; macroMap: Map<string, MacroDef> } { ): { blockTag: Tag; selectorTag: Tag; macroMap: Map<string, MacroDef> } {
const macroMap = new Map<string, MacroDef>(); const macroMap = new Map<string, MacroDef>();
for (const macro of macros) { for (const macro of macros) {
macroMap.set(macro.name, macro); macroMap.set(macro.name, macro);
} }
const blockTag: Tag = { const blockTag: Tag = {
/*
* @name(args
* content
* )
*/
name: 'macro', name: 'macro',
match: (context) => { match: (context) => {
const parsed = parseBlockMacro(context.lines, context.index); const parsed = parseBlockMacro(context.lines, context.index);
@@ -175,37 +312,59 @@ export function buildMacroTags(
content = convert.block(content); content = convert.block(content);
} }
} }
return macro.toHTML({ const innerHtml = macro.toHTML({
keywords: parsed.keywords, keywords: parsed.keywords,
params: parsed.params, params: parsed.params,
content, content,
convert, convert,
}); });
return wrapMacro(
parsed.name, parsed.keywords, parsed.params,
parsed.verbatim, true, innerHtml,
);
}, },
selector: '[data-macro]', selector: '[data-macro]',
toMarkdown: () => '', toMarkdown: () => '',
}; };
const selectorEntries: Record<string, Tag> = {}; /**
for (const macro of macros) { * Generic selector tag — matches any element with data-macro
if (macro.selector && macro.toMarkdown) { * and reconstructs the macro source from data- attributes.
const macroCopy = macro; * Separate from blockTag so the selector-based HTML→markdown
selectorEntries[macro.selector] = { * path can find macro elements independently.
name: `macro:${macro.name}`, */
const selectorTag: Tag = {
name: 'macro:generic',
match: () => null, match: () => null,
toHTML: () => '', toHTML: () => '',
selector: macro.selector, selector: '[data-macro]',
toMarkdown: (element, convert) => macroCopy.toMarkdown!(element, convert), toMarkdown: macroToMarkdown,
}; };
}
}
return { blockTag, selectorEntries, macroMap }; return {
blockTag,
selectorTag,
macroMap,
};
} }
/** /**
* Process inline macros in a text string, replacing them with rendered HTML. * Process inline macros in a text string, replacing them with rendered HTML.
* Called during inline processing pass 1 (placeholder extraction). *
* 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( export function processInlineMacros(
text: string, text: string,
@@ -213,19 +372,26 @@ export function processInlineMacros(
convert: Converter, convert: Converter,
placeholders: string[], placeholders: string[],
): string { ): string {
return text.replace(INLINE_MACRO_GLOBAL, (match, nameStr: string, argsStr: string | undefined) => { return text.replace(
const macro = macroMap.get(nameStr); 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) { if (!macro) {
placeholders.push(macroError(nameStr)); placeholders.push(macroError(macroName));
return '\x00P' + (placeholders.length - 1) + '\x00'; return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
} }
const { keywords, params } = parseArgs(argsStr); const { keywords, params } = parseArgs(groups.inlineArgs);
const html = macro.toHTML({ const innerHtml = macro.toHTML({
keywords, keywords,
params, params,
convert, convert,
}); });
placeholders.push(html); const wrapped = wrapMacro(macroName, keywords, params, false, false, innerHtml);
return '\x00P' + (placeholders.length - 1) + '\x00'; placeholders.push(wrapped);
}); return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
},
);
} }
+21
View File
@@ -0,0 +1,21 @@
/*
* ribbit-core.ts — lightweight entry point without optional features (vim).
*
* Same API as ribbit-editor.ts but excludes VimHandler.
*/
import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme';
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { type MacroDef } from './macros';
export { RibbitEditor as Editor } from './ribbit-editor';
export { Ribbit as Viewer };
export { HopDown };
export { inlineTag };
export { defaultTags, defaultBlockTags, defaultInlineTags };
export { defaultTheme };
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
export { ToolbarManager } from './toolbar';
export type { MacroDef };
+793 -54
View File
@@ -1,116 +1,855 @@
/* /*
* ribbit-editor.ts — WYSIWYG editing extension for Ribbit. * ribbit-editor.ts — Styled-source editing extension for Ribbit.
*
* The editor is always a markdown text editor. There is no separate
* WYSIWYG mode — the user edits markdown directly, but CSS styling
* makes it look like rendered output. Delimiters (**, *, `, etc.)
* are hidden when the cursor is outside their span, and revealed
* when the cursor enters it.
*
* Two CSS states replace the old three-mode system:
* editing: contentEditable="true", delimiters revealed on focus
* viewing: contentEditable="false", all delimiters hidden
*
* The DOM is identical in both states — only CSS changes. This
* eliminates all conversion-during-editing bugs and removes the
* flatten→rebuild pipeline entirely.
*
* getMarkdown() reads element.textContent directly. Because every
* delimiter character lives in a text node inside a .md-delim span,
* textContent always equals the original markdown source — no
* conversion is needed.
*
* getHTML() runs the existing HopDown tokenizer on demand (export,
* save, API calls) — never during editing.
*/ */
import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags'; import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme'; import { defaultTheme } from './default-theme';
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit'; import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import type { Tag } from './types';
import { type MacroDef } from './macros';
// ─── Constants ────────────────────────────────────────────────────────────────
// CSS class applied to the formatting span the cursor is currently inside.
// CSS uses this to reveal .md-delim children for that span only.
const EDITING_CONTEXT_CLASS = 'ribbit-editing';
// CSS class prefix for all styled-source block divs.
const BLOCK_CLASS_PREFIX = 'md-';
// CSS class applied to all delimiter spans (e.g. the ** in **bold**).
const DELIM_CLASS = 'md-delim';
// CSS class applied to list-item prefix spans (e.g. "- " or "1. ").
const LIST_PREFIX_CLASS = 'md-list-prefix';
// data- attribute on inline formatting spans.
const INLINE_SPAN_ATTR = 'data-md-span';
// Characters this implementation inserts purely as internal bookkeeping
// (never something the user typed, never part of real markdown content):
// \u200B — placeholder text node so an empty block-prefix line (e.g.
// "# " with nothing after it) still has a text node for the
// caret to land in.
// \u200C — boundary marker inserted by disambiguateAsteriskRuns
// between two adjacent closing delimiters that would
// otherwise form an ambiguous run (e.g. "***" closing both
// an inner "*" and an outer "**").
// These are stripped entirely from getMarkdown() output and skipped
// (never counted) by caret-offset math in both directions. \u00A0 is
// handled separately by normalizeLineText below — it is NOT an
// internal marker, since it stands in for a real space the user typed.
const INTERNAL_MARKER_CHARS = '\u200B\u200C';
const INTERNAL_MARKER_PATTERN = new RegExp(`[${INTERNAL_MARKER_CHARS}]`, 'g');
const INTERNAL_MARKER_SINGLE = new RegExp(`^[${INTERNAL_MARKER_CHARS}]$`);
/** /**
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes. * Remove all internal bookkeeping markers from a string.
* stripInternalMarkers('**bold *italic*\u200C**') // '**bold *italic***'
*/
function stripInternalMarkers(text: string): string {
return text.replace(INTERNAL_MARKER_PATTERN, '');
}
/**
* True if a single character is purely internal bookkeeping.
* isInternalMarker('\u200C') // true
*/
function isInternalMarker(character: string): boolean {
return INTERNAL_MARKER_SINGLE.test(character);
}
/**
* Normalize a block's raw textContent into clean markdown source:
* convert NBSP (inserted for Chromium caret-stability when a line ends
* in a space — never deleted, since it represents a real space the
* user typed) back to a plain space, then strip internal bookkeeping
* markers (never user content, always deleted entirely).
* *
* Extends Ribbit with contentEditable support and bidirectional * normalizeLineText('#\u00A0Title\u200C') // '# Title'
* markdown↔HTML conversion on mode switches. */
function normalizeLineText(text: string): string {
return stripInternalMarkers(text.replace(/\u00A0/g, ' '));
}
// ─── Block classification ─────────────────────────────────────────────────────
interface BlockRule {
name: string;
prefixLength: (line: string) => number | null;
isList?: boolean;
}
const HEADING_PATTERN = /^(?<hashes>#{1,6}) /;
const BLOCKQUOTE_PATTERN = /^> /;
const UNORDERED_LIST_PATTERN = /^[-*+] /;
const ORDERED_LIST_PATTERN = /^\d+\. /;
const BLOCK_RULES: BlockRule[] = [
{
name: 'pre',
prefixLength: (line) => {
const FENCE_PATTERN = /^(?<fence>`{3,}|~{3,})/;
const match = line.match(FENCE_PATTERN);
return match ? match[0].length : null;
},
},
{
name: 'heading',
prefixLength: (line) => {
const match = line.match(HEADING_PATTERN);
return match ? match[0].length : null;
},
},
{
name: 'blockquote',
prefixLength: (line) => BLOCKQUOTE_PATTERN.test(line) ? 2 : null,
},
{
name: 'list-item',
prefixLength: (line) => {
if (!UNORDERED_LIST_PATTERN.test(line)) {
return null;
}
return line.indexOf(' ') + 1;
},
isList: true,
},
{
name: 'ol-list-item',
prefixLength: (line) => {
if (!ORDERED_LIST_PATTERN.test(line)) {
return null;
}
return line.indexOf(' ') + 1;
},
isList: true,
},
];
// ─── Inline delimiter disambiguation ───────────────────────────────────────────
/**
* Pre-pass over a line of markdown text that disambiguates runs of 3
* consecutive asterisks (e.g. "**bold *italic***") into two separate,
* unambiguous tokens, separated by a sentinel.
*
* Markdown allows * and ** to nest (one inside the other) but this
* implementation does not support nesting a delimiter inside itself,
* so at most two distinct asterisk-based delimiters can be open at
* once. A run of exactly 3 asterisks therefore always means "open or
* close the most-recently-opened one first, then the other."
*
* Handles both directions of the ambiguity:
* - closing-side: "**bold *italic***" — the trailing run of 3
* closes italic (innermost) then bold.
* - opening-side: "***italic* bold**" — the leading run of 3 opens
* italic then bold (by convention, single-char delimiter is
* treated as inner/most-recently-opened, matching the closing
* rule, for symmetry).
*
* disambiguateAsteriskRuns('**bold *italic***')
* // '**bold *italic*\u200C**'
*/
const SENTINEL = '\u200C';
function disambiguateAsteriskRuns(line: string): string {
const ASTERISK_RUN = /\*{1,3}/g;
const stack: ('*' | '**' | '***')[] = [];
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
const bold = '**';
const italic = '*';
let lastSequence = '';
while ((match = ASTERISK_RUN.exec(line)) !== null) {
result += line.slice(lastIndex, match.index);
const sequence = match[0];
if (sequence.length === 3) {
// split *** into opening ** and *
if (stack.length === 0) {
result += sequence;
stack.push('***');
// closing ***, close the stack
} else if (stack.length === 1 && stack[0] === sequence) {
result = result.replace('***', bold + SENTINEL + italic);
result += italic + SENTINEL + bold;
stack.pop();
} else if (stack.length === 2) {
const inner = stack.pop();
const outer = stack.pop();
result += inner + SENTINEL + outer;
} else if (stack.length === 1) {
console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`);
} else {
console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`);
}
} else if (stack.length) {
if (stack[stack.length - 1] === sequence) {
result += stack.pop();
} else if (stack.length === 1 && stack[0] === '***') {
const opener = stack[0].substring(0, stack[0].length - sequence.length);
result = result.replace('***', opener + SENTINEL + sequence);
result += sequence;
stack[0] = opener as '*' | '**';
} else {
stack.push(sequence as '*' | '**');
result += sequence;
}
} else {
stack.push(sequence as '*' | '**');
result += sequence;
}
lastIndex = match.index + sequence.length;
}
result += line.slice(lastIndex);
return result;
}
// ─── RibbitEditor ─────────────────────────────────────────────────────────────
/**
* Styled-source WYSIWYG editor. Extends Ribbit's read-only viewer with
* contentEditable support. The user always edits raw markdown; CSS
* renders it visually. Per-line incremental DOM update.
* *
* Usage:
* const editor = new RibbitEditor({ editorId: 'my-element' }); * const editor = new RibbitEditor({ editorId: 'my-element' });
* editor.run(); * editor.run();
* editor.wysiwyg(); // switch to WYSIWYG mode * editor.wysiwyg();
* editor.edit(); // switch to source editing mode
* editor.view(); // switch to read-only view
*/ */
export class RibbitEditor extends Ribbit { export class RibbitEditor extends Ribbit {
private activeFormattingSpan: HTMLElement | null = null;
run(): void { run(): void {
this.states = { this.states = {
VIEW: 'view', VIEW: 'view',
EDIT: 'edit', WYSIWYG: 'wysiwyg',
WYSIWYG: 'wysiwyg'
}; };
this.#bindEvents(); this.#bindEvents();
this.plugins().forEach(plugin => {
plugin.setEditable();
});
this.element.classList.add('loaded'); this.element.classList.add('loaded');
if (this.autoToolbar) {
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
}
this.view(); this.view();
this.emitReady();
} }
#bindEvents(): void { #bindEvents(): void {
let debounceTimer: number | undefined;
this.element.addEventListener('input', () => { this.element.addEventListener('input', () => {
if (this.state !== this.states.VIEW) { if (this.state !== this.states.WYSIWYG) {
return;
}
this.#updateCurrentBlock();
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
this.notifyChange(); this.notifyChange();
}, 300);
});
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (event.key === 'Backspace') {
const children = Array.from(this.element.children);
const onlyChild = children.length === 1 ? children[0] as HTMLElement : null;
const isEmpty = onlyChild &&
!stripInternalMarkers(onlyChild.textContent!).trim() &&
onlyChild.querySelector('br');
if (isEmpty) {
event.preventDefault();
return;
}
}
this.#dispatchKeydown(event);
});
this.element.addEventListener('keyup', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (event.key.startsWith('Arrow')) {
this.#updateEditingContext();
} }
}); });
}
htmlToMarkdown(html?: string): string { document.addEventListener('selectionchange', () => {
return this.converter.toMarkdown(html || this.element.innerHTML); if (this.state !== this.states.WYSIWYG) {
return;
} }
this.#updateEditingContext();
});
getMarkdown(): string { document.addEventListener('click', (event: MouseEvent) => {
if (this.getState() === this.states.EDIT) { if (this.state !== this.states.WYSIWYG) {
let html = this.element.innerHTML; return;
html = html.replace(/<(?:div|br)>/ig, '');
html = html.replace(/<\/div>/ig, '\n');
this.cachedMarkdown = decodeHtmlEntities(html);
} else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
} }
if (!this.cachedMarkdown) { if (!this.element.contains(event.target as Node)) {
this.cachedMarkdown = this.element.textContent || ''; this.#clearEditingContext();
} }
return this.cachedMarkdown; });
} }
wysiwyg(): void { wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return; if (this.getState() === this.states.WYSIWYG) {
this.changed = false; return;
this.element.contentEditable = 'true'; }
this.element.innerHTML = this.getHTML(); const markdown = this.getMarkdown();
Array.from(this.element.querySelectorAll('.macro')).forEach(el => { this.sourceMarkdown = null;
const macroEl = el as HTMLElement; this.collaboration?.connect();
if (macroEl.dataset.editable === 'false') { this.element.innerHTML = '';
macroEl.contentEditable = 'false'; this.element.appendChild(this.#markdownToStyledDOM(markdown));
macroEl.style.opacity = '0.5'; this.element.contentEditable = 'true';
for (const macroElement of Array.from(this.element.querySelectorAll('.macro'))) {
const htmlMacro = macroElement as HTMLElement;
if (htmlMacro.dataset.editable === 'false') {
htmlMacro.contentEditable = 'false';
}
} }
});
this.setState(this.states.WYSIWYG); this.setState(this.states.WYSIWYG);
} }
edit(): void { getMarkdown(): string {
if (!this.theme.features?.sourceMode) { if (this.getState() === this.states.WYSIWYG) {
return; return Array.from(this.element.children)
.map((block) => this.#blockToMarkdown(block as HTMLElement))
.join('\n');
} }
if (this.state === this.states.EDIT) return; return super.getMarkdown();
this.changed = false;
this.element.contentEditable = 'true';
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
this.setState(this.states.EDIT);
} }
insertAtCursor(node: Node): void { insertAtCursor(node: Node): void {
const sel = window.getSelection()!; const selection = window.getSelection()!;
const range = sel.getRangeAt(0); const range = selection.getRangeAt(0);
range.deleteContents(); range.deleteContents();
range.insertNode(node); range.insertNode(node);
range.setStartAfter(node); range.setStartAfter(node);
this.element.focus(); this.element.focus();
selection.removeAllRanges();
selection.addRange(range);
}
#markdownToStyledDOM(markdown: string): DocumentFragment {
const fragment = document.createDocumentFragment();
for (const line of markdown.split('\n')) {
fragment.appendChild(this.#buildBlock(line));
}
return fragment;
}
#buildBlock(line: string): HTMLDivElement {
const block = document.createElement('div');
if (line === '') {
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
block.appendChild(document.createElement('br'));
return block;
}
//console.log(`Parsing line '${line}'`);
for (const rule of BLOCK_RULES) {
const prefixLength = rule.prefixLength(line);
if (prefixLength === null) {
continue;
}
if (rule.name === 'heading') {
const match = line.match(HEADING_PATTERN)!;
block.className = `${BLOCK_CLASS_PREFIX}h${match.groups!.hashes.length}`;
} else {
block.className = `${BLOCK_CLASS_PREFIX}${rule.name}`;
}
const prefixSpan = document.createElement('span');
prefixSpan.className = rule.isList ? LIST_PREFIX_CLASS : DELIM_CLASS;
prefixSpan.textContent = line.slice(0, prefixLength);
block.appendChild(prefixSpan);
const content = line.slice(prefixLength);
if (content) {
block.appendChild(this.#parseInline(content));
} else {
block.appendChild(document.createTextNode('\u200B'));
}
return block;
}
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
block.appendChild(this.#parseInline(line));
return block;
}
#createSpanForMatch(match: RegExpExecArray, className: string): Node {
const span = document.createElement('span');
span.className = className;
span.setAttribute(INLINE_SPAN_ATTR, '1');
if (span.className === 'md-link') {
span.appendChild(this.#makeDelimSpan('['));
const linkTextNode = document.createElement('span');
linkTextNode.className = 'md-link-text';
linkTextNode.textContent = match.groups!.linkLabel;
span.appendChild(linkTextNode);
span.appendChild(this.#makeDelimSpan(`](${match.groups!.linkHref})`));
return span;
}
span.appendChild(this.#makeDelimSpan(match.groups!.delimiter));
span.appendChild(this.#parseInline(match.groups!.content));
if (match.groups!.closer) {
span.appendChild(this.#makeDelimSpan(match.groups!.closer));
}
return span;
}
#parseInline(text: string): DocumentFragment {
var t = text;
text = disambiguateAsteriskRuns(text);
//console.log(`${t} => ${text}`);
const classes: Record<string, string> = {
'**': 'md-bold',
'*': 'md-italic',
'~~': 'md-strikethrough',
'`': 'md-code',
};
const INLINE_PATTERN = new RegExp(
'\\[(?<linkLabel>[^\\]]+)\\]\\((?<linkHref>[^)]+)\\)' +
'|' + '(?<![*])' +
'(?<delimiter>${SENTINEL}\\*{2}|\\*{2}|${SENTINEL}\\*|\\*|~~|`)' +
'(?<content>.*)' +
`(?<closer>(?:${SENTINEL}\\k<delimiter>|\\k<delimiter>)(?![*]))`,
'g'
);
const fragment = document.createDocumentFragment();
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = INLINE_PATTERN.exec(text)) !== null) {
// console.log(`DELIM: ${match.groups!.delimiter} CONTENT: ${match.groups!.content} CLOSER: ${match.groups!.closer}`)
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
const className = match.groups!.linkLabel !== undefined ? 'md-link' : classes[match.groups!.delimiter];
fragment.appendChild(this.#createSpanForMatch(match, className));
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
}
// console.log(`'${t}' => '${text.replace('\u200C', '|')}`, fragment.children);
return fragment;
}
#makeDelimSpan(text: string): HTMLSpanElement {
const span = document.createElement('span');
span.className = DELIM_CLASS;
span.textContent = text;
return span;
}
#updateCurrentBlock(): void {
const block = this.#findCurrentBlock();
if (!block) {
return;
}
const caretOffset = this.#getCaretOffset(block);
const lineText = normalizeLineText(block.textContent!);
console.log(`'${block.textContent}' normalized to '${lineText}'`);
const newBlock = this.#buildBlock(lineText);
block.className = newBlock.className;
block.innerHTML = '';
while (newBlock.firstChild) {
block.appendChild(newBlock.firstChild);
}
/*
if (lineText.endsWith(' ')) {
const lastChild = block.lastChild;
if (lastChild && lastChild.nodeType === 3) {
lastChild.textContent = lastChild.textContent!.replace(/ $/, '\u00A0');
}
}
if (lineText.endsWith(' ')) {
// Find the last text node in the block (may be nested inside spans)
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let lastTextNode: Text | null = null;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
lastTextNode = node;
}
if (lastTextNode) {
lastTextNode.textContent = lastTextNode.textContent!.replace(/ $/, '\u00A0');
}
console.log(`updating lastTextNode.textContent to '${lastTextNode!.textContent}'`);
}
*/
const prefixSpan = block.firstElementChild;
const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) ||
prefixSpan?.classList.contains(LIST_PREFIX_CLASS))
? prefixSpan.textContent!.length : 0;
console.log(`${block.textContent!}: ${caretOffset} <= ${prefixLen} && ${prefixSpan} ?`);
if (caretOffset <= prefixLen && prefixSpan) {
const sel = window.getSelection()!;
const range = document.createRange();
const next = prefixSpan.nextSibling;
if (next && next.nodeType === 3) {
range.setStart(next as Text, 0);
} else {
range.setStartAfter(prefixSpan);
}
range.collapse(true);
sel.removeAllRanges(); sel.removeAllRanges();
sel.addRange(range); sel.addRange(range);
} else {
this.#restoreCaret(block, caretOffset);
}
this.#updateEditingContext();
}
#dispatchKeydown(event: KeyboardEvent): void {
const block = this.#findCurrentBlock();
if (block) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const tagForBlock = this.converter.getBlockTags().find((tag) => {
if (typeof tag.selector !== 'string') {
return false;
}
return tag.selector.split(',').some(
(selector) => block.tagName === selector.trim()
);
});
if (tagForBlock?.handleKeydown) {
const handled = tagForBlock.handleKeydown(block, event, selection, this);
if (handled) {
event.preventDefault();
return;
}
}
} }
} }
import { type MacroDef } from './macros'; if (event.key === 'Enter') {
event.preventDefault();
this.#handleEnter();
return;
}
if (event.key === 'Backspace') {
if (this.#handleBackspace()) {
event.preventDefault();
}
}
}
#handleEnter(): void {
const block = this.#findCurrentBlock();
if (!block) {
return;
}
const offset = this.#getCaretOffset(block);
const text = normalizeLineText(block.textContent!);
const prefixSpan = block.firstElementChild;
const isListPrefix = prefixSpan?.classList.contains(LIST_PREFIX_CLASS);
const isBlockquote = prefixSpan?.classList.contains(DELIM_CLASS) &&
block.classList.contains('md-blockquote');
let prefix = '';
if (isListPrefix) {
const orderedMatch = prefixSpan!.textContent!.match(/^(?<num>\d+)\. /);
if (orderedMatch) {
const nextNum = parseInt(orderedMatch.groups!.num) + 1;
prefix = `${nextNum}. `;
} else {
prefix = prefixSpan!.textContent!;
}
} else if (isBlockquote) {
prefix = prefixSpan!.textContent!;
}
const currentPrefix = stripInternalMarkers(prefixSpan?.textContent ?? '');
if ((isListPrefix || isBlockquote) &&
(text === currentPrefix || text.trim() === currentPrefix.trim())) {
const emptyBlock = this.#buildBlock('');
block.className = emptyBlock.className;
block.innerHTML = '';
while (emptyBlock.firstChild) {
block.appendChild(emptyBlock.firstChild);
}
this.#restoreCaret(block, 0);
return;
}
const before = text.slice(0, offset);
const after = text.slice(offset);
const firstBlock = this.#buildBlock(before);
const secondBlock = this.#buildBlock(after ? prefix + after : prefix);
block.className = firstBlock.className;
block.innerHTML = '';
while (firstBlock.firstChild) {
block.appendChild(firstBlock.firstChild);
}
block.after(secondBlock);
const newPrefixSpan = secondBlock.firstElementChild;
if (newPrefixSpan && (newPrefixSpan.classList.contains(DELIM_CLASS) ||
newPrefixSpan.classList.contains(LIST_PREFIX_CLASS))) {
const sel = window.getSelection()!;
const range = document.createRange();
const next = newPrefixSpan.nextSibling;
if (next && next.nodeType === 3) {
range.setStart(next as Text, 0);
} else {
range.setStartAfter(newPrefixSpan);
}
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
this.#restoreCaret(secondBlock, 0);
}
}
#handleBackspace(): boolean {
const block = this.#findCurrentBlock();
if (!block) {
return false;
}
const offset = this.#getCaretOffset(block);
if (offset !== 0) {
return false;
}
const previousBlock = block.previousElementSibling as HTMLElement | null;
if (!previousBlock) {
return false;
}
const previousLength = stripInternalMarkers(previousBlock.textContent!).length;
const merged = stripInternalMarkers(previousBlock.textContent!) +
normalizeLineText(block.textContent!);
const mergedBlock = this.#buildBlock(merged);
previousBlock.className = mergedBlock.className;
previousBlock.innerHTML = '';
while (mergedBlock.firstChild) {
previousBlock.appendChild(mergedBlock.firstChild);
}
block.remove();
this.#restoreCaret(previousBlock, previousLength);
return true;
}
#findCurrentBlock(): HTMLElement | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
}
let node: Node | null = selection.anchorNode;
while (node && node !== this.element) {
if (node.nodeType === 1 && node.parentNode === this.element) {
return node as HTMLElement;
}
node = node.parentNode;
}
return null;
}
#getCaretOffset(block: HTMLElement): number {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return 0;
}
const range = document.createRange();
range.setStart(block, 0);
range.setEnd(selection.anchorNode!, selection.anchorOffset);
return range.toString().length;
}
#restoreCaret(block: HTMLElement, offset: number): void {
const selection = window.getSelection();
if (!selection) {
return;
}
const range = document.createRange();
const placed = this.#walkForCaret(block, range, offset);
if (!placed) {
range.selectNodeContents(block);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Recursively walk text nodes counting only "real" characters
* toward `remaining` — internal bookkeeping markers are skipped
* over entirely and never consume a unit of `remaining`.
*/
#walkForCaret(node: Node, range: Range, remaining: number): boolean {
if (node.nodeType === 3) {
const textNode = node as Text;
const text = textNode.textContent!;
let domOffset = 0;
for (const character of text) {
if (isInternalMarker(character)) {
domOffset += character.length;
continue;
}
if (remaining === 0) {
range.setStart(textNode, domOffset);
range.collapse(true);
return true;
}
remaining -= 1;
domOffset += character.length;
}
if (remaining === 0) {
range.setStart(textNode, domOffset);
range.collapse(true);
return true;
}
return false;
}
let consumed = 0;
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === 3) {
const textNode = child as Text;
const realLength = stripInternalMarkers(textNode.textContent!).length;
if (remaining - consumed <= realLength) {
const placed = this.#walkForCaret(textNode, range, remaining - consumed);
if (placed) {
return true;
}
}
consumed += realLength;
} else {
const childRealLength = stripInternalMarkers(child.textContent || '').length;
if (remaining - consumed <= childRealLength) {
const placed = this.#walkForCaret(child, range, remaining - consumed);
if (placed) {
return true;
}
}
consumed += childRealLength;
}
}
return false;
}
#updateEditingContext(): void {
this.#clearEditingContext();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
let node: Node | null = selection.anchorNode;
while (node && node !== this.element) {
if (node.nodeType === 1) {
const element = node as HTMLElement;
if (element.hasAttribute(INLINE_SPAN_ATTR)) {
element.classList.add(EDITING_CONTEXT_CLASS);
this.activeFormattingSpan = element;
return;
}
}
node = node.parentNode;
}
}
#clearEditingContext(): void {
if (this.activeFormattingSpan) {
this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS);
this.activeFormattingSpan = null;
}
}
#blockToMarkdown(block: HTMLElement): string {
if (!block.textContent!.trim() && block.querySelector('br')) {
return '';
}
if (block.dataset.macro) {
return block.dataset.source || '';
}
return normalizeLineText(block.textContent!);
}
}
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
export { RibbitEditor as Editor }; export { RibbitEditor as Editor };
export { Ribbit as Viewer }; export { Ribbit as Viewer };
export { RibbitPlugin as Plugin };
export { HopDown };
export { inlineTag }; export { inlineTag };
export { defaultTags, defaultBlockTags, defaultInlineTags }; export { defaultTags, defaultBlockTags, defaultInlineTags };
export { defaultTheme }; export { defaultTheme };
export { camelCase, decodeHtmlEntities, encodeHtmlEntities }; export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
export { ToolbarManager } from './toolbar';
export { CollaborationManager } from './collaboration';
export type { MacroDef }; export type { MacroDef };
+276 -101
View File
@@ -6,75 +6,55 @@ import { HopDown } from './hopdown';
import { defaultTheme } from './default-theme'; import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager'; import { ThemeManager } from './theme-manager';
import { RibbitEmitter, type RibbitEventMap } from './events'; import { RibbitEmitter, type RibbitEventMap } from './events';
import { buildMacroTags, type MacroDef } from './macros'; import { CollaborationManager } from './collaboration';
import type { RibbitTheme } from './types'; import { type MacroDef } from './macros';
import { ToolbarManager } from './toolbar';
import type { RibbitTheme, ToolbarSlot, CollaborationSettings, PeerInfo, Revision, RevisionMetadata } from './types';
export interface RibbitSettings { export interface RibbitSettings {
api?: unknown; api?: unknown;
editorId?: string; editorId?: string;
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
currentTheme?: string; currentTheme?: string;
themes?: RibbitTheme[]; themes?: RibbitTheme[];
themesPath?: string; themesPath?: string;
macros?: MacroDef[]; macros?: MacroDef[];
toolbar?: ToolbarSlot[];
/** Set to false to prevent auto-rendering the toolbar. Default true. */
autoToolbar?: boolean;
/** Collaboration settings. Omit to disable. */
collaboration?: CollaborationSettings;
on?: Partial<RibbitEventMap>; on?: Partial<RibbitEventMap>;
} }
/** /**
* Base class for editor plugins. Subclass and override toHTML/toMarkdown * Base class providing read-only markdown rendering. RibbitEditor extends
* to add custom processing hooks. * this with editing capabilities, so consumers who only need to display
*/ * rendered markdown can use Ribbit directly and avoid loading editor code.
export class RibbitPlugin {
name: string;
wiki: Ribbit;
precedence: number;
constructor(settings: { name: string; wiki: Ribbit }) {
this.name = settings.name;
this.wiki = settings.wiki;
this.precedence = 50;
}
setEditable(): void {
}
toMarkdown(html: string): string {
return html;
}
toHTML(md: string): string {
return md;
}
}
/**
* Read-only markdown viewer. Renders markdown content into an HTML element.
* *
* Usage: * const viewer = new Ribbit({ editorId: 'my-element' });
* const viewer = new Ribbit({
* editorId: 'my-element',
* on: {
* ready: ({ mode, theme }) => console.log(`Ready in ${mode}`),
* },
* });
* viewer.run(); * viewer.run();
*/ */
export class Ribbit { export class Ribbit {
api: unknown; api: unknown;
element: HTMLElement; element: HTMLElement;
states: Record<string, string>; states: Record<string, string>;
cachedHTML: string | null;
cachedMarkdown: string | null;
state: string | null; state: string | null;
changed: boolean;
enabledPlugins: Record<string, RibbitPlugin>;
theme: RibbitTheme; theme: RibbitTheme;
themes: ThemeManager; themes: ThemeManager;
converter: HopDown; converter: HopDown;
themesPath: string; themesPath: string;
toolbar: ToolbarManager;
collaboration?: CollaborationManager;
protected autoToolbar: boolean;
private emitter: RibbitEmitter; private emitter: RibbitEmitter;
private macros: MacroDef[]; private macros: MacroDef[];
// The markdown source as it existed before view() rendered it to HTML.
// Set by subclasses (RibbitEditor) before overwriting element.innerHTML.
// Allows getMarkdown() in view state to return the original source rather
// than textContent of the rendered HTML (which strips delimiters).
protected sourceMarkdown: string | null = null;
constructor(settings: RibbitSettings) { constructor(settings: RibbitSettings) {
this.api = settings.api || null; this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!; this.element = document.getElementById(settings.editorId || 'ribbit')!;
@@ -84,18 +64,13 @@ export class Ribbit {
this.states = { this.states = {
VIEW: 'view', VIEW: 'view',
}; };
this.cachedHTML = null;
this.cachedMarkdown = null;
this.state = null; this.state = null;
this.changed = false;
this.enabledPlugins = {};
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => { this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
this.theme = theme; this.theme = theme;
this.converter = theme.tags this.converter = theme.tags
? new HopDown({ tags: theme.tags, macros: this.macros }) ? new HopDown({ tags: theme.tags, macros: this.macros })
: new HopDown({ macros: this.macros }); : new HopDown({ macros: this.macros });
this.cachedHTML = null;
this.emitter.emit('themeChange', { this.emitter.emit('themeChange', {
current: theme, current: theme,
previous, previous,
@@ -117,13 +92,6 @@ export class Ribbit {
? new HopDown({ tags: this.theme.tags, macros: this.macros }) ? new HopDown({ tags: this.theme.tags, macros: this.macros })
: new HopDown({ macros: this.macros }); : new HopDown({ macros: this.macros });
(settings.plugins || []).forEach(plugin => {
this.enabledPlugins[plugin.name] = new plugin({
name: plugin.name,
wiki: this,
});
});
if (settings.on) { if (settings.on) {
for (const [event, handler] of Object.entries(settings.on)) { for (const [event, handler] of Object.entries(settings.on)) {
if (handler) { if (handler) {
@@ -131,31 +99,70 @@ export class Ribbit {
} }
} }
} }
this.toolbar = new ToolbarManager(
this,
this.theme.tags || {},
this.macros,
settings.toolbar,
);
this.autoToolbar = settings.autoToolbar !== false;
if (settings.collaboration) {
this.collaboration = new CollaborationManager(
settings.collaboration,
{
onRemoteUpdate: (content) => {
this.sourceMarkdown = content;
if (this.getState() !== this.states.VIEW) {
this.element.innerHTML = this.markdownToHTML(content);
}
this.emitter.emit('change', {
markdown: content,
html: this.markdownToHTML(content),
});
},
onPeersChange: (peers) => {
this.emitter.emit('peerChange', { peers });
},
onLockChange: (holder) => {
this.emitter.emit('lockChange', { holder });
if (holder && holder.userId !== settings.collaboration!.user.userId) {
this.toolbar.disable();
} else {
this.toolbar.enable();
}
},
onRemoteActivity: (count) => {
this.emitter.emit('remoteActivity', { count });
},
},
);
}
} }
/** /**
* Register a callback for an event. * Subscribe to editor events. Callbacks persist across mode switches.
* *
* editor.on('save', ({ markdown }) => { * editor.on('change', ({ markdown, html }) => console.log(markdown));
* fetch('/api/save', { method: 'POST', body: markdown }); * editor.on('save', ({ markdown }) => fetch('/api', { body: markdown }));
* });
*/ */
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void { on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
this.emitter.on(event, callback); this.emitter.on(event, callback);
} }
/** /**
* Remove a previously registered callback. * Unsubscribe a previously registered event callback.
* *
* editor.off('change', myHandler); * const handler = (e) => console.log(e);
* editor.on('change', handler);
* editor.off('change', handler);
*/ */
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void { off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
this.emitter.off(event, callback); this.emitter.off(event, callback);
} }
run(): void { protected emitReady(): void {
this.element.classList.add('loaded');
this.view();
this.emitter.emit('ready', { this.emitter.emit('ready', {
markdown: this.getMarkdown(), markdown: this.getMarkdown(),
html: this.getHTML(), html: this.getHTML(),
@@ -164,53 +171,88 @@ export class Ribbit {
}); });
} }
plugins(): RibbitPlugin[] { /**
return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence); * Initialize the viewer: render toolbar, switch to view mode, and
* fire the ready event. Call once after construction.
*
* const viewer = new Ribbit({ editorId: 'content' });
* viewer.run();
*/
run(): void {
this.element.classList.add('loaded');
if (this.autoToolbar) {
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
}
this.view();
this.emitReady();
} }
/**
* Current mode name ('view' or 'wysiwyg').
*
* if (editor.getState() === 'wysiwyg') { ... }
*/
getState(): string | null { getState(): string | null {
return this.state; return this.state;
} }
/**
* Transition to a new mode. Updates CSS classes on the editor element
* so themes can style each mode differently, and fires modeChange.
*
* editor.setState('wysiwyg');
*/
setState(newState: string): void { setState(newState: string): void {
const previous = this.state; const previous = this.state;
this.state = newState; if (previous) {
Object.values(this.states).forEach(state => { this.element.classList.remove(previous);
if (state === newState) {
this.element.classList.add(state);
} else {
this.element.classList.remove(state);
} }
}); this.state = newState;
this.element.classList.add(newState);
this.emitter.emit('modeChange', { this.emitter.emit('modeChange', {
current: newState, current: newState,
previous, previous,
}); });
} }
markdownToHTML(md: string): string { /**
return this.converter.toHTML(md); * One-shot markdown→HTML conversion using the current theme's tags.
} *
* const html = viewer.markdownToHTML('**hello**');
getHTML(): string { */
if (this.changed || !this.cachedHTML) { markdownToHTML(markdown: string): string {
this.cachedHTML = this.markdownToHTML(this.getMarkdown()); return this.converter.toHTML(markdown);
}
return this.cachedHTML;
}
getMarkdown(): string {
if (!this.cachedMarkdown) {
this.cachedMarkdown = this.element.textContent || '';
}
return this.cachedMarkdown;
} }
/** /**
* Request a save. Fires the 'save' event with the current content. * Rendered HTML of the current content.
* The consumer's callback handles persistence.
* *
* editor.save(); // triggers on.save({ markdown, html }) * document.getElementById('preview').innerHTML = viewer.getHTML();
*/
getHTML(): string {
return this.markdownToHTML(this.getMarkdown());
}
/**
* Raw markdown of the current content. In view state reads from
* sourceMarkdown if set (preserved before rendering overwrote the
* element), otherwise falls back to element.textContent.
*
* fetch('/save', { body: editor.getMarkdown() });
*/
getMarkdown(): string {
if (this.sourceMarkdown !== null) {
return this.sourceMarkdown;
}
return this.element.textContent || '';
}
/**
* Emit a save event with the current content. Ribbit never persists
* data itself — the consumer handles storage in the callback.
*
* editor.on('save', ({ markdown }) => localStorage.setItem('doc', markdown));
* editor.save();
*/ */
save(): void { save(): void {
this.emitter.emit('save', { this.emitter.emit('save', {
@@ -219,29 +261,155 @@ export class Ribbit {
}); });
} }
/**
* Switch to read-only view mode. Renders markdown to HTML and
* disables contentEditable. Disconnects collaboration if active.
*
* editor.view();
*/
view(): void { view(): void {
if (this.getState() === this.states.VIEW) return; if (this.getState() === this.states.VIEW) {
this.element.innerHTML = this.getHTML(); return;
}
// Capture markdown before overwriting the element with rendered HTML.
// getMarkdown() on the base class reads element.textContent when
// sourceMarkdown is null — correct for the initial load case where
// the element contains raw markdown text.
this.sourceMarkdown = this.getMarkdown();
this.collaboration?.disconnect();
this.element.innerHTML = this.markdownToHTML(this.sourceMarkdown);
this.setState(this.states.VIEW); this.setState(this.states.VIEW);
this.element.contentEditable = 'false'; this.element.contentEditable = 'false';
} }
/** /**
* Notify that content has changed. Called internally by the editor * Request an advisory editing lock. Returns false if another user
* on input events. Fires the 'change' event with current content. * holds the lock. Requires a collaboration transport.
*
* if (await editor.lockForEditing()) { editor.wysiwyg(); }
*/
async lockForEditing(): Promise<boolean> {
if (!this.collaboration) {
return false;
}
return this.collaboration.lock();
}
/**
* Release the advisory editing lock.
*
* editor.unlockEditing();
* editor.view();
*/
unlockEditing(): void {
this.collaboration?.unlock();
}
/**
* Steal the lock from another user. Use when an admin needs to
* override a stale lock.
*
* await editor.forceLockEditing();
*/
async forceLockEditing(): Promise<boolean> {
if (!this.collaboration) {
return false;
}
return this.collaboration.forceLock();
}
/**
* Fetch all saved revisions from the revision provider.
*
* const revisions = await editor.listRevisions();
* revisions.forEach(r => console.log(r.id, r.timestamp));
*/
async listRevisions(): Promise<Revision[]> {
if (!this.collaboration) {
return [];
}
return this.collaboration.listRevisions();
}
/**
* Fetch a single revision's content by ID.
*
* const rev = await editor.getRevision('abc-123');
* if (rev) { console.log(rev.content); }
*/
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
if (!this.collaboration) {
return null;
}
return this.collaboration.getRevision(id);
}
/**
* Replace the editor content with a previous revision and broadcast
* the change to collaborators.
*
* await editor.restoreRevision('abc-123');
*/
async restoreRevision(id: string): Promise<void> {
if (!this.collaboration) {
return;
}
const revision = await this.collaboration.getRevision(id);
if (!revision) {
return;
}
this.sourceMarkdown = revision.content;
const html = this.markdownToHTML(revision.content);
this.collaboration.sendUpdate(revision.content);
if (this.getState() !== this.states.VIEW) {
this.element.innerHTML = html;
}
this.emitter.emit('change', {
markdown: revision.content,
html,
});
}
/**
* Snapshot the current content as a named revision. The revision
* provider stores it; ribbit never persists data itself.
*
* const rev = await editor.createRevision({ label: 'v1.0' });
*/
async createRevision(metadata?: RevisionMetadata): Promise<Revision | null> {
if (!this.collaboration) {
return null;
}
const revision = await this.collaboration.createRevision(this.getMarkdown(), metadata);
if (revision) {
this.emitter.emit('revisionCreated', { revision });
}
return revision;
}
/**
* Broadcast the current content to collaborators and fire the
* change event. Called automatically on input; call manually
* after programmatic content changes.
*
* editor.element.innerHTML = '<p>new content</p>';
* editor.notifyChange();
*/ */
notifyChange(): void { notifyChange(): void {
this.changed = true; const markdown = this.getMarkdown();
this.collaboration?.sendUpdate(markdown);
this.emitter.emit('change', { this.emitter.emit('change', {
markdown: this.getMarkdown(), markdown,
html: this.getHTML(), html: this.getHTML(),
}); });
} }
} }
/** /**
* Convert a string to title case, splitting on whitespace. * Split a string into words and capitalize each one.
* Returns an array of capitalized words. * Used to generate camelCase IDs for heading anchors.
*
* camelCase('hello world') // ['Hello', 'World']
*/ */
export function camelCase(words: string): string[] { export function camelCase(words: string): string[] {
return words.trim().split(/\s+/g).map(word => { return words.trim().split(/\s+/g).map(word => {
@@ -251,7 +419,10 @@ export function camelCase(words: string): string[] {
} }
/** /**
* Decode HTML entities in a string using a textarea element. * Decode HTML entities back to characters. Uses a textarea element
* because the browser's HTML parser handles all entity forms.
*
* decodeHtmlEntities('&lt;b&gt;') // '<b>'
*/ */
export function decodeHtmlEntities(html: string): string { export function decodeHtmlEntities(html: string): string {
const txt = document.createElement('textarea'); const txt = document.createElement('textarea');
@@ -260,7 +431,11 @@ export function decodeHtmlEntities(html: string): string {
} }
/** /**
* Encode HTML-significant characters as numeric entities. * Encode characters that would be interpreted as HTML into numeric
* entities. Used when displaying raw markdown in contentEditable
* so the browser doesn't parse it as markup.
*
* encodeHtmlEntities('<b>hi</b>') // '&#60;b&#62;hi&#60;/b&#62;'
*/ */
export function encodeHtmlEntities(str: string): string { export function encodeHtmlEntities(str: string): string {
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';'); return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
+198
View File
@@ -0,0 +1,198 @@
/*
* serializer.ts — DOM to markdown serializer.
*
* Converts an HTML DOM tree back to markdown by walking the tree and
* producing a typed token stream. Text tokens are escaped during final
* serialization; delimiter tokens pass through verbatim. This separation
* is what makes round-trip correctness possible — the serializer always
* knows which characters are structural and which are literal.
*
* const serializer = new MarkdownSerializer(tagMap, delimiterChars);
* serializer.serialize(document.getElementById('content'))
* // '**bold** and *italic*'
*/
import type { InlineToken } from './tokenizer';
/**
* Maps HTML element names to their markdown serialization.
* Each entry defines how to convert an element back to markdown tokens.
*/
export interface SerializerTagDef {
/** The canonical delimiter (e.g. '**' for bold). */
delimiter?: string;
/** Custom serializer for elements that aren't simple delimiter wraps
* (e.g. links, code blocks, headings). Returns the full markdown
* string for the element and its children. */
serialize?: (element: HTMLElement, children: () => string) => string;
}
/**
* Converts a DOM tree to markdown. Walks the tree producing inline
* tokens, then serializes the token stream to a string with correct
* escaping.
*
* const serializer = new MarkdownSerializer(tagMap, new Set(['*', '`', '~', '[', '_']));
* const markdown = serializer.serialize(containerElement);
*/
export class MarkdownSerializer {
private tagMap: Map<string, SerializerTagDef>;
private delimiterChars: Set<string>;
constructor(
tagMap: Map<string, SerializerTagDef>,
delimiterChars: Set<string>,
) {
this.tagMap = tagMap;
this.delimiterChars = delimiterChars;
}
/**
* Serialize a DOM tree to a markdown string.
*
* serializer.serialize(document.querySelector('article'))
*/
serialize(node: Node): string {
const tokens = this.nodeToTokens(node);
return this.tokensToString(tokens);
}
/**
* Convert a DOM node to a stream of inline tokens.
* Text nodes become text tokens; elements with known tags
* become delimiter-wrapped token sequences; unknown elements
* recurse into their children.
*/
private nodeToTokens(node: Node): InlineToken[] {
if (node.nodeType === 3) {
return [{
role: 'text',
value: node.textContent || '',
}];
}
if (node.nodeType !== 1) {
return [];
}
const element = node as HTMLElement;
const tagDef = this.tagMap.get(element.nodeName);
// Custom serializer handles the entire element
if (tagDef?.serialize) {
const childrenMarkdown = () => this.serializeChildren(element);
const markdown = tagDef.serialize(element, childrenMarkdown);
// Custom serializers return raw markdown strings — wrap
// in a single text token that won't be escaped (it's already
// correctly formatted)
return [{
role: 'html',
value: markdown,
}];
}
// Delimiter-based element: emit open + children + close
if (tagDef?.delimiter) {
const delimiter = tagDef.delimiter;
return [
{
role: 'open',
value: delimiter,
delimiter,
},
...this.childrenToTokens(element),
{
role: 'close',
value: delimiter,
delimiter,
},
];
}
// Unknown element: just recurse into children
return this.childrenToTokens(element);
}
/**
* Collect tokens from all child nodes of an element.
*/
private childrenToTokens(element: HTMLElement): InlineToken[] {
const tokens: InlineToken[] = [];
for (const child of Array.from(element.childNodes)) {
tokens.push(...this.nodeToTokens(child));
}
return tokens;
}
/**
* Serialize an element's children directly to a markdown string.
* Used by custom serializers (links, headings, etc.) that need
* the children as a string, not as tokens.
*/
private serializeChildren(element: HTMLElement): string {
const tokens = this.childrenToTokens(element);
return this.tokensToString(tokens);
}
/**
* Convert a token stream to a markdown string. This is where
* escaping happens: text tokens have their delimiter characters
* backslash-escaped; all other token types pass through verbatim.
*/
private tokensToString(tokens: InlineToken[]): string {
let result = '';
for (const token of tokens) {
switch (token.role) {
case 'text':
result += this.escapeText(token.value);
break;
case 'open':
case 'close':
case 'html':
case 'break':
// Structural tokens are never escaped
result += token.value;
break;
case 'code':
result += token.value;
break;
case 'link':
result += token.value;
break;
case 'autolink':
result += token.value;
break;
default:
result += token.value;
}
}
return result;
}
/**
* Escape characters in literal text that would be misinterpreted
* as markdown syntax on re-parse. Only escapes characters that are
* registered as delimiter characters, plus `\`, `[`, `_`, and `<`
* before letters (HTML passthrough prevention).
*/
private escapeText(text: string): string {
let result = '';
for (let position = 0; position < text.length; position++) {
const character = text[position];
if (character === '\\') {
result += '\\\\';
} else if (character === '_') {
result += '\\_';
} else if (character === '[') {
result += '\\[';
} else if (character === '<' && position + 1 < text.length && /[a-zA-Z/]/.test(text[position + 1])) {
// Only escape < when it would start an HTML tag
result += '\\<';
} else if (this.delimiterChars.has(character)) {
result += '\\' + character;
} else {
result += character;
}
}
return result;
}
}
+1124 -298
View File
File diff suppressed because it is too large Load Diff
+43 -3
View File
@@ -3,8 +3,21 @@
*/ */
import type { RibbitTheme } from './types'; import type { RibbitTheme } from './types';
import { HopDown } from './hopdown';
/** CSS file name loaded from each theme's directory. */
const THEME_CSS_FILENAME = 'theme.css';
/**
* Manages theme registration, enabling/disabling, and CSS loading
* for a ribbit editor instance.
*
* @example
* const themes = new ThemeManager(defaultTheme, '/themes', (current, previous) => {
* editor.rebuild();
* });
* themes.add(customTheme);
* themes.set('custom');
*/
export class ThemeManager { export class ThemeManager {
private registered: Map<string, RibbitTheme>; private registered: Map<string, RibbitTheme>;
private disabled: Set<string>; private disabled: Set<string>;
@@ -24,7 +37,10 @@ export class ThemeManager {
} }
/** /**
* Register a theme. Themes must be added before they can be enabled. * Register a theme. Themes must be added before they can be activated.
*
* @example
* themes.add({ name: 'dark', tags: darkTags });
*/ */
add(theme: RibbitTheme): void { add(theme: RibbitTheme): void {
this.registered.set(theme.name, theme); this.registered.set(theme.name, theme);
@@ -32,6 +48,9 @@ export class ThemeManager {
/** /**
* Unregister a theme by name. Cannot remove the active theme. * Unregister a theme by name. Cannot remove the active theme.
*
* @example
* themes.remove('dark');
*/ */
remove(name: string): void { remove(name: string): void {
if (this.active.name === name) { if (this.active.name === name) {
@@ -42,6 +61,9 @@ export class ThemeManager {
/** /**
* Return the names of all registered and enabled themes. * Return the names of all registered and enabled themes.
*
* @example
* const available = themes.list(); // ['ribbit-default', 'dark']
*/ */
list(): string[] { list(): string[] {
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name)); return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
@@ -49,6 +71,9 @@ export class ThemeManager {
/** /**
* Get a registered theme by name, or undefined if not found. * Get a registered theme by name, or undefined if not found.
*
* @example
* const theme = themes.get('dark');
*/ */
get(name: string): RibbitTheme | undefined { get(name: string): RibbitTheme | undefined {
return this.registered.get(name); return this.registered.get(name);
@@ -56,6 +81,9 @@ export class ThemeManager {
/** /**
* Return the currently active theme. * Return the currently active theme.
*
* @example
* const active = themes.current();
*/ */
current(): RibbitTheme { current(): RibbitTheme {
return this.active; return this.active;
@@ -65,6 +93,9 @@ export class ThemeManager {
* Switch to a registered theme by name. The theme must be * Switch to a registered theme by name. The theme must be
* registered and enabled. Loads the theme's CSS and notifies * registered and enabled. Loads the theme's CSS and notifies
* the editor to rebuild its converter. * the editor to rebuild its converter.
*
* @example
* themes.set('dark');
*/ */
set(name: string): void { set(name: string): void {
const theme = this.registered.get(name); const theme = this.registered.get(name);
@@ -76,13 +107,19 @@ export class ThemeManager {
} }
const previous = this.active; const previous = this.active;
this.active = theme; this.active = theme;
// Only load CSS when actually switching to a different theme
if (previous !== theme) {
this.loadCSS(name); this.loadCSS(name);
}
this.onSwitch(theme, previous); this.onSwitch(theme, previous);
} }
/** /**
* Mark a theme as available for selection via set(). * Mark a theme as available for selection via set().
* Themes are enabled by default when added. * Themes are enabled by default when added.
*
* @example
* themes.enable('dark');
*/ */
enable(name: string): void { enable(name: string): void {
if (!this.registered.has(name)) { if (!this.registered.has(name)) {
@@ -94,6 +131,9 @@ export class ThemeManager {
/** /**
* Mark a theme as unavailable for selection via set(). * Mark a theme as unavailable for selection via set().
* Does not affect the current theme if it is already active. * Does not affect the current theme if it is already active.
*
* @example
* themes.disable('dark');
*/ */
disable(name: string): void { disable(name: string): void {
if (!this.registered.has(name)) { if (!this.registered.has(name)) {
@@ -108,7 +148,7 @@ export class ThemeManager {
} }
const link = document.createElement('link'); const link = document.createElement('link');
link.rel = 'stylesheet'; link.rel = 'stylesheet';
link.href = `${this.themesPath}/${name}/theme.css`; link.href = `${this.themesPath}/${name}/${THEME_CSS_FILENAME}`;
document.head.appendChild(link); document.head.appendChild(link);
this.themeLink = link; this.themeLink = link;
} }
+447
View File
@@ -0,0 +1,447 @@
/*
* tokenizer.ts — Inline markdown tokenizer.
*
* Scans markdown text left-to-right producing a typed token stream.
* Tokens carry their semantic role (delimiter, text, code, link, etc.)
* so downstream consumers can make correct escaping and pairing
* decisions without regex heuristics.
*
* const tokenizer = new InlineTokenizer(delimiterDefs);
* const tokens = tokenizer.tokenize('hello **bold** end');
* // [text "hello "] [open "**"] [text "bold"] [close "**"] [text " end"]
*/
/**
* A single token in the inline token stream. The `role` field
* distinguishes structural markers from literal content, which
* is the key insight that makes round-trip escaping correct.
*/
export interface InlineToken {
role: 'text' | 'open' | 'close' | 'code' | 'link' | 'autolink' | 'html' | 'break';
value: string;
/** For link tokens: the href and optional title. */
href?: string;
title?: string;
/** For delimiter tokens: which delimiter this is (e.g. '**'). */
delimiter?: string;
/** For code tokens: the raw content (not HTML-escaped). */
content?: string;
}
/**
* A delimiter definition used by the tokenizer to recognize
* opening and closing delimiter runs.
*/
export interface DelimiterDef {
/** The delimiter string, e.g. '**', '*', '~~', '`'. */
delimiter: string;
/** The HTML tag name to emit, e.g. 'strong', 'em', 'del'. */
htmlTag: string;
/** Whether content inside this delimiter is parsed for further
* inline markup. False for code spans. */
recursive: boolean;
/** Lower values are matched first. Ensures *** matches before **. */
precedence: number;
}
/**
* Characters that count as punctuation for flanking delimiter rules.
* A delimiter is left-flanking if preceded by whitespace/punctuation
* and followed by non-whitespace. Right-flanking is the reverse.
*/
const PUNCTUATION = new Set(
' \t\n\u00A0.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('')
);
/**
* Characters that can be backslash-escaped in markdown.
*/
const ESCAPABLE = new Set(
'\\`*_{}[]()#+-.!~|><'.split('')
);
/**
* Named HTML entities recognized by the tokenizer.
*/
const NAMED_ENTITIES: Record<string, string> = {
'amp': '&',
'lt': '<',
'gt': '>',
'quot': '"',
'apos': "'",
'nbsp': '\u00A0',
};
/**
* Scans markdown text into a stream of typed tokens. Handles
* backslash escapes, entities, flanking rules, code spans, links,
* autolinks, HTML tags, and hard line breaks.
*
* const tokenizer = new InlineTokenizer([
* { delimiter: '**', htmlTag: 'strong', recursive: true, precedence: 40 },
* { delimiter: '*', htmlTag: 'em', recursive: true, precedence: 50 },
* ]);
* const tokens = tokenizer.tokenize('**bold**');
*/
export class InlineTokenizer {
private delimiters: DelimiterDef[];
private codeSpansEnabled: boolean;
constructor(delimiters: DelimiterDef[], options?: { codeSpans?: boolean }) {
this.codeSpansEnabled = options?.codeSpans !== false;
// Sort by delimiter length descending so longer delimiters
// are tried first (*** before ** before *)
this.delimiters = [...delimiters].sort(
(first, second) => second.delimiter.length - first.delimiter.length
);
}
/**
* Tokenize a markdown string into an inline token stream.
*
* tokenizer.tokenize('hello **world**')
* // [text "hello "] [open "**"] [text "world"] [close "**"]
*/
tokenize(source: string): InlineToken[] {
const tokens: InlineToken[] = [];
let position = 0;
let textBuffer = '';
const flushText = () => {
if (textBuffer.length > 0) {
tokens.push({
role: 'text',
value: textBuffer,
});
textBuffer = '';
}
};
while (position < source.length) {
const remaining = source.slice(position);
// Backslash escape: \X → literal X
if (source[position] === '\\' && position + 1 < source.length) {
const nextChar = source[position + 1];
if (ESCAPABLE.has(nextChar)) {
textBuffer += nextChar;
position += 2;
continue;
}
// \ before newline is a hard break
if (nextChar === '\n') {
flushText();
tokens.push({ role: 'break', value: '<br>' });
position += 2;
continue;
}
}
// Hard line break: two+ trailing spaces before newline
if (source[position] === ' ') {
const spaceMatch = remaining.match(/^(?<spaces> {2,})\n/);
if (spaceMatch?.groups) {
flushText();
tokens.push({ role: 'break', value: '<br>' });
position += spaceMatch[0].length;
continue;
}
}
// HTML entity resolution: &name; or &#digits; or &#xhex;
if (source[position] === '&') {
const resolved = this.resolveEntity(remaining);
if (resolved) {
textBuffer += resolved.character;
position += resolved.length;
continue;
}
}
// Code span: `content` — not parsed for further inline markup
if (this.codeSpansEnabled && source[position] === '`') {
const codeSpan = this.matchCodeSpan(source, position);
if (codeSpan) {
flushText();
tokens.push({
role: 'code',
value: codeSpan.raw,
content: codeSpan.content,
});
position += codeSpan.raw.length;
continue;
}
}
// Link: [text](url) or [text](url "title")
if (source[position] === '[') {
const link = this.matchLink(source, position);
if (link) {
flushText();
tokens.push({
role: 'link',
value: link.text,
href: link.href,
title: link.title,
});
position += link.length;
continue;
}
}
// Autolink: <url>
if (source[position] === '<') {
const autolink = this.matchAutolink(remaining);
if (autolink) {
flushText();
tokens.push({
role: 'autolink',
value: autolink.url,
href: autolink.url,
});
position += autolink.length;
continue;
}
// HTML tag passthrough
const htmlTagMatch = this.matchHtmlTag(remaining);
if (htmlTagMatch) {
flushText();
tokens.push({
role: 'html',
value: htmlTagMatch.tag,
});
position += htmlTagMatch.length;
continue;
}
}
// Bare URL autolink: https://...
if (remaining.startsWith('http://') || remaining.startsWith('https://')) {
const bareUrl = this.matchBareUrl(remaining);
if (bareUrl) {
flushText();
tokens.push({
role: 'autolink',
value: bareUrl.url,
href: bareUrl.url,
});
position += bareUrl.length;
continue;
}
}
// Delimiter: check each registered delimiter
const delimiterMatch = this.matchDelimiter(source, position);
if (delimiterMatch) {
flushText();
tokens.push(delimiterMatch.token);
position += delimiterMatch.length;
continue;
}
// Plain character
textBuffer += source[position];
position++;
}
flushText();
return tokens;
}
/**
* Try to resolve an HTML entity at the start of the string.
* Returns the resolved character and the length consumed, or null.
*/
private resolveEntity(text: string): { character: string; length: number } | null {
const namedPattern = /^&(?<name>[a-zA-Z]+);/;
const numericPattern = /^&#(?<code>\d+);/;
const hexPattern = /^&#x(?<hex>[0-9a-fA-F]+);/;
const named = text.match(namedPattern);
if (named?.groups) {
const resolved = NAMED_ENTITIES[named.groups.name.toLowerCase()];
if (resolved) {
return {
character: resolved,
length: named[0].length,
};
}
}
const numeric = text.match(numericPattern);
if (numeric?.groups) {
return {
character: String.fromCharCode(parseInt(numeric.groups.code, 10)),
length: numeric[0].length,
};
}
const hex = text.match(hexPattern);
if (hex?.groups) {
return {
character: String.fromCharCode(parseInt(hex.groups.hex, 16)),
length: hex[0].length,
};
}
return null;
}
/**
* Match a code span starting at the given position.
* Handles single backtick delimiters only (not multi-backtick).
*/
private matchCodeSpan(
source: string,
position: number,
): { content: string; raw: string } | null {
if (source[position] !== '`') {
return null;
}
const closeIndex = source.indexOf('`', position + 1);
if (closeIndex === -1) {
return null;
}
const content = source.slice(position + 1, closeIndex);
return {
content,
raw: source.slice(position, closeIndex + 1),
};
}
/**
* Match a markdown link [text](url) or [text](url "title")
* starting at the given position. Disallows [ in link text
* to prevent nested link ambiguity.
*/
private matchLink(
source: string,
position: number,
): { text: string; href: string; title?: string; length: number } | null {
const linkPattern = /^\[(?<text>[^\[\]]+)\]\((?<href>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\)/;
const match = source.slice(position).match(linkPattern);
if (!match?.groups) {
return null;
}
return {
text: match.groups.text,
href: match.groups.href,
title: match.groups.title,
length: match[0].length,
};
}
/**
* Match an angle-bracket autolink <url> at the start of the string.
*/
private matchAutolink(text: string): { url: string; length: number } | null {
const pattern = /^<(?<url>https?:\/\/[^\s>]+)>/;
const match = text.match(pattern);
if (!match?.groups) {
return null;
}
return {
url: match.groups.url,
length: match[0].length,
};
}
/**
* Match a bare URL (https://...) at the start of the string.
*/
private matchBareUrl(text: string): { url: string; length: number } | null {
const pattern = /^https?:\/\/[^\s<>\x00]+/;
const match = text.match(pattern);
if (!match) {
return null;
}
return {
url: match[0],
length: match[0].length,
};
}
/**
* Match an HTML tag at the start of the string.
*/
private matchHtmlTag(text: string): { tag: string; length: number } | null {
const pattern = /^<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?\s*\/?>/;
const match = text.match(pattern);
if (!match) {
return null;
}
return {
tag: match[0],
length: match[0].length,
};
}
/**
* Try to match a delimiter at the given position. For runs of the
* same character (e.g. *** = 3 asterisks), the run is split into
* the longest registered delimiter that fits, then the remainder.
* This handles cases like **bold***italic* where *** must split
* into ** (close bold) + * (open italic).
*/
private matchDelimiter(
source: string,
position: number,
): { token: InlineToken; length: number } | null {
// Count the full run of the same character
const runChar = source[position];
let runLength = 0;
while (position + runLength < source.length && source[position + runLength] === runChar) {
runLength++;
}
if (runLength === 0) {
return null;
}
// Find registered delimiters that use this character
const candidates = this.delimiters.filter(
definition => definition.delimiter[0] === runChar
);
if (candidates.length === 0) {
return null;
}
// Try each candidate delimiter length (longest first, already sorted)
for (const definition of candidates) {
const delimiter = definition.delimiter;
if (delimiter.length > runLength) {
continue;
}
const charBefore = position > 0 ? source[position - 1] : '\n';
const charAfter = source[position + delimiter.length];
const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0';
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
if (leftFlanking) {
return {
token: {
role: 'open',
value: delimiter,
delimiter,
},
length: delimiter.length,
};
}
if (rightFlanking) {
return {
token: {
role: 'close',
value: delimiter,
delimiter,
},
length: delimiter.length,
};
}
}
return null;
}
}
+523
View File
@@ -0,0 +1,523 @@
/*
* toolbar.ts — toolbar manager for the ribbit editor.
*
* Resolves tags and macros into toolbar buttons. Renders the toolbar
* DOM. Manages button state (active/disabled/visible).
*
* Usage:
* const toolbar = editor.toolbar;
* toolbar.buttons.get('bold').click();
* toolbar.buttons.get('table').hide();
* document.body.prepend(toolbar.render());
*/
import type { Tag, ToolbarSlot, Button } from './types';
import type { MacroDef } from './macros';
const CSS_CLASS_ACTIVE = 'active';
const CSS_CLASS_DISABLED = 'disabled';
const CSS_CLASS_TOOLBAR = 'ribbit-toolbar';
const CSS_CLASS_SPACER = 'spacer';
const CSS_CLASS_GROUP = 'ribbit-btn-group';
const CSS_CLASS_DROPDOWN = 'ribbit-dropdown';
const CSS_DISPLAY_NONE = 'none';
const MACRO_ID_PREFIX = 'macro:';
const DROPDOWN_INDICATOR = ' ▾';
/** IDs of buttons that belong in the utility section, not the tag/macro area. */
const UTILITY_BUTTON_IDS = ['save', 'edit'];
const MAX_HEADING_LEVEL = 6;
const EDITOR_STATE_VIEW = 'view';
const EDITOR_STATE_EDIT = 'edit';
/**
* Concrete implementation of the Button interface.
*
* Wraps a button definition with DOM element tracking and
* visibility toggling. Created internally by ToolbarManager.
*
* @example
* const button = new ButtonImpl({ id: 'bold', label: 'Bold', action: 'wrap', delimiter: '**' });
* button.hide();
* button.show();
*/
class ButtonImpl implements Button {
id: string;
label: string;
icon?: string;
shortcut?: string;
action: 'wrap' | 'prefix' | 'insert' | 'custom';
delimiter?: string;
template?: string;
replaceSelection: boolean;
visible: boolean;
element?: HTMLElement;
handler?: () => void;
constructor(definition: Partial<Button> & { id: string }) {
this.id = definition.id;
this.label = definition.label || definition.id;
this.icon = definition.icon;
this.shortcut = definition.shortcut;
this.action = definition.action || 'insert';
this.delimiter = definition.delimiter;
this.template = definition.template;
this.replaceSelection = definition.replaceSelection ?? true;
this.visible = definition.visible ?? true;
this.handler = definition.handler;
}
/**
* Programmatically trigger this button's click event.
*
* @example
* toolbar.buttons.get('bold')?.click();
*/
click(): void {
this.element?.click();
}
/**
* Hide this button from the toolbar.
*
* @example
* toolbar.buttons.get('table')?.hide();
*/
hide(): void {
this.visible = false;
if (this.element) {
this.element.style.display = CSS_DISPLAY_NONE;
}
}
/**
* Show this button in the toolbar.
*
* @example
* toolbar.buttons.get('table')?.show();
*/
show(): void {
this.visible = true;
if (this.element) {
this.element.style.display = '';
}
}
}
/**
* Manages the editor toolbar: registers buttons from tags and macros,
* renders the toolbar DOM, handles keyboard shortcuts, and tracks
* active/disabled state.
*
* @example
* const manager = new ToolbarManager(editor, tags, macros);
* document.body.prepend(manager.render());
* manager.updateActiveState(['bold', 'italic']);
*/
export class ToolbarManager {
buttons: Map<string, Button>;
private layout: ToolbarSlot[];
private editor: any;
constructor(editor: any, tags: Record<string, Tag>, macros: MacroDef[], layout?: ToolbarSlot[]) {
this.editor = editor;
this.buttons = new Map();
this.registerTagButtons(tags);
this.registerHeadingButtons();
this.registerListButtons();
this.registerMacroButtons(macros);
this.registerUtilityButtons();
this.layout = layout || this.buildDefaultLayout();
this.bindShortcuts();
}
/** Register buttons for tags that have button config enabled. */
private registerTagButtons(tags: Record<string, Tag>): void {
for (const tag of Object.values(tags)) {
if (!tag.button || !tag.button.show) {
continue;
}
this.register(tag.name, {
label: tag.button.label,
icon: tag.button.icon,
shortcut: tag.button.shortcut,
action: tag.delimiter ? 'wrap' : 'insert',
delimiter: tag.delimiter,
template: tag.template,
replaceSelection: tag.replaceSelection,
});
}
}
/** Heading levels are derived from a single pattern rather than repeated blocks. */
private registerHeadingButtons(): void {
for (let level = 1; level <= MAX_HEADING_LEVEL; level++) {
this.register(`h${level}`, {
label: `H${level}`,
shortcut: `Ctrl+${level}`,
action: 'prefix',
delimiter: '#'.repeat(level) + ' ',
replaceSelection: true,
});
}
}
private registerListButtons(): void {
const listDefinitions: Array<{ id: string; label: string; shortcut: string; template: string }> = [
{
id: 'ul',
label: 'Bullet List',
shortcut: 'Ctrl+Shift+8',
template: '- Item 1\n- Item 2\n- Item 3',
},
{
id: 'ol',
label: 'Numbered List',
shortcut: 'Ctrl+Shift+7',
template: '1. Item 1\n2. Item 2\n3. Item 3',
},
];
for (const definition of listDefinitions) {
this.register(definition.id, {
label: definition.label,
shortcut: definition.shortcut,
action: 'insert',
template: definition.template,
replaceSelection: false,
});
}
}
private registerMacroButtons(macros: MacroDef[]): void {
for (const macro of macros) {
if (macro.button === false) {
continue;
}
const buttonConfig = typeof macro.button === 'object' ? macro.button : null;
const capitalizedName = macro.name.charAt(0).toUpperCase() + macro.name.slice(1);
this.register(`${MACRO_ID_PREFIX}${macro.name}`, {
label: buttonConfig?.label || capitalizedName,
icon: buttonConfig?.icon,
action: 'insert',
template: `@${macro.name}`,
replaceSelection: false,
});
}
}
private registerUtilityButtons(): void {
this.register('save', {
label: 'Save',
shortcut: 'Ctrl+S',
action: 'custom',
handler: () => this.editor.save(),
});
this.register('edit', {
label: 'Edit',
shortcut: 'Ctrl+Shift+V',
action: 'custom',
handler: () => {
if (this.editor.getState() === EDITOR_STATE_VIEW) {
this.editor.wysiwyg();
} else {
this.editor.view();
}
},
});
}
/**
* Builds a keyboard shortcut lookup and dispatches matching
* button actions on keydown events.
*/
private bindShortcuts(): void {
const shortcutMap = new Map<string, Button>();
for (const button of this.buttons.values()) {
if (button.shortcut) {
shortcutMap.set(button.shortcut.toLowerCase(), button);
}
}
document.addEventListener('keydown', (event: KeyboardEvent) => {
const combo = this.buildKeyCombo(event);
const button = shortcutMap.get(combo);
if (button) {
event.preventDefault();
this.executeAction(button);
}
});
}
/** Normalizes a KeyboardEvent into a comparable shortcut string like "ctrl+shift+b". */
private buildKeyCombo(event: KeyboardEvent): string {
const parts: string[] = [];
if (event.ctrlKey || event.metaKey) {
parts.push('ctrl');
}
if (event.shiftKey) {
parts.push('shift');
}
if (event.altKey) {
parts.push('alt');
}
// Special keys pass through as-is; letter keys are lowercased
const specialKeys = ['/', '.', '-'];
const key = specialKeys.includes(event.key) ? event.key : event.key.toLowerCase();
parts.push(key);
return parts.join('+');
}
private register(id: string, definition: Partial<Button>): void {
if (this.buttons.has(id)) {
return;
}
this.buttons.set(id, new ButtonImpl({ id, ...definition }));
}
private buildDefaultLayout(): ToolbarSlot[] {
const tagIds: string[] = [];
const macroIds: string[] = [];
for (const id of this.buttons.keys()) {
if (UTILITY_BUTTON_IDS.includes(id)) {
continue;
}
if (id.startsWith(MACRO_ID_PREFIX)) {
macroIds.push(id);
} else {
tagIds.push(id);
}
}
const slots: ToolbarSlot[] = [...tagIds];
if (macroIds.length > 0) {
slots.push('');
slots.push({
group: 'Macros',
items: macroIds,
});
}
slots.push('', 'save', 'edit');
return slots;
}
/**
* Toggle the active CSS class on buttons whose IDs appear in the
* given list of currently-active tag names.
*
* @example
* manager.updateActiveState(['bold', 'italic']);
*/
updateActiveState(activeTagNames: string[]): void {
for (const [id, button] of this.buttons) {
button.element?.classList.toggle(CSS_CLASS_ACTIVE, activeTagNames.includes(id));
}
}
/**
* Enable all toolbar buttons by removing the disabled CSS class.
*
* @example
* manager.enable();
*/
enable(): void {
for (const button of this.buttons.values()) {
button.element?.classList.remove(CSS_CLASS_DISABLED);
}
}
/**
* Disable all toolbar buttons by adding the disabled CSS class.
*
* @example
* manager.disable();
*/
disable(): void {
for (const button of this.buttons.values()) {
button.element?.classList.add(CSS_CLASS_DISABLED);
}
}
/**
* Build the toolbar DOM tree and return the root element.
* The caller is responsible for inserting it into the document.
*
* @example
* document.body.prepend(manager.render());
*/
render(): HTMLElement {
const nav = document.createElement('nav');
nav.className = CSS_CLASS_TOOLBAR;
const list = document.createElement('ul');
for (const slot of this.layout) {
const element = this.renderSlot(slot);
if (element) {
list.appendChild(element);
}
}
nav.appendChild(list);
return nav;
}
/** Dispatches a single layout slot to the appropriate renderer. */
private renderSlot(slot: ToolbarSlot): HTMLElement | null {
if (slot === '') {
return this.renderSpacer();
}
if (typeof slot === 'string') {
return this.renderStringSlot(slot);
}
return this.renderGroupSlot(slot);
}
private renderSpacer(): HTMLElement {
const listItem = document.createElement('li');
listItem.className = CSS_CLASS_SPACER;
return listItem;
}
private renderStringSlot(slot: string): HTMLElement | null {
if (slot === 'macros') {
const items = [...this.buttons.values()].filter(button => button.id.startsWith(MACRO_ID_PREFIX));
if (items.length > 0) {
return this.renderGroup({
label: 'Macros',
items,
});
}
return null;
}
const button = this.buttons.get(slot);
if (button) {
return this.renderButton(button);
}
return null;
}
private renderGroupSlot(slot: { group: string; items: string[] }): HTMLElement | null {
const items = slot.items
.map(id => this.buttons.get(id))
.filter((button): button is Button => button !== undefined);
if (items.length > 0) {
return this.renderGroup({
label: slot.group,
items,
});
}
return null;
}
private renderButton(button: Button): HTMLElement {
const listItem = document.createElement('li');
const buttonElement = document.createElement('button');
buttonElement.className = `ribbit-btn-${button.id}`;
//buttonElement.textContent = button.label;
buttonElement.setAttribute('aria-label', button.label);
buttonElement.title = button.shortcut
? `${button.label} (${button.shortcut})`
: button.label;
if (!button.visible) {
listItem.style.display = CSS_DISPLAY_NONE;
}
buttonElement.addEventListener('click', () => this.executeAction(button));
button.element = buttonElement;
listItem.appendChild(buttonElement);
return listItem;
}
private renderGroup(group: { label: string; items: Button[] }): HTMLElement {
const listItem = document.createElement('li');
const toggle = document.createElement('button');
toggle.className = CSS_CLASS_GROUP;
toggle.textContent = group.label + DROPDOWN_INDICATOR;
toggle.setAttribute('aria-label', group.label);
toggle.title = group.label;
const menu = document.createElement('div');
menu.className = CSS_CLASS_DROPDOWN;
menu.style.display = CSS_DISPLAY_NONE;
for (const button of group.items) {
const buttonElement = this.renderDropdownItem(button, menu);
menu.appendChild(buttonElement);
}
toggle.addEventListener('click', () => {
menu.style.display = menu.style.display === CSS_DISPLAY_NONE ? '' : CSS_DISPLAY_NONE;
});
listItem.appendChild(toggle);
listItem.appendChild(menu);
return listItem;
}
/** Creates a single button element inside a dropdown menu. */
private renderDropdownItem(button: Button, menu: HTMLElement): HTMLElement {
const buttonElement = document.createElement('button');
buttonElement.className = `ribbit-btn-${button.id}`;
buttonElement.setAttribute('aria-label', button.label);
buttonElement.title = button.label;
buttonElement.textContent = button.label;
if (!button.visible) {
buttonElement.style.display = CSS_DISPLAY_NONE;
}
buttonElement.addEventListener('click', () => {
this.executeAction(button);
menu.style.display = CSS_DISPLAY_NONE;
});
button.element = buttonElement;
return buttonElement;
}
private executeAction(button: Button): void {
if (!button.visible) {
return;
}
if (button.handler) {
button.handler();
this.editor.element.focus();
return;
}
if (button.action === 'wrap' && button.delimiter) {
this.wrapSelection(button.delimiter);
} else if (button.action === 'insert' && button.template) {
this.insertText(button.template, button.replaceSelection);
}
this.editor.invalidateCache();
this.editor.element.focus();
}
/** Wraps the current selection with the given delimiter on both sides. */
private wrapSelection(delimiter: string): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
const text = range.toString();
range.deleteContents();
range.insertNode(document.createTextNode(delimiter + text + delimiter));
}
/** Inserts text at the cursor, optionally replacing the current selection. */
private insertText(text: string, replaceSelection: boolean): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
if (replaceSelection) {
range.deleteContents();
} else {
range.collapse(false);
}
range.insertNode(document.createTextNode(text));
}
}
+201 -7
View File
@@ -1,7 +1,15 @@
/* /*
* types.ts — shared types for the hopdown converter. * types.ts — shared type definitions for the ribbit editor.
*
* All interfaces used across multiple modules live here to avoid
* circular imports. Module-specific types stay in their own files.
*/ */
/**
* The result of a Tag's match() call. Carries the matched content,
* the raw matched text, how many source lines were consumed, and
* optional metadata (e.g. heading level, link href).
*/
export interface SourceToken { export interface SourceToken {
content: string; content: string;
raw: string; raw: string;
@@ -9,13 +17,23 @@ export interface SourceToken {
meta?: Record<string, string>; meta?: Record<string, string>;
} }
/**
* Conversion functions passed to Tag.toHTML and Tag.toMarkdown so
* tags can recursively convert their children without knowing about
* the HopDown instance.
*/
export interface Converter { export interface Converter {
inline: (text: string) => string; inline: (text: string) => string;
block: (md: string) => string; block: (markdown: string) => string;
children: (node: Node) => string; children: (node: Node) => string;
node: (node: Node) => string; node: (node: Node) => string;
} }
/**
* Context passed to Tag.match() during block-level scanning.
* `lines` and `index` are for block matching; `text` and `offset`
* are for inline matching within a single line.
*/
export interface MatchContext { export interface MatchContext {
lines: string[]; lines: string[];
index: number; index: number;
@@ -23,40 +41,196 @@ export interface MatchContext {
offset: number; offset: number;
} }
/**
* Configuration for a toolbar button's appearance and shortcut.
*/
export interface ToolbarButton {
show: boolean;
label: string;
icon?: string;
shortcut?: string;
}
/**
* A Tag is the core abstraction: it knows how to match markdown syntax,
* convert it to HTML, and convert the HTML back to markdown. Tags are
* registered by HTML selector (e.g. 'STRONG,B') so the converter can
* look them up during HTML→markdown conversion.
*/
export interface Tag { export interface Tag {
name: string; name: string;
match: (context: MatchContext) => SourceToken | null; match: (context: MatchContext) => SourceToken | null;
toHTML: (token: SourceToken, convert: Converter) => string; toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean); selector: string | ((element: HTMLElement) => boolean);
toMarkdown: (element: HTMLElement, convert: Converter) => string; toMarkdown: (element: HTMLElement, convert: Converter) => string;
delimiter?: string;
precedence?: number;
recursive?: boolean;
pattern?: RegExp;
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;
} }
/**
* A single item in a parsed list, with optional nested sublist HTML.
*/
export interface ListItem { export interface ListItem {
text: string; text: string;
sub: string; sub: string;
} }
/**
* Result of parsing a list block: the generated HTML and the line
* index where the list ends (so the caller can advance past it).
*/
export interface ListResult { export interface ListResult {
html: string; html: string;
end: number; end: number;
} }
/**
* Shorthand definition for creating inline tags via the inlineTag()
* factory. Covers the common case where a delimiter wraps content
* and maps to a single HTML element.
*/
export interface InlineTagDef { export interface InlineTagDef {
name: string; name: string;
/** The markdown delimiter, e.g. '**' or '`' or '~~' */
delimiter: string; delimiter: string;
/** The HTML tag to wrap with, e.g. 'strong' or 'code' */
htmlTag: string; htmlTag: string;
/** Additional HTML selectors for reverse matching, e.g. 'B' for bold */
aliases?: string; aliases?: string;
/** Lower runs first. Default 50. */
precedence?: number; precedence?: number;
/** Process inner content for nested markdown? Default true. False for code spans. */
recursive?: boolean; recursive?: boolean;
button?: ToolbarButton | false;
} }
export interface RibbitThemeFeatures { export interface RibbitThemeFeatures {
sourceMode?: boolean; sourceMode?: boolean;
vim?: boolean;
collaboration?: boolean;
}
/**
* Transport for syncing document changes between clients.
* The consumer implements this with their choice of network layer
* (WebSocket, WebRTC, HTTP polling, etc.). Ribbit never makes
* network calls itself.
*
* const transport: DocumentTransport = {
* connect() { socket.open(); },
* disconnect() { socket.close(); },
* send(update) { socket.send(update); },
* onReceive(callback) { socket.onmessage = (event) => callback(event.data); },
* };
*/
export interface DocumentTransport {
connect(): void;
disconnect(): void;
send(update: Uint8Array): void;
onReceive(callback: (update: Uint8Array) => void): void;
lock?(): Promise<boolean>;
unlock?(): void;
forceLock?(): Promise<boolean>;
onLockChange?(callback: (holder: PeerInfo | null) => void): void;
}
/**
* Channel for broadcasting cursor position and user presence.
* Optional — collaboration works without it, but users won't see
* each other's cursors.
*
* const presence: PresenceChannel = {
* send(info) { socket.send(JSON.stringify(info)); },
* onUpdate(callback) { socket.onmessage = (event) => callback(JSON.parse(event.data)); },
* };
*/
export interface PresenceChannel {
send(info: PeerInfo): void;
onUpdate(callback: (peers: PeerInfo[]) => void): void;
}
export interface PeerInfo {
userId: string;
displayName: string;
cursor?: number;
color?: string;
status: 'active' | 'editing' | 'idle';
lastActive: number;
}
export interface CollaborationSettings {
transport: DocumentTransport;
presence?: PresenceChannel;
user: PeerInfo;
/** Milliseconds before a peer is considered idle. Default 30000. */
idleTimeout?: number;
/** Provider for revision storage. Required for auto-revision on source mode exit. */
revisions?: RevisionProvider;
}
/**
* Storage backend for document revisions. The consumer implements
* this with their persistence layer (database, API, localStorage, etc.).
*/
export interface RevisionProvider {
list(): Promise<Revision[]>;
get(id: string): Promise<Revision & { content: string }>;
create(content: string, metadata?: RevisionMetadata): Promise<Revision>;
}
export interface Revision {
id: string;
timestamp: string;
author: string;
summary?: string;
}
export interface RevisionMetadata {
summary?: string;
author: string;
}
/**
* A slot in the toolbar layout. Strings reference tag names or
* special values; objects define dropdown groups.
*
* 'bold' — single button
* '' — spacer
* 'macros' — auto-populated macro dropdown
* { group: 'Heading', items: ['h1', ...] } — dropdown group
*/
export type ToolbarSlot =
| string
| {
group: string;
items: string[];
};
/**
* A resolved toolbar button with DOM element and interaction methods.
*/
export interface Button {
id: string;
label: string;
icon?: string;
shortcut?: string;
action: 'wrap' | 'prefix' | 'insert' | 'custom';
delimiter?: string;
template?: string;
replaceSelection: boolean;
visible: boolean;
element?: HTMLElement;
handler?: () => void;
click(): void;
hide(): void;
show(): void;
} }
export interface RibbitTheme { export interface RibbitTheme {
@@ -64,3 +238,23 @@ export interface RibbitTheme {
tags?: Record<string, Tag>; tags?: Record<string, Tag>;
features?: RibbitThemeFeatures; features?: RibbitThemeFeatures;
} }
/**
* Result of finding a complete delimiter pair (e.g. **bold**) or
* an unclosed opener (e.g. **bold) in a text string. Used by the
* WYSIWYG editor to transform inline formatting in-place.
*/
export interface DelimiterMatch {
/** The Tag definition that matched. */
tag: Tag;
/** The HTML element name to use (e.g. 'strong', 'em'). */
htmlTag: string;
/** The matched content between delimiters. */
content: string;
/** Start index of the full match in the source string. */
index: number;
/** Length of the full match including delimiters. */
length: number;
/** The delimiter string (e.g. '**', '*', '`'). */
delimiter: string;
}
+110
View File
@@ -0,0 +1,110 @@
import { ribbit, resetDOM } from './setup';
import { HopDown } from '../src';
const lib = ribbit();
describe('Custom block tags', () => {
const spoiler = {
name: 'spoiler',
match: (context: any) => {
const fencePattern = /^\|{3,}/;
if (!fencePattern.test(context.lines[context.index])) {
return null;
}
const content: string[] = [];
let lineIndex = context.index + 1;
while (lineIndex < context.lines.length && !fencePattern.test(context.lines[lineIndex])) {
content.push(context.lines[lineIndex++]);
}
return {
content: content.join('\n'),
raw: '',
consumed: lineIndex + 1 - context.index,
};
},
toHTML: (token: any, convert: any) => '<details>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
};
const converter = new HopDown({
tags: {
'DETAILS': spoiler,
...lib.defaultTags,
},
});
it('renders', () => expect(converter.toHTML('|||\nhidden\n|||')).toContain('<details>'));
it('nested md', () => expect(converter.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
});
describe('HopDown({ exclude })', () => {
it('excludes table', () => {
const converter = new HopDown({ exclude: ['table'] });
expect(converter.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
});
it('excludes code', () => {
const converter = new HopDown({ exclude: ['code'] });
expect(converter.toHTML('`code`')).toBe('<p>`code`</p>');
});
it('other tags still work', () => {
const converter = new HopDown({ exclude: ['table'] });
expect(converter.toHTML('**bold**')).toContain('<strong>bold</strong>');
});
});
describe('Collision detection', () => {
it('delimiter collision throws', () => {
const bad = lib.inlineTag({
name: 'bad',
delimiter: '*',
htmlTag: 'span',
precedence: 10,
});
expect(() => new HopDown({
tags: {
...lib.defaultTags,
'SPAN': bad,
},
})).toThrow();
});
it('selector collision throws', () => {
const dup = {
name: 'dup',
match: () => null,
toHTML: () => '',
selector: 'STRONG',
toMarkdown: () => '',
};
expect(() => new HopDown({
tags: {
...lib.defaultTags,
'STRONG': dup,
},
})).toThrow();
});
it('valid precedence does not throw', () => {
const short = lib.inlineTag({
name: 'short',
delimiter: '~',
htmlTag: 's',
precedence: 50,
});
const long = lib.inlineTag({
name: 'long',
delimiter: '~~',
htmlTag: 'del',
precedence: 40,
});
// Remove default strikethrough to avoid collision with the custom S/DEL tags
const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags;
expect(() => new HopDown({
tags: {
...tagsWithoutStrikethrough,
'S': short,
'DEL': long,
},
})).not.toThrow();
});
});
+149
View File
@@ -0,0 +1,149 @@
function disambiguateAsteriskRuns(line: string): string {
const SENTINEL = '\u200C';
const ASTERISK_RUN = /\*{1,3}/g;
const stack: ('*' | '**')[] = [];
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
console.log(`DISAMBIGUATE: '${line}'`);
while ((match = ASTERISK_RUN.exec(line)) !== null) {
result += line.slice(lastIndex, match.index);
const run = match[0];
console.log(`DISAMBIGUATE: run == '${run}', stack == ${stack}`);
if (run.length === 3 && stack.length === 2) {
const innerCloser = stack.pop()!;
const outerCloser = stack.pop()!;
result += innerCloser + SENTINEL + outerCloser;
} else if (run.length === 3 && stack.length === 0) {
if (match.index == 0) {
result += '***';
} else {
result += '*' + SENTINEL + '**';
stack.push('**');
stack.push('*');
}
} else if (run.length === 3) {
result += run;
} else if (stack.length > 0 && stack[stack.length - 1] === run) {
stack.pop();
result += run;
} else {
stack.push(run as '*' | '**');
result += run;
}
lastIndex = match.index + run.length;
}
result += line.slice(lastIndex);
console.log(`DISAMBIGUATE: '${result}'`);
return result;
}
function assert(condition, message) {
if (!condition) { throw new Error(message); }
}
function rewrite(line: string): string {
const SENTINEL = '|' //'\u200C';
const ASTERISK_RUN = /\*{1,3}/g;
const stack: ('*' | '**' | '***')[] = [];
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
const bold = '**';
const italic = '*';
let lastSequence = '';
while ((match = ASTERISK_RUN.exec(line)) !== null) {
result += line.slice(lastIndex, match.index);
const sequence = match[0];
if (sequence.length === 3) {
// split *** into opening ** and *
if (stack.length === 0) {
result += sequence;
stack.push('***');
// closing ***, close the stack
} else if (stack.length === 1 && stack[0] === sequence) {
result = result.replace('***', bold + SENTINEL + italic);
result += italic + SENTINEL + bold;
stack.pop();
} else if (stack.length === 2) {
const inner = stack.pop();
const outer = stack.pop();
result += inner + SENTINEL + outer;
} else if (stack.length === 1) {
console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`);
} else {
console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`);
}
} else if (stack.length) {
if (stack[stack.length - 1] === sequence) {
result += stack.pop();
} else if (stack.length === 1 && stack[0] === '***') {
const opener = stack[0].substring(0, stack[0].length - sequence.length);
result = result.replace('***', opener + SENTINEL + sequence);
result += sequence;
stack[0] = opener as '*' | '**';
} else {
result += sequence;
stack.push(sequence as '*' | '**');
}
} else {
stack.push(sequence as '*' | '**');
result += sequence;
}
lastIndex = match.index + sequence.length;
}
result += line.slice(lastIndex);
return result;
}
function test_it() {
console.log(rewrite('*italic **bold***'));
let cases = [
{
'input': '***bold-italic***',
'expected': '**|*bold-italic*|**'
},
{
'input': '***bold** italic*',
'expected': '*|**bold** italic*',
},
{
'input': '***italic* bold**',
'expected': '**|*italic* bold**',
},
{
'input': '**bold, *italic***',
'expected': '**bold, *italic*|**'
},
{
'input': '*italic, **bold***',
'expected': '*italic, **bold**|*'
},
];
for (const testCase of cases) {
let result = rewrite(testCase.input);
assert(result == testCase.expected, `'${result}' (${result.length}) != '${testCase.expected}' (${testCase.expected.length})`);
}
};
test_it();
+253
View File
@@ -0,0 +1,253 @@
import { ribbit, resetDOM } from './setup';
const lib = ribbit();
describe('RibbitEmitter', () => {
beforeEach(() => resetDOM());
it('fires save event', () => {
const editor = new lib.Editor({});
editor.run();
let received: any = null;
editor.on('save', (payload: any) => {
received = payload;
});
editor.save();
expect(received).toHaveProperty('markdown');
expect(received).toHaveProperty('html');
});
it('off removes handler', () => {
const editor = new lib.Editor({});
editor.run();
let count = 0;
const handler = () => {
count++;
};
editor.on('save', handler);
editor.save();
editor.off('save', handler);
editor.save();
expect(count).toBe(1);
});
it('multiple listeners', () => {
const editor = new lib.Editor({});
editor.run();
let count = 0;
editor.on('save', () => {
count++;
});
editor.on('save', () => {
count++;
});
editor.save();
expect(count).toBe(2);
});
});
describe('Ribbit viewer', () => {
beforeEach(() => resetDOM('**bold**'));
it('starts with null state', () => {
const viewer = new lib.Viewer({});
expect(viewer.getState()).toBeNull();
});
it('run sets view state', () => {
const viewer = new lib.Viewer({});
viewer.run();
expect(viewer.getState()).toBe('view');
});
it('renders html', () => {
const viewer = new lib.Viewer({});
viewer.run();
expect(viewer.element.innerHTML).toContain('<strong>bold</strong>');
});
it('getMarkdown returns source', () => {
const viewer = new lib.Viewer({});
expect(viewer.getMarkdown()).toBe('**bold**');
});
});
describe('Ribbit events', () => {
it('ready fires on run', () => {
resetDOM('hello');
let payload: any = null;
const viewer = new lib.Viewer({
on: {
ready: (eventPayload: any) => {
payload = eventPayload;
},
},
});
viewer.run();
expect(payload).toHaveProperty('markdown');
expect(payload).toHaveProperty('mode', 'view');
expect(payload.theme.name).toBe('ribbit-default');
});
});
describe('RibbitEditor modes', () => {
beforeEach(() => resetDOM('**bold**'));
it('starts in view', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.getState()).toBe('view');
});
it('switches to wysiwyg', () => {
const editor = new lib.Editor({});
editor.run();
editor.wysiwyg();
expect(editor.getState()).toBe('wysiwyg');
expect(editor.element.contentEditable).toBe('true');
});
it('switches back to view', () => {
const editor = new lib.Editor({});
editor.run();
editor.wysiwyg();
editor.view();
expect(editor.getState()).toBe('view');
expect(editor.element.contentEditable).toBe('false');
});
it('fires modeChange events', () => {
const modes: string[] = [];
const editor = new lib.Editor({
on: {
modeChange: ({ current }: any) => {
modes.push(current);
},
},
});
editor.run();
editor.wysiwyg();
editor.view();
expect(modes).toEqual(['view', 'wysiwyg', 'view']);
});
});
describe('ThemeManager', () => {
beforeEach(() => resetDOM());
it('lists registered themes', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
expect(editor.themes.list()).toContain('ribbit-default');
expect(editor.themes.list()).toContain('dark');
});
it('set switches theme', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.set('dark');
expect(editor.themes.current().name).toBe('dark');
});
it('disable hides from list', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.disable('dark');
expect(editor.themes.list()).not.toContain('dark');
});
it('enable restores to list', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.disable('dark');
editor.themes.enable('dark');
expect(editor.themes.list()).toContain('dark');
});
it('set disabled throws', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.disable('dark');
expect(() => editor.themes.set('dark')).toThrow();
});
it('set unknown throws', () => {
const editor = new lib.Editor({});
editor.run();
expect(() => editor.themes.set('nonexistent')).toThrow();
});
it('remove active throws', () => {
const editor = new lib.Editor({});
editor.run();
expect(() => editor.themes.remove(editor.themes.current().name)).toThrow();
});
it('fires themeChange', () => {
let payload: any = null;
const editor = new lib.Editor({
themes: [{ name: 'dark' }],
on: {
themeChange: (eventPayload: any) => {
payload = eventPayload;
},
},
});
editor.run();
editor.themes.set('dark');
expect(payload.current.name).toBe('dark');
expect(payload.previous.name).toBe('ribbit-default');
});
});
describe('defaultTheme', () => {
it('has correct shape', () => {
expect(lib.defaultTheme.name).toBe('ribbit-default');
expect(lib.defaultTheme.tags).toBeDefined();
});
});
describe('Utility functions', () => {
it('encodeHtmlEntities', () => {
expect(lib.encodeHtmlEntities('<')).toBe('&#60;');
expect(lib.encodeHtmlEntities('>')).toBe('&#62;');
expect(lib.encodeHtmlEntities('&')).toBe('&#38;');
});
it('decodeHtmlEntities', () => {
expect(lib.decodeHtmlEntities('&#60;')).toBe('<');
expect(lib.decodeHtmlEntities('&amp;')).toBe('&');
});
it('camelCase', () => {
expect(lib.camelCase('hello').join('')).toBe('Hello');
expect(lib.camelCase('hello world').join(' ')).toBe('Hello World');
});
});
describe('Editor htmlToMarkdown', () => {
it('returns markdown in view state', () => {
resetDOM('**bold**');
const editor = new lib.Editor({});
editor.run();
expect(editor.getMarkdown()).toBe('**bold**');
});
it('returns markdown in wysiwyg state', () => {
resetDOM('**bold**');
const editor = new lib.Editor({});
editor.run();
editor.wysiwyg();
expect(editor.getMarkdown()).toBe('**bold**');
});
it('round-trips inline formatting', () => {
resetDOM('hello **world** and *italic*');
const editor = new lib.Editor({});
editor.run();
editor.wysiwyg();
expect(editor.getMarkdown()).toBe('hello **world** and *italic*');
});
});
+554
View File
@@ -0,0 +1,554 @@
import { ribbit } from './setup';
const lib = ribbit();
const editor = new lib.Editor({});
const hopdown = editor.converter;
const H = (md: string) => hopdown.toHTML(md);
const M = (html: string) => hopdown.toMarkdown(html);
const rt = (md: string) => M(H(md));
describe('Markdown → HTML', () => {
describe('inline formatting', () => {
it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>'));
it('italic', () => expect(H('*italic*')).toBe('<p><em>italic</em></p>'));
it('inline code', () => expect(H('`code`')).toBe('<p><code>code</code></p>'));
it('link', () => expect(H('[t](http://x)')).toBe('<p><a href="http://x">t</a></p>'));
it('bold+italic', () => expect(H('***bi***')).toBe('<p><em><strong>bi</strong></em></p>'));
it('mixed', () => expect(H('a **b** *c* `d`')).toBe('<p>a <strong>b</strong> <em>c</em> <code>d</code></p>'));
it('code before bold', () => expect(H('`a` **b**')).toBe('<p><code>a</code> <strong>b</strong></p>'));
});
describe('headings', () => {
it.each([1, 2, 3, 4, 5, 6])('h%i', (level) => {
const prefix = '#'.repeat(level);
expect(H(`${prefix} Sub`)).toContain(`<h${level}`);
});
it('heading id', () => expect(H('## Hello World')).toContain("id='HelloWorld'"));
it('heading inline md', () => expect(H('## **Bold** text')).toContain('<strong>Bold</strong>'));
});
describe('horizontal rules', () => {
it('***', () => expect(H('***')).toBe('<hr>'));
it('---', () => expect(H('---')).toBe('<hr>'));
it('___', () => expect(H('___')).toBe('<hr>'));
});
describe('lists', () => {
it('ul *', () => expect(H('* a\n* b')).toBe('<ul><li>a</li><li>b</li></ul>'));
it('ul -', () => expect(H('- a\n- b')).toBe('<ul><li>a</li><li>b</li></ul>'));
it('ol', () => expect(H('1. a\n2. b')).toBe('<ol><li>a</li><li>b</li></ol>'));
it('ul inline', () => expect(H('* **bold** item')).toContain('<strong>bold</strong>'));
});
describe('blockquotes', () => {
it('basic', () => expect(H('> text')).toContain('<blockquote>'));
it('content', () => expect(H('> hello')).toContain('hello'));
it('multi-line', () => expect(H('> a\n> b')).toContain('a'));
});
describe('fenced code', () => {
it('basic', () => expect(H('```\nx = 1\n```')).toContain('<pre><code>'));
it('content', () => expect(H('```\nx = 1\n```')).toContain('x = 1'));
it('language', () => expect(H('```js\nvar x;\n```')).toContain('language-js'));
it('escapes html', () => expect(H('```\n<div>\n```')).toContain('&lt;div&gt;'));
it('no lang when none', () => expect(H('```\nplain\n```')).not.toContain('language-'));
});
describe('tables', () => {
const tbl = '| a | b |\n|---|---|\n| 1 | 2 |';
it('table tag', () => expect(H(tbl)).toContain('<table>'));
it('thead', () => expect(H(tbl)).toContain('<thead>'));
it('th cells', () => expect(H(tbl)).toContain('<th>a</th>'));
it('td cells', () => expect(H(tbl)).toContain('<td>1</td>'));
it('center align', () => expect(H('| C |\n|:--:|\n| x |')).toContain('text-align:center'));
it('right align', () => expect(H('| R |\n|--:|\n| x |')).toContain('text-align:right'));
it('inline md', () => expect(H('| **b** |\n|---|\n| x |')).toContain('<strong>b</strong>'));
});
describe('paragraphs', () => {
it('single', () => expect(H('hello')).toBe('<p>hello</p>'));
it('two', () => expect(H('a\n\nb')).toBe('<p>a</p>\n<p>b</p>'));
it('soft break', () => expect(H('a\nb')).toBe('<p>a\nb</p>'));
});
describe('edge cases', () => {
it('empty', () => expect(H('')).toBe(''));
it('whitespace', () => expect(H(' ')).toBe(''));
it('html entities', () => expect(H('a & b < c')).toContain('&amp;'));
it('html in code', () => expect(H('`<div>`')).toContain('&lt;div&gt;'));
it('para then heading', () => expect(H('text\n\n## H')).toContain('<h2'));
it('list then para', () => expect(H('- a\n\ntext')).toContain('<p>text</p>'));
});
});
describe('HTML → Markdown', () => {
it('strong→**', () => expect(M('<p><strong>b</strong></p>')).toBe('**b**'));
it('em→*', () => expect(M('<p><em>i</em></p>')).toBe('*i*'));
it('code→`', () => expect(M('<p><code>c</code></p>')).toBe('`c`'));
it('a→[]', () => expect(M('<a href="http://x">t</a>')).toBe('[t](http://x)'));
it('h1→#', () => expect(M('<h1>T</h1>')).toBe('# T'));
it('hr→---', () => expect(M('<hr>')).toBe('---'));
it('ul→-', () => expect(M('<ul><li>a</li><li>b</li></ul>')).toBe('- a\n- b'));
it('ol→1.', () => expect(M('<ol><li>a</li><li>b</li></ol>')).toBe('1. a\n2. b'));
it('bq→>', () => expect(M('<blockquote><p>q</p></blockquote>')).toContain('> '));
it('pre→```', () => expect(M('<pre><code>x</code></pre>')).toContain('```'));
it('pre lang', () => expect(M('<pre><code class="language-py">x</code></pre>')).toContain('```py'));
it('table→pipes', () => {
const html = '<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>';
expect(M(html)).toContain('| a | b |');
});
});
describe('Round-trips', () => {
it.each([
['paragraph', 'Hello world'],
['bold', '**bold**'],
['italic', '*italic*'],
['code', '`code`'],
['link', '[t](http://x)'],
['h1', '# Title'],
['h2', '## Sub'],
['ul', '- a\n- b'],
['ol', '1. a\n2. b'],
])('%s', (_, md) => expect(rt(md)).toBe(md));
it('hr', () => expect(rt('---')).toBe('---'));
it('blockquote', () => expect(rt('> quoted')).toContain('> '));
it('code block', () => expect(rt('```\nx = 1\n```')).toContain('```'));
it('table', () => expect(rt('| a | b |\n|---|---|\n| 1 | 2 |')).toContain('| a | b |'));
});
describe('Nested inline', () => {
it('bold wraps italic', () => expect(H('**a *b* c**')).toBe('<p><strong>a <em>b</em> c</strong></p>'));
it('italic wraps bold', () => expect(H('*a **b** c*')).toBe('<p><em>a <strong>b</strong> c</em></p>'));
it('bold wraps code', () => expect(H('**a `b` c**')).toBe('<p><strong>a <code>b</code> c</strong></p>'));
it('bold wraps link', () => expect(H('**[t](u)**')).toBe('<p><strong><a href="u">t</a></strong></p>'));
it('link with bold', () => expect(H('[**t**](u)')).toBe('<p><a href="u"><strong>t</strong></a></p>'));
it('link with code', () => expect(H('[`t`](u)')).toBe('<p><a href="u"><code>t</code></a></p>'));
});
describe('Nested blocks', () => {
it('bq > heading', () => expect(H('> # Title')).toContain('<h1'));
it('bq > list', () => expect(H('> - a\n> - b')).toContain('<ul>'));
it('bq > bq', () => expect(H('> > nested')).toContain('<blockquote>'));
it('li > bold', () => expect(H('- **bold**')).toContain('<strong>bold</strong>'));
it('heading > code', () => expect(H('## `code`')).toContain('<code>code</code>'));
it('table > bold', () => expect(H('| **b** |\n|---|\n| x |')).toContain('<strong>b</strong>'));
});
describe('Nested lists', () => {
it('ul > ul', () => expect(H('- a\n - b\n - c\n- d')).toBe('<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>'));
it('ol > ol', () => expect(H('1. a\n 1. b\n 1. c\n2. d')).toBe('<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>'));
it('ul > ol', () => expect(H('- a\n 1. b\n 2. c\n- d')).toBe('<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>'));
it('3-level', () => expect(H('- a\n - b\n - c\n- d')).toBe('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>'));
it('ul>ul rt', () => expect(rt('- a\n - b\n - c\n- d')).toBe('- a\n - b\n - c\n- d'));
});
describe('Tables with nested markdown', () => {
it('td bold', () => expect(H('| h |\n|---|\n| **b** |')).toContain('<td><strong>b</strong></td>'));
it('td link>bold', () => expect(H('| h |\n|---|\n| [**t**](u) |')).toContain('<a href="u"><strong>t</strong></a>'));
it('td bold rt', () => expect(rt('| h |\n|---|\n| **b** |')).toBe('| h |\n| --- |\n| **b** |'));
it('multi-cell rt', () => expect(rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |')).toBe('| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |'));
});
describe('Backslash escapes', () => {
it('escaped asterisk', () => expect(H('\\*not italic\\*')).toBe('<p>*not italic*</p>'));
it('escaped backslash', () => expect(H('a \\\\ b')).toBe('<p>a \\ b</p>'));
it('escaped backtick', () => expect(H('\\`not code\\`')).toBe('<p>`not code`</p>'));
it('round-trip preserves escape', () => {
const html = H('\\*literal\\*');
expect(html).toContain('*literal*');
expect(html).not.toContain('<em>');
});
});
describe('Strikethrough', () => {
it('md→html', () => expect(H('~~deleted~~')).toBe('<p><del>deleted</del></p>'));
it('html→md', () => expect(M('<p><del>gone</del></p>')).toBe('~~gone~~'));
it('round-trip', () => expect(rt('~~struck~~')).toBe('~~struck~~'));
it('mixed with bold', () => expect(H('**bold** and ~~struck~~')).toContain('<del>struck</del>'));
});
describe('Link titles', () => {
it('link with title', () => expect(H('[t](http://x "My Title")')).toBe('<p><a href="http://x" title="My Title">t</a></p>'));
it('title round-trip', () => expect(rt('[t](http://x "My Title")')).toBe('[t](http://x "My Title")'));
});
describe('Reference links', () => {
it('basic reference', () => expect(H('[text][ref]\n\n[ref]: http://x')).toContain('<a href="http://x">text</a>'));
it('shortcut reference', () => expect(H('[ref][]\n\n[ref]: http://x')).toContain('<a href="http://x">ref</a>'));
it('reference with title', () => expect(H('[t][r]\n\n[r]: http://x "T"')).toContain('title="T"'));
it('case insensitive', () => expect(H('[t][REF]\n\n[ref]: http://x')).toContain('<a href="http://x">'));
it('undefined reference passes through', () => expect(H('[t][missing]')).toContain('[t][missing]'));
it('definition not rendered', () => expect(H('[ref]: http://x\n\ntext')).toBe('<p>text</p>'));
});
describe('HTML passthrough', () => {
it('inline html preserved', () => expect(H('a <span class="x">b</span> c')).toContain('<span class="x">b</span>'));
it('self-closing tag', () => expect(H('a <br/> b')).toContain('<br/>'));
it('html not double-escaped', () => expect(H('<em>hi</em>')).not.toContain('&lt;'));
});
describe('Autolinks', () => {
it('angle bracket autolink', () => expect(H('<https://example.com>')).toContain('<a href="https://example.com">'));
it('bare URL', () => expect(H('visit https://example.com today')).toContain('<a href="https://example.com">'));
it('URL not matched inside link', () => {
const html = H('[text](https://example.com)');
// Should have exactly one <a> tag, not nested
const anchorPattern = /<a /g;
const count = (html.match(anchorPattern) || []).length;
expect(count).toBe(1);
});
});
describe('Alternate syntax (parse-only, canonical output)', () => {
describe('underscore emphasis', () => {
it('_italic_ → *italic*', () => {
expect(H('_italic_')).toBe('<p><em>italic</em></p>');
expect(rt('_italic_')).toBe('*italic*');
});
it('__bold__ → **bold**', () => {
expect(H('__bold__')).toBe('<p><strong>bold</strong></p>');
expect(rt('__bold__')).toBe('**bold**');
});
it('___both___ → ***both***', () => {
expect(H('___both___')).toContain('<em><strong>both</strong></em>');
expect(rt('___both___')).toBe('***both***');
});
it('mid-word _ not converted', () => {
expect(H('foo_bar_baz')).toBe('<p>foo_bar_baz</p>');
});
});
describe('setext headings', () => {
it('=== underline → h1', () => {
expect(H('Title\n=====')).toContain('<h1');
expect(H('Title\n=====')).toContain('Title');
});
it('--- underline → h2', () => {
expect(H('Sub\n---')).toContain('<h2');
});
it('round-trips to ATX', () => {
expect(rt('Title\n=====')).toBe('# Title');
expect(rt('Sub\n---')).toBe('## Sub');
});
});
describe('ATX closing hashes', () => {
it('## Title ## → h2', () => {
expect(H('## Title ##')).toContain('<h2');
expect(H('## Title ##')).toContain('Title');
});
it('round-trips without closing', () => {
expect(rt('## Title ##')).toBe('## Title');
});
});
describe('tilde fenced code', () => {
it('~~~ fence accepted', () => {
expect(H('~~~\ncode\n~~~')).toContain('<code>code</code>');
});
it('round-trips to backtick', () => {
expect(rt('~~~\ncode\n~~~')).toContain('```');
});
});
describe('plus list marker', () => {
it('+ item accepted', () => {
expect(H('+ item')).toContain('<li>');
});
it('round-trips to -', () => {
expect(rt('+ item')).toContain('- item');
});
});
});
describe('HopDown delimiter matching API', () => {
describe('findCompletePair', () => {
it('finds bold pair', () => {
const result = hopdown.findCompletePair('hello **world** end');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('strong');
expect(result!.content).toBe('world');
expect(result!.delimiter).toBe('**');
});
it('finds italic pair', () => {
const result = hopdown.findCompletePair('hello *world* end');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('em');
});
it('finds strikethrough pair', () => {
const result = hopdown.findCompletePair('hello ~~gone~~ end');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('del');
});
it('returns null when no pair exists', () => {
expect(hopdown.findCompletePair('hello world')).toBeNull();
});
it('skips sentinel-wrapped content', () => {
expect(hopdown.findCompletePair('hello \x01<strong>world</strong>\x02 end')).toBeNull();
});
it('respects precedence (boldItalic before bold)', () => {
const result = hopdown.findCompletePair('***both***');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('em');
expect(result!.tag.name).toBe('boldItalic');
});
});
describe('findUnmatchedOpener', () => {
it('finds unclosed bold', () => {
const result = hopdown.findUnmatchedOpener('hello **world');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('strong');
expect(result!.content).toBe('world');
});
it('returns null when no opener exists', () => {
expect(hopdown.findUnmatchedOpener('hello world end')).toBeNull();
});
it('returns null for plain text', () => {
expect(hopdown.findUnmatchedOpener('hello world')).toBeNull();
});
});
describe('getTagForElement', () => {
it('returns tag for strong element', () => {
const element = document.createElement('strong');
const tag = hopdown.getTagForElement(element);
expect(tag).not.toBeNull();
expect(tag!.name).toBe('bold');
expect(tag!.delimiter).toBe('**');
});
it('returns tag for em element', () => {
const element = document.createElement('em');
const tag = hopdown.getTagForElement(element);
expect(tag).not.toBeNull();
expect(tag!.name).toBe('italic');
});
it('returns null for div element', () => {
const element = document.createElement('div');
expect(hopdown.getTagForElement(element)).toBeNull();
});
});
describe('getEditableSelector', () => {
it('returns a non-empty string', () => {
const selector = hopdown.getEditableSelector();
expect(selector.length).toBeGreaterThan(0);
});
it('includes inline tag selectors', () => {
const selector = hopdown.getEditableSelector();
expect(selector).toContain('strong');
expect(selector).toContain('em');
expect(selector).toContain('code');
});
it('includes block tag selectors', () => {
const selector = hopdown.getEditableSelector();
expect(selector).toContain('pre');
expect(selector).toContain('blockquote');
});
});
});
describe('Hard line breaks', () => {
it('trailing two spaces', () => {
expect(H('line one \nline two')).toContain('<br>');
});
it('trailing backslash', () => {
expect(H('line one\\\nline two')).toContain('<br>');
});
it('single space does not break', () => {
expect(H('line one \nline two')).not.toContain('<br>');
});
it('round-trip', () => {
const html = H('line one \nline two');
const markdown = M(html);
expect(markdown).toContain(' \n');
});
});
describe('Link nesting prevention', () => {
it('nested brackets prevent link match', () => {
const html = H('[outer [inner](http://b)](http://a)');
// The outer [ prevents matching as a single link — the inner
// link matches instead, and the outer brackets are literal text
expect(html).toContain('<a href="http://b">inner</a>');
});
it('preserves inner link text', () => {
const html = H('[outer [inner](http://b)](http://a)');
expect(html).toContain('inner');
});
it('autolink inside link is stripped', () => {
const html = H('[see <https://b.com>](http://a)');
const anchorPattern = /<a /g;
const linkCount = (html.match(anchorPattern) || []).length;
expect(linkCount).toBe(1);
});
});
describe('Multiple-of-3 emphasis rule', () => {
it('***foo*** is bold-italic', () => {
expect(H('***foo***')).toContain('<em><strong>foo</strong></em>');
});
it('**foo** is bold', () => {
expect(H('**foo**')).toBe('<p><strong>foo</strong></p>');
});
it('*foo* is italic', () => {
expect(H('*foo*')).toBe('<p><em>foo</em></p>');
});
it('*foo** does not match (1+2=3, rule applies)', () => {
const html = H('*foo**');
expect(html).not.toContain('<em>');
expect(html).not.toContain('<strong>');
});
it('**foo* does not match (2+1=3, rule applies)', () => {
const html = H('**foo*');
expect(html).not.toContain('<em>');
expect(html).not.toContain('<strong>');
});
});
describe('HTML entity resolution', () => {
it('&amp; resolves to &', () => {
expect(H('a &amp; b')).toBe('<p>a &amp; b</p>');
});
it('&lt; resolves to <', () => {
expect(H('a &lt; b')).toBe('<p>a &lt; b</p>');
});
it('&gt; resolves to >', () => {
expect(H('a &gt; b')).toBe('<p>a &gt; b</p>');
});
it('&#123; resolves to {', () => {
expect(H('&#123;')).toBe('<p>{</p>');
});
it('&#x7B; resolves to {', () => {
expect(H('&#x7B;')).toBe('<p>{</p>');
});
it('unknown entity passes through', () => {
expect(H('&unknown;')).toContain('&amp;unknown;');
});
});
describe('Nested inline scenarios', () => {
describe('markdown → HTML nesting', () => {
it('strikethrough wraps bold', () => {
expect(H('~~**bold** struck~~')).toBe('<p><del><strong>bold</strong> struck</del></p>');
});
it('bold wraps strikethrough', () => {
expect(H('**~~struck~~ bold**')).toBe('<p><strong><del>struck</del> bold</strong></p>');
});
it('italic wraps link', () => {
expect(H('*[text](http://x)*')).toContain('<em><a href="http://x">text</a></em>');
});
it('code inside strikethrough', () => {
expect(H('~~`code` struck~~')).toContain('<del><code>code</code> struck</del>');
});
it('adjacent bold and italic', () => {
const html = H('**bold***italic*');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<em>italic</em>');
});
});
describe('HTML → markdown → HTML round-trip nesting', () => {
it('bold wraps italic', () => {
const html = '<p><strong>a <em>b</em> c</strong></p>';
expect(H(M(html))).toBe(html);
});
it('italic wraps bold', () => {
const html = '<p><em>a <strong>b</strong> c</em></p>';
expect(H(M(html))).toBe(html);
});
it('bold wraps code', () => {
const html = '<p><strong>a <code>b</code> c</strong></p>';
expect(H(M(html))).toBe(html);
});
it('bold wraps link', () => {
const html = '<p><strong><a href="http://x">t</a></strong></p>';
expect(H(M(html))).toBe(html);
});
it('strikethrough wraps bold', () => {
const html = '<p><del><strong>bold</strong> struck</del></p>';
expect(H(M(html))).toBe(html);
});
it('italic wraps link', () => {
const html = '<p><em><a href="http://x">t</a></em></p>';
expect(H(M(html))).toBe(html);
});
});
describe('literal delimiters in text round-trip', () => {
it('literal * in bold', () => {
const html = '<p><strong>a * b</strong></p>';
expect(H(M(html))).toBe(html);
});
it('literal ~ in strikethrough', () => {
const html = '<p><del>a ~ b</del></p>';
expect(H(M(html))).toBe(html);
});
it('literal ` adjacent to code', () => {
const html = '<p>a ` b <code>c</code></p>';
expect(H(M(html))).toBe(html);
});
it('literal * in plain text', () => {
const html = '<p>hello * world</p>';
expect(H(M(html))).toBe(html);
});
it('literal ** in plain text', () => {
const html = '<p>hello ** world</p>';
expect(H(M(html))).toBe(html);
});
it('literal _ in plain text', () => {
const html = '<p>hello _ world</p>';
expect(H(M(html))).toBe(html);
});
});
});
describe('Backslash-escaped HTML tags', () => {
it('\\<em> does not produce a real em element', () => {
const html = H('\\<em>text');
expect(html).not.toContain('<em>');
expect(html).toContain('&lt;em&gt;');
});
it('\\<b> does not produce a real b element', () => {
const html = H('\\<b>text');
expect(html).not.toContain('<b>');
});
it('round-trip of escaped HTML tag in text', () => {
const html = '<p>~~\\<em>---\\<b></em></p>';
const markdown = M(html);
const rehtml = H(markdown);
const markdown2 = M(rehtml);
const rehtml2 = H(markdown2);
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);
});
});
+17
View File
@@ -0,0 +1,17 @@
var liveServer = require("live-server");
var params = {
port: 5023,
host: "0.0.0.0",
open: true,
root: "test/integration",
mount: [
['/static', 'dist/ribbit'],
['/test', 'test/integration'],
],
logLevel: 2, // 0 = errors only, 1 = some, 2 = lots
};
console.log(`\n🐸 Ribbit dev server running on http://localhost:${params['port']}`);
liveServer.start(params);
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Ribbit Integration Test Page</title>
<link rel="stylesheet" href="/static/themes/ribbit-default/theme.css">
</head>
<body>
<main>
<article id="ribbit">
| Type | To Get |
|------|--------|
| `*emphasis*` | *emphasis* |
| `**bold**` | **bold** |
| `abel](/link/address)` | [link label](/link/address) |
| ``inline`` | `inline` |
</article>
</main>
<script src="/static/ribbit.js"></script>
<script>
const editor = new ribbit.Editor({
on: {
ready: () => { window.__ribbitReady = true; },
},
});
editor.run();
window.__ribbitEditor = editor;
</script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
/**
* Minimal static file server for e2e tests.
* Serves the test page and ribbit dist files.
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const MIME = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.map': 'application/json',
};
function createServer(port = 9999) {
const distDir = path.join(__dirname, '..', '..', 'dist', 'ribbit');
const testDir = __dirname;
const server = http.createServer((req, res) => {
let filePath;
if (req.url === '/' || req.url === '/index.html') {
filePath = path.join(testDir, 'index.html');
} else if (req.url.startsWith('/ribbit/')) {
filePath = path.join(distDir, req.url.replace('/ribbit/', ''));
} else {
res.writeHead(404);
res.end('Not found');
return;
}
const ext = path.extname(filePath);
const mime = MIME[ext] || 'application/octet-stream';
try {
const content = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime });
res.end(content);
} catch {
res.writeHead(404);
res.end('Not found');
}
});
return {
start() {
return new Promise((resolve) => {
server.listen(port, () => resolve());
});
},
stop() {
return new Promise((resolve) => {
server.close(() => resolve());
});
},
url: `http://localhost:${port}`,
};
}
module.exports = { createServer };
+307
View File
@@ -0,0 +1,307 @@
/**
* Integration tests for the ribbit editor using Selenium + Firefox.
*
* Run: npm run test:e2e
*/
const { Builder, By, Key, until } = require('selenium-webdriver');
const firefox = require('selenium-webdriver/firefox');
const { createServer } = require('./server');
let server;
let driver;
async function setup() {
server = createServer(9999);
await server.start();
const options = new firefox.Options().addArguments('--headless');
driver = await new Builder()
.forBrowser('firefox')
.setFirefoxOptions(options)
.build();
await driver.get(server.url);
// Wait for ribbit to initialize
await driver.wait(async () => {
return driver.executeScript('return window.__ribbitReady === true');
}, 10000).catch(async () => {
const logs = await driver.manage().logs().get('browser').catch(() => []);
console.log('Browser logs:', logs.map(l => l.message));
const ready = await driver.executeScript('return { ready: window.__ribbitReady, ribbit: typeof window.ribbit, editor: typeof window.__ribbitEditor }');
console.log('State:', ready);
throw new Error('Editor did not become ready');
});
}
async function teardown() {
if (driver) await driver.quit();
if (server) await server.stop();
}
// Test helpers
async function getEditorHTML() {
return driver.executeScript('return document.getElementById("ribbit").innerHTML');
}
async function getEditorText() {
return driver.executeScript('return document.getElementById("ribbit").textContent');
}
async function getState() {
return driver.executeScript('return window.__ribbitEditor.getState()');
}
async function clickButton(label) {
const buttons = await driver.findElements(By.css('.ribbit-toolbar button'));
for (const btn of buttons) {
const text = await btn.getText();
if (text === label) {
await btn.click();
return;
}
}
throw new Error(`Button "${label}" not found`);
}
async function clickEditor() {
const editor = await driver.findElement(By.id('ribbit'));
await editor.click();
}
// Test runner
let passed = 0;
let failed = 0;
const errors = [];
async function test(name, fn) {
try {
await fn();
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` ${e.message}`);
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || 'Assertion failed');
}
// Tests
async function runTests() {
console.log('\nRibbit Integration Tests\n');
await test('page loads', async () => {
const title = await driver.getTitle();
assert(title === 'Ribbit Integration Test Page', `Title: ${title}`);
});
await test('editor renders in view mode', async () => {
const state = await getState();
assert(state === 'view', `State: ${state}`);
});
await test('editor renders markdown as HTML', async () => {
const html = await getEditorHTML();
assert(html.includes('<strong>bold</strong>'), 'Missing bold');
assert(html.includes('<em>italic</em>'), 'Missing italic');
assert(html.includes('<code>code</code>'), 'Missing code');
});
await test('editor renders headings', async () => {
const html = await getEditorHTML();
assert(html.includes('<h2'), 'Missing h2');
});
await test('editor renders lists', async () => {
const html = await getEditorHTML();
assert(html.includes('<ul>'), 'Missing ul');
assert(html.includes('<li>'), 'Missing li');
});
await test('editor renders tables', async () => {
const html = await getEditorHTML();
assert(html.includes('<table>'), 'Missing table');
});
await test('editor renders blockquotes', async () => {
const html = await getEditorHTML();
assert(html.includes('<blockquote>'), 'Missing blockquote');
});
await test('toolbar is rendered', async () => {
const toolbar = await driver.findElements(By.css('.ribbit-toolbar'));
assert(toolbar.length > 0, 'No toolbar found');
});
await test('toolbar has buttons with labels', async () => {
const buttons = await driver.findElements(By.css('.ribbit-toolbar button'));
assert(buttons.length > 5, `Only ${buttons.length} buttons`);
const text = await buttons[0].getText();
assert(text.length > 0, 'Button has no label');
});
await test('toggle button switches to wysiwyg', async () => {
await clickButton('Edit');
const state = await getState();
assert(state === 'wysiwyg', `State: ${state}`);
});
await test('editor is contentEditable in wysiwyg', async () => {
const editable = await driver.executeScript(
'return document.getElementById("ribbit").contentEditable'
);
assert(editable === 'true', `contentEditable: ${editable}`);
});
await test('can type in wysiwyg mode', async () => {
await clickEditor();
// Move to end and type
await driver.actions().keyDown(Key.CONTROL).sendKeys(Key.END).keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys('\nhello from selenium').perform();
const text = await getEditorText();
assert(text.includes('hello from selenium'), 'Typed text not found');
});
await test('source button switches to edit mode', async () => {
await clickButton('Source');
const state = await getState();
assert(state === 'edit', `State: ${state}`);
});
await test('edit mode shows raw markdown', async () => {
const text = await getEditorText();
assert(text.includes('**bold**'), 'Missing raw markdown');
});
await test('toggle back to view mode', async () => {
await clickButton('Edit');
const state = await getState();
assert(state === 'view', `State: ${state}`);
});
await test('view mode renders HTML again', async () => {
const html = await getEditorHTML();
assert(html.includes('<strong>bold</strong>'), 'Not rendered as HTML');
});
await test('save button fires save event', async () => {
await driver.executeScript('window.__saved = false; window.__ribbitEditor.on("save", () => { window.__saved = true; })');
await clickButton('Edit');
await clickButton('Save');
const saved = await driver.executeScript('return window.__saved');
assert(saved === true, 'Save event not fired');
});
await test('enter key creates new line in wysiwyg', async () => {
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
// Clear and type two lines
await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys(Key.DELETE).perform();
await driver.actions().sendKeys('line one').perform();
await driver.actions().sendKeys(Key.ENTER).perform();
await driver.actions().sendKeys('line two').perform();
const text = await getEditorText();
assert(text.includes('line one'), `Missing "line one" in: ${text}`);
assert(text.includes('line two'), `Missing "line two" in: ${text}`);
// Check that they're on separate lines (not concatenated)
const html = await getEditorHTML();
const hasBreak = html.includes('<br') || html.includes('<div') || html.includes('<p');
assert(hasBreak, `No line break in HTML: ${html}`);
});
await test('enter key in wysiwyg produces valid markdown', async () => {
// Get the markdown from the content typed above
const md = await driver.executeScript('return window.__ribbitEditor.getMarkdown()');
assert(md.includes('line one'), `Missing "line one" in markdown: ${md}`);
assert(md.includes('line two'), `Missing "line two" in markdown: ${md}`);
// Lines should be separate (not on same line)
const lines = md.split('\n').filter(l => l.trim());
const hasLineOne = lines.some(l => l.includes('line one'));
const hasLineTwo = lines.some(l => l.includes('line two'));
assert(hasLineOne, `"line one" not on its own line in: ${md}`);
assert(hasLineTwo, `"line two" not on its own line in: ${md}`);
});
await test('multiple enters create blank lines in wysiwyg', async () => {
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys(Key.DELETE).perform();
await driver.actions().sendKeys('para one').perform();
await driver.actions().sendKeys(Key.ENTER, Key.ENTER).perform();
await driver.actions().sendKeys('para two').perform();
const text = await getEditorText();
assert(text.includes('para one'), `Missing "para one" in: ${text}`);
assert(text.includes('para two'), `Missing "para two" in: ${text}`);
});
await test('enter after heading in wysiwyg', async () => {
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys(Key.DELETE).perform();
await driver.actions().sendKeys('## My Heading').perform();
await driver.actions().sendKeys(Key.ENTER).perform();
await driver.actions().sendKeys('paragraph text').perform();
const md = await driver.executeScript('return window.__ribbitEditor.getMarkdown()');
assert(md.includes('Heading') || md.includes('heading'), `Missing heading in: ${md}`);
assert(md.includes('paragraph'), `Missing paragraph in: ${md}`);
});
await test('typing heading prefix in wysiwyg', async () => {
// Start fresh
await driver.executeScript(`
var e = window.__ribbitEditor;
e.wysiwyg();
e.element.innerHTML = '<p><br></p>';
`);
await clickEditor();
await driver.sleep(100);
// Type "# Hello"
await driver.actions().sendKeys('# Hello').perform();
await driver.sleep(100);
const html = await getEditorHTML();
console.log(' HTML:', html.slice(0, 200));
assert(html.includes('<h1'), `Expected <h1> in HTML: ${html.slice(0, 200)}`);
});
await test('Ctrl+B shortcut works in wysiwyg', async () => {
// Switch to wysiwyg
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
// Type and select
await driver.actions().sendKeys('test text').perform();
await driver.actions()
.keyDown(Key.SHIFT)
.sendKeys(Key.ARROW_LEFT, Key.ARROW_LEFT, Key.ARROW_LEFT, Key.ARROW_LEFT)
.keyUp(Key.SHIFT)
.perform();
// Ctrl+B
await driver.actions().keyDown(Key.CONTROL).sendKeys('b').keyUp(Key.CONTROL).perform();
const html = await getEditorHTML();
assert(html.includes('**'), 'Bold delimiter not inserted');
});
}
(async () => {
try {
await setup();
await runTests();
} catch (e) {
console.error('Setup failed:', e.message);
failed++;
} finally {
console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`);
if (errors.length) {
console.log('\nFailed:');
errors.forEach(e => console.log(`${e}`));
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
}
})();
+471
View File
@@ -0,0 +1,471 @@
/**
* WYSIWYG fuzz test.
*
* Generates random keystroke sequences, types them char-by-char,
* and checks structural invariants after every keystroke. When a
* failure is found, the seed is logged for deterministic replay
* and the sequence is shrunk to a minimal reproducing case.
*
* Run:
* node test/integration/test_fuzz.js
* node test/integration/test_fuzz.js --seed 12345
* node test/integration/test_fuzz.js --rounds 200
* node test/integration/test_fuzz.js --seed 12345 --shrink
*/
const { Builder, By, Key } = require('selenium-webdriver');
const firefox = require('selenium-webdriver/firefox');
const { createServer } = require('./server');
let server, driver;
const DELAY = 20;
/* ── Seeded PRNG (mulberry32) ── */
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* ── Keystroke generation ── */
const PRINTABLE = 'abcdefghijklmnopqrstuvwxyz 0123456789.,!?';
const DELIMITERS = ['*', '**', '***', '`', '~~', '_', '__', '___'];
const BLOCK_PREFIXES = ['# ', '## ', '### ', '- ', '+ ', '1. ', '> ', '---', '~~~'];
const SPECIAL_KEYS = [
{ name: 'Enter', keys: Key.ENTER, isSpecial: true },
{ name: 'Backspace', keys: Key.BACK_SPACE, isSpecial: true },
{ name: 'ArrowLeft', keys: Key.ARROW_LEFT, isSpecial: true },
{ name: 'ArrowRight', keys: Key.ARROW_RIGHT, isSpecial: true },
];
/**
* Generate a random keystroke sequence.
* Returns array of { name, keys } where keys is a string or Key constant.
*/
function generateSequence(random, length) {
const sequence = [];
for (let i = 0; i < length; i++) {
const roll = random();
if (roll < 0.50) {
/* printable character */
const character = PRINTABLE[Math.floor(random() * PRINTABLE.length)];
sequence.push({ name: character === ' ' ? 'Space' : character, keys: character });
} else if (roll < 0.70) {
/* delimiter */
const delimiter = DELIMITERS[Math.floor(random() * DELIMITERS.length)];
sequence.push({ name: delimiter, keys: delimiter });
} else if (roll < 0.80) {
/* special key */
const special = SPECIAL_KEYS[Math.floor(random() * SPECIAL_KEYS.length)];
sequence.push(special);
} else if (roll < 0.88) {
/* block prefix (only useful at line start, but fuzz doesn't care) */
const prefix = BLOCK_PREFIXES[Math.floor(random() * BLOCK_PREFIXES.length)];
sequence.push({ name: `"${prefix.trim()}"`, keys: prefix });
} else if (roll < 0.94) {
/* repeated delimiter (stress test) */
const count = 2 + Math.floor(random() * 4);
const delimiters = ['*', '_', '~'];
const character = delimiters[Math.floor(random() * delimiters.length)];
sequence.push({ name: character.repeat(count), keys: character.repeat(count) });
} else if (roll < 0.97) {
/* backslash sequences */
const escaped = ['\\*', '\\_', '\\`', '\\~', '\\\\', '\\'];
const fragment = escaped[Math.floor(random() * escaped.length)];
sequence.push({ name: fragment, keys: fragment });
} else {
/* angle bracket / HTML-like content */
const fragments = ['<', '>', '<div>', '</div>', '<b>', '&amp;'];
const fragment = fragments[Math.floor(random() * fragments.length)];
sequence.push({ name: fragment, keys: fragment });
}
}
return sequence;
}
/* ── Invariant checks ── */
/**
* Valid direct children of the editor element.
* Everything the WYSIWYG produces must be one of these.
*/
const VALID_BLOCK_TAGS = new Set([
'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'HR', 'TABLE',
]);
/**
* Valid inline elements that can appear inside block content.
*/
const VALID_INLINE_TAGS = new Set([
'STRONG', 'B', 'EM', 'I', 'CODE', 'A', 'BR',
]);
/**
* Elements that can only contain specific children.
*/
const REQUIRED_CHILDREN = {
'UL': ['LI'],
'OL': ['LI'],
'TABLE': ['THEAD', 'TBODY', 'TR', 'CAPTION', 'COLGROUP'],
'THEAD': ['TR'],
'TBODY': ['TR'],
'TR': ['TH', 'TD'],
};
/**
* Elements that must not contain certain descendants.
*/
const FORBIDDEN_NESTING = {
'LI': ['TABLE'],
'A': ['A'],
'STRONG': ['STRONG', 'B'],
'B': ['STRONG', 'B'],
'EM': ['EM', 'I'],
'I': ['EM', 'I'],
'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'],
};
/**
* Run all invariant checks on the current editor state.
* Returns null if all pass, or a string describing the violation.
*/
async function checkInvariants() {
return driver.executeScript(function () {
var editor = document.getElementById('ribbit');
if (!editor) { return 'Editor element not found'; }
if (editor.contentEditable !== 'true') { return 'contentEditable is not true'; }
/* Invariant 1: all direct children are valid block elements */
for (var i = 0; i < editor.childNodes.length; i++) {
var child = editor.childNodes[i];
if (child.nodeType === 3) {
if (child.textContent.replace(/[\u200B\s]/g, '').length > 0) {
return 'Bare text node in editor: "' + child.textContent.slice(0, 40) + '"';
}
continue;
}
if (child.nodeType !== 1) { continue; }
var validBlocks = ['P','H1','H2','H3','H4','H5','H6','UL','OL','BLOCKQUOTE','PRE','HR','TABLE'];
if (validBlocks.indexOf(child.nodeName) === -1) {
return 'Invalid block element: <' + child.nodeName.toLowerCase() + '>';
}
}
/* Invariant 2: no nested speculative elements */
var specs = editor.querySelectorAll('[data-speculative]');
for (var s = 0; s < specs.length; s++) {
if (specs[s].querySelector('[data-speculative]')) {
return 'Nested speculative elements';
}
}
/* Invariant 3: required children (UL must contain LI, etc.) */
var parentChildRules = {
'UL': ['LI'], 'OL': ['LI'],
'TABLE': ['THEAD','TBODY','TR','CAPTION','COLGROUP'],
'THEAD': ['TR'], 'TBODY': ['TR'], 'TR': ['TH','TD'],
};
function checkChildren(element) {
var allowed = parentChildRules[element.nodeName];
if (!allowed) { return null; }
for (var c = 0; c < element.children.length; c++) {
if (allowed.indexOf(element.children[c].nodeName) === -1) {
return '<' + element.children[c].nodeName.toLowerCase() +
'> inside <' + element.nodeName.toLowerCase() +
'> (allowed: ' + allowed.join(', ') + ')';
}
}
for (var c = 0; c < element.children.length; c++) {
var result = checkChildren(element.children[c]);
if (result) { return result; }
}
return null;
}
var childViolation = checkChildren(editor);
if (childViolation) { return 'Invalid nesting: ' + childViolation; }
/* Invariant 4: forbidden nesting (no <strong> inside <strong>, etc.) */
var forbiddenRules = {
'STRONG': ['STRONG','B'], 'B': ['STRONG','B'],
'EM': ['EM','I'], 'I': ['EM','I'],
'CODE': ['CODE','STRONG','B','EM','I','A','DEL'],
'DEL': ['DEL','S','STRIKE'], 'S': ['DEL','S','STRIKE'], 'STRIKE': ['DEL','S','STRIKE'],
'A': ['A'],
};
var allElements = editor.querySelectorAll('*');
for (var e = 0; e < allElements.length; e++) {
var el = allElements[e];
var forbidden = forbiddenRules[el.nodeName];
if (!forbidden) { continue; }
for (var f = 0; f < forbidden.length; f++) {
if (el.querySelector(forbidden[f].toLowerCase() + ',' + forbidden[f])) {
return 'Forbidden nesting: <' + forbidden[f].toLowerCase() +
'> inside <' + el.nodeName.toLowerCase() + '>';
}
}
}
/* Invariant 5: getMarkdown() must not throw */
try {
window.__ribbitEditor.getMarkdown();
} catch (err) {
return 'getMarkdown() threw: ' + err.message;
}
/* Invariant 6: rendered HTML is stable through markdown round-trip.
md → toHTML → toMarkdown → toHTML must eventually stabilize.
The first round-trip may change the HTML (e.g. literal <strong>
in text becomes a real element via HTML passthrough, then
serializes as **). But the second round-trip must be stable.
Skip if there are speculative elements (in-progress editing). */
var hasSpeculative = editor.querySelector('[data-speculative]');
if (!hasSpeculative) {
try {
var md = window.__ribbitEditor.getMarkdown();
var converter = window.__ribbitEditor.converter;
// Two round-trips: allow the first to normalize, check
// that the second produces identical HTML
var html1 = converter.toHTML(md);
var md2 = converter.toMarkdown(html1);
var html2 = converter.toHTML(md2);
var md3 = converter.toMarkdown(html2);
var html3 = converter.toHTML(md3);
var normalize = function(html) {
return html
.replace(/\s*id='[^']*'/g, '')
.replace(/\s+/g, ' ')
.trim();
};
if (normalize(html2) !== normalize(html3)) {
return 'Round-trip HTML not stable after 2 passes:\n pass2: "' + normalize(html2).slice(0, 80) +
'"\n pass3: "' + normalize(html3).slice(0, 80) + '"';
}
} catch (err) {
return 'Round-trip check threw: ' + err.message;
}
}
/* Invariant 7: only valid inline elements inside block content */
var validInline = ['STRONG','B','EM','I','CODE','A','BR','DEL','S','STRIKE'];
var blocks = editor.querySelectorAll('p,h1,h2,h3,h4,h5,h6,li,blockquote,td,th');
for (var b = 0; b < blocks.length; b++) {
var inlineEls = blocks[b].querySelectorAll('*');
for (var ie = 0; ie < inlineEls.length; ie++) {
var inEl = inlineEls[ie];
/* Skip nested block elements (blockquote can contain blocks) */
if (inEl.parentElement !== blocks[b] && inEl.closest('blockquote,ul,ol,table,pre') !== blocks[b]) {
continue;
}
if (validInline.indexOf(inEl.nodeName) === -1 &&
['P','H1','H2','H3','H4','H5','H6','UL','OL','BLOCKQUOTE','PRE','HR','TABLE','LI','THEAD','TBODY','TR','TH','TD','CAPTION','COLGROUP'].indexOf(inEl.nodeName) === -1) {
return 'Invalid inline element <' + inEl.nodeName.toLowerCase() +
'> inside <' + blocks[b].nodeName.toLowerCase() + '>';
}
}
}
return null;
});
}
/* ── Test runner ── */
async function setup() {
server = createServer(9996);
await server.start();
const options = new firefox.Options().addArguments('--headless');
driver = await new Builder().forBrowser('firefox').setFirefoxOptions(options).build();
await driver.get(server.url);
await driver.wait(async () => driver.executeScript('return window.__ribbitReady === true'), 10000);
}
async function teardown() {
if (driver) { await driver.quit(); }
if (server) { await server.stop(); }
}
async function resetEditor() {
await driver.executeScript(`
var e = window.__ribbitEditor;
e.wysiwyg();
e.element.innerHTML = '<p><br></p>';
`);
await driver.findElement(By.id('ribbit')).click();
await driver.sleep(50);
}
async function typeKeystroke(keystroke) {
const keys = keystroke.keys;
if (typeof keys !== 'string') {
throw new Error('Invalid keystroke: ' + JSON.stringify(keystroke));
}
if (keys.length === 1 || keystroke.isSpecial) {
await driver.actions().sendKeys(keys).perform();
await driver.sleep(DELAY);
} else {
/* Multi-char string: type char by char */
for (const character of keys) {
await driver.actions().sendKeys(character).perform();
await driver.sleep(DELAY);
}
}
}
function formatSequence(sequence, upTo) {
return sequence.slice(0, upTo + 1).map(s => s.name).join(' ');
}
/**
* Replay a sequence and return the index of the first invariant failure,
* or -1 if no failure.
*/
async function replaySequence(sequence) {
await resetEditor();
for (let i = 0; i < sequence.length; i++) {
await typeKeystroke(sequence[i]);
const violation = await checkInvariants();
if (violation) { return { index: i, violation }; }
}
return null;
}
/**
* Shrink a failing sequence to find the minimal reproducing prefix.
* Uses binary search on the sequence length.
*/
async function shrinkSequence(sequence, failIndex) {
let lo = 0;
let hi = failIndex;
let bestSequence = sequence.slice(0, failIndex + 1);
let bestViolation = '';
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
const candidate = sequence.slice(0, mid + 1);
const result = await replaySequence(candidate);
if (result) {
hi = mid;
bestSequence = candidate;
bestViolation = result.violation;
} else {
lo = mid + 1;
}
}
/* Try removing individual keystrokes from the beginning */
let shrunk = true;
while (shrunk) {
shrunk = false;
for (let i = 0; i < bestSequence.length - 1; i++) {
const candidate = [...bestSequence.slice(0, i), ...bestSequence.slice(i + 1)];
const result = await replaySequence(candidate);
if (result) {
bestSequence = candidate;
bestViolation = result.violation;
shrunk = true;
break;
}
}
}
return { sequence: bestSequence, violation: bestViolation };
}
async function runFuzz(options) {
const { rounds, minLength, maxLength, seed: baseSeed, doShrink } = options;
let totalKeystrokes = 0;
let failures = 0;
console.log(`\nWYSIWYG Fuzz Test — ${rounds} rounds, seed ${baseSeed}\n`);
for (let round = 0; round < rounds; round++) {
const roundSeed = baseSeed + round;
const random = mulberry32(roundSeed);
const length = minLength + Math.floor(random() * (maxLength - minLength));
const sequence = generateSequence(random, length);
await resetEditor();
let failed = false;
for (let i = 0; i < sequence.length; i++) {
await typeKeystroke(sequence[i]);
const violation = await checkInvariants();
if (violation) {
failures++;
failed = true;
const html = await driver.executeScript('return document.getElementById("ribbit").innerHTML');
console.log(` ✗ Round ${round + 1} [seed=${roundSeed}] — keystroke ${i + 1}/${length}`);
console.log(` Invariant: ${violation}`);
console.log(` Sequence: ${formatSequence(sequence, i)}`);
console.log(` HTML: ${html.slice(0, 200)}`);
if (doShrink) {
console.log(` Shrinking...`);
const shrunk = await shrinkSequence(sequence, i);
console.log(` Minimal (${shrunk.sequence.length} keystrokes): ${shrunk.sequence.map(s => s.name).join(' ')}`);
console.log(` Violation: ${shrunk.violation}`);
}
console.log(` Replay: node test/integration/test_fuzz.js --seed ${roundSeed}\n`);
break;
}
}
if (!failed) {
totalKeystrokes += length;
if ((round + 1) % 10 === 0 || round === rounds - 1) {
process.stdout.write(`${round + 1}/${rounds} rounds (${totalKeystrokes} keystrokes)\r`);
}
}
}
console.log(`\n\n${rounds - failures}/${rounds} rounds passed — ${totalKeystrokes} keystrokes checked`);
if (failures > 0) {
console.log(`${failures} failure(s) found`);
}
return failures;
}
/* ── CLI ── */
function parseArgs() {
const args = process.argv.slice(2);
const options = {
rounds: 50,
minLength: 20,
maxLength: 80,
seed: Date.now() % 100000,
doShrink: true,
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--seed' && args[i + 1]) { options.seed = parseInt(args[i + 1]); i++; }
if (args[i] === '--rounds' && args[i + 1]) { options.rounds = parseInt(args[i + 1]); i++; }
if (args[i] === '--min' && args[i + 1]) { options.minLength = parseInt(args[i + 1]); i++; }
if (args[i] === '--max' && args[i + 1]) { options.maxLength = parseInt(args[i + 1]); i++; }
if (args[i] === '--no-shrink') { options.doShrink = false; }
if (args[i] === '--shrink') { options.doShrink = true; }
}
return options;
}
(async () => {
const options = parseArgs();
try {
await setup();
const failures = await runFuzz(options);
process.exitCode = failures > 0 ? 1 : 0;
} catch (error) {
console.error('Setup failed:', error.message);
process.exitCode = 1;
} finally {
await teardown();
}
})();
+749
View File
@@ -0,0 +1,749 @@
/**
* test_wysiwyg.js — Styled-source WYSIWYG integration tests.
*
* Tests the new styled-source editor implementation. Key differences
* from the old test suite:
*
* - No data-speculative, no <strong>/<em>/<del> DOM elements.
* The editor always stores raw markdown; CSS renders it visually.
* - Inline formatting uses .md-bold, .md-italic, .md-code spans
* with .md-delim children holding the delimiter characters.
* - getMarkdown() reads textContent directly — always returns the
* original markdown source, never converted HTML.
* - Block structure uses <div class="md-*"> elements, not <p>/<h1> etc.
*
* Run headless: node test/integration/test_wysiwyg.js
* Run against dev server: node test/integration/test_wysiwyg.js --port=5023
*/
const { chromium } = require('playwright');
const { createServer } = require('./server');
// ── Config ────────────────────────────────────────────────────────────────────
const HEADLESS = !process.argv.includes('--headed');
const PORT = (() => {
const portArg = process.argv.find(arg => arg.startsWith('--port='));
return portArg ? parseInt(portArg.split('=')[1]) : 5023;
})();
const FILTER = (() => {
const filterArg = process.argv.find(arg => arg.startsWith('--filter='));
return filterArg ? filterArg.split('=')[1] : null;
})();
const DELAY = 20; // ms between keystrokes
// ── State ─────────────────────────────────────────────────────────────────────
let browser, page, server;
let passed = 0, failed = 0;
const errors = [];
// ── Setup / teardown ──────────────────────────────────────────────────────────
async function getCaretInfo() {
return page.evaluate(() => {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return 'no selection';
const range = sel.getRangeAt(0);
return {
container: range.startContainer.nodeType === 3
? `text:"${range.startContainer.textContent}"`
: `element:${range.startContainer.nodeName}.${range.startContainer.className}`,
offset: range.startOffset,
parentClass: range.startContainer.parentElement?.className,
};
});
}
async function isPortInUse(port) {
return new Promise((resolve) => {
const net = require('net');
const tester = net.createServer()
.once('error', () => resolve(true))
.once('listening', () => tester.close(() => resolve(false)))
.listen(port, '127.0.0.1');
});
}
async function serverStart() {
var liveServer = require("live-server");
var params = {
port: PORT,
host: "0.0.0.0",
open: true,
root: "test/integration",
mount: [
['/static', 'dist/ribbit'],
['/test', 'test/integration'],
],
logLevel: 2, // 0 = errors only, 1 = some, 2 = lots
};
console.log(`\n🐸 Ribbit dev server running on http://localhost:${params['port']}`);
liveServer.start(params);
}
async function setup() {
if (!await isPortInUse(PORT)) {
await serverStart();
await new Promise(resolve => setTimeout(resolve, 500));
}
browser = await chromium.launch({ headless: HEADLESS, channel: 'chromium' });
page = await browser.newPage();
await page.goto(`http://localhost:${PORT}`);
await page.waitForFunction(() => window.__ribbitReady === true, { timeout: 10000 });
}
async function teardown() {
if (browser) { await browser.close(); }
}
// ── Editor helpers ────────────────────────────────────────────────────────────
/**
* Reset the editor to an empty state in wysiwyg mode.
* Clears the DOM and places the cursor ready for typing.
*/
async function resetEditor() {
await page.evaluate(() => {
const editor = window.__ribbitEditor;
editor.view();
editor.sourceMarkdown = '';
editor.element.innerHTML = '';
editor.wysiwyg();
});
await page.focus('#ribbit');
await page.waitForTimeout(30);
}
/**
* Type a string one character at a time with delay between each.
* Matches real user behaviour so block/inline transforms fire correctly.
*/
async function typeString(text) {
for (const character of text) {
await page.keyboard.insertText(character);
await page.waitForTimeout(DELAY);
}
}
/**
* Press a special key (Enter, Backspace, ArrowRight, etc).
*/
async function pressKey(key) {
await page.keyboard.press(key);
await page.waitForTimeout(DELAY);
}
/**
* Get the editor's current innerHTML.
*/
async function getHTML() {
return page.evaluate(() => document.getElementById('ribbit').innerHTML);
}
/**
* Get the editor's current markdown via getMarkdown().
*/
async function getMarkdown() {
return page.evaluate(() => window.__ribbitEditor.getMarkdown());
}
/**
* Get all CSS classes on block divs inside the editor.
*/
async function getBlockClasses() {
return page.evaluate(() =>
Array.from(document.getElementById('ribbit').children)
.map(block => block.className)
);
}
// ── Test runner ───────────────────────────────────────────────────────────────
function assert(condition, message) {
if (!condition) { throw new Error(message); }
}
async function test(name, fn) {
if (FILTER && !name.toLowerCase().includes(FILTER.toLowerCase())) {
return;
}
try {
await fn();
passed++;
console.log(`${name}`);
} catch (error) {
failed++;
errors.push({ name, message: error.message });
console.log(`${name}`);
console.log(` ${error.message}`);
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
async function runTests() {
console.log('\nStyled-source WYSIWYG Integration Tests\n');
// ── Block classification ───────────────────────────────────────────────────
console.log('Block classification:');
await test('# space becomes md-h1', async () => {
await resetEditor();
await typeString('#');
let classes = await getBlockClasses();
assert(!classes.some(c => c.includes('md-h')), `Premature heading after just #: ${classes}`);
await typeString(' ');
classes = await getBlockClasses();
assert(classes.some(c => c.includes('md-h1')), `Expected md-h1 after "# ", got: ${classes}`);
await typeString('Title');
const markdown = await getMarkdown();
assert(markdown.includes('# Title'), `Expected "# Title" in markdown: ${markdown}`);
});
await test('plain text becomes md-paragraph', async () => {
await resetEditor();
await typeString("hello\n\n");
const classes = await getBlockClasses();
assert(classes.some(c => c.includes('md-paragraph')), `Expected md-paragraph, got: ${classes}`);
});
await test('## space becomes md-h2', async () => {
await resetEditor();
await typeString('## ');
const classes = await getBlockClasses();
assert(classes.some(c => c.includes('md-h2')), `Expected md-h2, got: ${classes}`);
});
await test('### space becomes md-h3', async () => {
await resetEditor();
await typeString('### ');
const classes = await getBlockClasses();
assert(classes.some(c => c.includes('md-h3')), `Expected md-h3, got: ${classes}`);
});
await test('> space becomes md-blockquote', async () => {
await resetEditor();
await typeString('>');
let classes = await getBlockClasses();
assert(!classes.some(c => c.includes('md-blockquote')), `Premature blockquote: ${classes}`);
await typeString(' ');
classes = await getBlockClasses();
assert(classes.some(c => c.includes('md-blockquote')), `Expected md-blockquote, got: ${classes}`);
});
await test('- space becomes md-list-item', async () => {
await resetEditor();
await typeString('-');
let classes = await getBlockClasses();
assert(!classes.some(c => c.includes('md-list')), `Premature list: ${classes}`);
await typeString(' ');
classes = await getBlockClasses();
assert(classes.some(c => c.includes('md-list-item')), `Expected md-list-item, got: ${classes}`);
});
await test('1. space becomes md-ol-list-item', async () => {
await resetEditor();
await typeString('1. ');
const classes = await getBlockClasses();
assert(classes.some(c => c.includes('md-ol-list-item')), `Expected md-ol-list-item, got: ${classes}`);
});
// ── Inline formatting ──────────────────────────────────────────────────────
console.log('\nInline formatting:');
await test('**bold** produces md-bold span', async () => {
await resetEditor();
await typeString('**bold**');
const html = await getHTML();
assert(html.includes('md-bold'), `Expected md-bold span: ${html}`);
const markdown = await getMarkdown();
assert(markdown === '**bold**', `Expected "**bold**", got: "${markdown}"`);
});
await test('*italic* produces md-italic span', async () => {
await resetEditor();
await typeString('*italic*');
const html = await getHTML();
assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
const markdown = await getMarkdown();
assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`);
});
await test('***bold-italic*** produces md-bold anad md-italic spans', async () => {
await resetEditor();
await typeString('***both***');
const html = await getHTML();
assert(html.includes('md-bold'), `Expected md-bold-italic span: ${html}`);
assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
const markdown = await getMarkdown();
assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
});
await test('nested bold and italic are handled correctly', async() => {
const NESTED_CASES = [
{
name: 'close-side ambiguity, outer=bold (bold *italic***)',
markdown: '**bold *italic***',
outerClass: 'md-bold',
innerClass: 'md-italic',
},
{
name: 'close-side ambiguity, outer=italic (*italic **bold***)',
markdown: '*italic **bold***',
outerClass: 'md-italic',
innerClass: 'md-bold',
},
{
name: 'open-side ambiguity, outer=bold (***italic* bold**)',
markdown: '***italic* bold**',
outerClass: 'md-bold',
innerClass: 'md-italic',
},
{
name: 'open-side ambiguity, outer=italic (***bold** italic*)',
markdown: '***bold** italic*',
outerClass: 'md-italic',
innerClass: 'md-bold',
},
];
for (const testCase of NESTED_CASES) {
await test(testCase.name, async () => {
await resetEditor();
await typeString(testCase.markdown);
const markdown = await getMarkdown();
const html = await getHTML();
assert(
markdown === testCase.markdown,
`Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"`
);
const outerIndex = html.indexOf(testCase.outerClass);
const innerIndex = html.indexOf(testCase.innerClass);
assert(
outerIndex !== -1 && innerIndex !== -1,
`Missing expected classes (${testCase.outerClass}, ${testCase.innerClass}) in: ${html}`
);
assert(
outerIndex < innerIndex,
`Expected ${testCase.outerClass} to wrap (appear before) ${testCase.innerClass} in: ${html}`
);
});
}
});
await test('`code` produces md-code span', async () => {
await resetEditor();
await typeString('`code`');
const html = await getHTML();
assert(html.includes('md-code'), `Expected md-code span: ${html}`);
const markdown = await getMarkdown();
assert(markdown === '`code`', `Expected "\`code\`", got: "${markdown}"`);
});
await test('~~strike~~ produces md-strikethrough span', async () => {
await resetEditor();
await typeString('~~gone~~');
const html = await getHTML();
assert(html.includes('md-strikethrough'), `Expected md-strikethrough span: ${html}`);
const markdown = await getMarkdown();
assert(markdown === '~~gone~~', `Expected "~~gone~~", got: "${markdown}"`);
});
await test('delimiters are present in DOM as md-delim spans', async () => {
await resetEditor();
await typeString('**bold**');
const html = await getHTML();
assert(html.includes('md-delim'), `Expected md-delim spans: ${html}`);
// The delimiter text ** must appear in the DOM
assert(html.includes('**'), `Delimiter text missing from DOM: ${html}`);
});
await test('mixed inline on one line round-trips correctly', async () => {
await resetEditor();
await typeString('hello **world** and *italic*');
const markdown = await getMarkdown();
assert(
markdown === 'hello **world** and *italic*',
`Round-trip failed: "${markdown}"`
);
});
await test('bold followed by trailing space stays bold', async () => {
await resetEditor();
await typeString('**bold** ');
const html = await getHTML();
assert(html.includes('md-bold'), `Expected md-bold span to survive trailing space: ${html}`);
const markdown = await getMarkdown();
assert(
markdown === '**bold** ',
`Expected "**bold** ", got: "${markdown}"`
);
});
await test('heading prefix stays plain space, not nbsp, after rebuild', async () => {
await resetEditor();
await typeString('# Title');
const html = await getHTML();
// The delimiter span's text must be a literal space (U+0020),
// not a non-breaking space, or block classification breaks on
// the next keystroke read of textContent.
assert(
html.includes('<span class="md-delim"># </span>') ||
html.includes('class="md-delim">#&nbsp;'),
`Unexpected delim content: ${html}`
);
// The real check: typing more text after this should still
// classify as md-h1, not regress to md-paragraph.
await typeString(' more');
const classes = await getBlockClasses();
assert(
classes.some(c => c.includes('md-h1')),
`Lost md-h1 classification after additional typing: ${JSON.stringify(classes)}`
);
});
// ── getMarkdown round-trips ────────────────────────────────────────────────
console.log('\ngetMarkdown round-trips:');
await test('heading round-trips', async () => {
await resetEditor();
await typeString('# Hello World');
const markdown = await getMarkdown();
assert(markdown === '# Hello World', `Expected "# Hello World", got: "${markdown}"`);
});
await test('blockquote round-trips', async () => {
await resetEditor();
await typeString('> quoted text');
const markdown = await getMarkdown();
assert(markdown === '> quoted text', `Expected "> quoted text", got: "${markdown}"`);
});
await test('list item round-trips', async () => {
await resetEditor();
await typeString('- list item');
const markdown = await getMarkdown();
assert(markdown === '- list item', `Expected "- list item", got: "${markdown}"`);
});
await test('nested inline in heading round-trips', async () => {
await resetEditor();
await typeString('# Hello **world**');
const markdown = await getMarkdown();
assert(markdown === '# Hello **world**', `Expected "# Hello **world**", got: "${markdown}"`);
});
// ── Enter key behaviour ────────────────────────────────────────────────────
console.log('\nEnter key behaviour:');
await test('double Enter exits list', async () => {
await resetEditor();
await typeString('- item');
await pressKey('Enter');
await pressKey('Enter');
const blocks = await getBlockClasses();
assert(
blocks.some(c => c.includes('md-paragraph')),
`Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
);
});
await test('double Enter exits blockquote', async () => {
await resetEditor();
await typeString('> quote');
await pressKey('Enter');
await pressKey('Enter');
const blocks = await getBlockClasses();
assert(
blocks.some(c => c.includes('md-paragraph')),
`Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
);
});
await test('ordered list increments', async () => {
await resetEditor();
await typeString('1. first');
await pressKey('Enter');
const markdown = await getMarkdown();
assert(
markdown.includes('2. '),
`Expected "2. " on second line, got: "${markdown}"`
);
});
await test('heading followed by Enter creates paragraph', async () => {
await resetEditor();
await typeString('# Title');
await pressKey('Enter');
await typeString('body');
const blocks = await getBlockClasses();
assert(blocks.some(c => c.includes('md-h1')), `No h1: ${blocks}`);
assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph: ${blocks}`);
const markdown = await getMarkdown();
assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`);
});
await test('heading renders correctly after typing', async () => {
await resetEditor();
await typeString('# foo');
const blocks = await getBlockClasses();
assert(
blocks.some(c => c.includes('md-h1')),
`Expected md-h1, got: ${JSON.stringify(blocks)}`
);
const markdown = await getMarkdown();
assert(markdown === '# foo', `Expected "# foo", got: "${markdown}"`);
});
await test('Enter splits current block into two blocks', async () => {
await resetEditor();
await typeString('hello');
await pressKey('Enter');
await typeString('world');
const blocks = await getBlockClasses();
assert(blocks.length === 2, `Expected 2 blocks, got ${blocks.length}: ${JSON.stringify(blocks)}`);
const markdown = await getMarkdown();
assert(markdown === 'hello\nworld', `Expected "hello\\nworld", got: "${markdown}"`);
});
await test('Enter after heading creates new paragraph', async () => {
await resetEditor();
await typeString('# Title');
await pressKey('Enter');
await typeString('body');
const blocks = await getBlockClasses();
assert(blocks.some(c => c.includes('md-h1')), `No h1 block: ${blocks}`);
assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph block: ${blocks}`);
const markdown = await getMarkdown();
assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`);
});
await test('Enter inside blockquote continues with > prefix', async () => {
await resetEditor();
await typeString('> first line');
await pressKey('Enter');
await typeString('second line');
const markdown = await getMarkdown();
assert(
markdown.includes('> first line'),
`Missing "> first line" in markdown: "${markdown}"`
);
assert(
markdown.includes('> second line'),
`Missing "> second line" — continuation prefix not added: "${markdown}"`
);
});
await test('Enter inside list item continues with - prefix', async () => {
await resetEditor();
await typeString('- first item');
await pressKey('Enter');
await typeString('second item');
const markdown = await getMarkdown();
assert(
markdown.includes('- first item'),
`Missing "- first item": "${markdown}"`
);
assert(
markdown.includes('- second item'),
`Missing "- second item" — continuation prefix not added: "${markdown}"`
);
});
// ── Backspace key behaviour ────────────────────────────────────────────────
console.log('\nBackspace key behaviour:');
await test('backspace on last empty block does not break editor', async () => {
await resetEditor();
await typeString('foo');
// Select all and delete
await page.keyboard.press('Control+a');
await page.keyboard.press('Backspace');
await page.keyboard.press('Backspace');
// Editor should still be functional
await typeString('# bar');
const blocks = await getBlockClasses();
assert(
blocks.some(c => c.includes('md-h1')),
`Editor broken after double backspace, got: ${JSON.stringify(blocks)}`
);
const markdown = await getMarkdown();
assert(markdown === '# bar', `Expected "# bar", got: "${markdown}"`);
});
await test('Backspace at start of block merges with previous block', async () => {
await resetEditor();
await typeString('foo');
await pressKey('Enter');
await typeString('bar');
await pressKey('Home');
await pressKey('Backspace');
const blocks = await getBlockClasses();
assert(blocks.length === 1, `Expected 1 block after merge, got ${blocks.length}`);
const markdown = await getMarkdown();
assert(markdown === 'foobar', `Expected "foobar", got: "${markdown}"`);
});
await test('Backspace mid-block does not merge', async () => {
await resetEditor();
await typeString('foo');
await pressKey('Enter');
await typeString('bar');
await pressKey('Backspace');
const blocks = await getBlockClasses();
assert(blocks.length === 2, `Expected 2 blocks, got ${blocks.length}`);
});
// ── Mode switching ─────────────────────────────────────────────────────────
console.log('\nMode switching:');
await test('view() switches to view state', async () => {
await resetEditor();
await typeString('**bold**');
await page.evaluate(() => window.__ribbitEditor.view());
await page.waitForTimeout(50);
const state = await page.evaluate(() => window.__ribbitEditor.getState());
assert(state === 'view', `Expected "view", got: "${state}"`);
});
await test('wysiwyg() switches back to wysiwyg state', async () => {
await resetEditor();
await typeString('hello');
await page.evaluate(() => window.__ribbitEditor.view());
await page.waitForTimeout(50);
await page.evaluate(() => window.__ribbitEditor.wysiwyg());
await page.waitForTimeout(50);
const state = await page.evaluate(() => window.__ribbitEditor.getState());
assert(state === 'wysiwyg', `Expected "wysiwyg", got: "${state}"`);
});
await test('content survives wysiwyg → view → wysiwyg round-trip', async () => {
await resetEditor();
await typeString('**bold** and *italic*');
const markdownBefore = await getMarkdown();
await page.evaluate(() => window.__ribbitEditor.view());
await page.waitForTimeout(50);
await page.evaluate(() => window.__ribbitEditor.wysiwyg());
await page.waitForTimeout(50);
const markdownAfter = await getMarkdown();
assert(
markdownAfter === markdownBefore,
`Markdown changed after round-trip.\nBefore: "${markdownBefore}"\nAfter: "${markdownAfter}"`
);
});
await test('getMarkdown() returns source in view state', async () => {
await resetEditor();
await typeString('**bold**');
const markdownInEditor = await getMarkdown();
await page.evaluate(() => window.__ribbitEditor.view());
await page.waitForTimeout(50);
const markdownInView = await getMarkdown();
assert(
markdownInView === markdownInEditor,
`getMarkdown() changed on view switch.\nEditor: "${markdownInEditor}"\nView: "${markdownInView}"`
);
});
// ── Complex documents ──────────────────────────────────────────────────────
console.log('\nComplex documents:');
await test('multi-block document round-trips correctly', async () => {
await resetEditor();
await typeString('# Title');
await pressKey('Enter');
await typeString('Some **bold** text.');
await pressKey('Enter');
await typeString('> A quote');
await pressKey('Enter');
await typeString('- A list item');
const markdown = await getMarkdown();
assert(markdown.includes('# Title'), `Missing heading: "${markdown}"`);
assert(markdown.includes('Some **bold** text.'), `Missing bold paragraph: "${markdown}"`);
assert(markdown.includes('> A quote'), `Missing blockquote: "${markdown}"`);
assert(markdown.includes('- A list item'), `Missing list item: "${markdown}"`);
});
await test('empty lines between blocks preserved', async () => {
await resetEditor();
await typeString('first');
await pressKey('Enter');
await pressKey('Enter');
await typeString('second');
const blocks = await getBlockClasses();
assert(blocks.length === 3, `Expected 3 blocks (first, empty, second), got ${blocks.length}`);
const markdown = await getMarkdown();
assert(markdown === 'first\n\nsecond', `Expected "first\\n\\nsecond", got: "${markdown}"`);
});
await test('# space becomes md-h1', async () => {
await resetEditor();
await typeString('#');
let classes = await getBlockClasses();
assert(!classes.some(c => c.includes('md-h')), `Premature heading after just #: ${classes}`);
await typeString(' ');
const html = await page.evaluate(() => document.getElementById('ribbit').innerHTML);
classes = await getBlockClasses();
assert(classes.some(c => c.includes('md-h1')), `Expected md-h1 after "# ", got: ${classes}`);
await typeString('Title');
const markdown = await getMarkdown();
assert(markdown.includes('# Title'), `Expected "# Title" in markdown: ${markdown}`);
});
}
// ── Main ──────────────────────────────────────────────────────────────────────
(async () => {
try {
await setup();
await runTests();
} catch (error) {
console.error('\nSetup failed:', error.message);
failed++;
} finally {
const total = passed + failed;
console.log(`\n${passed}/${total} passed — ${failed} failed`);
if (errors.length) {
console.log('\nFailed tests:');
errors.forEach(({ name, message }) => {
console.log(`${name}`);
console.log(` ${message}`);
});
}
if (!HEADLESS) {
console.log('\nPress Enter to close...');
await new Promise(resolve => process.stdin.once('data', resolve));
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
}
})();
+100
View File
@@ -0,0 +1,100 @@
import { ribbit, resetDOM } from './setup';
const lib = ribbit();
const macros = [
{
name: 'user',
toHTML: () => '<a href="/user">TestUser</a>',
},
{
name: 'npc',
toHTML: ({ keywords }: any) => {
const name = keywords.join(' ');
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
},
},
{
name: 'style',
toHTML: ({ keywords, content }: any) => '<div class="' + keywords.join(' ') + '">' + (content || '') + '</div>',
},
{
name: 'toc',
toHTML: ({ params }: any) => '<aside class="toc" data-depth="' + (params.depth || '3') + '"></aside>',
},
];
const editor = new lib.Editor({macros: macros});
const converter = editor.converter;
const H = (md: string) => converter.toHTML(md);
const M = (html: string) => converter.toMarkdown(html);
describe('Macros', () => {
describe('self-closing', () => {
it('bare name renders', () => expect(H('hello @user world')).toContain('<a href="/user">TestUser</a>'));
it('bare name wrapped', () => expect(H('hello @user world')).toContain('data-macro="user"'));
it('empty parens', () => expect(H('hello @user() world')).toContain('data-macro="user"'));
it('keywords', () => expect(H('@npc(Goblin King)')).toContain('Goblin King'));
it('keywords in data attr', () => expect(H('@npc(Goblin King)')).toContain('data-keywords="Goblin King"'));
it('params', () => expect(H('@toc(depth="2")')).toContain('data-param-depth="2"'));
});
describe('unknown macros', () => {
it('renders error', () => expect(H('@bogus')).toContain('ribbit-error'));
it('shows name', () => expect(H('@bogus')).toContain('@bogus'));
it('block error', () => expect(H('@bogus(args\ncontent\n)')).toContain('ribbit-error'));
});
it('email not matched', () => expect(H('user@example.com')).toBe('<p>user@example.com</p>'));
describe('block macros', () => {
it('content processed', () => expect(H('@style(box\n**bold**\n)')).toContain('<strong>bold</strong>'));
it('wrapped with data-macro', () => expect(H('@style(box\ncontent\n)')).toContain('data-macro="style"'));
it('keywords in data attr', () => expect(H('@style(box center\ncontent\n)')).toContain('data-keywords="box center"'));
});
describe('verbatim', () => {
it('skips markdown', () => expect(H('@style(box verbatim\n**bold**\n)')).toContain('**bold**'));
it('no strong tag', () => expect(H('@style(box verbatim\n**bold**\n)')).not.toContain('<strong>'));
it('escapes html', () => expect(H('@style(box verbatim\n<b>tag</b>\n)')).toContain('&lt;b&gt;'));
it('preserves newlines', () => expect(H('@style(box verbatim\nline1\nline2\n)')).toContain('line1<br>'));
it('data-verbatim set', () => expect(H('@style(box verbatim\ncontent\n)')).toContain('data-verbatim="true"'));
it('keyword stripped from data-keywords', () => {
const html = H('@style(box verbatim\ncontent\n)');
expect(html).toContain('data-keywords="box"');
const verbatimKeywordPattern = /data-keywords="[^"]*verbatim/;
expect(html).not.toMatch(verbatimKeywordPattern);
});
});
describe('nesting', () => {
it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('<strong>'));
it('block contains list', () => expect(H('@style(box\n- item 1\n- item 2\n)')).toContain('<ul>'));
it('inline inside block', () => expect(H('@style(box\nhello @user world\n)')).toContain('data-macro="user"'));
});
describe('fenced code protection', () => {
it('not in code block', () => expect(H('```\n@user\n```')).not.toContain('data-macro'));
it('literal in code block', () => expect(H('```\n@user\n```')).toContain('@user'));
it('not in inline code', () => expect(H('`@user`')).not.toContain('data-macro'));
});
describe('generic round-trip via data- attributes', () => {
it('inline macro', () => expect(M(H('hello @user world'))).toBe('hello @user world'));
it('inline with keywords', () => expect(M(H('@npc(Goblin King)'))).toBe('@npc(Goblin King)'));
it('inline with params', () => expect(M(H('@toc(depth="2")'))).toBe('@toc(depth="2")'));
it('block macro', () => {
const md = '@style(box\n**bold** content\n)';
const result = M(H(md)).trim();
expect(result).toContain('@style(box');
expect(result).toContain('**bold** content');
expect(result).toContain(')');
});
it('verbatim round-trip preserves keyword', () => {
const md = '@style(box verbatim\n<b>literal</b>\n)';
const result = M(H(md)).trim();
expect(result).toContain('@style(box verbatim');
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { Window } from 'happy-dom';
import * as fs from 'fs';
import * as path from 'path';
let _window: any;
export function getWindow(): any {
if (!_window) {
_window = new Window({ url: 'http://localhost' });
(global as any).window = _window;
(global as any).document = _window.document;
(global as any).HTMLElement = _window.HTMLElement;
(global as any).Node = _window.Node;
(global as any).NodeFilter = _window.NodeFilter;
(global as any).TextEncoder = _window.TextEncoder || require('util').TextEncoder;
(global as any).TextDecoder = _window.TextDecoder || require('util').TextDecoder;
const { TextEncoder, TextDecoder } = require('util');
_window.TextEncoder = TextEncoder;
_window.TextDecoder = TextDecoder;
const bundle = fs.readFileSync(
path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8'
);
_window.eval(bundle.replace('var ribbit =', 'window.ribbit ='));
}
return _window;
}
export function ribbit(): any {
const browserWindow = getWindow();
const lib = browserWindow.ribbit;
lib.window = browserWindow;
return lib;
}
export function resetDOM(content = 'test'): void {
getWindow().document.body.innerHTML = `<article id="ribbit">${content}</article>`;
}
-507
View File
@@ -1,507 +0,0 @@
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
// Set up a DOM environment and load the bundle
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'http://localhost',
pretendToBeVisual: true,
});
global.window = dom.window;
global.document = dom.window.document;
global.HTMLElement = dom.window.HTMLElement;
global.Node = dom.window.Node;
// Load the compiled bundle — esbuild IIFE assigns to var ribbit,
// but eval in jsdom doesn't attach vars to window, so we patch it.
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8');
dom.window.eval(bundle.replace('var ribbit =', 'window.ribbit ='));
const hopdown = new dom.window.ribbit.HopDown();
const H = hopdown.toHTML.bind(hopdown);
const M = hopdown.toMarkdown.bind(hopdown);
function rt(md) { return M(H(md)); }
// Test harness
let passed = 0, failed = 0, errors = [];
function norm(s) { return (s || '').replace(/\r\n/g, '\n').trim(); }
function eq(name, actual, expected) {
const a = norm(actual), e = norm(expected);
if (a === e) {
passed++;
} else {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` expected: ${e}`);
console.log(` actual: ${a}`);
}
}
function has(name, actual, sub) {
if (norm(actual).indexOf(norm(sub)) !== -1) {
passed++;
} else {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` expected to contain: ${sub}`);
console.log(` actual: ${actual}`);
}
}
function not(name, actual, sub) {
if (norm(actual).indexOf(norm(sub)) === -1) {
passed++;
} else {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` should NOT contain: ${sub}`);
console.log(` actual: ${actual}`);
}
}
function section(n) { /* silent */ }
// ── 1. Inline formatting ────────────────────────────────
section('1. Inline Formatting → HTML');
eq('bold', H('**bold**'), '<p><strong>bold</strong></p>');
eq('italic', H('*italic*'), '<p><em>italic</em></p>');
eq('inline code', H('`code`'), '<p><code>code</code></p>');
eq('link', H('[t](http://x)'), '<p><a href="http://x">t</a></p>');
eq('bold+italic', H('***bi***'), '<p><em><strong>bi</strong></em></p>');
eq('mixed inline', H('a **b** *c* `d`'), '<p>a <strong>b</strong> <em>c</em> <code>d</code></p>');
eq('code before bold', H('`a` **b**'), '<p><code>a</code> <strong>b</strong></p>');
// ── 2. Headings ─────────────────────────────────────────
eq('h1', H('# Title'), "<h1 id='Title'>Title</h1>");
eq('h2', H('## Sub'), "<h2 id='Sub'>Sub</h2>");
eq('h3', H('### Sub3'), "<h3 id='Sub3'>Sub3</h3>");
eq('h4', H('#### Sub4'), "<h4 id='Sub4'>Sub4</h4>");
eq('h5', H('##### Sub5'), "<h5 id='Sub5'>Sub5</h5>");
eq('h6', H('###### Sub6'), "<h6 id='Sub6'>Sub6</h6>");
has('heading id multi-word', H('## Hello World'), "id='HelloWorld'");
has('heading inline md', H('## **Bold** text'), '<strong>Bold</strong>');
// ── 3. Horizontal rules ─────────────────────────────────
eq('*** rule', H('***'), '<hr>');
eq('--- rule', H('---'), '<hr>');
eq('___ rule', H('___'), '<hr>');
// ── 4. Lists ────────────────────────────────────────────
eq('ul *', H('* a\n* b'), '<ul><li>a</li><li>b</li></ul>');
eq('ul -', H('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
eq('ol', H('1. a\n2. b'),'<ol><li>a</li><li>b</li></ol>');
has('ul inline', H('* **bold** item'), '<strong>bold</strong>');
has('ol inline', H('1. *em* item'), '<em>em</em>');
// ── 5. Blockquotes ──────────────────────────────────────
has('blockquote', H('> text'), '<blockquote>');
has('bq content', H('> hello'), 'hello');
has('multi-line bq', H('> a\n> b'), 'a');
// ── 6. Fenced code blocks ───────────────────────────────
has('code block', H('```\nx = 1\n```'), '<pre><code>');
has('code content', H('```\nx = 1\n```'), 'x = 1');
has('lang class', H('```js\nvar x;\n```'), 'language-js');
has('html escaped', H('```\n<div>\n```'), '&lt;div&gt;');
not('no lang attr when none', H('```\nplain\n```'), 'language-');
// ── 7. Tables ───────────────────────────────────────────
var tbl = '| a | b |\n|---|---|\n| 1 | 2 |';
has('table tag', H(tbl), '<table>');
has('thead', H(tbl), '<thead>');
has('tbody', H(tbl), '<tbody>');
has('th cells', H(tbl), '<th>a</th>');
has('td cells', H(tbl), '<td>1</td>');
var aligned = '| L | C | R |\n|:--|:--:|--:|\n| a | b | c |';
has('left align (default)', H(aligned), '<td>a</td>');
has('center align', H(aligned), 'text-align:center');
has('right align', H(aligned), 'text-align:right');
has('table inline md', H('| **b** | *i* |\n|---|---|\n| x | y |'), '<strong>b</strong>');
// ── 8. Paragraphs ───────────────────────────────────────
eq('single para', H('hello'), '<p>hello</p>');
eq('two paras', H('a\n\nb'), '<p>a</p>\n<p>b</p>');
eq('soft line break', H('a\nb'), '<p>a\nb</p>');
// ── 9. HTML → Markdown ──────────────────────────────────
eq('strong→**', M('<p><strong>b</strong></p>'), '**b**');
eq('em→*', M('<p><em>i</em></p>'), '*i*');
eq('code→`', M('<p><code>c</code></p>'), '`c`');
eq('a→[]', M('<a href="http://x">t</a>'), '[t](http://x)');
eq('p→text', M('<p>hello</p>'), 'hello');
eq('h1→#', M('<h1>T</h1>'), '# T');
eq('h2→##', M('<h2>T</h2>'), '## T');
eq('h3→###', M('<h3>T</h3>'), '### T');
eq('hr→---', M('<hr>'), '---');
eq('ul→-', M('<ul><li>a</li><li>b</li></ul>'), '- a\n- b');
eq('ol→1.', M('<ol><li>a</li><li>b</li></ol>'), '1. a\n2. b');
has('bq→>', M('<blockquote><p>q</p></blockquote>'), '> ');
has('pre→```', M('<pre><code>x</code></pre>'), '```');
has('pre content', M('<pre><code>x = 1</code></pre>'), 'x = 1');
has('pre lang', M('<pre><code class="language-py">x</code></pre>'), '```py');
var tableHtml = '<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>';
has('table→pipes', M(tableHtml), '| a | b |');
has('table separator', M(tableHtml), '| --- | --- |');
has('table body', M(tableHtml), '| 1 | 2 |');
// ── 10. Round-trip ──────────────────────────────────────
eq('para rt', rt('Hello world'), 'Hello world');
eq('bold rt', rt('**bold**'), '**bold**');
eq('italic rt', rt('*italic*'), '*italic*');
eq('code rt', rt('`code`'), '`code`');
eq('link rt', rt('[t](http://x)'), '[t](http://x)');
eq('h1 rt', rt('# Title'), '# Title');
eq('h2 rt', rt('## Sub'), '## Sub');
eq('hr rt', rt('---'), '---');
eq('ul rt', rt('- a\n- b'), '- a\n- b');
eq('ol rt', rt('1. a\n2. b'), '1. a\n2. b');
has('bq rt', rt('> quoted'), '> ');
has('code block rt', rt('```\nx = 1\n```'), '```');
has('code block rt content', rt('```\nx = 1\n```'), 'x = 1');
has('table rt', rt('| a | b |\n|---|---|\n| 1 | 2 |'), '| a | b |');
// ── 11. Edge cases ──────────────────────────────────────
eq('empty string', H(''), '');
eq('whitespace only', H(' '), '');
has('html entities', H('a & b < c'), '&amp;');
has('html in code', H('`<div>`'), '&lt;div&gt;');
eq('empty html→md', M(''), '');
has('para then heading', H('text\n\n## H'), '<h2');
has('list then para', H('- a\n\ntext'), '<p>text</p>');
has('table no leading pipe', H('a | b\n---|---\n1 | 2'), '<table>');
// ── 12. Complex document ────────────────────────────────
var doc = '# Title\n\nSome **bold** and *italic* text with `code`.\n\n## Section One\n\n- item 1\n- item 2\n\n## Section Two\n\n| Col A | Col B |\n|-------|-------|\n| 1 | 2 |\n\n> A blockquote\n\n```js\nvar x = 1;\n```\n\n[A link](http://example.com)\n\n---';
var html = H(doc);
has('doc: h1', html, "<h1 id='Title'>Title</h1>");
has('doc: bold', html, '<strong>bold</strong>');
has('doc: italic', html, '<em>italic</em>');
has('doc: code', html, '<code>code</code>');
has('doc: h2', html, '<h2');
has('doc: ul', html, '<ul>');
has('doc: table', html, '<table>');
has('doc: blockquote', html, '<blockquote>');
has('doc: pre', html, '<pre>');
has('doc: link', html, '<a href="http://example.com">');
has('doc: hr', html, '<hr>');
var md = M(html);
has('doc rt: heading', md, '# Title');
has('doc rt: bold', md, '**bold**');
has('doc rt: italic', md, '*italic*');
has('doc rt: code', md, '`code`');
has('doc rt: list', md, '- item 1');
has('doc rt: table', md, '| Col A | Col B |');
has('doc rt: bq', md, '> ');
has('doc rt: fenced', md, '```');
has('doc rt: link', md, '[A link](http://example.com)');
has('doc rt: hr', md, '---');
// ── 13. Nested Inline ───────────────────────────────────
eq('bold wraps italic', H('**a *b* c**'), '<p><strong>a <em>b</em> c</strong></p>');
eq('italic wraps bold', H('*a **b** c*'), '<p><em>a <strong>b</strong> c</em></p>');
eq('bold wraps code', H('**a `b` c**'), '<p><strong>a <code>b</code> c</strong></p>');
eq('italic wraps code', H('*a `b` c*'), '<p><em>a <code>b</code> c</em></p>');
eq('bold wraps link', H('**[t](u)**'), '<p><strong><a href="u">t</a></strong></p>');
eq('italic wraps link', H('*[t](u)*'), '<p><em><a href="u">t</a></em></p>');
eq('link with bold text', H('[**t**](u)'), '<p><a href="u"><strong>t</strong></a></p>');
eq('link with italic text', H('[*t*](u)'), '<p><a href="u"><em>t</em></a></p>');
eq('link with code text', H('[`t`](u)'), '<p><a href="u"><code>t</code></a></p>');
eq('bold>italic>code', H('***`x`***'), '<p><em><strong><code>x</code></strong></em></p>');
eq('bold wraps bold-italic', H('**a ***b*** c**'), '<p><strong>a <em><strong>b</strong></em> c</strong></p>');
// ── 14. Nested Blocks ───────────────────────────────────
has('bq > heading', H('> # Title'), '<h1');
has('bq > heading content', H('> # Title'), 'Title');
has('bq > list', H('> - a\n> - b'), '<ul>');
has('bq > list items', H('> - a\n> - b'), '<li>a</li>');
has('bq > inline md', H('> **bold**'), '<strong>bold</strong>');
has('bq > code', H('> `code`'), '<code>code</code>');
has('bq > link', H('> [t](u)'), '<a href="u">');
has('bq > bq', H('> > nested'), '<blockquote>');
has('bq > fenced code', H('> ```\n> x\n> ```'), '<code>');
has('li > bold', H('- **bold**'), '<strong>bold</strong>');
has('li > italic', H('- *italic*'), '<em>italic</em>');
has('li > code', H('- `code`'), '<code>code</code>');
has('li > link', H('- [t](u)'), '<a href="u">');
has('heading > link', H('## [t](u)'), '<a href="u">');
has('heading > code', H('## `code`'), '<code>code</code>');
has('table > bold', H('| **b** |\n|---|\n| x |'), '<strong>b</strong>');
has('table > italic', H('| *i* |\n|---|\n| x |'), '<em>i</em>');
has('table > code', H('| `c` |\n|---|\n| x |'), '<code>c</code>');
has('table > link', H('| [t](u) |\n|---|\n| x |'), '<a href="u">');
// ── 15. Nested Round-Trips ──────────────────────────────
eq('bold>italic rt', rt('**a *b* c**'), '**a *b* c**');
eq('italic>bold rt', rt('*a **b** c*'), '*a **b** c*');
eq('bold>code rt', rt('**a `b` c**'), '**a `b` c**');
eq('bold>link rt', rt('**[t](u)**'), '**[t](u)**');
eq('link>bold rt', rt('[**t**](u)'), '[**t**](u)');
has('bq>heading rt', rt('> # Title'), '> ');
has('bq>heading rt title', rt('> # Title'), '# Title');
has('bq>list rt', rt('> - a\n> - b'), '> ');
has('li>bold rt', rt('- **bold**'), '**bold**');
has('heading>code rt', rt('## `code`'), '`code`');
// ── 16. Nested Lists ────────────────────────────────────
eq('ul > ul', H('- a\n - b\n - c\n- d'), '<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>');
eq('ol > ol', H('1. a\n 1. b\n 1. c\n2. d'), '<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>');
eq('ul > ol', H('- a\n 1. b\n 2. c\n- d'), '<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>');
eq('ol > ul', H('1. a\n - b\n - c\n2. d'), '<ol><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ol>');
eq('3-level nesting', H('- a\n - b\n - c\n- d'), '<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>');
has('nested li > bold', H('- a\n - **bold**'), '<strong>bold</strong>');
has('nested li > link', H('- a\n - [t](u)'), '<a href="u">');
eq('ul>ul → md', M('<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>'), '- a\n - b\n - c\n- d');
eq('ol>ol → md', M('<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>'), '1. a\n 1. b\n 2. c\n2. d');
eq('ul>ol → md', M('<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>'), '- a\n 1. b\n 2. c\n- d');
eq('3-level → md', M('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>'), '- a\n - b\n - c\n- d');
eq('ul>ul rt', rt('- a\n - b\n - c\n- d'), '- a\n - b\n - c\n- d');
eq('ol>ol rt', rt('1. a\n 1. b\n 1. c\n2. d'), '1. a\n 1. b\n 2. c\n2. d');
eq('ul>ol rt', rt('- a\n 1. b\n 2. c\n- d'), '- a\n 1. b\n 2. c\n- d');
eq('3-level rt', rt('- a\n - b\n - c\n- d'), '- a\n - b\n - c\n- d');
// ── 17. Tables with nested markdown ─────────────────────
has('td bold', H('| h |\n|---|\n| **b** |'), '<td><strong>b</strong></td>');
has('td italic', H('| h |\n|---|\n| *i* |'), '<td><em>i</em></td>');
has('td code', H('| h |\n|---|\n| `c` |'), '<td><code>c</code></td>');
has('td link', H('| h |\n|---|\n| [t](u) |'), '<td><a href="u">t</a></td>');
has('td bold+italic', H('| h |\n|---|\n| ***bi*** |'), '<td><em><strong>bi</strong></em></td>');
has('td bold>italic', H('| h |\n|---|\n| **a *b* c** |'), '<strong>a <em>b</em> c</strong>');
has('td link>bold', H('| h |\n|---|\n| [**t**](u) |'), '<a href="u"><strong>t</strong></a>');
has('td link>code', H('| h |\n|---|\n| [`c`](u) |'), '<a href="u"><code>c</code></a>');
has('multi-cell bold+italic', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '<strong>a</strong>');
has('multi-cell code+link', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '<a href="e">d</a>');
eq('td bold → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><strong>b</strong></td></tr></tbody></table>'), '| h |\n| --- |\n| **b** |');
eq('td italic → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><em>i</em></td></tr></tbody></table>'), '| h |\n| --- |\n| *i* |');
eq('td code → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><code>c</code></td></tr></tbody></table>'), '| h |\n| --- |\n| `c` |');
eq('td link → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><a href="u">t</a></td></tr></tbody></table>'), '| h |\n| --- |\n| [t](u) |');
eq('td bold rt', rt('| h |\n|---|\n| **b** |'), '| h |\n| --- |\n| **b** |');
eq('td italic rt', rt('| h |\n|---|\n| *i* |'), '| h |\n| --- |\n| *i* |');
eq('td code rt', rt('| h |\n|---|\n| `c` |'), '| h |\n| --- |\n| `c` |');
eq('td link rt', rt('| h |\n|---|\n| [t](u) |'), '| h |\n| --- |\n| [t](u) |');
eq('td bold+italic rt', rt('| h |\n|---|\n| ***bi*** |'), '| h |\n| --- |\n| ***bi*** |');
eq('td link>bold rt', rt('| h |\n|---|\n| [**t**](u) |'), '| h |\n| --- |\n| [**t**](u) |');
eq('multi-cell rt', rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |');
// ── 18. inlineTag() factory ─────────────────────────────
const strikethrough = dom.window.ribbit.inlineTag({
name: 'strikethrough',
delimiter: '~~',
htmlTag: 'del',
aliases: 'S,STRIKE',
precedence: 45,
});
const customInline = new dom.window.ribbit.HopDown({
tags: { ...dom.window.ribbit.defaultTags, 'DEL,S,STRIKE': strikethrough },
});
eq('factory: md→html', customInline.toHTML('~~struck~~'), '<p><del>struck</del></p>');
has('factory: html→md', customInline.toMarkdown('<p><del>struck</del></p>'), '~~struck~~');
eq('factory: round-trip', customInline.toMarkdown(customInline.toHTML('~~struck~~')), '~~struck~~');
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<del>struck</del>');
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<strong>bold</strong>');
eq('factory: non-recursive', dom.window.ribbit.inlineTag({
name: 'test',
delimiter: '%%',
htmlTag: 'mark',
recursive: false,
}).toHTML({ content: '<b>x</b>', raw: '', consumed: 0 }, { inline: s => s, block: s => s, children: n => '', node: n => '' }),
'<mark>&lt;b&gt;x&lt;/b&gt;</mark>');
// ── 19. Custom block tag ────────────────────────────────
const spoiler = {
name: 'spoiler',
match: (context) => {
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
const content = [];
let i = context.index + 1;
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i])) content.push(context.lines[i++]);
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
},
toHTML: (token, convert) => '<details><summary>Spoiler</summary>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (element, convert) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
};
const customBlock = new dom.window.ribbit.HopDown({
tags: { 'DETAILS': spoiler, ...dom.window.ribbit.defaultTags },
});
has('custom block: md→html', customBlock.toHTML('|||\nhidden\n|||'), '<details>');
has('custom block: content', customBlock.toHTML('|||\nhidden\n|||'), 'hidden');
has('custom block: html→md', customBlock.toMarkdown('<details><summary>Spoiler</summary><p>hidden</p></details>'), '|||');
has('custom block: nested md', customBlock.toHTML('|||\n**bold** inside\n|||'), '<strong>bold</strong>');
// ── 20. HopDown({ exclude }) ────────────────────────────
const noTables = new dom.window.ribbit.HopDown({ exclude: ['table'] });
// With table excluded, pipe lines fall through to paragraph but isBlockStart
// still detects table-like patterns, so lines are split across paragraphs.
has('exclude: table not rendered', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<p>');
not('exclude: no table tag', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<table>');
has('exclude: bold still works', noTables.toHTML('**bold**'), '<strong>bold</strong>');
const noCode = new dom.window.ribbit.HopDown({ exclude: ['code'] });
eq('exclude: code not processed', noCode.toHTML('`code`'), '<p>`code`</p>');
has('exclude: bold still works', noCode.toHTML('**bold**'), '<strong>bold</strong>');
// ── 21. Collision detection: delimiter ───────────────────
let threw = false;
try {
const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
} catch (e) {
threw = true;
}
eq('delimiter collision throws', String(threw), 'true');
threw = false;
try {
// Same delimiter, higher precedence than existing — should throw
const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 });
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
} catch (e) {
threw = true;
}
eq('duplicate delimiter collision throws', String(threw), 'true');
// ── 22. Collision detection: selector ───────────────────
threw = false;
try {
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'STRONG': dup } });
} catch (e) {
threw = true;
}
eq('selector collision throws', String(threw), 'true');
// ── 23. Precedence ordering ─────────────────────────────
// Longer delimiter with lower precedence should win
const tilde = dom.window.ribbit.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 });
const doubleTilde = dom.window.ribbit.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 });
const precTest = new dom.window.ribbit.HopDown({
tags: { ...dom.window.ribbit.defaultTags, 'S': tilde, 'DEL': doubleTilde },
});
has('precedence: ~~ matches before ~', precTest.toHTML('~~struck~~'), '<del>struck</del>');
has('precedence: ~ still works', precTest.toHTML('~light~'), '<s>light</s>');
// Valid: longer delimiter has lower precedence
threw = false;
try {
const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
} catch (e) {
threw = true;
}
eq('valid precedence does not throw', String(threw), 'false');
// Invalid: longer delimiter has higher precedence
threw = false;
try {
const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 });
const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 });
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
} catch (e) {
threw = true;
}
eq('invalid precedence throws', String(threw), 'true');
// ── 24. Macros ──────────────────────────────────────────
const macroConverter = new dom.window.ribbit.HopDown({
macros: [
{
name: 'user',
toHTML: () => '<a href="/user">TestUser</a>',
selector: 'A[href="/user"]',
toMarkdown: () => '@user',
},
{
name: 'npc',
toHTML: ({ keywords }) => {
const name = keywords.join(' ');
const target = name.replace(/ /g, '');
return '<a href="/NPC/' + target + '">' + name + '</a>';
},
selector: 'A[href^="/NPC/"]',
toMarkdown: (el) => '@npc(' + el.textContent + ')',
},
{
name: 'toc',
toHTML: ({ params }) =>
'<aside class="toc" data-depth="' + (params.depth || '3') + '"></aside>',
},
{
name: 'style',
toHTML: ({ keywords, content }) => {
const classes = keywords.join(' ');
return '<div class="' + classes + '">' + (content || '') + '</div>';
},
selector: 'DIV[class]',
toMarkdown: (el, convert) => {
return '\n\n@style(' + el.className + '\n' + convert.children(el) + '\n)\n\n';
},
},
],
});
const MH = macroConverter.toHTML.bind(macroConverter);
const MM = macroConverter.toMarkdown.bind(macroConverter);
function mrt(md) { return MM(MH(md)); }
// Self-closing macros
eq('macro: bare name', MH('hello @user world'), '<p>hello <a href="/user">TestUser</a> world</p>');
eq('macro: empty parens', MH('hello @user() world'), '<p>hello <a href="/user">TestUser</a> world</p>');
eq('macro: with keywords', MH('@npc(Goblin King)'), '<p><a href="/NPC/GoblinKing">Goblin King</a></p>');
has('macro: with params', MH('@toc(depth="2")'), 'data-depth="2"');
// Unknown macro — error
has('macro: unknown renders error', MH('@bogus'), 'ribbit-error');
has('macro: unknown shows name', MH('@bogus'), '@bogus');
// Email addresses not matched
eq('macro: email not matched', MH('user@example.com'), '<p>user@example.com</p>');
// Block macros
has('macro: block content processed', MH('@style(box\n**bold** inside\n)'), '<strong>bold</strong>');
has('macro: block wraps in div', MH('@style(box\ncontent\n)'), '<div class="box">');
has('macro: block multiple keywords', MH('@style(box center\ncontent\n)'), 'class="box center"');
// Verbatim
has('macro: verbatim skips markdown', MH('@style(box verbatim\n**bold**\n)'), '**bold**');
not('macro: verbatim no strong', MH('@style(box verbatim\n**bold**\n)'), '<strong>');
has('macro: verbatim escapes html', MH('@style(box verbatim\n<b>tag</b>\n)'), '&lt;b&gt;');
has('macro: verbatim preserves newlines', MH('@style(box verbatim\nline1\nline2\n)'), 'line1<br>');
not('macro: verbatim keyword stripped', MH('@style(box verbatim\ncontent\n)'), 'verbatim');
// Nesting
has('macro: inline inside bold', MH('**@npc(Goblin King)**'), '<strong><a href="/NPC/GoblinKing">');
has('macro: block contains list', MH('@style(box\n- item 1\n- item 2\n)'), '<ul>');
has('macro: block contains heading', MH('@style(box\n## Title\n)'), '<h2');
has('macro: inline inside block', MH('@style(box\nhello @user world\n)'), '<a href="/user">TestUser</a>');
// Inside other elements
has('macro: in list item', MH('- @npc(Goblin King)'), '<a href="/NPC/GoblinKing">');
has('macro: in heading', MH('## @npc(Goblin King)'), '<a href="/NPC/GoblinKing">');
// Fenced code protection
not('macro: not in code block', MH('```\n@user\n```'), '<a href="/user">');
has('macro: literal in code block', MH('```\n@user\n```'), '@user');
not('macro: not in inline code', MH('`@user`'), '<a href="/user">');
// Edge cases
has('macro: multiple inline', MH('@npc(Alice) and @npc(Bob)'), 'Alice');
has('macro: multiple inline second', MH('@npc(Alice) and @npc(Bob)'), 'Bob');
has('macro: unknown block renders error', MH('@bogus(args\ncontent\n)'), 'ribbit-error');
// Round-trips
eq('macro: npc round-trip', mrt('@npc(Goblin King)'), '@npc(Goblin King)');
eq('macro: user round-trip', mrt('hello @user world'), 'hello @user world');
// ── Results ─────────────────────────────────────────────
const total = passed + failed;
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
if (errors.length) {
console.log('\nFailed:');
errors.forEach(e => console.log(`${e}`));
}
process.exit(failed > 0 ? 1 : 0);
+322
View File
@@ -0,0 +1,322 @@
import { ribbit, getWindow } from './setup';
import { InlineTokenizer, type InlineToken } from '../src/ts/tokenizer';
import { MarkdownSerializer, type SerializerTagDef } from '../src/ts/serializer';
// Set up DOM globals before any tests run
getWindow();
const boldDef = {
delimiter: '**',
htmlTag: 'strong',
recursive: true,
precedence: 40,
};
const italicDef = {
delimiter: '*',
htmlTag: 'em',
recursive: true,
precedence: 50,
};
const strikeDef = {
delimiter: '~~',
htmlTag: 'del',
recursive: true,
precedence: 45,
};
const codeDef = {
delimiter: '`',
htmlTag: 'code',
recursive: false,
precedence: 10,
};
const tokenizer = new InlineTokenizer([boldDef, italicDef, strikeDef, codeDef]);
function roles(tokens: InlineToken[]): string[] {
return tokens.map(token => token.role);
}
function values(tokens: InlineToken[]): string[] {
return tokens.map(token => token.value);
}
describe('InlineTokenizer', () => {
describe('plain text', () => {
it('produces a single text token', () => {
const tokens = tokenizer.tokenize('hello world');
expect(roles(tokens)).toEqual(['text']);
expect(values(tokens)).toEqual(['hello world']);
});
});
describe('bold', () => {
it('tokenizes **bold**', () => {
const tokens = tokenizer.tokenize('**bold**');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
expect(tokens[0].delimiter).toBe('**');
expect(tokens[1].value).toBe('bold');
});
it('tokenizes text **bold** text', () => {
const tokens = tokenizer.tokenize('hello **bold** end');
expect(roles(tokens)).toEqual(['text', 'open', 'text', 'close', 'text']);
});
});
describe('italic', () => {
it('tokenizes *italic*', () => {
const tokens = tokenizer.tokenize('*italic*');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
expect(tokens[0].delimiter).toBe('*');
});
});
describe('strikethrough', () => {
it('tokenizes ~~struck~~', () => {
const tokens = tokenizer.tokenize('~~struck~~');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
expect(tokens[0].delimiter).toBe('~~');
});
});
describe('code spans', () => {
it('tokenizes `code`', () => {
const tokens = tokenizer.tokenize('`code`');
expect(roles(tokens)).toEqual(['code']);
expect(tokens[0].content).toBe('code');
});
it('does not parse delimiters inside code', () => {
const tokens = tokenizer.tokenize('`**not bold**`');
expect(roles(tokens)).toEqual(['code']);
expect(tokens[0].content).toBe('**not bold**');
});
});
describe('backslash escapes', () => {
it('\\* becomes literal *', () => {
const tokens = tokenizer.tokenize('\\*hello');
expect(roles(tokens)).toEqual(['text']);
expect(tokens[0].value).toBe('*hello');
});
it('\\\\ becomes literal \\', () => {
const tokens = tokenizer.tokenize('\\\\');
expect(roles(tokens)).toEqual(['text']);
expect(tokens[0].value).toBe('\\');
});
it('\\n at end of line is a hard break', () => {
const tokens = tokenizer.tokenize('hello\\\nworld');
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
});
});
describe('hard line breaks', () => {
it('two trailing spaces before newline', () => {
const tokens = tokenizer.tokenize('hello \nworld');
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
});
it('single space does not break', () => {
const tokens = tokenizer.tokenize('hello \nworld');
const breakTokens = tokens.filter(token => token.role === 'break');
expect(breakTokens.length).toBe(0);
});
});
describe('entity resolution', () => {
it('&amp; becomes &', () => {
const tokens = tokenizer.tokenize('a &amp; b');
expect(tokens[0].value).toBe('a & b');
});
it('&#123; becomes {', () => {
const tokens = tokenizer.tokenize('&#123;');
expect(tokens[0].value).toBe('{');
});
it('&#x7B; becomes {', () => {
const tokens = tokenizer.tokenize('&#x7B;');
expect(tokens[0].value).toBe('{');
});
});
describe('links', () => {
it('tokenizes [text](url)', () => {
const tokens = tokenizer.tokenize('[click](http://x)');
expect(roles(tokens)).toEqual(['link']);
expect(tokens[0].href).toBe('http://x');
expect(tokens[0].value).toBe('click');
});
it('tokenizes [text](url "title")', () => {
const tokens = tokenizer.tokenize('[click](http://x "My Title")');
expect(tokens[0].title).toBe('My Title');
});
it('disallows [ in link text', () => {
const tokens = tokenizer.tokenize('[outer [inner](b)](a)');
// Should not match as a single link
const linkTokens = tokens.filter(token => token.role === 'link');
expect(linkTokens.length).toBeLessThanOrEqual(1);
});
});
describe('autolinks', () => {
it('tokenizes <url>', () => {
const tokens = tokenizer.tokenize('<https://example.com>');
expect(roles(tokens)).toEqual(['autolink']);
expect(tokens[0].href).toBe('https://example.com');
});
it('tokenizes bare URL', () => {
const tokens = tokenizer.tokenize('visit https://example.com today');
expect(tokens.some(token => token.role === 'autolink')).toBe(true);
});
});
describe('HTML passthrough', () => {
it('tokenizes HTML tags', () => {
const tokens = tokenizer.tokenize('a <span>b</span> c');
const htmlTokens = tokens.filter(token => token.role === 'html');
expect(htmlTokens.length).toBe(2);
expect(htmlTokens[0].value).toBe('<span>');
expect(htmlTokens[1].value).toBe('</span>');
});
});
describe('flanking rules', () => {
it('mid-word * is not a delimiter', () => {
const tokens = tokenizer.tokenize('2*3*4');
expect(roles(tokens)).toEqual(['text']);
});
it('* at word boundary is a delimiter', () => {
const tokens = tokenizer.tokenize('*hello*');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
});
});
describe('nested delimiters', () => {
it('bold inside italic', () => {
const tokens = tokenizer.tokenize('*hello **world***');
const openTokens = tokens.filter(token => token.role === 'open');
expect(openTokens.length).toBe(2);
});
});
});
describe('MarkdownSerializer', () => {
const tagMap = new Map<string, SerializerTagDef>([
['STRONG', { delimiter: '**' }],
['B', { delimiter: '**' }],
['EM', { delimiter: '*' }],
['I', { delimiter: '*' }],
['DEL', { delimiter: '~~' }],
['CODE', {
serialize: (element) => '`' + (element.textContent || '') + '`',
}],
['A', {
serialize: (element, children) => {
const href = element.getAttribute('href') || '';
const title = element.getAttribute('title');
const titlePart = title ? ` "${title}"` : '';
return '[' + children() + '](' + href + titlePart + ')';
},
}],
['BR', {
serialize: () => ' \n',
}],
]);
const delimiterChars = new Set(['*', '`', '~']);
const serializer = new MarkdownSerializer(tagMap, delimiterChars);
it('serializes plain text', () => {
const div = document.createElement('div');
div.textContent = 'hello world';
expect(serializer.serialize(div)).toBe('hello world');
});
it('serializes bold', () => {
const div = document.createElement('div');
div.innerHTML = '<strong>bold</strong>';
expect(serializer.serialize(div)).toBe('**bold**');
});
it('serializes italic', () => {
const div = document.createElement('div');
div.innerHTML = '<em>italic</em>';
expect(serializer.serialize(div)).toBe('*italic*');
});
it('escapes * in text nodes', () => {
const div = document.createElement('div');
div.textContent = 'hello * world';
expect(serializer.serialize(div)).toBe('hello \\* world');
});
it('escapes _ in text nodes', () => {
const div = document.createElement('div');
div.textContent = 'hello_world';
expect(serializer.serialize(div)).toBe('hello\\_world');
});
it('escapes \\ in text nodes', () => {
const div = document.createElement('div');
div.textContent = 'back\\slash';
expect(serializer.serialize(div)).toBe('back\\\\slash');
});
it('escapes < before letters', () => {
const div = document.createElement('div');
div.textContent = 'a <b> c';
expect(serializer.serialize(div)).toBe('a \\<b> c');
});
it('does not escape < before non-letters', () => {
const div = document.createElement('div');
div.textContent = '1 < 2';
expect(serializer.serialize(div)).toBe('1 < 2');
});
it('does not escape * inside delimiters', () => {
const div = document.createElement('div');
div.innerHTML = '<strong>bold</strong>';
const result = serializer.serialize(div);
// The ** are delimiter tokens, not escaped
expect(result).toBe('**bold**');
expect(result).not.toContain('\\*');
});
it('escapes * in text adjacent to delimiters', () => {
const div = document.createElement('div');
div.innerHTML = '<strong>bold</strong> * text';
const result = serializer.serialize(div);
expect(result).toContain('\\*');
});
it('serializes link', () => {
const div = document.createElement('div');
div.innerHTML = '<a href="http://x">click</a>';
expect(serializer.serialize(div)).toBe('[click](http://x)');
});
it('serializes link with title', () => {
const div = document.createElement('div');
div.innerHTML = '<a href="http://x" title="T">click</a>';
expect(serializer.serialize(div)).toBe('[click](http://x "T")');
});
it('serializes code', () => {
const div = document.createElement('div');
div.innerHTML = '<code>x</code>';
expect(serializer.serialize(div)).toBe('`x`');
});
it('serializes hard break', () => {
const div = document.createElement('div');
div.innerHTML = 'hello<br>world';
expect(serializer.serialize(div)).toBe('hello \nworld');
});
});
+343
View File
@@ -0,0 +1,343 @@
import { ribbit, resetDOM } from './setup';
const lib = ribbit();
describe('ToolbarManager', () => {
beforeEach(() => resetDOM('**bold** text'));
describe('button registration', () => {
it('registers tag buttons', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('bold')).toBeDefined();
expect(editor.toolbar.buttons.get('italic')).toBeDefined();
expect(editor.toolbar.buttons.get('code')).toBeDefined();
});
it('registers editor actions', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('save')).toBeDefined();
expect(editor.toolbar.buttons.get('toggle')).toBeDefined();
expect(editor.toolbar.buttons.get('markdown')).toBeDefined();
});
it('registers macro buttons', () => {
const editor = new lib.Editor({
macros: [{
name: 'user',
toHTML: () => 'u',
}],
});
editor.run();
expect(editor.toolbar.buttons.get('macro:user')).toBeDefined();
});
it('skips macros with button: false', () => {
const editor = new lib.Editor({
macros: [{
name: 'hidden',
toHTML: () => '',
button: false,
}],
});
editor.run();
expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined();
});
it('skips tags without button', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined();
});
});
describe('button properties', () => {
it('bold has correct label and shortcut', () => {
const editor = new lib.Editor({});
editor.run();
const bold = editor.toolbar.buttons.get('bold')!;
expect(bold.label).toBe('Bold');
expect(bold.shortcut).toBe('Ctrl+B');
});
it('bold action is wrap', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap');
});
it('save action is custom', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('save')!.action).toBe('custom');
});
it('table has template', () => {
const editor = new lib.Editor({});
editor.run();
const table = editor.toolbar.buttons.get('table')!;
expect(table.template).toContain('Header');
expect(table.replaceSelection).toBe(false);
});
it('macro button has insert action', () => {
const editor = new lib.Editor({
macros: [{
name: 'toc',
toHTML: () => '',
}],
});
editor.run();
const btn = editor.toolbar.buttons.get('macro:toc')!;
expect(btn.action).toBe('insert');
expect(btn.template).toBe('@toc');
});
});
describe('button.hide() and button.show()', () => {
it('hide sets visible false', () => {
const editor = new lib.Editor({});
editor.run();
const bold = editor.toolbar.buttons.get('bold')!;
expect(bold.visible).toBe(true);
bold.hide();
expect(bold.visible).toBe(false);
});
it('show restores visible', () => {
const editor = new lib.Editor({});
editor.run();
const bold = editor.toolbar.buttons.get('bold')!;
bold.hide();
bold.show();
expect(bold.visible).toBe(true);
});
});
describe('render()', () => {
it('returns an HTMLElement', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.tagName).toBe('NAV');
expect(toolbar.className).toBe('ribbit-toolbar');
});
it('contains buttons', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelector('.ribbit-btn-bold')).not.toBeNull();
expect(toolbar.querySelector('.ribbit-btn-save')).not.toBeNull();
});
it('buttons have aria-label', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.getAttribute('aria-label')).toBe('Bold');
});
it('buttons have title with shortcut', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)');
});
it('renders spacers', () => {
const editor = new lib.Editor({
autoToolbar: false,
toolbar: ['bold', '', 'save'],
});
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelector('.spacer')).not.toBeNull();
});
it('renders dropdown groups', () => {
const editor = new lib.Editor({
autoToolbar: false,
toolbar: [{
group: 'Test',
items: ['bold', 'italic'],
}],
});
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelector('.ribbit-dropdown')).not.toBeNull();
});
});
describe('auto-render', () => {
it('inserts toolbar before editor by default', () => {
resetDOM();
const editor = new lib.Editor({});
editor.run();
const toolbarElement = editor.element.previousElementSibling;
expect(toolbarElement?.className).toBe('ribbit-toolbar');
});
it('does not insert when autoToolbar is false', () => {
resetDOM();
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbarElement = editor.element.previousElementSibling;
expect(toolbarElement?.className || '').not.toBe('ribbit-toolbar');
});
});
describe('custom layout', () => {
it('respects custom toolbar order', () => {
const editor = new lib.Editor({
autoToolbar: false,
toolbar: ['save', 'bold'],
});
editor.run();
const toolbar = editor.toolbar.render();
const buttons = toolbar.querySelectorAll('button');
expect(buttons[0]?.className).toBe('ribbit-btn-save');
expect(buttons[1]?.className).toBe('ribbit-btn-bold');
});
it('auto-generates layout when not specified', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelectorAll('button').length).toBeGreaterThan(3);
});
});
describe('enable/disable', () => {
it('disable adds disabled class', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
editor.toolbar.disable();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.classList.contains('disabled')).toBe(true);
});
it('enable removes disabled class', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
editor.toolbar.disable();
editor.toolbar.enable();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.classList.contains('disabled')).toBe(false);
});
});
describe('updateActiveState', () => {
it('sets active class on matching buttons', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
editor.toolbar.updateActiveState(['bold']);
expect(editor.toolbar.buttons.get('bold')!.element?.classList.contains('active')).toBe(true);
expect(editor.toolbar.buttons.get('italic')!.element?.classList.contains('active')).toBe(false);
});
it('clears active when not in list', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
editor.toolbar.updateActiveState(['bold']);
editor.toolbar.updateActiveState([]);
expect(editor.toolbar.buttons.get('bold')!.element?.classList.contains('active')).toBe(false);
});
});
describe('heading and list buttons', () => {
it('registers h1-h6', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
for (let level = 1; level <= 6; level++) {
const btn = editor.toolbar.buttons.get(`h${level}`);
expect(btn).toBeDefined();
expect(btn!.label).toBe(`H${level}`);
expect(btn!.shortcut).toBe(`Ctrl+${level}`);
expect(btn!.action).toBe('prefix');
}
});
it('registers ul and ol', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8');
expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7');
});
});
describe('keyboard shortcuts', () => {
it('all formatting buttons have shortcuts', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const expected = ['bold', 'italic', 'code', 'link', 'save'];
for (const id of expected) {
expect(editor.toolbar.buttons.get(id)!.shortcut).toBeDefined();
}
});
it('block buttons have shortcuts', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
expect(editor.toolbar.buttons.get('fencedCode')!.shortcut).toBe('Ctrl+Shift+E');
expect(editor.toolbar.buttons.get('blockquote')!.shortcut).toBe('Ctrl+Shift+.');
expect(editor.toolbar.buttons.get('table')!.shortcut).toBe('Ctrl+Shift+T');
expect(editor.toolbar.buttons.get('hr')!.shortcut).toBe('Ctrl+Shift+-');
});
it('editor actions have shortcuts', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V');
expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/');
});
});
describe('save button', () => {
it('triggers editor.save()', () => {
resetDOM();
let saved = false;
const editor = new lib.Editor({
autoToolbar: false,
on: {
save: () => {
saved = true;
},
},
});
editor.run();
editor.toolbar.render();
editor.toolbar.buttons.get('save')!.click();
expect(saved).toBe(true);
});
});
describe('toggle button', () => {
it('switches from view to wysiwyg', () => {
resetDOM();
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
expect(editor.getState()).toBe('view');
editor.toolbar.buttons.get('toggle')!.click();
expect(editor.getState()).toBe('wysiwyg');
});
it('switches from wysiwyg to view', () => {
resetDOM();
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.wysiwyg();
editor.toolbar.render();
editor.toolbar.buttons.get('toggle')!.click();
expect(editor.getState()).toBe('view');
});
});
});
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"target": "ES2017", "target": "ES2018",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"outDir": "dist", "outDir": "dist",
@@ -10,5 +10,6 @@
"sourceMap": true, "sourceMap": true,
"lib": ["ES2019", "DOM"] "lib": ["ES2019", "DOM"]
}, },
"include": ["src/ts/**/*.ts"] "include": ["src/ts/**/*.ts"],
"exclude": ["test/**/*"]
} }