Compare commits

..

No commits in common. "5b2bd9438870c7b06a8e3523d517005ace000107" and "2e285982433c623b0f31d21d45092f98f5690702" have entirely different histories.

39 changed files with 1113 additions and 7867 deletions

View File

@ -1,118 +0,0 @@
# 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

View File

@ -1,46 +0,0 @@
# 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.

View File

@ -1,281 +0,0 @@
"""
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)

View File

@ -1 +0,0 @@
/tmp/ribbit/dist/ribbit

View File

@ -1,188 +0,0 @@
<!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; }
#ribbit { border: 1px solid #ccc; border-radius: 4px; padding: 20px; min-height: 200px; }
.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 { padding: 4px 8px; border: 1px solid #ddd; border-radius: 3px; background: white; cursor: pointer; font-size: 12px; }
.ribbit-toolbar button:hover { background: #e8e8e8; }
.ribbit-toolbar button.active { background: #d0d0ff; border-color: #99f; }
.ribbit-toolbar button.disabled { opacity: 0.3; cursor: default; }
.ribbit-toolbar .spacer { width: 12px; }
.ribbit-dropdown { position: absolute; background: white; 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; }
</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>

View File

@ -3,7 +3,6 @@ module.exports = {
preset: 'ts-jest', preset: 'ts-jest',
testEnvironment: 'node', testEnvironment: 'node',
roots: ['<rootDir>/test'], roots: ['<rootDir>/test'],
testPathIgnorePatterns: ['/node_modules/', '/test/integration/'],
moduleNameMapper: { moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1', '^(\\.{1,2}/.*)\\.js$': '$1',
}, },
@ -11,7 +10,7 @@ module.exports = {
'^.+\\.tsx?$': ['ts-jest', { '^.+\\.tsx?$': ['ts-jest', {
tsconfig: { tsconfig: {
strict: true, strict: true,
target: 'ES2018', target: 'ES2017',
module: 'CommonJS', module: 'CommonJS',
moduleResolution: 'node', moduleResolution: 'node',
esModuleInterop: true, esModuleInterop: true,

279
package-lock.json generated
View File

@ -13,7 +13,6 @@
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"happy-dom": "^14.12.3", "happy-dom": "^14.12.3",
"jest": "^29.7.0", "jest": "^29.7.0",
"selenium-webdriver": "^4.43.0",
"ts-jest": "^29.4.9", "ts-jest": "^29.4.9",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
@ -473,12 +472,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bazel/runfiles": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz",
"integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==",
"dev": true
},
"node_modules/@bcoe/v8-coverage": { "node_modules/@bcoe/v8-coverage": {
"version": "0.2.3", "version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
@ -1802,12 +1795,6 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true "dev": true
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true
},
"node_modules/create-jest": { "node_modules/create-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@ -2281,12 +2268,6 @@
"node": ">=10.17.0" "node": ">=10.17.0"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true
},
"node_modules/import-local": { "node_modules/import-local": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@ -2392,12 +2373,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -3098,18 +3073,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dev": true,
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -3128,15 +3091,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dev": true,
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -3387,12 +3341,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true
},
"node_modules/parse-json": { "node_modules/parse-json": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -3509,12 +3457,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true
},
"node_modules/prompts": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -3550,21 +3492,6 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true "dev": true
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -3625,37 +3552,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
},
"node_modules/selenium-webdriver": {
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.43.0.tgz",
"integrity": "sha512-dV4zBTT37or3Z3/8uD6rS8zvd4ZxPuG4EJVlqYIbZCGZCYttZm7xb9rlFLSk4rrsQHAeDYvudl7cquo0vWpHjg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/SeleniumHQ"
},
{
"type": "opencollective",
"url": "https://opencollective.com/selenium"
}
],
"dependencies": {
"@bazel/runfiles": "^6.5.0",
"jszip": "^3.10.1",
"tmp": "^0.2.5",
"ws": "^8.20.0"
},
"engines": {
"node": ">= 20.0.0"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -3665,12 +3561,6 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -3750,15 +3640,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-length": { "node_modules/string-length": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@ -3866,15 +3747,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"engines": {
"node": ">=14.14"
}
},
"node_modules/tmpl": { "node_modules/tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@ -4052,12 +3924,6 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@ -4156,27 +4022,6 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0" "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
} }
}, },
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@ -4558,12 +4403,6 @@
"@babel/helper-validator-identifier": "^7.28.5" "@babel/helper-validator-identifier": "^7.28.5"
} }
}, },
"@bazel/runfiles": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz",
"integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==",
"dev": true
},
"@bcoe/v8-coverage": { "@bcoe/v8-coverage": {
"version": "0.2.3", "version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
@ -5466,12 +5305,6 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true "dev": true
}, },
"core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true
},
"create-jest": { "create-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@ -5807,12 +5640,6 @@
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true "dev": true
}, },
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"dev": true
},
"import-local": { "import-local": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@ -5884,12 +5711,6 @@
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true "dev": true
}, },
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true
},
"isexe": { "isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -6423,18 +6244,6 @@
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true "dev": true
}, },
"jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dev": true,
"requires": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"kleur": { "kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -6447,15 +6256,6 @@
"integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
"dev": true "dev": true
}, },
"lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dev": true,
"requires": {
"immediate": "~3.0.5"
}
},
"lines-and-columns": { "lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -6653,12 +6453,6 @@
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true "dev": true
}, },
"pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true
},
"parse-json": { "parse-json": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -6741,12 +6535,6 @@
} }
} }
}, },
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true
},
"prompts": { "prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -6769,21 +6557,6 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true "dev": true
}, },
"readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"require-directory": { "require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -6823,36 +6596,12 @@
"integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==",
"dev": true "dev": true
}, },
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
},
"selenium-webdriver": {
"version": "4.43.0",
"resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.43.0.tgz",
"integrity": "sha512-dV4zBTT37or3Z3/8uD6rS8zvd4ZxPuG4EJVlqYIbZCGZCYttZm7xb9rlFLSk4rrsQHAeDYvudl7cquo0vWpHjg==",
"dev": true,
"requires": {
"@bazel/runfiles": "^6.5.0",
"jszip": "^3.10.1",
"tmp": "^0.2.5",
"ws": "^8.20.0"
}
},
"semver": { "semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true "dev": true
}, },
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"dev": true
},
"shebang-command": { "shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -6917,15 +6666,6 @@
"escape-string-regexp": "^2.0.0" "escape-string-regexp": "^2.0.0"
} }
}, },
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.0"
}
},
"string-length": { "string-length": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@ -7000,12 +6740,6 @@
"minimatch": "^3.0.4" "minimatch": "^3.0.4"
} }
}, },
"tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true
},
"tmpl": { "tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@ -7093,12 +6827,6 @@
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
} }
}, },
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"v8-to-istanbul": { "v8-to-istanbul": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@ -7173,13 +6901,6 @@
"signal-exit": "^3.0.7" "signal-exit": "^3.0.7"
} }
}, },
"ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"requires": {}
},
"y18n": { "y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -15,7 +15,6 @@
"build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.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 && jest --verbose", "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" "test:coverage": "npm run build && jest --coverage"
}, },
"license": "MIT", "license": "MIT",
@ -25,7 +24,6 @@
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"happy-dom": "^14.12.3", "happy-dom": "^14.12.3",
"jest": "^29.7.0", "jest": "^29.7.0",
"selenium-webdriver": "^4.43.0",
"ts-jest": "^29.4.9", "ts-jest": "^29.4.9",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }

View File

@ -30,11 +30,6 @@
font-size: 0.85em; font-size: 0.85em;
} }
[data-speculative]::before,
[data-speculative]::after {
content: none !important;
}
#ribbit.wysiwyg strong.ribbit-editing::before, #ribbit.wysiwyg strong.ribbit-editing::before,
#ribbit.wysiwyg strong.ribbit-editing::after { #ribbit.wysiwyg strong.ribbit-editing::after {
content: "**"; content: "**";

View File

@ -4,7 +4,7 @@
* 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";
a { a {
text-decoration: none; text-decoration: none;

View File

@ -1,373 +0,0 @@
/*
* 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),
}));
}
}

View File

@ -7,18 +7,8 @@
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: DEFAULT_THEME_NAME, name: 'ribbit-default',
tags: defaultTags, tags: defaultTags,
features: { features: {
sourceMode: true, sourceMode: true,

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, PeerInfo, Revision } from './types'; import type { RibbitTheme } from './types';
export interface ContentPayload { export interface ContentPayload {
markdown: string; markdown: string;
@ -72,55 +72,10 @@ 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>>;
@ -130,9 +85,6 @@ 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)) {
@ -143,9 +95,6 @@ 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);
@ -153,9 +102,6 @@ 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) || []) {

View File

@ -1,18 +1,18 @@
/* /*
* hopdown.ts configurable markdownHTML converter. * hopdown.ts configurable markdownHTML converter.
* *
* HopDown orchestrates markdownHTML conversion using a tokenizer for * Usage:
* inline parsing and a serializer for HTMLmarkdown. Block-level parsing * const converter = new HopDown();
* uses Tag definitions directly. The tokenizer/serializer architecture * const converter = new HopDown({ exclude: ['table'] });
* ensures correct round-trips by separating structural delimiters from * const converter = new HopDown({ tags: { ...defaultTags, 'DEL,S,STRIKE': strikethrough } });
* literal text at the type level. *
* converter.toHTML('**bold**');
* converter.toMarkdown('<strong>bold</strong>');
*/ */
import type { Converter, MatchContext, Tag, DelimiterMatch } from './types'; import type { Converter, MatchContext, Tag } from './types';
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml } from './tags'; import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } 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,25 +23,17 @@ export interface HopDownOptions {
} }
/** /**
* Configurable markdownHTML converter. Uses a tokenizer for inline * A configurable markdownHTML converter.
* parsing (markdownHTML) and a serializer for HTMLmarkdown. Block
* parsing delegates to Tag definitions.
* *
* const converter = new HopDown(); * By default includes all standard tags. Pass options to customize:
* converter.toHTML('**bold**'); * - tags: a mapping of HTML selectors to Tag definitions
* converter.toMarkdown('<strong>bold</strong>'); * - exclude: remove specific tags by name from the defaults
*/ */
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;
@ -57,8 +49,8 @@ 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, selectorTag, macroMap } = buildMacroTags(options.macros); const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros);
this.macroMap = macroMap; this.macroMap = macroMap;
@ -67,27 +59,20 @@ export class HopDown {
} }
const allTags = Object.values(tagMap); const allTags = Object.values(tagMap);
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(tag => tag.name)); const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name));
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(tag => tag.name)); const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.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.pattern) (!defaultInlineNames.has(tag.name) && !tag.pattern)
); );
// Macro block tag must run after fencedCode (so code blocks aren't // Ensure macro block tag runs after fencedCode but before everything else
// parsed as macros) but before paragraph (the catch-all)
this.blockTags.sort((a, b) => { this.blockTags.sort((a, b) => {
const order = (tag: Tag) => { const order = (t: Tag) => {
if (tag.name === 'fencedCode') { if (t.name === 'fencedCode') return 0;
return 0; if (t.name === 'macro') return 1;
} 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);
@ -98,35 +83,30 @@ export class HopDown {
); );
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)) {
const parts = selector.split(',').map(part => part.trim()).filter(Boolean); for (const sel of selector.split(',').map(s => s.trim()).filter(Boolean)) {
for (const part of parts) { if (sel.startsWith('_')) {
if (part.startsWith('_')) {
continue; continue;
} }
const existing = this.tags.get(part); const existing = this.tags.get(sel);
if (existing && existing !== tag) { if (existing && existing !== tag) {
throw new Error( throw new Error(
`HTML tag "${part}" is claimed by both "${existing.name}" and "${tag.name}". ` + `HTML tag "${sel}" 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(part, tag); this.tags.set(sel, 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.delimiter) .filter(tag => tag.delimiter)
@ -136,17 +116,17 @@ export class HopDown {
precedence: tag.precedence as number ?? 50, precedence: tag.precedence as number ?? 50,
})); }));
for (let outer = 0; outer < withDelimiters.length; outer++) { for (let i = 0; i < withDelimiters.length; i++) {
for (let inner = outer + 1; inner < withDelimiters.length; inner++) { for (let j = i + 1; j < withDelimiters.length; j++) {
const first = withDelimiters[outer]; const a = withDelimiters[i];
const second = withDelimiters[inner]; const b = withDelimiters[j];
const firstIsPrefix = second.delimiter.startsWith(first.delimiter); const aPrefix = b.delimiter.startsWith(a.delimiter);
const secondIsPrefix = first.delimiter.startsWith(second.delimiter); const bPrefix = a.delimiter.startsWith(b.delimiter);
if (!firstIsPrefix && !secondIsPrefix) { if (!aPrefix && !bPrefix) {
continue; continue;
} }
const longer = first.delimiter.length > second.delimiter.length ? first : second; const longer = a.delimiter.length > b.delimiter.length ? a : b;
const shorter = first.delimiter.length > second.delimiter.length ? second : first; const shorter = a.delimiter.length > b.delimiter.length ? b : a;
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 ` +
@ -161,145 +141,42 @@ export class HopDown {
/** /**
* Convert a markdown string to HTML. * Convert a markdown string to HTML.
*
* converter.toHTML('# Hello\n\n**bold** text')
*/ */
toHTML(markdown: string): string { toHTML(md: string): string {
return this.processBlocks(markdown); return this.processBlocks(md);
} }
/** /**
* Convert an HTML string back to markdown. Uses the serializer * Convert an HTML string back to markdown.
* 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.serializeNode(container).replace(/\n{3,}/g, '\n\n').trim(); return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
} }
/** /**
* The registered block-level tags. Used by the WYSIWYG editor * Return the block tags for external iteration (e.g. speculative rendering).
* to detect block syntax patterns during live editing.
*
* converter.getBlockTags().forEach(tag => console.log(tag.name))
*/ */
getBlockTags(): Tag[] { getBlockTags(): Tag[] {
return this.blockTags; return this.blockTags;
} }
/** /**
* The registered inline tags. Used by the WYSIWYG editor to * Return the inline tags for external iteration (e.g. speculative rendering).
* build delimiter regexes for speculative rendering.
*
* converter.getInlineTags().filter(tag => tag.delimiter)
*/ */
getInlineTags(): Tag[] { getInlineTags(): Tag[] {
return this.inlineTags; return this.inlineTags;
} }
/** private processBlocks(md: string): string {
* Find the first complete delimiter pair in the text. const lines = md.replace(/\r\n/g, '\n').split('\n');
*
* 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 output: string[] = [];
const blankLine = /^\s*$/; let index = 0;
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
let lineIndex = 0;
// Collect reference link definitions while (index < lines.length) {
this.referenceLinks = new Map(); if (/^\s*$/.test(lines[index])) {
for (const line of lines) { index++;
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;
} }
@ -307,435 +184,166 @@ export class HopDown {
for (const tag of this.blockTags) { for (const tag of this.blockTags) {
const context: MatchContext = { const context: MatchContext = {
lines, lines,
index: lineIndex, index,
text: '', text: '',
offset: 0, offset: 0,
}; };
const token = tag.match(context); const token = tag.match(context);
if (!token) { if (!token) continue;
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) {
lineIndex++; index++;
} }
} }
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;
// Process inline macros before tokenizing — they produce HTML // Extract inline macros before other processing
// that should pass through without further parsing
if (this.macroMap.size > 0) { if (this.macroMap.size > 0) {
const placeholders: string[] = []; text = processInlineMacros(text, this.macroMap, this.makeConverter(), placeholders);
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)]
);
} }
// Resolve reference links before tokenizing // Pass 1: extract links and non-recursive tags into placeholders before escaping
text = this.resolveReferenceLinks(text); for (const tag of sorted) {
// Normalize _ emphasis to * const recursive = tag.recursive ?? true;
text = this.normalizeUnderscores(text);
const tokens = this.tokenizer.tokenize(text);
return this.tokensToHTML(tokens);
}
/** if (tag.name === 'link') {
* Replace [text][ref] and [text][] with [text](url) using the text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
* reference definitions collected during block parsing. let inner = linkText;
*/ const hasPlaceholders = /\x00P\d+\x00/.test(inner);
private resolveReferenceLinks(text: string): string { if (hasPlaceholders) {
if (this.referenceLinks.size === 0) { inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
return text; } else {
inner = this.processInline(inner);
} }
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g; placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
return text.replace(refLink, (...args) => { return '\x00P' + (placeholders.length - 1) + '\x00';
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})`;
}); });
} } else if (!recursive && tag.pattern) {
const globalPattern = tag.pattern as RegExp;
globalPattern.lastIndex = 0;
/** text = text.replace(globalPattern, (_, content: string) => {
* Normalize flanking underscore runs to asterisks so the tokenizer placeholders.push(tag.toHTML(
* only needs to handle * delimiters for emphasis. { content, raw: '', consumed: 0 },
*/ this.makeConverter(),
private normalizeUnderscores(text: string): string { ));
// Protect backslash-escaped underscores from normalization return '\x00P' + (placeholders.length - 1) + '\x00';
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 text = escapeHtml(text);
const paired = this.pairDelimiters(tokens);
// Second pass: build HTML from paired tokens // Pass 2: apply recursive tags in precedence order.
let html = ''; // Content is already HTML-escaped from pass 1, so we wrap directly
for (const token of paired) { // without re-processing through convert.inline().
switch (token.role) { for (const tag of sorted) {
case 'text': const recursive = tag.recursive ?? true;
html += escapeHtml(token.value); if (tag.name === 'link' || !recursive) {
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; continue;
} }
openStack.push(index); const globalPattern = tag.pattern as RegExp | undefined;
openDelimiters.add(token.delimiter!); if (globalPattern) {
} else if (token.role === 'close') { globalPattern.lastIndex = 0;
let matched = false; text = text.replace(globalPattern, (_, content: string) => {
for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex--) { const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
const openerIndex = openStack[stackIndex]; const htmlTag = tag.name === 'boldItalic'
if (result[openerIndex].delimiter === token.delimiter) { ? null
openStack.splice(stackIndex, 1); : ((tag.selector as string) || '').split(',')[0].toLowerCase();
openDelimiters.delete(token.delimiter!); if (tag.name === 'boldItalic') {
matched = true; return '<em><strong>' + restored + '</strong></em>';
break;
}
}
if (!matched) {
result[index] = {
role: 'text',
value: token.value,
};
} }
return `<${htmlTag}>${restored}</${htmlTag}>`;
});
} }
} }
// Any remaining unmatched openers become literal text text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
for (const openerIndex of openStack) { return text;
result[openerIndex] = {
role: 'text',
value: result[openerIndex].value,
};
} }
return result; private nodeToMd(node: Node): string {
}
/**
* 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 this.serializer.serialize(node); return node.textContent || '';
} }
if (node.nodeType !== 1) { if (node.nodeType !== 1) {
return ''; return '';
} }
const element = node as HTMLElement; const element = node as HTMLElement;
// CSS selectors (e.g. [data-macro]) are more specific // Check CSS selectors first (macro selectors are more specific)
const cssSelectorMatch = this.matchCssSelector(element); for (const [selector, selectorTag] of this.tags.entries()) {
if (cssSelectorMatch) { if (selector.includes('[') || selector.includes('.') || selector.includes('#')) {
return cssSelectorMatch.toMarkdown(element, this.cachedConverter); // Lowercase only the tag name portion for case-insensitive matching
} const normalized = selector.replace(/^[A-Z]+/, s => s.toLowerCase());
// 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);
if (tag) {
return tag.toMarkdown(element, this.cachedConverter);
}
return this.serializeChildren(node);
}
private matchCssSelector(element: HTMLElement): Tag | null {
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 { try {
if (element.matches(normalized)) { if (element.matches(normalized)) {
return tag; return selectorTag.toMarkdown(element, this.makeConverter());
} }
} catch { } catch {
// Invalid selector — skip // 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 // Then check by element name
tagMap.set('CODE', { const tag = this.tags.get(element.nodeName);
serialize: (element) => { if (tag) {
// Code inside <pre> is handled by the PRE serializer return tag.toMarkdown(element, this.makeConverter());
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 }[] { return this.childrenToMd(node);
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 { private childrenToMd(node: Node): string {
return [ return Array.from(node.childNodes).map(child => this.nodeToMd(child)).join('');
...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: (markdown) => this.processBlocks(markdown), block: (md) => this.processBlocks(md),
children: (node) => this.serializeChildren(node), children: (node) => this.childrenToMd(node),
node: (node) => this.serializeNode(node), node: (node) => this.nodeToMd(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;

View File

@ -21,63 +21,6 @@
import type { Tag, Converter, ToolbarButton } 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;
/** /**
@ -101,58 +44,34 @@ export interface MacroDef {
button?: ToolbarButton | false; button?: ToolbarButton | false;
} }
/** 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;
} }
/* ── Module-level helpers ──────────────────────────────────────── */ const PARAM_PATTERN = /(\w+)="([^"]*)"/g;
/** 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 (!argumentString || !argumentString.trim()) { if (!argsStr || !argsStr.trim()) {
return { return { keywords: [], params: {}, verbatim: false };
keywords: [],
params: {},
verbatim: false,
};
} }
const params: Record<string, string> = {}; const params: Record<string, string> = {};
/* Strip key="value" pairs, collecting them into params */ const withoutParams = argsStr.replace(new RegExp(PARAM_PATTERN.source, 'g'), (_, key, val) => {
const withoutParams = argumentString.replace( params[key] = val;
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_KEYWORD); const verbatim = allKeywords.includes('verbatim');
const keywords = allKeywords.filter(keyword => keyword !== VERBATIM_KEYWORD); const keywords = allKeywords.filter(k => k !== 'verbatim');
return { return { keywords, params, verbatim };
keywords,
params,
verbatim,
};
} }
function macroError(name: string): string { function macroError(name: string): string {
@ -161,7 +80,7 @@ function macroError(name: string): string {
/** /**
* Wrap a macro's rendered HTML with data- attributes for round-tripping. * Wrap a macro's rendered HTML with data- attributes for round-tripping.
* Block macros (with content) use `<div>`, inline macros use `<span>`. * Block macros (with content) use <div>, inline macros use <span>.
*/ */
function wrapMacro( function wrapMacro(
name: string, name: string,
@ -176,36 +95,34 @@ function wrapMacro(
if (keywords.length) { if (keywords.length) {
attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`; attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`;
} }
for (const [paramKey, paramValue] of Object.entries(params)) { for (const [key, val] of Object.entries(params)) {
attrs += ` data-param-${escapeHtml(paramKey)}="${escapeHtml(paramValue)}"`; attrs += ` data-param-${escapeHtml(key)}="${escapeHtml(val)}"`;
} }
if (verbatim) { if (verbatim) {
attrs += ` data-verbatim="${VERBATIM_DATA_VALUE}"`; attrs += ` data-verbatim="true"`;
} }
return `<${tag}${attrs}>${innerHtml}</${tag}>`; return `<${tag}${attrs}>${innerHtml}</${tag}>`;
} }
/** /**
* Reconstruct macro source from a DOM element's data- attributes. * Reconstruct macro source from a DOM element's data- attributes.
* This is the generic toMarkdown for all macros it reads the * This is the generic toMarkdown for all macros.
* 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 { function macroToMarkdown(element: HTMLElement, convert: Converter): string {
const name = element.dataset.macro || ''; const name = element.dataset.macro || '';
const keywords = element.dataset.keywords || ''; const keywords = element.dataset.keywords || '';
const verbatim = element.dataset.verbatim === VERBATIM_DATA_VALUE; const verbatim = element.dataset.verbatim === 'true';
const paramParts: string[] = []; const paramParts: string[] = [];
for (const [datasetKey, datasetValue] of Object.entries(element.dataset)) { for (const [key, val] of Object.entries(element.dataset)) {
if (datasetKey.startsWith(DATASET_PARAM_PREFIX) && datasetKey.length > DATASET_PARAM_PREFIX_LENGTH) { if (key.startsWith('param') && key.length > 5) {
const paramName = datasetKey.slice(DATASET_PARAM_PREFIX_LENGTH).toLowerCase(); const paramName = key.slice(5).toLowerCase();
paramParts.push(`${paramName}="${datasetValue}"`); paramParts.push(`${paramName}="${val}"`);
} }
} }
const allKeywords = verbatim const allKeywords = verbatim
? [keywords, VERBATIM_KEYWORD].filter(Boolean).join(' ') ? [keywords, 'verbatim'].filter(Boolean).join(' ')
: keywords; : keywords;
const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' '); const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' ');
@ -219,36 +136,32 @@ function macroToMarkdown(element: HTMLElement, convert: Converter): string {
/** /**
* Try to parse a block macro starting at the given line index. * 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 { function parseBlockMacro(lines: string[], index: number): ParsedMacro | null {
const line = lines[lineIndex]; const line = lines[index];
const openMatch = BLOCK_MACRO_OPEN.exec(line); const m = line.match(/^@(\w+)\(([^)]*)\s*$/);
if (!openMatch || !openMatch.groups) { if (!m) {
return null; return null;
} }
const name = openMatch.groups.macroName; const name = m[1];
const { keywords, params, verbatim } = parseArgs(openMatch.groups.macroArgs); const { keywords, params, verbatim } = parseArgs(m[2]);
const contentLines: string[] = []; const contentLines: string[] = [];
let scanIndex = lineIndex + 1; let i = index + 1;
let nestingDepth = 1; let depth = 1;
while (scanIndex < lines.length && nestingDepth > 0) { while (i < lines.length && depth > 0) {
if (BLOCK_CLOSE_LINE.test(lines[scanIndex])) { if (/^\)\s*$/.test(lines[i])) {
nestingDepth--; depth--;
if (nestingDepth === 0) { if (depth === 0) {
break; break;
} }
} }
if (NESTED_BLOCK_OPEN.test(lines[scanIndex])) { if (/^@\w+\([^)]*\s*$/.test(lines[i])) {
nestingDepth++; depth++;
} }
contentLines.push(lines[scanIndex]); contentLines.push(lines[i]);
scanIndex++; i++;
} }
/* Unclosed macro — treat as plain text */ if (depth !== 0) {
if (nestingDepth !== 0) {
return null; return null;
} }
return { return {
@ -257,25 +170,14 @@ function parseBlockMacro(lines: string[], lineIndex: number): ParsedMacro | null
params, params,
verbatim, verbatim,
content: contentLines.join('\n'), content: contentLines.join('\n'),
consumed: scanIndex + 1 - lineIndex, consumed: i + 1 - index,
}; };
} }
/* ── Public API ────────────────────────────────────────────────── */ 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 HTMLmarkdown 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[],
@ -286,6 +188,11 @@ export function buildMacroTags(
} }
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);
@ -328,10 +235,8 @@ export function buildMacroTags(
}; };
/** /**
* Generic selector tag matches any element with data-macro * Generic selector tag that matches any element with data-macro
* and reconstructs the macro source from data- attributes. * and reconstructs the macro source from data- attributes.
* Separate from blockTag so the selector-based HTMLmarkdown
* path can find macro elements independently.
*/ */
const selectorTag: Tag = { const selectorTag: Tag = {
name: 'macro:generic', name: 'macro:generic',
@ -341,30 +246,11 @@ export function buildMacroTags(
toMarkdown: macroToMarkdown, toMarkdown: macroToMarkdown,
}; };
return { return { blockTag, selectorTag, macroMap };
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.
*
* 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,
@ -372,26 +258,20 @@ export function processInlineMacros(
convert: Converter, convert: Converter,
placeholders: string[], placeholders: string[],
): string { ): string {
return text.replace( return text.replace(INLINE_MACRO_GLOBAL, (match, nameStr: string, argsStr: string | undefined) => {
INLINE_MACRO_GLOBAL, const macro = macroMap.get(nameStr);
(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(macroName)); placeholders.push(macroError(nameStr));
return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR; return '\x00P' + (placeholders.length - 1) + '\x00';
} }
const { keywords, params } = parseArgs(groups.inlineArgs); const { keywords, params } = parseArgs(argsStr);
const innerHtml = macro.toHTML({ const innerHtml = macro.toHTML({
keywords, keywords,
params, params,
convert, convert,
}); });
const wrapped = wrapMacro(macroName, keywords, params, false, false, innerHtml); const wrapped = wrapMacro(nameStr, keywords, params, false, false, innerHtml);
placeholders.push(wrapped); placeholders.push(wrapped);
return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR; return '\x00P' + (placeholders.length - 1) + '\x00';
}, });
);
} }

View File

@ -7,38 +7,24 @@ import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './t
import { defaultTheme } from './default-theme'; import { defaultTheme } from './default-theme';
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit'; import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { VimHandler } from './vim'; import { VimHandler } from './vim';
import type { DelimiterMatch } from './types';
import { type MacroDef } from './macros'; import { type MacroDef } from './macros';
/** /**
* WYSIWYG markdown editor. Extends Ribbit's read-only viewer with * WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
* contentEditable support, live inline transforms (typing `**bold**`
* immediately wraps in `<strong>`), and source editing mode.
* *
* Extends Ribbit with contentEditable support and bidirectional
* markdownHTML conversion on mode switches.
*
* Usage:
* const editor = new RibbitEditor({ editorId: 'my-element' }); * const editor = new RibbitEditor({ editorId: 'my-element' });
* editor.run(); * editor.run();
* editor.wysiwyg(); * editor.wysiwyg(); // switch to WYSIWYG mode
* 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 vim?: VimHandler; private vim?: VimHandler;
// Elements that must not be nested inside each other.
// Used by transformInline and rebuildBlock to prevent
// invalid structures like <em> inside <em>.
private static readonly forbiddenNesting: Record<string, string[]> = {
'strong': ['strong', 'b'],
'em': ['em', 'i'],
'del': ['del', 's', 'strike'],
'code': ['code', 'strong', 'b', 'em', 'i', 'a', 'del'],
};
/**
* Initialize the editor with all three modes (view/edit/wysiwyg),
* bind DOM events, and optionally attach vim keybindings.
*
* const editor = new RibbitEditor({ editorId: 'content' });
* editor.run();
*/
run(): void { run(): void {
this.states = { this.states = {
VIEW: 'view', VIEW: 'view',
@ -66,613 +52,90 @@ export class RibbitEditor extends Ribbit {
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element); this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
} }
this.view(); this.view();
this.emitReady();
} }
#bindEvents(): void { #bindEvents(): void {
let debounceTimer: number | undefined; let debounceTimer: number | undefined;
let lastThrottle = 0;
this.element.addEventListener('input', () => { this.element.addEventListener('input', () => {
if (this.state !== this.states.WYSIWYG) { if (this.state === this.states.VIEW) {
return; return;
} }
this.ensureBlockStructure();
this.transformCurrentBlock(); this.invalidateCache();
this.updateEditingContext();
const now = Date.now();
if (now - lastThrottle >= 150) {
lastThrottle = now;
this.refreshPreview();
}
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => { debounceTimer = window.setTimeout(() => {
this.refreshPreview();
this.notifyChange(); this.notifyChange();
}, 300); }, 150);
});
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (event.key === 'Enter') {
this.handleEnter(event);
}
});
this.element.addEventListener('keyup', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (event.key.startsWith('Arrow')) {
this.closeOrphanedSpeculative();
this.updateEditingContext();
}
});
this.element.addEventListener('blur', () => {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.closeOrphanedSpeculative();
});
this.element.addEventListener('focusout', () => {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.closeOrphanedSpeculative();
});
document.addEventListener('click', (event: MouseEvent) => {
if (this.state !== this.states.WYSIWYG) {
return;
}
if (!this.element.contains(event.target as Node)) {
this.closeAllSpeculative();
}
});
document.addEventListener('selectionchange', () => {
if (this.state !== this.states.WYSIWYG) {
return;
}
this.closeOrphanedSpeculative();
this.updateEditingContext();
}); });
} }
/** /**
* Browsers create bare <div> and <br> elements in contentEditable * Re-render the WYSIWYG preview from the current content.
* that aren't valid markdown block containers. Convert them to <p> * Applies speculative rendering for unclosed inline delimiters
* so every editor child is a recognized block element. * at the cursor position, and uses toHtmlPreview for visible syntax.
*/ */
private ensureBlockStructure(): void { refreshPreview(): void {
for (const child of Array.from(this.element.childNodes)) { if (this.state !== this.states.WYSIWYG) {
if (child.nodeType === 1) {
const element = child as HTMLElement;
if (element.tagName === 'BR') {
const p = document.createElement('p');
p.innerHTML = '<br>';
element.replaceWith(p);
} else if (element.tagName === 'DIV') {
const p = document.createElement('p');
while (element.firstChild) {
p.appendChild(element.firstChild);
}
if (!p.firstChild) {
p.innerHTML = '<br>';
}
element.replaceWith(p);
// Cursor must follow the content into the new <p>,
// otherwise the next keystroke creates another <div>
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = document.createRange();
const target = p.lastChild || p;
if (target.nodeType === 3) {
range.setStart(target, target.textContent?.length || 0);
} else {
range.selectNodeContents(target);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
}
}
}
}
if (!this.element.firstChild) {
this.element.innerHTML = '<p><br></p>';
}
}
/**
* Walk up from the cursor to find the nearest block-level ancestor.
* Returns <li> for list items (not the <ul>/<ol>) because list items
* are the editable unit inside a list.
*/
private findCurrentBlock(): HTMLElement | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
}
let node: Node | null = selection.anchorNode;
// Bare text nodes in contentEditable cause cursor issues;
// wrap in <p> before the browser can create a <div> around it
if (node && node.nodeType === 3 && node.parentNode === this.element) {
const p = document.createElement('p');
node.parentNode.insertBefore(p, node);
p.appendChild(node);
// Restore cursor inside the new <p> so typing continues there
const range = document.createRange();
range.setStart(node, selection.anchorOffset);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return p;
}
while (node && node !== this.element) {
if (node.nodeType === 1) {
const element = node as HTMLElement;
if (element.tagName === 'LI' || element.parentNode === this.element) {
return element;
}
}
node = node.parentNode;
}
return null;
}
/**
* Detect markdown block syntax at the start of the current line
* and transform the DOM element in-place. Runs on every input event.
* Non-breaking spaces are normalized because browsers insert &nbsp;
* in contentEditable instead of regular spaces.
*/
private transformCurrentBlock(): void {
const block = this.findCurrentBlock();
if (!block) {
return;
}
// Normalize &nbsp; → space so patterns like "- " and "> " match
const text = (block.textContent || '').replace(/\u00A0/g, ' ');
const headingMatch = text.match(/^(#{1,6})\s/);
if (headingMatch) {
const level = headingMatch[1].length;
const targetTag = 'H' + level;
if (block.tagName !== targetTag) {
this.replaceBlock(block, targetTag, headingMatch[0].length);
return;
}
}
if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') {
this.replaceBlock(block, 'BLOCKQUOTE', 2);
return; return;
} }
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) { const cursorInfo = this.getCursorInfo();
const hr = document.createElement('hr'); const text = this.element.textContent || '';
const p = document.createElement('p'); const lines = text.split('\n');
p.innerHTML = '<br>';
block.replaceWith(hr, p);
const range = document.createRange();
range.setStart(p, 0);
range.collapse(true);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
return;
}
if (/^[-*+]\s/.test(text) && block.tagName !== 'LI') { // Speculatively close unclosed delimiters on the cursor line
this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1); if (cursorInfo) {
return; const inlineTags = this.converter.getInlineTags();
} const sorted = [...inlineTags].sort((a, b) =>
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') { );
this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1); for (const tag of sorted) {
return; if (tag.openPattern && tag.delimiter) {
} const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset);
const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if ((text.startsWith('```') || text.startsWith('~~~')) && block.tagName !== 'PRE') { const re = new RegExp(escaped, 'g');
const pre = document.createElement('pre'); const count = (before.match(re) || []).length;
const code = document.createElement('code'); if (count % 2 === 1) {
code.textContent = ''; lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter;
pre.appendChild(code);
block.replaceWith(pre);
const range = document.createRange();
range.setStart(code, 0);
range.collapse(true);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
return;
}
this.transformInline(block);
}
/**
* Serialize a block's children into a mixed string of markdown text
* and sentinel-wrapped HTML. Completed inline elements (e.g. a
* finished `<strong>`) are preserved as HTML between \x01...\x02
* markers so the transform regex won't re-match their delimiters.
* Speculative elements restore only their opening delimiter.
*/
private blockToMarkdown(block: HTMLElement): string {
let markdown = '';
for (const child of Array.from(block.childNodes)) {
markdown += this.nodeToMarkdown(child);
}
return markdown;
}
private nodeToMarkdown(node: Node): string {
if (node.nodeType === 3) {
return (node.textContent || '').replace(/\u200B/g, '');
}
if (node.nodeType !== 1) {
return '';
}
const element = node as HTMLElement;
const specDelim = element.getAttribute('data-speculative');
if (specDelim) {
let inner = '';
for (const child of Array.from(element.childNodes)) {
inner += this.nodeToMarkdown(child);
}
return specDelim + inner;
}
const tag = this.findTagForElement(element);
if (tag?.delimiter) {
return '\x01' + element.outerHTML + '\x02';
}
let inner = '';
for (const child of Array.from(element.childNodes)) {
inner += this.nodeToMarkdown(child);
}
return inner;
}
/**
* Look up the Tag definition for an HTML element by matching its
* tagName against registered inline tag selectors. Returns null
* for elements that aren't delimiter-based inline formatting.
*/
private findTagForElement(element: HTMLElement): { delimiter?: string; name: string } | null {
return this.converter.getTagForElement(element);
}
/**
* The core WYSIWYG pipeline: flatten match rebuild.
*
* 1. Flatten the block's DOM to a markdown string (preserving
* completed elements as sentinel-wrapped HTML)
* 2. Match complete delimiter pairs and replace with HTML tags
* 3. Find one unclosed opener for speculative preview
* 4. Rebuild the block's DOM from the result string
*
* Sentinel markers (\x01...\x02) prevent the regex from matching
* delimiters that belong to already-transformed elements.
*/
private transformInline(block: HTMLElement): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
let markdown = this.blockToMarkdown(block);
if (markdown.replace(/\s/g, '').length < 2) {
return;
}
// Nesting rules: which elements must not appear inside which
const forbiddenChildren = RibbitEditor.forbiddenNesting;
// Apply complete pairs until stable (each match restarts
// because the replacement may enable new matches)
let changed = true;
while (changed) {
changed = false;
const pair = this.converter.findCompletePair(markdown);
if (!pair) {
break; break;
} }
}
const banned = forbiddenChildren[pair.htmlTag]; }
if (banned && banned.some(tag => pair.content.includes('<' + tag))) {
break;
} }
// HTML entities in code content would be parsed as const html = this.converter.toHTML(lines.join('\n'));
// real elements by innerHTML (e.g. `<div>` → actual <div>) this.updatePreview(html, cursorInfo);
const content = pair.htmlTag === 'code'
? pair.content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
: pair.content;
const inner = pair.tag.name === 'boldItalic'
? `\x01<${pair.htmlTag}><strong>${content}</strong></${pair.htmlTag}>\x02`
: `\x01<${pair.htmlTag}>${content}</${pair.htmlTag}>\x02`;
markdown = markdown.slice(0, pair.index) + inner + markdown.slice(pair.index + pair.length);
changed = true;
}
// Strip sentinels now — the speculative check below needs to
// see the actual HTML tags to detect forbidden nesting
markdown = markdown.replace(/[\x01\x02]/g, '');
const opener = this.converter.findUnmatchedOpener(markdown);
this.rebuildBlock(block, markdown, opener, forbiddenChildren);
} }
/** /**
* Rebuild a block's DOM from the transformed markdown string. * Track which formatting element contains the cursor and toggle
* If an unclosed opener was found, wrap the trailing content in * the .ribbit-editing class so CSS ::before/::after show delimiters.
* a speculative element; otherwise set innerHTML directly.
*/
private rebuildBlock(
block: HTMLElement,
markdown: string,
opener: DelimiterMatch | null,
forbiddenChildren: Record<string, string[]>,
): void {
if (!opener) {
block.innerHTML = markdown;
this.sanitizeNesting(block);
this.appendZwsIfNeeded(block);
this.placeCursorAtEnd(block);
return;
}
const inside = markdown.slice(opener.index + opener.delimiter.length);
const banned = forbiddenChildren[opener.htmlTag];
// Check for forbidden nesting before wrapping
const probe = document.createElement('div');
probe.innerHTML = inside;
if (banned && banned.some(tag => probe.querySelector(tag))) {
block.innerHTML = markdown;
this.sanitizeNesting(block);
this.appendZwsIfNeeded(block);
this.placeCursorAtEnd(block);
return;
}
const before = markdown.slice(0, opener.index);
const wrapper = document.createElement(opener.htmlTag);
wrapper.classList.add('ribbit-editing');
wrapper.setAttribute('data-speculative', opener.delimiter);
wrapper.innerHTML = inside;
this.sanitizeNesting(wrapper);
block.innerHTML = '';
if (before) {
block.appendChild(document.createTextNode(before));
}
block.appendChild(wrapper);
// ZWS after wrapper so arrow-right can escape the element
block.appendChild(document.createTextNode('\u200B'));
this.placeCursorAtEnd(wrapper);
}
/**
* Append a zero-width space after the last child if it's an element,
* so the cursor can land outside it instead of inside.
*/
private appendZwsIfNeeded(block: HTMLElement): void {
if (block.lastChild && block.lastChild.nodeType === 1) {
block.appendChild(document.createTextNode('\u200B'));
}
}
/**
* Place the cursor at the deepest last text node inside an element.
* Used after DOM rebuilds to restore the cursor to where the user
* was typing.
*/
private placeCursorAtEnd(element: HTMLElement): void {
const selection = window.getSelection();
if (!selection) {
return;
}
const range = document.createRange();
let target: Node = element;
while (target.lastChild) {
target = target.lastChild;
}
if (target.nodeType === 3) {
range.setStart(target, target.textContent?.length || 0);
} else {
range.selectNodeContents(target);
range.collapse(false);
}
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Replace a block element with a different tag (e.g. <p> <h1>),
* stripping the markdown prefix (e.g. "# ") from the content.
*/
private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
const newEl = document.createElement(newTag);
const content = (block.textContent || '').slice(prefixLength);
if (content) {
newEl.textContent = content;
} else {
newEl.innerHTML = '<br>';
}
block.replaceWith(newEl);
newEl.classList.add('ribbit-editing');
// Cursor at start so the user sees the content, not the prefix
const range = document.createRange();
if (newEl.firstChild && newEl.firstChild.nodeType === 3) {
range.setStart(newEl.firstChild, 0);
} else {
range.setStart(newEl, 0);
}
range.collapse(true);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
/**
* Replace a block element with a list containing one item.
* Triggered when the user types "- " or "1. " at the start of a line.
*/
private replaceBlockWithList(block: HTMLElement, listTag: string, prefixLength: number): void {
const list = document.createElement(listTag);
const li = document.createElement('li');
const content = (block.textContent || '').slice(prefixLength);
if (content) {
li.textContent = content;
} else {
li.innerHTML = '<br>';
}
list.appendChild(li);
block.replaceWith(list);
const range = document.createRange();
if (li.firstChild && li.firstChild.nodeType === 3) {
range.setStart(li.firstChild, 0);
} else {
range.setStart(li, 0);
}
range.collapse(true);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}
/**
* On Enter, strip editing decorations from the current block so
* the browser's default newline behavior creates a clean element.
*/
private handleEnter(_event: KeyboardEvent): void {
const prev = this.element.querySelector('.ribbit-editing');
if (prev) {
prev.classList.remove('ribbit-editing');
prev.removeAttribute('data-speculative');
}
}
/**
* Replace an element with its children. Used to dissolve speculative
* wrappers and fix forbidden nesting the formatting is removed
* but the text content is preserved.
*/
private unwrapElement(element: HTMLElement): void {
const parent = element.parentNode;
if (!parent) {
return;
}
while (element.firstChild) {
parent.insertBefore(element.firstChild, element);
}
parent.removeChild(element);
}
/**
* Remove forbidden nesting (e.g. <em> inside <em>, <strong> inside
* <code>) by unwrapping the inner element. Runs as a post-processing
* pass after innerHTML is set, catching cases the regex guards miss.
*/
private sanitizeNesting(block: HTMLElement): void {
const rules: Record<string, string[]> = {
'STRONG': ['STRONG', 'B'],
'B': ['STRONG', 'B'],
'EM': ['EM', 'I'],
'I': ['EM', 'I'],
'DEL': ['DEL', 'S', 'STRIKE'],
'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A', 'DEL'],
};
let found = true;
while (found) {
found = false;
for (const [parent, forbidden] of Object.entries(rules)) {
const parents = block.querySelectorAll(parent.toLowerCase());
for (const parentEl of Array.from(parents)) {
for (const tag of forbidden) {
const nested = parentEl.querySelector(tag.toLowerCase());
if (nested && nested !== parentEl) {
this.unwrapElement(nested as HTMLElement);
found = true;
}
}
}
}
}
}
/**
* Unwrap all speculative elements. Called when the user clicks
* outside the editor nothing should remain speculative.
*/
private closeAllSpeculative(): void {
for (const element of Array.from(this.element.querySelectorAll('[data-speculative]'))) {
this.unwrapElement(element as HTMLElement);
}
}
/**
* Unwrap speculative elements the cursor has left. An orphaned
* speculative element was never completed by the user, so it
* should not become permanent formatting.
*/
private closeOrphanedSpeculative(): void {
const speculative = this.element.querySelectorAll('[data-speculative]');
if (speculative.length === 0) {
return;
}
const selection = window.getSelection();
const anchor = selection?.anchorNode;
for (const element of Array.from(speculative)) {
const htmlElement = element as HTMLElement;
let inside = false;
let node: Node | null = anchor || null;
while (node) {
if (node === htmlElement) {
inside = true;
break;
}
node = node.parentNode;
}
if (!inside) {
this.unwrapElement(htmlElement);
}
}
}
/**
* Toggle .ribbit-editing on the formatting element containing the
* cursor. CSS uses this class to show delimiter pseudo-elements
* (::before/::after) so the user sees the markdown syntax.
*/ */
private updateEditingContext(): void { private updateEditingContext(): void {
const prev = this.element.querySelector('.ribbit-editing'); const prev = this.element.querySelector('.ribbit-editing');
if (prev) { if (prev) {
prev.classList.remove('ribbit-editing'); prev.classList.remove('ribbit-editing');
} }
const selection = window.getSelection(); const sel = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!sel || sel.rangeCount === 0) {
return; return;
} }
let node: Node | null = selection.anchorNode; let node: Node | null = sel.anchorNode;
while (node && node !== this.element) { while (node && node !== this.element) {
if (node.nodeType === 1) { if (node.nodeType === 1) {
const element = node as HTMLElement; const el = node as HTMLElement;
// Derive the selector list from registered tags so it if (el.matches('strong, b, em, i, code, h1, h2, h3, h4, h5, h6, blockquote')) {
// stays in sync when tags are added or removed el.classList.add('ribbit-editing');
if (element.matches(this.converter.getEditableSelector())) {
element.classList.add('ribbit-editing');
return; return;
} }
} }
@ -681,72 +144,95 @@ export class RibbitEditor extends Ribbit {
} }
/** /**
* Convert the editor's current HTML back to markdown. * Get the cursor's line index and offset within that line.
*
* const md = editor.htmlToMarkdown();
* const md2 = editor.htmlToMarkdown('<p><strong>hi</strong></p>');
*/ */
private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return null;
}
const range = sel.getRangeAt(0);
const preRange = document.createRange();
preRange.selectNodeContents(this.element);
preRange.setEnd(range.startContainer, range.startOffset);
const absoluteOffset = preRange.toString().length;
const text = this.element.textContent || '';
const beforeCursor = text.slice(0, absoluteOffset);
const lineIndex = beforeCursor.split('\n').length - 1;
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
const offset = absoluteOffset - lineStart;
return { lineIndex, offset, absoluteOffset };
}
/**
* Replace the editor's HTML and restore the cursor to its
* previous text offset position.
*/
private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void {
this.element.innerHTML = html;
if (!cursorInfo) {
return;
}
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
let remaining = cursorInfo.absoluteOffset;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (remaining <= node.length) {
const sel = window.getSelection()!;
const range = document.createRange();
range.setStart(node, remaining);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
break;
}
remaining -= node.length;
}
this.updateEditingContext();
}
htmlToMarkdown(html?: string): string { htmlToMarkdown(html?: string): string {
return this.converter.toMarkdown(html || this.element.innerHTML); return this.converter.toMarkdown(html || this.element.innerHTML);
} }
/**
* Get the markdown representation of the current content.
* Behavior depends on mode: edit mode decodes HTML entities from
* the raw source; wysiwyg mode converts the DOM back to markdown.
*
* const md = editor.getMarkdown();
*/
getMarkdown(): string { getMarkdown(): string {
if (this.cachedMarkdown !== null) {
return this.cachedMarkdown;
}
if (this.getState() === this.states.EDIT) { if (this.getState() === this.states.EDIT) {
let html = this.element.innerHTML; let html = this.element.innerHTML;
html = html.replace(/<(?:div|br)>/ig, ''); html = html.replace(/<(?:div|br)>/ig, '');
html = html.replace(/<\/div>/ig, '\n'); html = html.replace(/<\/div>/ig, '\n');
return decodeHtmlEntities(html); this.cachedMarkdown = decodeHtmlEntities(html);
} else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
} else {
this.cachedMarkdown = this.element.textContent || '';
} }
if (this.getState() === this.states.WYSIWYG || this.getState() === this.states.VIEW) { return this.cachedMarkdown;
return this.htmlToMarkdown(this.element.innerHTML);
}
// Before run() — element has raw markdown as text
return this.element.textContent || '';
} }
/**
* Switch to WYSIWYG mode with live inline transforms.
*
* editor.wysiwyg();
* // now typing **bold** immediately wraps in <strong>
*/
wysiwyg(): void { wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return; if (this.getState() === this.states.WYSIWYG) return;
const wasEditing = this.getState() === this.states.EDIT;
this.vim?.detach(); this.vim?.detach();
this.collaboration?.connect();
if (wasEditing && this.collaboration?.isPaused()) {
this.collaboration.resume(this.getMarkdown());
}
this.element.contentEditable = 'true'; this.element.contentEditable = 'true';
this.element.innerHTML = this.getHTML(); this.element.innerHTML = this.getHTML();
// Ensure there's a block element for the cursor to land in Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
if (!this.element.firstElementChild) { const macroEl = el as HTMLElement;
this.element.innerHTML = '<p><br></p>'; if (macroEl.dataset.editable === 'false') {
} macroEl.contentEditable = 'false';
Array.from(this.element.querySelectorAll('.macro')).forEach(macroElement => { macroEl.style.opacity = '0.5';
const htmlMacro = macroElement as HTMLElement;
if (htmlMacro.dataset.editable === 'false') {
htmlMacro.contentEditable = 'false';
htmlMacro.style.opacity = '0.5';
} }
}); });
this.setState(this.states.WYSIWYG); this.setState(this.states.WYSIWYG);
} }
/**
* Switch to source editing mode (raw markdown). Requires the theme
* to have sourceMode enabled. Attaches vim keybindings if configured.
*
* editor.edit();
*/
edit(): void { edit(): void {
if (!this.theme.features?.sourceMode) { if (!this.theme.features?.sourceMode) {
return; return;
@ -755,28 +241,18 @@ export class RibbitEditor extends Ribbit {
this.element.contentEditable = 'true'; this.element.contentEditable = 'true';
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown()); this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
this.vim?.attach(this.element); this.vim?.attach(this.element);
this.collaboration?.connect();
this.collaboration?.pause(this.getMarkdown());
this.setState(this.states.EDIT); this.setState(this.states.EDIT);
} }
/**
* Insert a DOM node at the current cursor position. Used by toolbar
* buttons and macros to inject content.
*
* const img = document.createElement('img');
* img.src = '/photo.jpg';
* editor.insertAtCursor(img);
*/
insertAtCursor(node: Node): void { insertAtCursor(node: Node): void {
const selection = window.getSelection()!; const sel = window.getSelection()!;
const range = selection.getRangeAt(0); const range = sel.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(); sel.removeAllRanges();
selection.addRange(range); sel.addRange(range);
} }
} }
@ -790,5 +266,4 @@ export { defaultTheme };
export { camelCase, decodeHtmlEntities, encodeHtmlEntities }; export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
export { ToolbarManager } from './toolbar'; export { ToolbarManager } from './toolbar';
export { VimHandler } from './vim'; export { VimHandler } from './vim';
export { CollaborationManager } from './collaboration';
export type { MacroDef }; export type { MacroDef };

View File

@ -6,10 +6,9 @@ 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 { CollaborationManager } from './collaboration';
import { type MacroDef } from './macros'; import { type MacroDef } from './macros';
import { ToolbarManager } from './toolbar'; import { ToolbarManager } from './toolbar';
import type { RibbitTheme, ToolbarSlot, CollaborationSettings, PeerInfo, Revision, RevisionMetadata } from './types'; import type { RibbitTheme, ToolbarSlot } from './types';
export interface RibbitSettings { export interface RibbitSettings {
api?: unknown; api?: unknown;
@ -21,18 +20,11 @@ export interface RibbitSettings {
toolbar?: ToolbarSlot[]; toolbar?: ToolbarSlot[];
/** Set to false to prevent auto-rendering the toolbar. Default true. */ /** Set to false to prevent auto-rendering the toolbar. Default true. */
autoToolbar?: boolean; autoToolbar?: boolean;
/** Collaboration settings. Omit to disable. */
collaboration?: CollaborationSettings;
on?: Partial<RibbitEventMap>; on?: Partial<RibbitEventMap>;
} }
/** /**
* Base class providing read-only markdown rendering. RibbitEditor extends * Read-only markdown viewer. Renders markdown content into an HTML element.
* this with editing capabilities, so consumers who only need to display
* rendered markdown can use Ribbit directly and avoid loading editor code.
*
* const viewer = new Ribbit({ editorId: 'my-element' });
* viewer.run();
*/ */
export class Ribbit { export class Ribbit {
api: unknown; api: unknown;
@ -41,12 +33,12 @@ export class Ribbit {
cachedHTML: string | null; cachedHTML: string | null;
cachedMarkdown: string | null; cachedMarkdown: string | null;
state: string | null; state: string | null;
changed: boolean;
theme: RibbitTheme; theme: RibbitTheme;
themes: ThemeManager; themes: ThemeManager;
converter: HopDown; converter: HopDown;
themesPath: string; themesPath: string;
toolbar: ToolbarManager; toolbar: ToolbarManager;
collaboration?: CollaborationManager;
protected autoToolbar: boolean; protected autoToolbar: boolean;
private emitter: RibbitEmitter; private emitter: RibbitEmitter;
private macros: MacroDef[]; private macros: MacroDef[];
@ -63,6 +55,7 @@ export class Ribbit {
this.cachedHTML = null; this.cachedHTML = null;
this.cachedMarkdown = null; this.cachedMarkdown = null;
this.state = null; this.state = null;
this.changed = false;
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => { this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
this.theme = theme; this.theme = theme;
@ -106,63 +99,22 @@ export class Ribbit {
settings.toolbar, settings.toolbar,
); );
this.autoToolbar = settings.autoToolbar !== false; this.autoToolbar = settings.autoToolbar !== false;
if (settings.collaboration) {
this.collaboration = new CollaborationManager(
settings.collaboration,
{
onRemoteUpdate: (content) => {
this.cachedMarkdown = content;
this.cachedHTML = null;
if (this.getState() !== this.states.VIEW) {
this.element.innerHTML = this.getHTML();
}
this.emitter.emit('change', {
markdown: content,
html: this.getHTML(),
});
},
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 });
},
},
);
}
} }
/**
* Subscribe to editor events. Callbacks persist across mode switches.
*
* editor.on('change', ({ markdown, html }) => console.log(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);
} }
/**
* Unsubscribe a previously registered event callback.
*
* 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);
} }
protected emitReady(): void { run(): void {
this.element.classList.add('loaded');
if (this.autoToolbar) {
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
}
this.view();
this.emitter.emit('ready', { this.emitter.emit('ready', {
markdown: this.getMarkdown(), markdown: this.getMarkdown(),
html: this.getHTML(), html: this.getHTML(),
@ -171,37 +123,10 @@ export class Ribbit {
}); });
} }
/**
* 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', 'edit', 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('edit');
*/
setState(newState: string): void { setState(newState: string): void {
const previous = this.state; const previous = this.state;
if (previous) { if (previous) {
@ -215,20 +140,10 @@ export class Ribbit {
}); });
} }
/** markdownToHTML(md: string): string {
* One-shot markdownHTML conversion using the current theme's tags. return this.converter.toHTML(md);
*
* const html = viewer.markdownToHTML('**hello**');
*/
markdownToHTML(markdown: string): string {
return this.converter.toHTML(markdown);
} }
/**
* Rendered HTML of the current content, cached until invalidated.
*
* document.getElementById('preview').innerHTML = viewer.getHTML();
*/
getHTML(): string { getHTML(): string {
if (this.cachedHTML === null) { if (this.cachedHTML === null) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown()); this.cachedHTML = this.markdownToHTML(this.getMarkdown());
@ -236,12 +151,6 @@ export class Ribbit {
return this.cachedHTML; return this.cachedHTML;
} }
/**
* Raw markdown of the current content. In view mode this is the
* original text; in edit/wysiwyg mode it's derived from the DOM.
*
* fetch('/save', { body: editor.getMarkdown() });
*/
getMarkdown(): string { getMarkdown(): string {
if (this.cachedMarkdown === null) { if (this.cachedMarkdown === null) {
this.cachedMarkdown = this.element.textContent || ''; this.cachedMarkdown = this.element.textContent || '';
@ -249,13 +158,6 @@ export class Ribbit {
return this.cachedMarkdown; return this.cachedMarkdown;
} }
/**
* 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', {
markdown: this.getMarkdown(), markdown: this.getMarkdown(),
@ -263,147 +165,27 @@ 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) return;
this.collaboration?.disconnect();
this.element.innerHTML = this.getHTML(); this.element.innerHTML = this.getHTML();
this.setState(this.states.VIEW); this.setState(this.states.VIEW);
this.element.contentEditable = 'false'; this.element.contentEditable = 'false';
} }
/**
* Force re-conversion on next getHTML()/getMarkdown() call.
* Call after programmatically changing element content.
*
* editor.element.innerHTML = newContent;
* editor.invalidateCache();
*/
invalidateCache(): void { invalidateCache(): void {
this.changed = true;
this.cachedMarkdown = null; this.cachedMarkdown = null;
this.cachedHTML = null; this.cachedHTML = null;
} }
/**
* Request an advisory editing lock. Returns false if another user
* 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.cachedMarkdown = revision.content;
this.cachedHTML = this.markdownToHTML(revision.content);
this.collaboration.sendUpdate(revision.content);
if (this.getState() !== this.states.VIEW) {
this.element.innerHTML = this.cachedHTML;
}
this.emitter.emit('change', {
markdown: revision.content,
html: this.cachedHTML,
});
}
/**
* 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 {
const markdown = this.getMarkdown();
this.collaboration?.sendUpdate(markdown);
this.emitter.emit('change', { this.emitter.emit('change', {
markdown, markdown: this.getMarkdown(),
html: this.getHTML(), html: this.getHTML(),
}); });
} }
} }
/**
* Split a string into words and capitalize each one.
* 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 => {
const lc = word.toLowerCase(); const lc = word.toLowerCase();
@ -411,25 +193,12 @@ export function camelCase(words: string): string[] {
}); });
} }
/**
* 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');
txt.innerHTML = html; txt.innerHTML = html;
return txt.value; return txt.value;
} }
/**
* Encode characters that would be interpreted as HTML into numeric
* entities. Used when displaying raw markdown in contentEditable
* (edit mode) 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) + ';');
} }

View File

@ -1,198 +0,0 @@
/*
* 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,20 +4,6 @@
import type { RibbitTheme } from './types'; import type { RibbitTheme } from './types';
/** 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>;
@ -37,10 +23,7 @@ export class ThemeManager {
} }
/** /**
* Register a theme. Themes must be added before they can be activated. * Register a theme. Themes must be added before they can be enabled.
*
* @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);
@ -48,9 +31,6 @@ 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) {
@ -61,9 +41,6 @@ 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));
@ -71,9 +48,6 @@ 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);
@ -81,9 +55,6 @@ 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;
@ -93,9 +64,6 @@ 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);
@ -107,19 +75,13 @@ 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)) {
@ -131,9 +93,6 @@ 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)) {
@ -148,7 +107,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_FILENAME}`; link.href = `${this.themesPath}/${name}/theme.css`;
document.head.appendChild(link); document.head.appendChild(link);
this.themeLink = link; this.themeLink = link;
} }

View File

@ -1,447 +0,0 @@
/*
* 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.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.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';
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t'
&& (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;
}
}

View File

@ -14,35 +14,6 @@
import type { Tag, ToolbarSlot, Button } from './types'; import type { Tag, ToolbarSlot, Button } from './types';
import type { MacroDef } from './macros'; 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', 'toggle', 'markdown'];
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 { class ButtonImpl implements Button {
id: string; id: string;
label: string; label: string;
@ -56,48 +27,30 @@ class ButtonImpl implements Button {
element?: HTMLElement; element?: HTMLElement;
handler?: () => void; handler?: () => void;
constructor(definition: Partial<Button> & { id: string }) { constructor(def: Partial<Button> & { id: string }) {
this.id = definition.id; this.id = def.id;
this.label = definition.label || definition.id; this.label = def.label || def.id;
this.icon = definition.icon; this.icon = def.icon;
this.shortcut = definition.shortcut; this.shortcut = def.shortcut;
this.action = definition.action || 'insert'; this.action = def.action || 'insert';
this.delimiter = definition.delimiter; this.delimiter = def.delimiter;
this.template = definition.template; this.template = def.template;
this.replaceSelection = definition.replaceSelection ?? true; this.replaceSelection = def.replaceSelection ?? true;
this.visible = definition.visible ?? true; this.visible = def.visible ?? true;
this.handler = definition.handler; this.handler = def.handler;
} }
/**
* Programmatically trigger this button's click event.
*
* @example
* toolbar.buttons.get('bold')?.click();
*/
click(): void { click(): void {
this.element?.click(); this.element?.click();
} }
/**
* Hide this button from the toolbar.
*
* @example
* toolbar.buttons.get('table')?.hide();
*/
hide(): void { hide(): void {
this.visible = false; this.visible = false;
if (this.element) { if (this.element) {
this.element.style.display = CSS_DISPLAY_NONE; this.element.style.display = 'none';
} }
} }
/**
* Show this button in the toolbar.
*
* @example
* toolbar.buttons.get('table')?.show();
*/
show(): void { show(): void {
this.visible = true; this.visible = true;
if (this.element) { if (this.element) {
@ -106,16 +59,6 @@ class ButtonImpl implements Button {
} }
} }
/**
* 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 { export class ToolbarManager {
buttons: Map<string, Button>; buttons: Map<string, Button>;
private layout: ToolbarSlot[]; private layout: ToolbarSlot[];
@ -125,18 +68,6 @@ export class ToolbarManager {
this.editor = editor; this.editor = editor;
this.buttons = new Map(); 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)) { for (const tag of Object.values(tags)) {
if (!tag.button || !tag.button.show) { if (!tag.button || !tag.button.show) {
continue; continue;
@ -151,101 +82,74 @@ export class ToolbarManager {
replaceSelection: tag.replaceSelection, replaceSelection: tag.replaceSelection,
}); });
} }
}
/** Heading levels are derived from a single pattern rather than repeated blocks. */ // Heading and list variants (derived from their parent tags)
private registerHeadingButtons(): void { for (let i = 1; i <= 6; i++) {
for (let level = 1; level <= MAX_HEADING_LEVEL; level++) { this.register(`h${i}`, {
this.register(`h${level}`, { label: `H${i}`,
label: `H${level}`, shortcut: `Ctrl+${i}`,
shortcut: `Ctrl+${level}`,
action: 'prefix', action: 'prefix',
delimiter: '#'.repeat(level) + ' ', delimiter: '#'.repeat(i) + ' ',
replaceSelection: true, replaceSelection: true,
}); });
} }
} this.register('ul', {
private registerListButtons(): void {
const listDefinitions: Array<{ id: string; label: string; shortcut: string; template: string }> = [
{
id: 'ul',
label: 'Bullet List', label: 'Bullet List',
shortcut: 'Ctrl+Shift+8', 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', action: 'insert',
template: definition.template, template: '- Item 1\n- Item 2\n- Item 3',
replaceSelection: false,
});
this.register('ol', {
label: 'Numbered List',
shortcut: 'Ctrl+Shift+7',
action: 'insert',
template: '1. Item 1\n2. Item 2\n3. Item 3',
replaceSelection: false, replaceSelection: false,
}); });
}
}
private registerMacroButtons(macros: MacroDef[]): void {
for (const macro of macros) { for (const macro of macros) {
if (macro.button === false) { if (macro.button === false) {
continue; continue;
} }
const buttonConfig = typeof macro.button === 'object' ? macro.button : null; const btn = typeof macro.button === 'object' ? macro.button : null;
const capitalizedName = macro.name.charAt(0).toUpperCase() + macro.name.slice(1); this.register(`macro:${macro.name}`, {
this.register(`${MACRO_ID_PREFIX}${macro.name}`, { label: btn?.label || macro.name.charAt(0).toUpperCase() + macro.name.slice(1),
label: buttonConfig?.label || capitalizedName, icon: btn?.icon,
icon: buttonConfig?.icon,
action: 'insert', action: 'insert',
template: `@${macro.name}`, template: `@${macro.name}`,
replaceSelection: false, replaceSelection: false,
}); });
} }
}
private registerUtilityButtons(): void {
this.register('save', { this.register('save', {
label: 'Save', label: 'Save', shortcut: 'Ctrl+S', action: 'custom',
shortcut: 'Ctrl+S',
action: 'custom',
handler: () => this.editor.save(), handler: () => this.editor.save(),
}); });
this.register('toggle', { this.register('toggle', {
label: 'Edit', label: 'Edit', shortcut: 'Ctrl+Shift+V', action: 'custom',
shortcut: 'Ctrl+Shift+V',
action: 'custom',
handler: () => { handler: () => {
if (this.editor.getState() === EDITOR_STATE_VIEW) { this.editor.getState() === 'view'
this.editor.wysiwyg(); ? this.editor.wysiwyg()
} else { : this.editor.view();
this.editor.view();
}
}, },
}); });
this.register('markdown', { this.register('markdown', {
label: 'Source', label: 'Source', shortcut: 'Ctrl+/', action: 'custom',
shortcut: 'Ctrl+/',
action: 'custom',
handler: () => { handler: () => {
if (this.editor.getState() === EDITOR_STATE_EDIT) { this.editor.getState() === 'edit'
this.editor.wysiwyg(); ? this.editor.wysiwyg()
} else { : this.editor.edit();
this.editor.edit();
}
}, },
}); });
this.layout = layout || this.defaultLayout();
this.bindShortcuts();
} }
/** /**
* Builds a keyboard shortcut lookup and dispatches matching * Listen for keyboard shortcuts on the document and dispatch
* button actions on keydown events. * to the matching toolbar button.
*/ */
private bindShortcuts(): void { private bindShortcuts(): void {
const shortcutMap = new Map<string, Button>(); const shortcutMap = new Map<string, Button>();
@ -256,7 +160,20 @@ export class ToolbarManager {
} }
document.addEventListener('keydown', (event: KeyboardEvent) => { document.addEventListener('keydown', (event: KeyboardEvent) => {
const combo = this.buildKeyCombo(event); const parts: string[] = [];
if (event.ctrlKey || event.metaKey) parts.push('ctrl');
if (event.shiftKey) parts.push('shift');
if (event.altKey) parts.push('alt');
let key = event.key;
if (key === '/') key = '/';
else if (key === '.') key = '.';
else if (key === '-') key = '-';
else key = key.toLowerCase();
parts.push(key);
const combo = parts.join('+');
const button = shortcutMap.get(combo); const button = shortcutMap.get(combo);
if (button) { if (button) {
event.preventDefault(); event.preventDefault();
@ -265,42 +182,21 @@ export class ToolbarManager {
}); });
} }
/** Normalizes a KeyboardEvent into a comparable shortcut string like "ctrl+shift+b". */ private register(id: string, def: Partial<Button>): void {
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)) { if (this.buttons.has(id)) {
return; return;
} }
this.buttons.set(id, new ButtonImpl({ id, ...definition })); this.buttons.set(id, new ButtonImpl({ id, ...def }));
} }
private buildDefaultLayout(): ToolbarSlot[] { private defaultLayout(): ToolbarSlot[] {
const tagIds: string[] = []; const tagIds: string[] = [];
const macroIds: string[] = []; const macroIds: string[] = [];
for (const id of this.buttons.keys()) { for (const id of this.buttons.keys()) {
if (UTILITY_BUTTON_IDS.includes(id)) { if (['save', 'toggle', 'markdown'].includes(id)) {
continue; continue;
} }
if (id.startsWith(MACRO_ID_PREFIX)) { if (id.startsWith('macro:')) {
macroIds.push(id); macroIds.push(id);
} else { } else {
tagIds.push(id); tagIds.push(id);
@ -309,183 +205,130 @@ export class ToolbarManager {
const slots: ToolbarSlot[] = [...tagIds]; const slots: ToolbarSlot[] = [...tagIds];
if (macroIds.length > 0) { if (macroIds.length > 0) {
slots.push(''); slots.push('');
slots.push({ slots.push({ group: 'Macros', items: macroIds });
group: 'Macros',
items: macroIds,
});
} }
slots.push('', 'markdown', 'save', 'toggle'); slots.push('', 'markdown', 'save', 'toggle');
return slots; return slots;
} }
/** /**
* Toggle the active CSS class on buttons whose IDs appear in the * Update .active class on buttons matching the cursor's formatting context.
* given list of currently-active tag names.
*
* @example
* manager.updateActiveState(['bold', 'italic']);
*/ */
updateActiveState(activeTagNames: string[]): void { updateActiveState(activeTagNames: string[]): void {
for (const [id, button] of this.buttons) { for (const [id, button] of this.buttons) {
button.element?.classList.toggle(CSS_CLASS_ACTIVE, activeTagNames.includes(id)); button.element?.classList.toggle('active', activeTagNames.includes(id));
} }
} }
/** /**
* Enable all toolbar buttons by removing the disabled CSS class. * Enable all toolbar buttons.
*
* @example
* manager.enable();
*/ */
enable(): void { enable(): void {
for (const button of this.buttons.values()) { for (const button of this.buttons.values()) {
button.element?.classList.remove(CSS_CLASS_DISABLED); button.element?.classList.remove('disabled');
} }
} }
/** /**
* Disable all toolbar buttons by adding the disabled CSS class. * Disable all toolbar buttons.
*
* @example
* manager.disable();
*/ */
disable(): void { disable(): void {
for (const button of this.buttons.values()) { for (const button of this.buttons.values()) {
button.element?.classList.add(CSS_CLASS_DISABLED); button.element?.classList.add('disabled');
} }
} }
/** /**
* Build the toolbar DOM tree and return the root element. * Build the toolbar DOM and return it. Caller inserts it.
* The caller is responsible for inserting it into the document.
*
* @example
* document.body.prepend(manager.render());
*/ */
render(): HTMLElement { render(): HTMLElement {
const nav = document.createElement('nav'); const nav = document.createElement('nav');
nav.className = CSS_CLASS_TOOLBAR; nav.className = 'ribbit-toolbar';
const list = document.createElement('ul'); const ul = document.createElement('ul');
for (const slot of this.layout) { for (const slot of this.layout) {
const element = this.renderSlot(slot); if (slot === '') {
if (element) { const li = document.createElement('li');
list.appendChild(element); li.className = 'spacer';
ul.appendChild(li);
} else if (typeof slot === 'string') {
if (slot === 'macros') {
const items = [...this.buttons.values()].filter(b => b.id.startsWith('macro:'));
if (items.length > 0) {
ul.appendChild(this.renderGroup({ label: 'Macros', items }));
}
} else {
const button = this.buttons.get(slot);
if (button) {
ul.appendChild(this.renderButton(button));
}
}
} else {
const items = slot.items
.map(id => this.buttons.get(id))
.filter((b): b is Button => b !== undefined);
if (items.length > 0) {
ul.appendChild(this.renderGroup({ label: slot.group, items }));
}
} }
} }
nav.appendChild(list); nav.appendChild(ul);
return nav; 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 { private renderButton(button: Button): HTMLElement {
const listItem = document.createElement('li'); const li = document.createElement('li');
const buttonElement = document.createElement('button'); const btn = document.createElement('button');
buttonElement.className = `ribbit-btn-${button.id}`; btn.className = `ribbit-btn-${button.id}`;
buttonElement.textContent = button.label; btn.setAttribute('aria-label', button.label);
buttonElement.setAttribute('aria-label', button.label); btn.title = button.shortcut
buttonElement.title = button.shortcut
? `${button.label} (${button.shortcut})` ? `${button.label} (${button.shortcut})`
: button.label; : button.label;
if (!button.visible) { if (!button.visible) {
listItem.style.display = CSS_DISPLAY_NONE; li.style.display = 'none';
} }
buttonElement.addEventListener('click', () => this.executeAction(button)); btn.addEventListener('click', () => this.executeAction(button));
button.element = buttonElement; button.element = btn;
listItem.appendChild(buttonElement); li.appendChild(btn);
return listItem; return li;
} }
private renderGroup(group: { label: string; items: Button[] }): HTMLElement { private renderGroup(group: { label: string; items: Button[] }): HTMLElement {
const listItem = document.createElement('li'); const li = document.createElement('li');
const toggle = document.createElement('button'); const toggle = document.createElement('button');
toggle.className = CSS_CLASS_GROUP; toggle.className = 'ribbit-btn-group';
toggle.textContent = group.label + DROPDOWN_INDICATOR;
toggle.setAttribute('aria-label', group.label); toggle.setAttribute('aria-label', group.label);
toggle.title = group.label; toggle.title = group.label;
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = CSS_CLASS_DROPDOWN; menu.className = 'ribbit-dropdown';
menu.style.display = CSS_DISPLAY_NONE; menu.style.display = 'none';
for (const button of group.items) { for (const button of group.items) {
const buttonElement = this.renderDropdownItem(button, menu); const btn = document.createElement('button');
menu.appendChild(buttonElement); btn.className = `ribbit-btn-${button.id}`;
btn.setAttribute('aria-label', button.label);
btn.title = button.label;
btn.textContent = button.label;
if (!button.visible) {
btn.style.display = 'none';
}
btn.addEventListener('click', () => {
this.executeAction(button);
menu.style.display = 'none';
});
button.element = btn;
menu.appendChild(btn);
} }
toggle.addEventListener('click', () => { toggle.addEventListener('click', () => {
menu.style.display = menu.style.display === CSS_DISPLAY_NONE ? '' : CSS_DISPLAY_NONE; menu.style.display = menu.style.display === 'none' ? '' : 'none';
}); });
listItem.appendChild(toggle); li.appendChild(toggle);
listItem.appendChild(menu); li.appendChild(menu);
return listItem; return li;
}
/** 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 { private executeAction(button: Button): void {
@ -506,25 +349,23 @@ export class ToolbarManager {
this.editor.element.focus(); this.editor.element.focus();
} }
/** Wraps the current selection with the given delimiter on both sides. */
private wrapSelection(delimiter: string): void { private wrapSelection(delimiter: string): void {
const selection = window.getSelection(); const sel = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!sel || sel.rangeCount === 0) {
return; return;
} }
const range = selection.getRangeAt(0); const range = sel.getRangeAt(0);
const text = range.toString(); const text = range.toString();
range.deleteContents(); range.deleteContents();
range.insertNode(document.createTextNode(delimiter + text + delimiter)); range.insertNode(document.createTextNode(delimiter + text + delimiter));
} }
/** Inserts text at the cursor, optionally replacing the current selection. */
private insertText(text: string, replaceSelection: boolean): void { private insertText(text: string, replaceSelection: boolean): void {
const selection = window.getSelection(); const sel = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!sel || sel.rangeCount === 0) {
return; return;
} }
const range = selection.getRangeAt(0); const range = sel.getRangeAt(0);
if (replaceSelection) { if (replaceSelection) {
range.deleteContents(); range.deleteContents();
} else { } else {

View File

@ -1,15 +1,7 @@
/* /*
* types.ts shared type definitions for the ribbit editor. * types.ts shared types 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;
@ -17,23 +9,13 @@ 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: (markdown: string) => string; block: (md: 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;
@ -41,9 +23,6 @@ export interface MatchContext {
offset: number; offset: number;
} }
/**
* Configuration for a toolbar button's appearance and shortcut.
*/
export interface ToolbarButton { export interface ToolbarButton {
show: boolean; show: boolean;
label: string; label: string;
@ -51,18 +30,13 @@ export interface ToolbarButton {
shortcut?: 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 HTMLmarkdown 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;
openPattern?: RegExp;
delimiter?: string; delimiter?: string;
precedence?: number; precedence?: number;
recursive?: boolean; recursive?: boolean;
@ -72,28 +46,16 @@ export interface Tag {
button?: ToolbarButton; button?: ToolbarButton;
} }
/**
* 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;
delimiter: string; delimiter: string;
@ -107,92 +69,10 @@ export interface InlineTagDef {
export interface RibbitThemeFeatures { export interface RibbitThemeFeatures {
sourceMode?: boolean; sourceMode?: boolean;
vim?: boolean; vim?: boolean;
collaboration?: boolean;
} }
/** /**
* Transport for syncing document changes between clients. * A slot in the toolbar layout.
* 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 * 'bold' single button
* '' spacer * '' spacer
@ -201,13 +81,10 @@ export interface RevisionMetadata {
*/ */
export type ToolbarSlot = export type ToolbarSlot =
| string | string
| { | { group: string; items: string[] };
group: string;
items: string[];
};
/** /**
* A resolved toolbar button with DOM element and interaction methods. * A resolved toolbar button with methods for interaction.
*/ */
export interface Button { export interface Button {
id: string; id: string;
@ -231,23 +108,3 @@ 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;
}

View File

@ -21,53 +21,10 @@
type VimMode = 'normal' | 'insert'; type VimMode = 'normal' | 'insert';
/** Direction constants for cursor movement to avoid magic strings. */
const DIRECTION = {
LEFT: 'left' as const,
RIGHT: 'right' as const,
UP: 'up' as const,
DOWN: 'down' as const,
};
/** Selection API direction mappings. */
const SELECTION_DIRECTION = {
BACKWARD: 'backward' as const,
FORWARD: 'forward' as const,
};
/** Selection API granularity mappings. */
const SELECTION_GRANULARITY = {
CHARACTER: 'character' as const,
LINE: 'line' as const,
WORD: 'word' as const,
LINE_BOUNDARY: 'lineboundary' as const,
};
/** Regex to match digit keys for count prefix accumulation. */
const DIGIT_PATTERN = /^[0-9]$/;
/** Default repeat count when no count prefix is given. */
const DEFAULT_REPEAT_COUNT = '1';
/** Radix for parsing count prefix strings. */
const DECIMAL_RADIX = 10;
/**
* Handles vim-style keybindings in ribbit's source edit mode.
*
* Supports normal and insert modes with standard vim motions,
* editing commands, and count prefixes.
*
* @example
* const vim = new VimHandler((mode) => {
* statusBar.textContent = mode;
* });
* vim.attach(editorElement);
*/
export class VimHandler { export class VimHandler {
mode: VimMode; mode: VimMode;
private element: HTMLElement | null; private element: HTMLElement | null;
private listener: ((event: KeyboardEvent) => void) | null; private listener: ((e: KeyboardEvent) => void) | null;
private pending: string; private pending: string;
private count: string; private count: string;
private onModeChange: (mode: VimMode) => void; private onModeChange: (mode: VimMode) => void;
@ -81,27 +38,15 @@ export class VimHandler {
this.onModeChange = onModeChange; this.onModeChange = onModeChange;
} }
/**
* Bind vim keybindings to a DOM element.
*
* @example
* vim.attach(document.getElementById('editor'));
*/
attach(element: HTMLElement): void { attach(element: HTMLElement): void {
this.detach(); this.detach();
this.element = element; this.element = element;
this.pending = ''; this.pending = '';
this.listener = (event: KeyboardEvent) => this.handleKey(event); this.listener = (e: KeyboardEvent) => this.handleKey(e);
this.element.addEventListener('keydown', this.listener); this.element.addEventListener('keydown', this.listener);
this.setMode('insert'); this.setMode('insert');
} }
/**
* Remove vim keybindings from the current element.
*
* @example
* vim.detach();
*/
detach(): void { detach(): void {
if (this.element && this.listener) { if (this.element && this.listener) {
this.element.removeEventListener('keydown', this.listener); this.element.removeEventListener('keydown', this.listener);
@ -120,64 +65,54 @@ export class VimHandler {
this.onModeChange(mode); this.onModeChange(mode);
} }
/** private handleKey(e: KeyboardEvent): void {
* Routes keystrokes to insert-mode or normal-mode handling.
* Insert mode only intercepts Escape; normal mode handles
* all vim commands and suppresses default text input.
*/
private handleKey(event: KeyboardEvent): void {
if (this.mode === 'insert') { if (this.mode === 'insert') {
if (event.key === 'Escape') { if (e.key === 'Escape') {
event.preventDefault(); e.preventDefault();
this.setMode('normal'); this.setMode('normal');
} }
return; return;
} }
// Suppress default text input in normal mode // Normal mode — prevent all default text input
event.preventDefault(); e.preventDefault();
if (event.ctrlKey) { // Undo/redo with Ctrl
if (event.key === 'r') { if (e.ctrlKey) {
if (e.key === 'r') {
document.execCommand('redo'); document.execCommand('redo');
} }
return; return;
} }
const key = event.key; const key = e.key;
// Accumulate count prefix — 0 as first char is line-start, not count // Accumulate count prefix (digits, but not 0 as first char — that's line start)
if (DIGIT_PATTERN.test(key) && (this.count || key !== '0')) { if (/^[0-9]$/.test(key) && (this.count || key !== '0')) {
this.count += key; this.count += key;
return; return;
} }
const repeat = parseInt(this.count || DEFAULT_REPEAT_COUNT, DECIMAL_RADIX); const repeat = parseInt(this.count || '1', 10);
this.count = ''; this.count = '';
// Two-char commands
if (this.pending) { if (this.pending) {
const combo = this.pending + key; const combo = this.pending + key;
this.pending = ''; this.pending = '';
for (let step = 0; step < repeat; step++) { for (let n = 0; n < repeat; n++) {
this.handlePending(combo); this.handlePending(combo);
} }
return; return;
} }
this.dispatchNormalKey(key, repeat);
}
/**
* Dispatches a normal-mode key to the appropriate command.
* Separated from handleKey to keep nesting shallow.
*/
private dispatchNormalKey(key: string, repeat: number): void {
switch (key) { switch (key) {
// Mode switching — no repeat
case 'i': case 'i':
this.setMode('insert'); this.setMode('insert');
break; break;
case 'a': case 'a':
this.moveCursor(DIRECTION.RIGHT); this.moveCursor('right');
this.setMode('insert'); this.setMode('insert');
break; break;
case 'o': case 'o':
@ -188,39 +123,28 @@ export class VimHandler {
case 'O': case 'O':
this.startOfLine(); this.startOfLine();
this.insertNewline(); this.insertNewline();
this.moveCursor(DIRECTION.UP); this.moveCursor('up');
this.setMode('insert'); this.setMode('insert');
break; break;
// Movement — repeatable
case 'h': case 'h':
for (let step = 0; step < repeat; step++) { for (let n = 0; n < repeat; n++) this.moveCursor('left');
this.moveCursor(DIRECTION.LEFT);
}
break; break;
case 'j': case 'j':
for (let step = 0; step < repeat; step++) { for (let n = 0; n < repeat; n++) this.moveCursor('down');
this.moveCursor(DIRECTION.DOWN);
}
break; break;
case 'k': case 'k':
for (let step = 0; step < repeat; step++) { for (let n = 0; n < repeat; n++) this.moveCursor('up');
this.moveCursor(DIRECTION.UP);
}
break; break;
case 'l': case 'l':
for (let step = 0; step < repeat; step++) { for (let n = 0; n < repeat; n++) this.moveCursor('right');
this.moveCursor(DIRECTION.RIGHT);
}
break; break;
case 'w': case 'w':
for (let step = 0; step < repeat; step++) { for (let n = 0; n < repeat; n++) this.wordForward();
this.wordForward();
}
break; break;
case 'b': case 'b':
for (let step = 0; step < repeat; step++) { for (let n = 0; n < repeat; n++) this.wordBack();
this.wordBack();
}
break; break;
case '0': case '0':
this.startOfLine(); this.startOfLine();
@ -232,21 +156,19 @@ export class VimHandler {
this.endOfDocument(); this.endOfDocument();
break; break;
// Editing — repeatable
case 'x': case 'x':
for (let step = 0; step < repeat; step++) { for (let n = 0; n < repeat; n++) this.deleteChar();
this.deleteChar();
}
break; break;
case 'u': case 'u':
for (let step = 0; step < repeat; step++) { for (let n = 0; n < repeat; n++) document.execCommand('undo');
document.execCommand('undo');
}
break; break;
// Two-char commands — preserve count for the second key // Pending commands — count preserved for the second key
case 'd': case 'd':
case 'g': case 'g':
this.pending = key; this.pending = key;
// Restore count so it's available for the pending handler
if (repeat > 1) { if (repeat > 1) {
this.count = String(repeat); this.count = String(repeat);
} }
@ -266,57 +188,46 @@ export class VimHandler {
} }
private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void { private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void {
const selection = window.getSelection(); const sel = window.getSelection();
if (!selection) { if (!sel) return;
return; sel.modify('move', direction === 'left' || direction === 'up' ? 'backward' : 'forward',
} direction === 'up' || direction === 'down' ? 'line' : 'character');
const selectionDirection = (direction === DIRECTION.LEFT || direction === DIRECTION.UP)
? SELECTION_DIRECTION.BACKWARD
: SELECTION_DIRECTION.FORWARD;
const granularity = (direction === DIRECTION.UP || direction === DIRECTION.DOWN)
? SELECTION_GRANULARITY.LINE
: SELECTION_GRANULARITY.CHARACTER;
selection.modify('move', selectionDirection, granularity);
} }
private wordForward(): void { private wordForward(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.WORD); window.getSelection()?.modify('move', 'forward', 'word');
} }
private wordBack(): void { private wordBack(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.WORD); window.getSelection()?.modify('move', 'backward', 'word');
} }
private startOfLine(): void { private startOfLine(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.LINE_BOUNDARY); window.getSelection()?.modify('move', 'backward', 'lineboundary');
} }
private endOfLine(): void { private endOfLine(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY); window.getSelection()?.modify('move', 'forward', 'lineboundary');
} }
private startOfDocument(): void { private startOfDocument(): void {
const selection = window.getSelection(); const sel = window.getSelection();
if (!selection || !this.element) { if (!sel || !this.element) return;
return;
}
const range = document.createRange(); const range = document.createRange();
range.setStart(this.element, 0); range.setStart(this.element, 0);
range.collapse(true); range.collapse(true);
selection.removeAllRanges(); sel.removeAllRanges();
selection.addRange(range); sel.addRange(range);
} }
private endOfDocument(): void { private endOfDocument(): void {
const selection = window.getSelection(); const sel = window.getSelection();
if (!selection || !this.element) { if (!sel || !this.element) return;
return;
}
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(this.element); range.selectNodeContents(this.element);
range.collapse(false); range.collapse(false);
selection.removeAllRanges(); sel.removeAllRanges();
selection.addRange(range); sel.addRange(range);
} }
private deleteChar(): void { private deleteChar(): void {
@ -325,9 +236,9 @@ export class VimHandler {
private deleteLine(): void { private deleteLine(): void {
this.startOfLine(); this.startOfLine();
window.getSelection()?.modify('extend', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY); window.getSelection()?.modify('extend', 'forward', 'lineboundary');
document.execCommand('delete'); document.execCommand('delete');
// Remove the trailing newline left after deleting line content // Delete the newline too
document.execCommand('forwardDelete'); document.execCommand('forwardDelete');
} }

View File

@ -1,491 +0,0 @@
import { ribbit, resetDOM } from './setup';
const lib = ribbit();
function mockTransport() {
const receiveListeners: Array<(update: Uint8Array) => void> = [];
const lockListeners: Array<(holder: any) => void> = [];
return {
connected: false,
sent: [] as Uint8Array[],
locked: false,
connect() {
this.connected = true;
},
disconnect() {
this.connected = false;
},
send(update: Uint8Array) {
this.sent.push(update);
},
onReceive(cb: (update: Uint8Array) => void) {
receiveListeners.push(cb);
},
simulateRemote(content: string) {
const encoded = new TextEncoder().encode(content);
receiveListeners.forEach(cb => cb(encoded));
},
lock: async function() {
this.locked = true;
return true;
},
unlock() {
this.locked = false;
},
forceLock: async function() {
this.locked = true;
return true;
},
onLockChange(cb: (holder: any) => void) {
lockListeners.push(cb);
},
simulateLock(holder: any) {
lockListeners.forEach(cb => cb(holder));
},
};
}
function mockPresence() {
const listeners: Array<(peers: any[]) => void> = [];
return {
lastSent: null as any,
send(info: any) {
this.lastSent = info;
},
onUpdate(cb: (peers: any[]) => void) {
listeners.push(cb);
},
simulatePeers(peers: any[]) {
listeners.forEach(cb => cb(peers));
},
};
}
function mockRevisions() {
const store: any[] = [];
return {
store,
list: async () => store,
get: async (id: string) => store.find((rev: any) => rev.id === id),
create: async (content: string, meta?: any) => {
const rev = {
id: String(store.length + 1),
timestamp: new Date().toISOString(),
content,
...meta,
};
store.push(rev);
return rev;
},
};
}
describe('CollaborationManager', () => {
beforeEach(() => resetDOM('initial'));
it('does not create manager without settings', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.collaboration).toBeUndefined();
});
it('creates manager with settings', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
expect(editor.collaboration).toBeDefined();
});
describe('connection lifecycle', () => {
it('connects on wysiwyg', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.wysiwyg();
expect(transport.connected).toBe(true);
});
it('connects on edit', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
expect(transport.connected).toBe(true);
});
it('disconnects on view', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.wysiwyg();
editor.view();
expect(transport.connected).toBe(false);
});
});
describe('source mode pausing', () => {
it('pauses on entering source mode', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
expect(editor.collaboration!.isPaused()).toBe(true);
});
it('counts remote changes while paused', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
transport.simulateRemote('change 1');
transport.simulateRemote('change 2');
expect(editor.collaboration!.getRemoteChangeCount()).toBe(2);
});
it('fires remoteActivity event while paused', (done) => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
on: {
remoteActivity: ({ count }: any) => {
if (count === 1) {
done();
}
},
},
});
editor.run();
editor.edit();
transport.simulateRemote('change');
});
it('resumes on switching to wysiwyg', () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
editor.wysiwyg();
expect(editor.collaboration!.isPaused()).toBe(false);
});
});
describe('locking', () => {
it('lock returns true', async () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
expect(await editor.lockForEditing()).toBe(true);
});
it('forceLock returns true', async () => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
expect(await editor.forceLockEditing()).toBe(true);
});
it('fires lockChange event', (done) => {
const transport = mockTransport();
const editor = new lib.Editor({
collaboration: {
transport,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
on: {
lockChange: ({ holder }: any) => {
if (holder?.userId === 'alice') {
done();
}
},
},
});
editor.run();
transport.simulateLock({
userId: 'alice',
displayName: 'Alice',
status: 'active',
lastActive: Date.now(),
});
});
});
describe('presence', () => {
it('sends cursor with status', () => {
const transport = mockTransport();
const presence = mockPresence();
const editor = new lib.Editor({
collaboration: {
transport,
presence,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
color: '#f00',
},
},
});
editor.run();
editor.wysiwyg();
editor.collaboration!.sendCursor(42);
expect(presence.lastSent.status).toBe('active');
expect(presence.lastSent.cursor).toBe(42);
});
it('sends editing status when paused', () => {
const transport = mockTransport();
const presence = mockPresence();
const editor = new lib.Editor({
collaboration: {
transport,
presence,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.edit();
editor.collaboration!.sendCursor(10);
expect(presence.lastSent.status).toBe('editing');
});
it('applies idle status to peers', () => {
const transport = mockTransport();
const presence = mockPresence();
const editor = new lib.Editor({
collaboration: {
transport,
presence,
idleTimeout: 100,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
presence.simulatePeers([
{
userId: 'a',
displayName: 'A',
status: 'active',
lastActive: Date.now() - 200,
},
{
userId: 'b',
displayName: 'B',
status: 'active',
lastActive: Date.now(),
},
]);
const peers = editor.collaboration!.getPeers();
expect(peers[0].status).toBe('idle');
expect(peers[1].status).toBe('active');
});
});
describe('revisions', () => {
it('lists revisions', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
await revisions.create('v1', { author: 'test' });
const editor = new lib.Editor({
collaboration: {
transport,
revisions,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
const list = await editor.listRevisions();
expect(list).toHaveLength(1);
});
it('creates revision', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
const editor = new lib.Editor({
collaboration: {
transport,
revisions,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
const rev = await editor.createRevision({
author: 'test',
summary: 'test rev',
});
expect(rev).toBeDefined();
expect(revisions.store).toHaveLength(1);
});
it('restores revision', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
await revisions.create('old content', { author: 'test' });
const editor = new lib.Editor({
collaboration: {
transport,
revisions,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
});
editor.run();
editor.wysiwyg();
await editor.restoreRevision('1');
expect(editor.getMarkdown()).toBe('old content');
});
it('fires revisionCreated event', async () => {
const transport = mockTransport();
const revisions = mockRevisions();
let fired = false;
const editor = new lib.Editor({
collaboration: {
transport,
revisions,
user: {
userId: 'test',
displayName: 'Test',
status: 'active',
lastActive: Date.now(),
},
},
on: {
revisionCreated: () => {
fired = true;
},
},
});
editor.run();
await editor.createRevision({ author: 'test' });
expect(fired).toBe(true);
});
});
});

View File

@ -1,109 +1,68 @@
import { ribbit, resetDOM } from './setup'; import { ribbit, resetDOM } from './setup';
const lib = ribbit(); const r = ribbit();
describe('Custom inline tags', () => {
const strikethrough = r.inlineTag({
name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE', precedence: 45,
});
const h = new r.HopDown({ tags: { ...r.defaultTags, 'DEL,S,STRIKE': strikethrough } });
it('md→html', () => expect(h.toHTML('~~struck~~')).toBe('<p><del>struck</del></p>'));
it('html→md', () => expect(h.toMarkdown('<p><del>struck</del></p>')).toContain('~~struck~~'));
it('round-trip', () => expect(h.toMarkdown(h.toHTML('~~struck~~'))).toBe('~~struck~~'));
it('mixed with bold', () => expect(h.toHTML('**bold** and ~~struck~~')).toContain('<del>struck</del>'));
});
describe('Custom block tags', () => { describe('Custom block tags', () => {
const spoiler = { const spoiler = {
name: 'spoiler', name: 'spoiler',
match: (context: any) => { match: (context: any) => {
const fencePattern = /^\|{3,}/; if (!/^\|{3,}/.test(context.lines[context.index])) return null;
if (!fencePattern.test(context.lines[context.index])) {
return null;
}
const content: string[] = []; const content: string[] = [];
let lineIndex = context.index + 1; let i = context.index + 1;
while (lineIndex < context.lines.length && !fencePattern.test(context.lines[lineIndex])) { while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i])) content.push(context.lines[i++]);
content.push(context.lines[lineIndex++]); return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
}
return {
content: content.join('\n'),
raw: '',
consumed: lineIndex + 1 - context.index,
};
}, },
toHTML: (token: any, convert: any) => '<details>' + convert.block(token.content) + '</details>', toHTML: (token: any, convert: any) => '<details>' + convert.block(token.content) + '</details>',
selector: 'DETAILS', selector: 'DETAILS',
toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n', toMarkdown: (el: any, convert: any) => '\n\n|||\n' + convert.children(el).trim() + '\n|||\n\n',
}; };
const converter = new lib.HopDown({ const h = new r.HopDown({ tags: { 'DETAILS': spoiler, ...r.defaultTags } });
tags: {
'DETAILS': spoiler,
...lib.defaultTags,
},
});
it('renders', () => expect(converter.toHTML('|||\nhidden\n|||')).toContain('<details>')); it('renders', () => expect(h.toHTML('|||\nhidden\n|||')).toContain('<details>'));
it('nested md', () => expect(converter.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>')); it('nested md', () => expect(h.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
}); });
describe('HopDown({ exclude })', () => { describe('HopDown({ exclude })', () => {
it('excludes table', () => { it('excludes table', () => {
const converter = new lib.HopDown({ exclude: ['table'] }); const h = new r.HopDown({ exclude: ['table'] });
expect(converter.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>'); expect(h.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
}); });
it('excludes code', () => { it('excludes code', () => {
const converter = new lib.HopDown({ exclude: ['code'] }); const h = new r.HopDown({ exclude: ['code'] });
expect(converter.toHTML('`code`')).toBe('<p>`code`</p>'); expect(h.toHTML('`code`')).toBe('<p>`code`</p>');
}); });
it('other tags still work', () => { it('other tags still work', () => {
const converter = new lib.HopDown({ exclude: ['table'] }); const h = new r.HopDown({ exclude: ['table'] });
expect(converter.toHTML('**bold**')).toContain('<strong>bold</strong>'); expect(h.toHTML('**bold**')).toContain('<strong>bold</strong>');
}); });
}); });
describe('Collision detection', () => { describe('Collision detection', () => {
it('delimiter collision throws', () => { it('delimiter collision throws', () => {
const bad = lib.inlineTag({ const bad = r.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
name: 'bad', expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'SPAN': bad } })).toThrow();
delimiter: '*',
htmlTag: 'span',
precedence: 10,
});
expect(() => new lib.HopDown({
tags: {
...lib.defaultTags,
'SPAN': bad,
},
})).toThrow();
}); });
it('selector collision throws', () => { it('selector collision throws', () => {
const dup = { const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
name: 'dup', expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'STRONG': dup } })).toThrow();
match: () => null,
toHTML: () => '',
selector: 'STRONG',
toMarkdown: () => '',
};
expect(() => new lib.HopDown({
tags: {
...lib.defaultTags,
'STRONG': dup,
},
})).toThrow();
}); });
it('valid precedence does not throw', () => { it('valid precedence does not throw', () => {
const short = lib.inlineTag({ const short = r.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
name: 'short', const long = r.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
delimiter: '~', expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'S': short, 'DEL': long } })).not.toThrow();
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 lib.HopDown({
tags: {
...tagsWithoutStrikethrough,
'S': short,
'DEL': long,
},
})).not.toThrow();
}); });
}); });

View File

@ -1,29 +1,25 @@
import { ribbit, resetDOM } from './setup'; import { ribbit, resetDOM } from './setup';
const lib = ribbit(); const r = ribbit();
describe('RibbitEmitter', () => { describe('RibbitEmitter', () => {
beforeEach(() => resetDOM()); beforeEach(() => resetDOM());
it('fires save event', () => { it('fires save event', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
let received: any = null; let received: any = null;
editor.on('save', (payload: any) => { editor.on('save', (p: any) => { received = p; });
received = payload;
});
editor.save(); editor.save();
expect(received).toHaveProperty('markdown'); expect(received).toHaveProperty('markdown');
expect(received).toHaveProperty('html'); expect(received).toHaveProperty('html');
}); });
it('off removes handler', () => { it('off removes handler', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
let count = 0; let count = 0;
const handler = () => { const handler = () => { count++; };
count++;
};
editor.on('save', handler); editor.on('save', handler);
editor.save(); editor.save();
editor.off('save', handler); editor.off('save', handler);
@ -32,15 +28,11 @@ describe('RibbitEmitter', () => {
}); });
it('multiple listeners', () => { it('multiple listeners', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
let count = 0; let count = 0;
editor.on('save', () => { editor.on('save', () => { count++; });
count++; editor.on('save', () => { count++; });
});
editor.on('save', () => {
count++;
});
editor.save(); editor.save();
expect(count).toBe(2); expect(count).toBe(2);
}); });
@ -50,24 +42,24 @@ describe('Ribbit viewer', () => {
beforeEach(() => resetDOM('**bold**')); beforeEach(() => resetDOM('**bold**'));
it('starts with null state', () => { it('starts with null state', () => {
const viewer = new lib.Viewer({}); const viewer = new r.Viewer({});
expect(viewer.getState()).toBeNull(); expect(viewer.getState()).toBeNull();
}); });
it('run sets view state', () => { it('run sets view state', () => {
const viewer = new lib.Viewer({}); const viewer = new r.Viewer({});
viewer.run(); viewer.run();
expect(viewer.getState()).toBe('view'); expect(viewer.getState()).toBe('view');
}); });
it('renders html', () => { it('renders html', () => {
const viewer = new lib.Viewer({}); const viewer = new r.Viewer({});
viewer.run(); viewer.run();
expect(viewer.element.innerHTML).toContain('<strong>bold</strong>'); expect(viewer.element.innerHTML).toContain('<strong>bold</strong>');
}); });
it('getMarkdown returns source', () => { it('getMarkdown returns source', () => {
const viewer = new lib.Viewer({}); const viewer = new r.Viewer({});
expect(viewer.getMarkdown()).toBe('**bold**'); expect(viewer.getMarkdown()).toBe('**bold**');
}); });
}); });
@ -76,13 +68,7 @@ describe('Ribbit events', () => {
it('ready fires on run', () => { it('ready fires on run', () => {
resetDOM('hello'); resetDOM('hello');
let payload: any = null; let payload: any = null;
const viewer = new lib.Viewer({ const viewer = new r.Viewer({ on: { ready: (p: any) => { payload = p; } } });
on: {
ready: (eventPayload: any) => {
payload = eventPayload;
},
},
});
viewer.run(); viewer.run();
expect(payload).toHaveProperty('markdown'); expect(payload).toHaveProperty('markdown');
expect(payload).toHaveProperty('mode', 'view'); expect(payload).toHaveProperty('mode', 'view');
@ -94,13 +80,13 @@ describe('RibbitEditor modes', () => {
beforeEach(() => resetDOM('**bold**')); beforeEach(() => resetDOM('**bold**'));
it('starts in view', () => { it('starts in view', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(editor.getState()).toBe('view'); expect(editor.getState()).toBe('view');
}); });
it('switches to wysiwyg', () => { it('switches to wysiwyg', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
editor.wysiwyg(); editor.wysiwyg();
expect(editor.getState()).toBe('wysiwyg'); expect(editor.getState()).toBe('wysiwyg');
@ -108,7 +94,7 @@ describe('RibbitEditor modes', () => {
}); });
it('switches to edit', () => { it('switches to edit', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
editor.wysiwyg(); editor.wysiwyg();
editor.edit(); editor.edit();
@ -116,7 +102,7 @@ describe('RibbitEditor modes', () => {
}); });
it('switches back to view', () => { it('switches back to view', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
editor.wysiwyg(); editor.wysiwyg();
editor.view(); editor.view();
@ -126,12 +112,8 @@ describe('RibbitEditor modes', () => {
it('fires modeChange events', () => { it('fires modeChange events', () => {
const modes: string[] = []; const modes: string[] = [];
const editor = new lib.Editor({ const editor = new r.Editor({
on: { on: { modeChange: ({ current }: any) => { modes.push(current); } },
modeChange: ({ current }: any) => {
modes.push(current);
},
},
}); });
editor.run(); editor.run();
editor.wysiwyg(); editor.wysiwyg();
@ -142,12 +124,9 @@ describe('RibbitEditor modes', () => {
it('sourceMode disabled blocks edit', () => { it('sourceMode disabled blocks edit', () => {
resetDOM(); resetDOM();
const editor = new lib.Editor({ const editor = new r.Editor({
currentTheme: 'no-source', currentTheme: 'no-source',
themes: [{ themes: [{ name: 'no-source', features: { sourceMode: false } }],
name: 'no-source',
features: { sourceMode: false },
}],
}); });
editor.run(); editor.run();
editor.wysiwyg(); editor.wysiwyg();
@ -160,28 +139,28 @@ describe('ThemeManager', () => {
beforeEach(() => resetDOM()); beforeEach(() => resetDOM());
it('lists registered themes', () => { it('lists registered themes', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] }); const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run(); editor.run();
expect(editor.themes.list()).toContain('ribbit-default'); expect(editor.themes.list()).toContain('ribbit-default');
expect(editor.themes.list()).toContain('dark'); expect(editor.themes.list()).toContain('dark');
}); });
it('set switches theme', () => { it('set switches theme', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] }); const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run(); editor.run();
editor.themes.set('dark'); editor.themes.set('dark');
expect(editor.themes.current().name).toBe('dark'); expect(editor.themes.current().name).toBe('dark');
}); });
it('disable hides from list', () => { it('disable hides from list', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] }); const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run(); editor.run();
editor.themes.disable('dark'); editor.themes.disable('dark');
expect(editor.themes.list()).not.toContain('dark'); expect(editor.themes.list()).not.toContain('dark');
}); });
it('enable restores to list', () => { it('enable restores to list', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] }); const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run(); editor.run();
editor.themes.disable('dark'); editor.themes.disable('dark');
editor.themes.enable('dark'); editor.themes.enable('dark');
@ -189,33 +168,29 @@ describe('ThemeManager', () => {
}); });
it('set disabled throws', () => { it('set disabled throws', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] }); const editor = new r.Editor({ themes: [{ name: 'dark' }] });
editor.run(); editor.run();
editor.themes.disable('dark'); editor.themes.disable('dark');
expect(() => editor.themes.set('dark')).toThrow(); expect(() => editor.themes.set('dark')).toThrow();
}); });
it('set unknown throws', () => { it('set unknown throws', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(() => editor.themes.set('nonexistent')).toThrow(); expect(() => editor.themes.set('nonexistent')).toThrow();
}); });
it('remove active throws', () => { it('remove active throws', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(() => editor.themes.remove(editor.themes.current().name)).toThrow(); expect(() => editor.themes.remove(editor.themes.current().name)).toThrow();
}); });
it('fires themeChange', () => { it('fires themeChange', () => {
let payload: any = null; let payload: any = null;
const editor = new lib.Editor({ const editor = new r.Editor({
themes: [{ name: 'dark' }], themes: [{ name: 'dark' }],
on: { on: { themeChange: (p: any) => { payload = p; } },
themeChange: (eventPayload: any) => {
payload = eventPayload;
},
},
}); });
editor.run(); editor.run();
editor.themes.set('dark'); editor.themes.set('dark');
@ -227,27 +202,27 @@ describe('ThemeManager', () => {
describe('defaultTheme', () => { describe('defaultTheme', () => {
it('has correct shape', () => { it('has correct shape', () => {
expect(lib.defaultTheme.name).toBe('ribbit-default'); expect(r.defaultTheme.name).toBe('ribbit-default');
expect(lib.defaultTheme.tags).toBeDefined(); expect(r.defaultTheme.tags).toBeDefined();
expect(lib.defaultTheme.features.sourceMode).toBe(true); expect(r.defaultTheme.features.sourceMode).toBe(true);
}); });
}); });
describe('Utility functions', () => { describe('Utility functions', () => {
it('encodeHtmlEntities', () => { it('encodeHtmlEntities', () => {
expect(lib.encodeHtmlEntities('<')).toBe('&#60;'); expect(r.encodeHtmlEntities('<')).toBe('&#60;');
expect(lib.encodeHtmlEntities('>')).toBe('&#62;'); expect(r.encodeHtmlEntities('>')).toBe('&#62;');
expect(lib.encodeHtmlEntities('&')).toBe('&#38;'); expect(r.encodeHtmlEntities('&')).toBe('&#38;');
}); });
it('decodeHtmlEntities', () => { it('decodeHtmlEntities', () => {
expect(lib.decodeHtmlEntities('&#60;')).toBe('<'); expect(r.decodeHtmlEntities('&#60;')).toBe('<');
expect(lib.decodeHtmlEntities('&amp;')).toBe('&'); expect(r.decodeHtmlEntities('&amp;')).toBe('&');
}); });
it('camelCase', () => { it('camelCase', () => {
expect(lib.camelCase('hello').join('')).toBe('Hello'); expect(r.camelCase('hello').join('')).toBe('Hello');
expect(lib.camelCase('hello world').join(' ')).toBe('Hello World'); expect(r.camelCase('hello world').join(' ')).toBe('Hello World');
}); });
}); });
@ -255,13 +230,13 @@ describe('Editor htmlToMarkdown', () => {
beforeEach(() => resetDOM()); beforeEach(() => resetDOM());
it('converts strong', () => { it('converts strong', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**'); expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
}); });
it('converts em', () => { it('converts em', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*'); expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
}); });

View File

@ -1,7 +1,7 @@
import { ribbit } from './setup'; import { ribbit } from './setup';
const lib = ribbit(); const r = ribbit();
const hopdown = new lib.HopDown(); const hopdown = new r.HopDown();
const H = (md: string) => hopdown.toHTML(md); const H = (md: string) => hopdown.toHTML(md);
const M = (html: string) => hopdown.toMarkdown(html); const M = (html: string) => hopdown.toMarkdown(html);
const rt = (md: string) => M(H(md)); const rt = (md: string) => M(H(md));
@ -18,9 +18,9 @@ describe('Markdown → HTML', () => {
}); });
describe('headings', () => { describe('headings', () => {
it.each([1, 2, 3, 4, 5, 6])('h%i', (level) => { it.each([1,2,3,4,5,6])('h%i', (n) => {
const prefix = '#'.repeat(level); const prefix = '#'.repeat(n);
expect(H(`${prefix} Sub`)).toContain(`<h${level}`); expect(H(`${prefix} Sub`)).toContain(`<h${n}`);
}); });
it('heading id', () => expect(H('## Hello World')).toContain("id='HelloWorld'")); it('heading id', () => expect(H('## Hello World')).toContain("id='HelloWorld'"));
it('heading inline md', () => expect(H('## **Bold** text')).toContain('<strong>Bold</strong>')); it('heading inline md', () => expect(H('## **Bold** text')).toContain('<strong>Bold</strong>'));
@ -149,388 +149,3 @@ describe('Tables with nested markdown', () => {
it('td bold rt', () => expect(rt('| h |\n|---|\n| **b** |')).toBe('| h |\n| --- |\n| **b** |')); 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) |')); 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);
});
});

View File

@ -1,46 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Ribbit Integration Test Page</title>
<link rel="stylesheet" href="/ribbit/themes/ribbit-default/theme.css">
<style>
body { font-family: sans-serif; margin: 20px; }
#ribbit { border: 1px solid #ccc; padding: 20px; min-height: 200px; }
.ribbit-toolbar { background: #f5f5f5; border: 1px solid #ccc; padding: 4px; margin-bottom: 8px; }
.ribbit-toolbar ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 2px; }
.ribbit-toolbar button { padding: 4px 8px; border: 1px solid #ddd; border-radius: 3px; background: white; cursor: pointer; font-size: 12px; }
.ribbit-toolbar button.active { background: #d0d0ff; }
.ribbit-toolbar button.disabled { opacity: 0.3; }
.ribbit-toolbar .spacer { width: 12px; }
.ribbit-dropdown { position: absolute; background: white; border: 1px solid #ccc; padding: 4px; }
.ribbit-dropdown button { display: block; width: 100%; }
</style>
</head>
<body>
<article id="ribbit">**bold** and *italic* and `code`
## Heading
- list item 1
- list item 2
> a blockquote
| A | B |
|---|---|
| 1 | 2 |
</article>
<script src="/ribbit/ribbit.js"></script>
<script>
const editor = new ribbit.Editor({
on: {
ready: () => { window.__ribbitReady = true; },
},
});
editor.run();
window.__ribbitEditor = editor;
</script>
</body>
</html>

View File

@ -1,60 +0,0 @@
/**
* 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 };

View File

@ -1,307 +0,0 @@
/**
* 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);
}
})();

View File

@ -1,471 +0,0 @@
/**
* 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();
}
})();

View File

@ -1,503 +0,0 @@
/**
* WYSIWYG integration tests with character-by-character typing.
*
* Every keystroke is sent individually with a delay, matching real
* user behavior. Assertions check intermediate DOM states to verify
* transforms fire at the right moments.
*
* Run: node test/integration/test_wysiwyg.js
*/
const { Builder, By, Key } = require('selenium-webdriver');
const firefox = require('selenium-webdriver/firefox');
const { createServer } = require('./server');
let server, driver;
const DELAY = 30;
async function setup() {
server = createServer(9997);
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);
}
/**
* Send a single character and wait for the editor to process it.
*/
async function typeChar(character) {
await driver.actions().sendKeys(character).perform();
await driver.sleep(DELAY);
}
/**
* Type a string one character at a time with delay between each.
*/
async function typeString(text) {
for (const character of text) {
await typeChar(character);
}
}
async function getHTML() {
return driver.executeScript('return document.getElementById("ribbit").innerHTML');
}
async function getMarkdown() {
return driver.executeScript('return window.__ribbitEditor.getMarkdown()');
}
let passed = 0, failed = 0;
const errors = [];
function assert(condition, message) {
if (!condition) { throw new Error(message); }
}
async function test(name, fn) {
try {
await fn();
passed++;
console.log(`${name}`);
} catch (error) {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` ${error.message}`);
}
}
async function runTests() {
console.log('\nWYSIWYG Integration Tests (char-by-char)\n');
// ── Headings ──
console.log(' Headings:');
await test('# transforms to h1 after space', async () => {
await resetEditor();
await typeChar('#');
let html = await getHTML();
assert(!html.includes('<h1'), `Premature h1 after just #: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<h1'), `No h1 after "# ": ${html}`);
await typeString('Hello');
html = await getHTML();
assert(html.includes('<h1') && html.includes('Hello'), `Missing content in h1: ${html}`);
});
await test('## transforms to h2 after space', async () => {
await resetEditor();
await typeString('##');
let html = await getHTML();
assert(!html.includes('<h2'), `Premature h2: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<h2'), `No h2 after "## ": ${html}`);
});
await test('enter after heading creates new paragraph', async () => {
await resetEditor();
await typeString('# Title');
await typeChar(Key.ENTER);
await typeString('body');
const html = await getHTML();
assert(html.includes('<h1'), `No h1: ${html}`);
assert(html.includes('body'), `No body text: ${html}`);
});
// ── Bold ──
console.log(' Bold:');
await test('** does not transform without content', async () => {
await resetEditor();
await typeString('**');
const html = await getHTML();
assert(!html.includes('<strong'), `Premature strong after just **: ${html}`);
});
await test('**x starts speculative bold', async () => {
await resetEditor();
await typeString('**');
await typeChar('x');
const html = await getHTML();
assert(html.includes('<strong'), `No strong after **x: ${html}`);
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
});
await test('**hello** completes bold', async () => {
await resetEditor();
await typeString('**hello');
let html = await getHTML();
assert(html.includes('data-speculative'), `Not speculative during typing: ${html}`);
await typeString('**');
html = await getHTML();
assert(html.includes('<strong'), `No strong after closing: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative after closing: ${html}`);
assert(html.includes('hello'), `Missing content: ${html}`);
});
await test('typing after **bold** goes outside strong', async () => {
await resetEditor();
await typeString('**bold**');
await typeString(' after');
const html = await getHTML();
assert(html.includes('<strong'), `No strong: ${html}`);
assert(html.includes('after'), `Missing "after" text: ${html}`);
// "after" should NOT be inside <strong>
const strongMatch = html.match(/<strong[^>]*>.*?<\/strong>/);
if (strongMatch) {
assert(!strongMatch[0].includes('after'),
`"after" is inside strong — cursor not placed correctly: ${html}`);
}
});
// ── Italic ──
console.log(' Italic:');
await test('*x starts speculative italic', async () => {
await resetEditor();
await typeChar('*');
let html = await getHTML();
assert(!html.includes('<em'), `Premature em after just *: ${html}`);
await typeChar('x');
html = await getHTML();
assert(html.includes('<em'), `No em after *x: ${html}`);
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
});
await test('*hello* completes italic', async () => {
await resetEditor();
await typeString('*hello');
let html = await getHTML();
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
await typeChar('*');
html = await getHTML();
assert(html.includes('<em'), `No em: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
});
// ── Code ──
console.log(' Code:');
await test('`hello` completes code span', async () => {
await resetEditor();
await typeString('`hello`');
const html = await getHTML();
assert(html.includes('<code'), `No code: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
assert(html.includes('hello'), `Missing content: ${html}`);
});
// ── Nested inline ──
console.log(' Nested inline:');
await test('**bold *italic* still typing bold', async () => {
await resetEditor();
// Type **
await typeString('**');
let html = await getHTML();
assert(!html.includes('<strong'), `Premature strong after **: ${html}`);
// Type b — speculative bold starts
await typeChar('b');
html = await getHTML();
assert(html.includes('<strong'), `No strong after **b: ${html}`);
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
// Type "old " — still speculative bold
await typeString('old ');
html = await getHTML();
assert(html.includes('data-speculative'), `Lost speculative during bold: ${html}`);
// Type * — just a * inside the speculative bold
await typeChar('*');
html = await getHTML();
assert(html.includes('data-speculative'), `Lost speculative after *: ${html}`);
// Type "italic" — speculative italic should nest inside speculative bold
await typeString('italic');
html = await getHTML();
// Should have both strong and em
assert(html.includes('<strong'), `Lost strong: ${html}`);
// Type * — closes italic, bold still speculative
await typeChar('*');
html = await getHTML();
assert(html.includes('<em'), `No em after closing *: ${html}`);
assert(html.includes('italic'), `Missing italic content: ${html}`);
// Bold should still be speculative (unclosed)
assert(html.includes('data-speculative'), `Bold not speculative anymore: ${html}`);
});
await test('**bold** and *italic* on same line', async () => {
await resetEditor();
await typeString('**bold**');
let html = await getHTML();
assert(html.includes('<strong'), `No strong: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
await typeString(' and ');
await typeString('*italic*');
html = await getHTML();
assert(html.includes('<strong'), `Lost strong: ${html}`);
assert(html.includes('<em'), `No em: ${html}`);
assert(html.includes('italic'), `Missing italic content: ${html}`);
});
// ── Lists ──
console.log(' Lists:');
await test('- space transforms to unordered list', async () => {
await resetEditor();
await typeChar('-');
let html = await getHTML();
assert(!html.includes('<ul'), `Premature ul after just -: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<ul') || html.includes('<li'), `No list after "- ": ${html}`);
await typeString('item');
html = await getHTML();
assert(html.includes('item'), `Missing content: ${html}`);
});
await test('1. space transforms to ordered list', async () => {
await resetEditor();
await typeString('1.');
let html = await getHTML();
assert(!html.includes('<ol'), `Premature ol: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<ol') || html.includes('<li'), `No list after "1. ": ${html}`);
});
// ── Blockquote ──
console.log(' Blockquote:');
await test('> space transforms to blockquote', async () => {
await resetEditor();
await typeChar('>');
let html = await getHTML();
assert(!html.includes('<blockquote'), `Premature blockquote: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<blockquote'), `No blockquote after "> ": ${html}`);
});
// ── Horizontal rule ──
console.log(' Horizontal rule:');
await test('--- transforms to hr', async () => {
await resetEditor();
await typeString('--');
let html = await getHTML();
assert(!html.includes('<hr'), `Premature hr: ${html}`);
await typeChar('-');
await driver.sleep(50);
html = await getHTML();
assert(html.includes('<hr'), `No hr after ---: ${html}`);
});
// ── Round-trip ──
console.log(' Round-trip:');
await test('**hello** round-trips to markdown', async () => {
await resetEditor();
await typeString('**hello**');
await driver.sleep(50);
const markdown = await getMarkdown();
assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`);
});
await test('# Title round-trips to markdown', async () => {
await resetEditor();
await typeString('# Title');
await driver.sleep(50);
const markdown = await getMarkdown();
assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`);
});
await test('mode switch preserves content', async () => {
await resetEditor();
await typeString('**bold**');
await typeString(' and ');
await typeString('*italic*');
await driver.sleep(50);
await driver.executeScript('window.__ribbitEditor.view()');
await driver.sleep(50);
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await driver.sleep(50);
const html = await getHTML();
assert(html.includes('<strong'), `Bold lost after mode switch: ${html}`);
assert(html.includes('<em'), `Italic lost after mode switch: ${html}`);
});
// ── Speculative closing ──
console.log(' Speculative closing:');
await test('right arrow closes speculative', async () => {
await resetEditor();
await typeString('**hello');
await driver.sleep(50);
let html = await getHTML();
assert(html.includes('data-speculative'), `No speculative: ${html}`);
await typeChar(Key.ARROW_RIGHT);
await driver.sleep(50);
html = await getHTML();
assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
});
await test('click outside closes speculative', async () => {
await resetEditor();
await typeString('**hello');
await driver.sleep(50);
let html = await getHTML();
assert(html.includes('data-speculative'), `No speculative: ${html}`);
// Add an element outside the editor and click it
await driver.executeScript(`
if (!document.getElementById('outside')) {
var btn = document.createElement('button');
btn.id = 'outside';
btn.textContent = 'outside';
btn.style.display = 'block';
btn.style.padding = '20px';
document.body.appendChild(btn);
}
`);
await driver.findElement(By.id('outside')).click();
await driver.sleep(100);
html = await getHTML();
assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
});
// ── Complex document ──
console.log(' Complex document:');
await test('multi-element document', async () => {
await resetEditor();
await typeString('# Title');
await typeChar(Key.ENTER);
await typeString('Some **bold** text.');
await typeChar(Key.ENTER);
await typeString('## Section');
await typeChar(Key.ENTER);
await typeString('- item one');
await driver.sleep(100);
const html = await getHTML();
assert(html.includes('<h1'), `Missing h1: ${html}`);
assert(html.includes('<strong'), `Missing strong: ${html}`);
assert(html.includes('<h2'), `Missing h2: ${html}`);
assert(html.includes('<li') || html.includes('<ul'), `Missing list: ${html}`);
});
console.log(' Strikethrough:');
await test('~~text~~ transforms to <del>', async () => {
await resetEditor();
await typeString('~~gone~~');
const html = await getHTML();
assert(html.includes('<del'), `No <del>: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
assert(html.includes('gone'), `Missing content: ${html}`);
});
await test('~~text shows speculative strikethrough', async () => {
await resetEditor();
await typeString('~~hel');
const html = await getHTML();
assert(html.includes('data-speculative'), `No speculative: ${html}`);
assert(html.includes('<del'), `No <del>: ${html}`);
});
console.log(' Alternate syntax:');
await test('~~~ transforms to fenced code', async () => {
await resetEditor();
await typeString('~~~');
await driver.sleep(50);
const html = await getHTML();
assert(html.includes('<pre') || html.includes('<code'), `No code block: ${html}`);
});
await test('+ space transforms to unordered list', async () => {
await resetEditor();
await typeChar('+');
let html = await getHTML();
assert(!html.includes('<ul'), `Premature ul: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<ul') || html.includes('<li'), `No list after "+ ": ${html}`);
});
console.log(' Backslash escapes:');
await test('backslash is just a character in WYSIWYG', async () => {
await resetEditor();
await typeString('hello\\world');
const html = await getHTML();
assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
});
}
(async () => {
try {
await setup();
await runTests();
} catch (error) {
console.error('Setup failed:', error.message);
failed++;
} finally {
console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`);
if (errors.length) {
console.log('\nFailed:');
errors.forEach(error => console.log(`${error}`));
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
}
})();

View File

@ -1,8 +1,6 @@
import { ribbit } from './setup'; import { ribbit } from './setup';
const lib = ribbit(); const r = ribbit();
const spacePattern = / /g;
const macros = [ const macros = [
{ {
@ -13,7 +11,7 @@ const macros = [
name: 'npc', name: 'npc',
toHTML: ({ keywords }: any) => { toHTML: ({ keywords }: any) => {
const name = keywords.join(' '); const name = keywords.join(' ');
return '<a href="/NPC/' + name.replace(spacePattern, '') + '">' + name + '</a>'; return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
}, },
}, },
{ {
@ -26,9 +24,9 @@ const macros = [
}, },
]; ];
const converter = new lib.HopDown({ macros }); const h = new r.HopDown({ macros });
const H = (md: string) => converter.toHTML(md); const H = (md: string) => h.toHTML(md);
const M = (html: string) => converter.toMarkdown(html); const M = (html: string) => h.toMarkdown(html);
describe('Macros', () => { describe('Macros', () => {
describe('self-closing', () => { describe('self-closing', () => {
@ -63,8 +61,7 @@ describe('Macros', () => {
it('keyword stripped from data-keywords', () => { it('keyword stripped from data-keywords', () => {
const html = H('@style(box verbatim\ncontent\n)'); const html = H('@style(box verbatim\ncontent\n)');
expect(html).toContain('data-keywords="box"'); expect(html).toContain('data-keywords="box"');
const verbatimKeywordPattern = /data-keywords="[^"]*verbatim/; expect(html).not.toMatch(/data-keywords="[^"]*verbatim/);
expect(html).not.toMatch(verbatimKeywordPattern);
}); });
}); });

View File

@ -12,12 +12,6 @@ export function getWindow(): any {
(global as any).HTMLElement = _window.HTMLElement; (global as any).HTMLElement = _window.HTMLElement;
(global as any).Node = _window.Node; (global as any).Node = _window.Node;
(global as any).NodeFilter = _window.NodeFilter; (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( const bundle = fs.readFileSync(
path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8' path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8'
@ -28,10 +22,10 @@ export function getWindow(): any {
} }
export function ribbit(): any { export function ribbit(): any {
const browserWindow = getWindow(); const w = getWindow();
const lib = browserWindow.ribbit; const r = w.ribbit;
lib.window = browserWindow; r.window = w;
return lib; return r;
} }
export function resetDOM(content = 'test'): void { export function resetDOM(content = 'test'): void {

View File

@ -1,322 +0,0 @@
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');
});
});

View File

@ -1,13 +1,13 @@
import { ribbit, resetDOM } from './setup'; import { ribbit, resetDOM } from './setup';
const lib = ribbit(); const r = ribbit();
describe('ToolbarManager', () => { describe('ToolbarManager', () => {
beforeEach(() => resetDOM('**bold** text')); beforeEach(() => resetDOM('**bold** text'));
describe('button registration', () => { describe('button registration', () => {
it('registers tag buttons', () => { it('registers tag buttons', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('bold')).toBeDefined(); expect(editor.toolbar.buttons.get('bold')).toBeDefined();
expect(editor.toolbar.buttons.get('italic')).toBeDefined(); expect(editor.toolbar.buttons.get('italic')).toBeDefined();
@ -15,7 +15,7 @@ describe('ToolbarManager', () => {
}); });
it('registers editor actions', () => { it('registers editor actions', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('save')).toBeDefined(); expect(editor.toolbar.buttons.get('save')).toBeDefined();
expect(editor.toolbar.buttons.get('toggle')).toBeDefined(); expect(editor.toolbar.buttons.get('toggle')).toBeDefined();
@ -23,30 +23,23 @@ describe('ToolbarManager', () => {
}); });
it('registers macro buttons', () => { it('registers macro buttons', () => {
const editor = new lib.Editor({ const editor = new r.Editor({
macros: [{ macros: [{ name: 'user', toHTML: () => 'u' }],
name: 'user',
toHTML: () => 'u',
}],
}); });
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('macro:user')).toBeDefined(); expect(editor.toolbar.buttons.get('macro:user')).toBeDefined();
}); });
it('skips macros with button: false', () => { it('skips macros with button: false', () => {
const editor = new lib.Editor({ const editor = new r.Editor({
macros: [{ macros: [{ name: 'hidden', toHTML: () => '', button: false }],
name: 'hidden',
toHTML: () => '',
button: false,
}],
}); });
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined(); expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined();
}); });
it('skips tags without button', () => { it('skips tags without button', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined(); expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined();
}); });
@ -54,7 +47,7 @@ describe('ToolbarManager', () => {
describe('button properties', () => { describe('button properties', () => {
it('bold has correct label and shortcut', () => { it('bold has correct label and shortcut', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
const bold = editor.toolbar.buttons.get('bold')!; const bold = editor.toolbar.buttons.get('bold')!;
expect(bold.label).toBe('Bold'); expect(bold.label).toBe('Bold');
@ -62,19 +55,19 @@ describe('ToolbarManager', () => {
}); });
it('bold action is wrap', () => { it('bold action is wrap', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap'); expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap');
}); });
it('save action is custom', () => { it('save action is custom', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('save')!.action).toBe('custom'); expect(editor.toolbar.buttons.get('save')!.action).toBe('custom');
}); });
it('table has template', () => { it('table has template', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
const table = editor.toolbar.buttons.get('table')!; const table = editor.toolbar.buttons.get('table')!;
expect(table.template).toContain('Header'); expect(table.template).toContain('Header');
@ -82,11 +75,8 @@ describe('ToolbarManager', () => {
}); });
it('macro button has insert action', () => { it('macro button has insert action', () => {
const editor = new lib.Editor({ const editor = new r.Editor({
macros: [{ macros: [{ name: 'toc', toHTML: () => '' }],
name: 'toc',
toHTML: () => '',
}],
}); });
editor.run(); editor.run();
const btn = editor.toolbar.buttons.get('macro:toc')!; const btn = editor.toolbar.buttons.get('macro:toc')!;
@ -97,7 +87,7 @@ describe('ToolbarManager', () => {
describe('button.hide() and button.show()', () => { describe('button.hide() and button.show()', () => {
it('hide sets visible false', () => { it('hide sets visible false', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
const bold = editor.toolbar.buttons.get('bold')!; const bold = editor.toolbar.buttons.get('bold')!;
expect(bold.visible).toBe(true); expect(bold.visible).toBe(true);
@ -106,7 +96,7 @@ describe('ToolbarManager', () => {
}); });
it('show restores visible', () => { it('show restores visible', () => {
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
const bold = editor.toolbar.buttons.get('bold')!; const bold = editor.toolbar.buttons.get('bold')!;
bold.hide(); bold.hide();
@ -117,124 +107,121 @@ describe('ToolbarManager', () => {
describe('render()', () => { describe('render()', () => {
it('returns an HTMLElement', () => { it('returns an HTMLElement', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
expect(toolbar.tagName).toBe('NAV'); expect(el.tagName).toBe('NAV');
expect(toolbar.className).toBe('ribbit-toolbar'); expect(el.className).toBe('ribbit-toolbar');
}); });
it('contains buttons', () => { it('contains buttons', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
expect(toolbar.querySelector('.ribbit-btn-bold')).not.toBeNull(); expect(el.querySelector('.ribbit-btn-bold')).not.toBeNull();
expect(toolbar.querySelector('.ribbit-btn-save')).not.toBeNull(); expect(el.querySelector('.ribbit-btn-save')).not.toBeNull();
}); });
it('buttons have aria-label', () => { it('buttons have aria-label', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
const bold = toolbar.querySelector('.ribbit-btn-bold'); const bold = el.querySelector('.ribbit-btn-bold');
expect(bold?.getAttribute('aria-label')).toBe('Bold'); expect(bold?.getAttribute('aria-label')).toBe('Bold');
}); });
it('buttons have title with shortcut', () => { it('buttons have title with shortcut', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
const bold = toolbar.querySelector('.ribbit-btn-bold'); const bold = el.querySelector('.ribbit-btn-bold');
expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)'); expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)');
}); });
it('renders spacers', () => { it('renders spacers', () => {
const editor = new lib.Editor({ const editor = new r.Editor({
autoToolbar: false, autoToolbar: false,
toolbar: ['bold', '', 'save'], toolbar: ['bold', '', 'save'],
}); });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
expect(toolbar.querySelector('.spacer')).not.toBeNull(); expect(el.querySelector('.spacer')).not.toBeNull();
}); });
it('renders dropdown groups', () => { it('renders dropdown groups', () => {
const editor = new lib.Editor({ const editor = new r.Editor({
autoToolbar: false, autoToolbar: false,
toolbar: [{ toolbar: [{ group: 'Test', items: ['bold', 'italic'] }],
group: 'Test',
items: ['bold', 'italic'],
}],
}); });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
expect(toolbar.querySelector('.ribbit-dropdown')).not.toBeNull(); expect(el.querySelector('.ribbit-dropdown')).not.toBeNull();
}); });
}); });
describe('auto-render', () => { describe('auto-render', () => {
it('inserts toolbar before editor by default', () => { it('inserts toolbar before editor by default', () => {
resetDOM(); resetDOM();
const editor = new lib.Editor({}); const editor = new r.Editor({});
editor.run(); editor.run();
const toolbarElement = editor.element.previousElementSibling; const toolbar = editor.element.previousElementSibling;
expect(toolbarElement?.className).toBe('ribbit-toolbar'); expect(toolbar?.className).toBe('ribbit-toolbar');
}); });
it('does not insert when autoToolbar is false', () => { it('does not insert when autoToolbar is false', () => {
resetDOM(); resetDOM();
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
const toolbarElement = editor.element.previousElementSibling; const toolbar = editor.element.previousElementSibling;
expect(toolbarElement?.className || '').not.toBe('ribbit-toolbar'); expect(toolbar?.className || '').not.toBe('ribbit-toolbar');
}); });
}); });
describe('custom layout', () => { describe('custom layout', () => {
it('respects custom toolbar order', () => { it('respects custom toolbar order', () => {
const editor = new lib.Editor({ const editor = new r.Editor({
autoToolbar: false, autoToolbar: false,
toolbar: ['save', 'bold'], toolbar: ['save', 'bold'],
}); });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
const buttons = toolbar.querySelectorAll('button'); const buttons = el.querySelectorAll('button');
expect(buttons[0]?.className).toBe('ribbit-btn-save'); expect(buttons[0]?.className).toBe('ribbit-btn-save');
expect(buttons[1]?.className).toBe('ribbit-btn-bold'); expect(buttons[1]?.className).toBe('ribbit-btn-bold');
}); });
it('auto-generates layout when not specified', () => { it('auto-generates layout when not specified', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
expect(toolbar.querySelectorAll('button').length).toBeGreaterThan(3); expect(el.querySelectorAll('button').length).toBeGreaterThan(3);
}); });
}); });
describe('enable/disable', () => { describe('enable/disable', () => {
it('disable adds disabled class', () => { it('disable adds disabled class', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
editor.toolbar.disable(); editor.toolbar.disable();
const bold = toolbar.querySelector('.ribbit-btn-bold'); const bold = el.querySelector('.ribbit-btn-bold');
expect(bold?.classList.contains('disabled')).toBe(true); expect(bold?.classList.contains('disabled')).toBe(true);
}); });
it('enable removes disabled class', () => { it('enable removes disabled class', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
const toolbar = editor.toolbar.render(); const el = editor.toolbar.render();
editor.toolbar.disable(); editor.toolbar.disable();
editor.toolbar.enable(); editor.toolbar.enable();
const bold = toolbar.querySelector('.ribbit-btn-bold'); const bold = el.querySelector('.ribbit-btn-bold');
expect(bold?.classList.contains('disabled')).toBe(false); expect(bold?.classList.contains('disabled')).toBe(false);
}); });
}); });
describe('updateActiveState', () => { describe('updateActiveState', () => {
it('sets active class on matching buttons', () => { it('sets active class on matching buttons', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
editor.toolbar.render(); editor.toolbar.render();
editor.toolbar.updateActiveState(['bold']); editor.toolbar.updateActiveState(['bold']);
@ -243,7 +230,7 @@ describe('ToolbarManager', () => {
}); });
it('clears active when not in list', () => { it('clears active when not in list', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
editor.toolbar.render(); editor.toolbar.render();
editor.toolbar.updateActiveState(['bold']); editor.toolbar.updateActiveState(['bold']);
@ -254,19 +241,19 @@ describe('ToolbarManager', () => {
describe('heading and list buttons', () => { describe('heading and list buttons', () => {
it('registers h1-h6', () => { it('registers h1-h6', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
for (let level = 1; level <= 6; level++) { for (let i = 1; i <= 6; i++) {
const btn = editor.toolbar.buttons.get(`h${level}`); const btn = editor.toolbar.buttons.get(`h${i}`);
expect(btn).toBeDefined(); expect(btn).toBeDefined();
expect(btn!.label).toBe(`H${level}`); expect(btn!.label).toBe(`H${i}`);
expect(btn!.shortcut).toBe(`Ctrl+${level}`); expect(btn!.shortcut).toBe(`Ctrl+${i}`);
expect(btn!.action).toBe('prefix'); expect(btn!.action).toBe('prefix');
} }
}); });
it('registers ul and ol', () => { it('registers ul and ol', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8'); expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8');
expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7'); expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7');
@ -275,7 +262,7 @@ describe('ToolbarManager', () => {
describe('keyboard shortcuts', () => { describe('keyboard shortcuts', () => {
it('all formatting buttons have shortcuts', () => { it('all formatting buttons have shortcuts', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
const expected = ['bold', 'italic', 'code', 'link', 'save']; const expected = ['bold', 'italic', 'code', 'link', 'save'];
for (const id of expected) { for (const id of expected) {
@ -284,7 +271,7 @@ describe('ToolbarManager', () => {
}); });
it('block buttons have shortcuts', () => { it('block buttons have shortcuts', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('fencedCode')!.shortcut).toBe('Ctrl+Shift+E'); 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('blockquote')!.shortcut).toBe('Ctrl+Shift+.');
@ -293,7 +280,7 @@ describe('ToolbarManager', () => {
}); });
it('editor actions have shortcuts', () => { it('editor actions have shortcuts', () => {
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V'); expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V');
expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/'); expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/');
@ -304,13 +291,9 @@ describe('ToolbarManager', () => {
it('triggers editor.save()', () => { it('triggers editor.save()', () => {
resetDOM(); resetDOM();
let saved = false; let saved = false;
const editor = new lib.Editor({ const editor = new r.Editor({
autoToolbar: false, autoToolbar: false,
on: { on: { save: () => { saved = true; } },
save: () => {
saved = true;
},
},
}); });
editor.run(); editor.run();
editor.toolbar.render(); editor.toolbar.render();
@ -322,7 +305,7 @@ describe('ToolbarManager', () => {
describe('toggle button', () => { describe('toggle button', () => {
it('switches from view to wysiwyg', () => { it('switches from view to wysiwyg', () => {
resetDOM(); resetDOM();
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
editor.toolbar.render(); editor.toolbar.render();
expect(editor.getState()).toBe('view'); expect(editor.getState()).toBe('view');
@ -332,7 +315,7 @@ describe('ToolbarManager', () => {
it('switches from wysiwyg to view', () => { it('switches from wysiwyg to view', () => {
resetDOM(); resetDOM();
const editor = new lib.Editor({ autoToolbar: false }); const editor = new r.Editor({ autoToolbar: false });
editor.run(); editor.run();
editor.wysiwyg(); editor.wysiwyg();
editor.toolbar.render(); editor.toolbar.render();

View File

@ -1,127 +1,65 @@
import { ribbit, resetDOM } from './setup'; import { ribbit, resetDOM } from './setup';
const lib = ribbit(); const r = ribbit();
describe('VimHandler', () => { describe('VimHandler', () => {
beforeEach(() => resetDOM('hello world')); beforeEach(() => resetDOM('hello world'));
it('starts in insert mode', () => { it('starts in insert mode', () => {
const editor = new lib.Editor({ const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run(); editor.run();
editor.edit(); editor.edit();
expect(editor.element.classList.contains('vim-insert')).toBe(true); expect(editor.element.classList.contains('vim-insert')).toBe(true);
}); });
it('Esc enters normal mode', () => { it('Esc enters normal mode', () => {
const editor = new lib.Editor({ const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run(); editor.run();
editor.edit(); editor.edit();
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
expect(editor.element.classList.contains('vim-normal')).toBe(true); expect(editor.element.classList.contains('vim-normal')).toBe(true);
expect(editor.element.classList.contains('vim-insert')).toBe(false); expect(editor.element.classList.contains('vim-insert')).toBe(false);
}); });
it('i returns to insert mode', () => { it('i returns to insert mode', () => {
const editor = new lib.Editor({ const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run(); editor.run();
editor.edit(); editor.edit();
// Enter normal mode // Enter normal mode
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
// Back to insert // Back to insert
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' })); editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'i' }));
expect(editor.element.classList.contains('vim-insert')).toBe(true); expect(editor.element.classList.contains('vim-insert')).toBe(true);
expect(editor.element.classList.contains('vim-normal')).toBe(false); expect(editor.element.classList.contains('vim-normal')).toBe(false);
}); });
it('disables toolbar in normal mode', () => { it('disables toolbar in normal mode', () => {
const editor = new lib.Editor({ const editor = new r.Editor({ autoToolbar: false, currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
autoToolbar: false,
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run(); editor.run();
editor.toolbar.render(); editor.toolbar.render();
editor.edit(); editor.edit();
editor.toolbar.enable(); editor.toolbar.enable();
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
const bold = editor.toolbar.buttons.get('bold'); const bold = editor.toolbar.buttons.get('bold');
expect(bold?.element?.classList.contains('disabled')).toBe(true); expect(bold?.element?.classList.contains('disabled')).toBe(true);
}); });
it('re-enables toolbar in insert mode', () => { it('re-enables toolbar in insert mode', () => {
const editor = new lib.Editor({ const editor = new r.Editor({ autoToolbar: false, currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
autoToolbar: false,
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run(); editor.run();
editor.toolbar.render(); editor.toolbar.render();
editor.edit(); editor.edit();
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' })); editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'i' }));
const bold = editor.toolbar.buttons.get('bold'); const bold = editor.toolbar.buttons.get('bold');
expect(bold?.element?.classList.contains('disabled')).toBe(false); expect(bold?.element?.classList.contains('disabled')).toBe(false);
}); });
it('detaches when leaving edit mode', () => { it('detaches when leaving edit mode', () => {
const editor = new lib.Editor({ const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run(); editor.run();
editor.edit(); editor.edit();
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
expect(editor.element.classList.contains('vim-normal')).toBe(true); expect(editor.element.classList.contains('vim-normal')).toBe(true);
editor.wysiwyg(); editor.wysiwyg();
// vim classes should be gone after mode switch // vim classes should be gone after mode switch
@ -130,21 +68,11 @@ describe('VimHandler', () => {
}); });
it('only activates in edit mode', () => { it('only activates in edit mode', () => {
const editor = new lib.Editor({ const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run(); editor.run();
editor.wysiwyg(); editor.wysiwyg();
// Esc in wysiwyg should not add vim classes // Esc in wysiwyg should not add vim classes
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
expect(editor.element.classList.contains('vim-normal')).toBe(false); expect(editor.element.classList.contains('vim-normal')).toBe(false);
}); });
}); });

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"target": "ES2018", "target": "ES2017",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"outDir": "dist", "outDir": "dist",