Compare commits

...
19 Commits
Author SHA1 Message Date
gsb 818ee418d5 docs: Styled source editor design plan
Design document for replacing the WYSIWYG innerHTML rebuild
approach with a styled-source model where the editor always
contains markdown text with CSS styling. No mode conversions
during editing, no innerHTML rebuild, no round-trip bugs.
2026-04-30 22:49:09 +00:00
gsb bfc20f56bf Add dev server with livereload
npm run dev starts a dev server on port 8080 serving the test
page and ribbit dist files. Watches src/ and test/integration/
for changes, rebuilds automatically, and notifies connected
browsers to reload via EventSource on port 8081.

The test page includes a livereload script that auto-reloads
when the dev server signals a rebuild.
2026-04-30 22:47:26 +00:00
gsb 24560a4d21 bug fixes and wysiwyg behaviour improvements 2026-04-30 22:47:26 +00:00
gsb 5b2bd94388 Add @block macros to Flask example 2026-04-30 07:09:33 +00:00
gsb d41716c8b2 Reimplement as a tokenizer with GFM parity 2026-04-30 07:09:19 +00:00
gsb 005db2f431 Add integration tests 2026-04-29 22:15:19 +00:00
gsb 3e8d3388f6 Add Selenium integration test framework 2026-04-29 22:15:02 +00:00
gsb 1198791505 Add collaboration support
Real-time collaboration through consumer-provided transport and
presence interfaces. Also includes a sample backend app.
2026-04-29 22:15:02 +00:00
gsb 2e28598243 Two build targets — core and full
dist/ribbit/ribbit-core.js    — editor without extra features
dist/ribbit/ribbit.js         — editor with all features

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

Normal mode commands:
  h/j/k/l: cursor movement
  w/b: word forward/back
  0/$: line start/end
  gg/G: document start/end
  i/a/o/O: enter insert mode
  x: delete char
  dd: delete line
  u: undo
  Ctrl+r: redo
2026-04-29 07:37:09 +00:00
gsb 8bef75e59f Add keyboard shortcuts to all toolbar buttons 2026-04-29 07:23:10 +00:00
gsb 1f523cbc0f Implement Toolbar 2026-04-29 07:11:31 +00:00
gsb 98719ec8cd Use data- attributes to preserve macro configs 2026-04-29 05:19:17 +00:00
gsb 2b88d2c10b Reimplement tests 2026-04-29 05:02:25 +00:00
gsb 4237a3f6a2 Add support for wysiwyg markdown preview 2026-04-29 05:01:51 +00:00
gsb 86d59877f1 feat: Add macro support
New: macros.ts with MacroDef, parseBlockMacro, matchInlineMacro,
buildMacroTags, processInlineMacros.

Macro syntax:
  @user                     — bare, no args
  @user()                   — empty parens, same as bare
  @npc(Goblin King)         — self-closing with args
  @style(box center         — block: no closing paren on first line
  Content here.             — content on subsequent lines
  )                         — closing paren on its own line

Unknown macro names now render as an error:
  <span class="ribbit-error">Unknown macro: @bogus</span>

The verbatim keyword causes the contents to render as literals and also
preserves line breaks.
2026-04-29 03:03:58 +00:00
gsb df49ce7545 Single ribbit namespace instead of window globals
Use esbuild --global-name=ribbit to expose a single namespace.
2026-04-29 01:40:18 +00:00
gsb f76ebbf2e5 feat: Add typed event system with on/off/emit
New events with structured payloads:

  change({ markdown, html })
    Fires on every content edit.

  save({ markdown, html })
    Fires when editor.save() is called. Consumer handles persistence.

  modeChange({ current, previous })
    Fires on VIEW/EDIT/WYSIWYG transitions.

  themeChange({ current, previous })
    Fires when themes.set() switches the active theme.

  ready({ markdown, html, mode, theme })
    Fires after editor.run() completes first render.

Events can be registered in the constructor via the 'on' setting
or at any time via editor.on(event, callback) / editor.off().

202/202 tests passing.
2026-04-29 01:35:06 +00:00
gsb ac7a698c4f Add themes support
Usage:

  const editor = new RibbitEditor({
    themes: [
        { name: 'dark', features: { sourceMode: false } },
        { name: 'minimal', tags: minimalTags },
    ],
    currentTheme: 'dark',
  });

The built-in theme is 'ribbit-default' and is always available.
Additional themes from the themes array are registered on top.
2026-04-29 01:20:55 +00:00
50 changed files with 17598 additions and 2537 deletions
+78 -25
View File
@@ -1,44 +1,97 @@
# ribbit # ribbit
Zero-dependency WYSIWYG markdown editor Zero-dependency WYSIWYG markdown editor for the browser.
## Files ## Source Layout
- `src/hopdown.js` — Markdown ↔ HTML converter (`HopDown.toHTML()`, `HopDown.toMarkdown()`) - `src/ts/` — TypeScript source files
- `src/ribbit.js` — Base viewer class (`Ribbit`), plugin base class (`RibbitPlugin`), utilities - `types.ts` — shared interfaces (Tag, SourceToken, Converter, etc.)
- `src/ribbit-editor.js` — Editor class (`RibbitEditor`) with VIEW/EDIT/WYSIWYG modes - `tags.ts` — tag definitions and `inlineTag()` factory
- `src/ribbit.css` — Editor and content styles - `hopdown.ts` — configurable markdown↔HTML converter (HopDown class)
- `macros.ts` — macro parsing and Tag generation
- `ribbit.ts` — Ribbit viewer, RibbitPlugin, utilities
- `ribbit-editor.ts` — RibbitEditor with WYSIWYG support, public API exports
- `default-theme.ts` — built-in theme definition
- `theme-manager.ts` — theme registration and switching
- `events.ts` — typed event emitter
- `src/static/` — CSS and static assets
- `ribbit-core.css` — functional editor styles (always load)
- `themes/ribbit-default/theme.css` — default theme
## Build Output
```
dist/ribbit/
├── ribbit.js # readable IIFE bundle + source map
├── ribbit.min.js # minified bundle
├── ribbit-core.css # functional styles
└── themes/
└── ribbit-default/
└── theme.css # default theme (imports ribbit-core.css)
```
## Usage ## Usage
```html ```html
<link rel="stylesheet" href="ribbit/src/ribbit.css"> <link rel="stylesheet" href="ribbit/themes/ribbit-default/theme.css">
<article id="ribbit">your markdown here</article> <article id="ribbit">your markdown here</article>
<script src="ribbit/src/hopdown.js"></script> <script src="ribbit/ribbit.js"></script>
<script src="ribbit/src/ribbit.js"></script>
<script src="ribbit/src/ribbit-editor.js"></script>
<script> <script>
const editor = new RibbitEditor({ plugins: [] }); const editor = new ribbit.Editor({
on: {
save: ({ markdown }) => {
fetch('/api/save', { method: 'POST', body: markdown });
},
},
macros: [
{
name: 'npc',
toHTML: ({ keywords }) => {
const name = keywords.join(' ');
return `<a href="/NPC/${name}">${name}</a>`;
},
},
],
});
editor.run(); editor.run();
editor.wysiwyg();
// Switch modes
editor.wysiwyg(); // WYSIWYG editing
editor.edit(); // Source editing
editor.view(); // Read-only view
// Get content
editor.getMarkdown();
editor.getHTML();
</script> </script>
``` ```
## Custom Block Tags
```javascript
const spoiler = {
name: 'spoiler',
match: (context) => {
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
const content = [];
let i = context.index + 1;
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i]))
content.push(context.lines[i++]);
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
},
toHTML: (token, convert) =>
'<details><summary>Spoiler</summary>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (element, convert) =>
'\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
};
const converter = new ribbit.HopDown({
tags: { ...ribbit.defaultTags, 'DETAILS': spoiler },
});
```
## Tests
```
npm test
```
## Supported Markdown ## Supported Markdown
Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists, Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists,
blockquotes, fenced code blocks with language, horizontal rules, GFM tables with blockquotes, fenced code blocks with language, horizontal rules, GFM tables with
column alignment, and paragraphs. Arbitrary nesting of all inline formatting. column alignment, paragraphs, and macros (@name syntax).
## Tests
Open `test/test_ribbit-down.html` in a browser.
+161
View File
@@ -0,0 +1,161 @@
# Styled Source Editor — Design Plan
## Core Concept
The editor is always a markdown text editor. There is no separate "WYSIWYG mode" —
the user edits markdown directly, but the editor applies CSS styling that makes it
look like rendered output. Delimiters (`**`, `*`, `` ` ``, etc.) are hidden when the
cursor is outside the element and revealed when the cursor enters it.
## Two CSS States (not modes)
- **Editing**: `contentEditable="true"`, delimiters revealed on cursor focus
- **Viewing**: `contentEditable="false"`, all delimiters hidden
No content transformation on state switch. The DOM is identical in both states —
only CSS changes. This eliminates all conversion-during-editing bugs.
## DOM Structure
The editor contains markdown text wrapped in styled spans:
```html
<div id="ribbit">
<div class="md-heading" data-level="2">
<span class="md-delim">## </span>Hello World
</div>
<div class="md-paragraph">
Some <span class="md-bold">
<span class="md-delim">**</span>bold<span class="md-delim">**</span>
</span> and <span class="md-italic">
<span class="md-delim">*</span>italic<span class="md-delim">*</span>
</span> text.
</div>
<div class="md-list-item">
<span class="md-delim">- </span>First item
</div>
<div class="md-blockquote">
<span class="md-delim">&gt; </span>Quoted text
</div>
</div>
```
CSS handles all visual rendering:
```css
.md-delim { display: none; color: #999; font-weight: normal; }
.md-bold.editing .md-delim,
.md-italic.editing .md-delim { display: inline; }
.md-bold { font-weight: bold; }
.md-italic { font-style: italic; }
.md-heading[data-level="1"] { font-size: 2em; font-weight: bold; }
.md-list-item { display: list-item; margin-left: 1.5em; }
.md-blockquote { border-left: 3px solid #ccc; padding-left: 1em; }
.md-code { font-family: monospace; background: #f5f5f5; }
```
## Per-Keystroke Pipeline
1. User types a character → browser inserts it into the DOM (contentEditable)
2. `input` event fires
3. Parser scans the **current line only** (the block element containing the cursor)
4. If the span structure needs updating (e.g. user just typed the closing `**`):
- Wrap/unwrap the affected text range using targeted DOM operations
- No innerHTML rebuild, no full-document re-parse
5. If a block pattern is detected (e.g. `# ` at start of line):
- Update the block element's class and data attributes
- Move the delimiter text into a `.md-delim` span
## Key Operations
### Inline formatting detection
When the user types a delimiter character, scan backward in the current
text node for a matching opener. If found, wrap the range:
```
Before: <span class="md-paragraph">hello **world**</span>
After: <span class="md-paragraph">hello <span class="md-bold">
<span class="md-delim">**</span>world<span class="md-delim">**</span>
</span></span>
```
Use `Range` and `surroundContents` for the wrap — no innerHTML.
### Block detection
When the user types a space after `#`, `>`, `-`, `1.`, etc. at the start
of a line, update the block element:
```
Before: <div class="md-paragraph"># Title</div>
After: <div class="md-heading" data-level="1">
<span class="md-delim"># </span>Title
</div>
```
### Cursor focus tracking
On `selectionchange`, find the nearest formatting span and add an
`.editing` class so CSS reveals its delimiters. Remove `.editing`
from the previous span.
## getMarkdown()
Read `textContent` from the editor element. The delimiter spans contain
the actual delimiter characters, so `textContent` produces valid markdown.
No conversion needed.
## getHTML()
Run the existing tokenizer + `toHTML` pipeline on the markdown string
from `getMarkdown()`. This is only called on demand (export, save, API),
never during editing.
## Macros
Macros are rendered as `contentEditable="false"` islands within the
editable text. The macro source (`@user`) is stored in a `data-source`
attribute. The rendered output is displayed inside the island. On focus,
the island could expand to show the source for editing.
For `toMarkdown`, macro islands emit their `data-source` value.
## Initial Load
Markdown → styled source DOM is a one-time conversion on editor init:
1. Parse markdown using the existing tokenizer (produces token stream)
2. Walk the token stream, creating the span structure described above
3. Set the editor's innerHTML once
This replaces the current `toHTML` → innerHTML path.
## What This Eliminates
- `transformInline` and its innerHTML rebuild
- `blockToMarkdown` / `nodeToMarkdown` (DOM → markdown string → DOM)
- The flatten-rebuild pipeline and all its escaping bugs
- The `<br>` + ZWS cursor anchor workarounds
- The sentinel marker system for preserved HTML elements
- Mode switch conversions (WYSIWYG ↔ view ↔ edit)
## What This Keeps
- The tokenizer (for initial load and `getHTML()`)
- The serializer (for `getHTML()` via `toMarkdown` → `toHTML`)
- Tag definitions (for block pattern matching and toolbar buttons)
- The `BaseTag` keyboard dispatch system
- The collaboration transport layer
- The macro system
## Implementation Order
1. Build the markdown → styled DOM renderer (replaces `toHTML` for editor init)
2. Build the per-line parser that updates span structure on keystroke
3. Build the inline delimiter detection (wrap/unwrap via Range)
4. Wire up cursor focus tracking for delimiter reveal
5. Implement `getMarkdown()` as `textContent` read
6. Remove `transformInline`, `blockToMarkdown`, and the rebuild pipeline
7. Update tests
## Branch
Work on the `styled-source` branch, branched from current `main`.
+118
View File
@@ -0,0 +1,118 @@
# HopDown Tokenizer Design
## Problem
The regex-based inline parser and serializer can't reliably distinguish
structural delimiters from literal text characters. This causes:
- `toMarkdown` escaping bugs (over-escaping inside inline tags, under-escaping
in text nodes)
- Round-trip failures (`toHTML(toMarkdown(html)) !== html`)
- Fragile interactions between features (underscore normalization + strikethrough,
HTML passthrough + escaping)
## Invariants
1. `toHTML` satisfies GFM spec rules 1-15
2. `toMarkdown` always emits the canonical form
3. `toHTML(toMarkdown(html)) === html` (single-pass round-trip)
## Architecture
### Token types
```
text — literal characters, will be escaped during serialization
delimiter — structural marker (**, *, ~~, `, etc.)
html — raw HTML tag passthrough
break — hard line break (<br>)
```
### Inline tokenizer (markdown → tokens)
Scans left-to-right, character by character. Maintains a stack of open
delimiters. Produces a flat token stream:
```
Input: "hello **bold *nested*** end"
Tokens: [text "hello "] [open **] [text "bold "] [open *] [text "nested"] [close *] [close **] [text " end"]
```
The tokenizer handles:
- Backslash escapes: `\*` → text token containing `*`
- Entity resolution: `&amp;` → text token containing `&`
- Flanking rules: only emit delimiter tokens when flanking conditions are met
- Code spans: `` ` `` opens a code span that consumes everything until the matching `` ` ``
- Links: `[text](url)` parsed as a unit
- Autolinks: `<url>` and bare URLs
- Hard line breaks: trailing spaces or `\` before newline
- HTML tags: `<span>` etc. passed through as html tokens
### Inline parser (tokens → HTML)
Walks the token stream and matches open/close delimiter pairs using a
stack. Produces HTML string. Handles:
- Delimiter pairing with precedence (*** before ** before *)
- Multiple-of-3 rule
- Nesting validation (no em inside em, no links inside links)
### Serializer (DOM → tokens → markdown)
Walks the DOM tree. For each node:
- Text nodes → text tokens (the serializer knows these need escaping)
- Element nodes → look up the tag, emit delimiter tokens + recurse into children
- Unknown elements → recurse into children
Then the token stream is serialized to a string:
- Delimiter tokens → emitted verbatim (they're structural)
- Text tokens → characters that would be misinterpreted as delimiters are
backslash-escaped. The serializer knows exactly which characters are
dangerous because it knows what delimiters exist.
- HTML tokens → emitted verbatim
### Why this solves the round-trip problem
The key insight: delimiter tokens and text tokens are different types.
When serializing `<strong>hello *world*</strong>`, the output is:
```
[delim **] [text "hello "] [delim *] [text "world"] [delim *] [delim **]
```
The `*` around "world" are delimiter tokens (from the nested `<em>`).
If instead the text contained a literal `*`:
```
<strong>hello * world</strong>
```
The output would be:
```
[delim **] [text "hello * world"] [delim **]
```
The `*` is a text token. During serialization, the text token scanner
sees `*` and escapes it to `\*` because `*` is a known delimiter character.
The delimiter tokens are never escaped. No ambiguity.
## Files
- `types.ts` — Token type, updated Tag interface
- `tokenizer.ts` — Inline tokenizer (markdown → tokens)
- `serializer.ts` — DOM → tokens → markdown string
- `hopdown.ts` — Orchestrator (block parsing, delegates inline to tokenizer)
- `tags.ts` — Tag definitions (simplified: no more regex patterns)
## Migration
The Tag interface changes:
- `pattern` field removed (tokenizer handles delimiter matching)
- `toMarkdown` returns Token[] instead of string
- `match` stays the same (block-level matching is already clean)
- `toHTML` stays the same
The HopDown public API stays the same:
- `toHTML(markdown)` — unchanged
- `toMarkdown(html)` — unchanged
- `findCompletePair`, `findUnmatchedOpener` — reimplemented on tokenizer
- `getTagForElement`, `getEditableSelector` — unchanged
+46
View File
@@ -0,0 +1,46 @@
# Flask Collaboration Example
A minimal Flask server demonstrating ribbit's collaboration features:
real-time sync, presence, locking, and revisions.
## Setup
```sh
pip install flask flask-sock
```
Copy (or symlink) the ribbit dist into the static directory:
```sh
ln -s /path/to/ribbit/dist/ribbit static/ribbit
```
## Run
```sh
python server.py
```
Open http://localhost:5000 in multiple browser tabs. Edits in one tab
appear in the others in real time.
## What it demonstrates
- **Real-time sync**: WebSocket relays document updates between clients
- **Presence**: colored badges show connected users and their status
- **Revisions**: save button creates named revisions, click to restore
- **Locking**: (available via console: `editor.lockForEditing()`)
- **Source mode**: entering markdown mode pauses sync, shows remote change count
## Architecture
```
Browser A ──┐
├── WebSocket ──→ Flask server ──→ WebSocket ──→ Browser B
Browser C ──┘ │
├── /api/revisions (REST)
└── /api/lock (REST)
```
The server is ~160 lines. In production you'd replace the in-memory
stores with a database and add authentication.
+281
View File
@@ -0,0 +1,281 @@
"""
Flask collaboration server example for ribbit.
Demonstrates: WebSocket relay, presence, revisions, and locking.
Requires: flask, flask-sock
pip install flask flask-sock
python server.py
Then open http://localhost:5000 in multiple browser tabs.
"""
import json
import time
import uuid
from pathlib import Path
from threading import Lock
from flask import Flask, jsonify, render_template, request
from flask_sock import Sock
app = Flask(__name__)
sock = Sock(app)
# In-memory state (replace with a database in production)
document = {"content": """# Ribbit Demo Document
## Inline Formatting
@block(examples
@block(example
### Type this
`**bold**`
### To get this
**bold**
)
@block(example
### Type this
`*italic*`
### To get this
*italic*
)
@block(example
### Type this
`***bold italic***`
### To get this
***bold italic***
)
@block(example
### Type this
`~~strikethrough~~`
### To get this
~~strikethrough~~
)
@block(example
### Type this
`` `inline code` ``
### To get this
`inline code`
)
@block(example
### Type this
`[link](http://example.com)`
### To get this
[link](http://example.com)
)
)
## Block Elements
@block(examples
@block(example
### Type this
```
- apples
- bananas
- cherries
```
### To get this
- apples
- bananas
- cherries
)
@block(example
### Type this
```
1. Step one
2. Step two
3. Step three
```
### To get this
1. Step one
2. Step two
3. Step three
)
@block(example
### Type this
```
> First line
> Second line
> Third line
```
### To get this
> First line
> Second line
> Third line
)
@block(example
### Type this
````
```python
def hello():
print("Hello!")
```
````
### To get this
```python
def hello():
print("Hello!")
```
)
)
## Full Example
Here is a paragraph with **bold**, *italic*, and `code` inline.
A [link](http://example.com) and ~~deleted text~~ too.
> A blockquote with **formatting** inside.
- List with *italic*
- And `code`
***
"""}
revisions = []
lock_holder = None
lock_mutex = Lock()
clients = {} # ws -> user info
# ── Pages ────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html", content=document["content"])
# ── Revisions API ────────────────────────────────────────
@app.route("/api/revisions", methods=["GET"])
def list_revisions():
return jsonify([{k: v for k, v in r.items() if k != "content"} for r in revisions])
@app.route("/api/revisions/<revision_id>", methods=["GET"])
def get_revision(revision_id):
for r in revisions:
if r["id"] == revision_id:
return jsonify(r)
return jsonify({"error": "not found"}), 404
@app.route("/api/revisions", methods=["POST"])
def create_revision():
data = request.json
rev = {
"id": str(uuid.uuid4())[:8],
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
"author": data.get("author", "anonymous"),
"summary": data.get("summary", ""),
"content": data.get("content", document["content"]),
}
revisions.append(rev)
broadcast_json({"type": "revision", "revision": {k: v for k, v in rev.items() if k != "content"}})
return jsonify(rev), 201
# ── Locking API ──────────────────────────────────────────
@app.route("/api/lock", methods=["POST"])
def acquire_lock():
global lock_holder
with lock_mutex:
if lock_holder is None:
lock_holder = request.json
broadcast_json({"type": "lock", "holder": lock_holder})
return jsonify({"ok": True})
return jsonify({"ok": False, "holder": lock_holder}), 409
@app.route("/api/lock", methods=["DELETE"])
def release_lock():
global lock_holder
with lock_mutex:
lock_holder = None
broadcast_json({"type": "lock", "holder": None})
return jsonify({"ok": True})
@app.route("/api/lock/force", methods=["POST"])
def force_lock():
global lock_holder
with lock_mutex:
lock_holder = request.json
broadcast_json({"type": "lock", "holder": lock_holder})
return jsonify({"ok": True})
# ── WebSocket relay ──────────────────────────────────────
@sock.route("/ws")
def websocket(ws):
client_id = str(uuid.uuid4())[:8]
clients[client_id] = {"ws": ws, "user": None}
try:
while True:
data = ws.receive()
if isinstance(data, bytes):
# Binary = document update, relay to all other clients
document["content"] = data.decode("utf-8")
for cid, client in clients.items():
if cid != client_id:
try:
client["ws"].send(data)
except Exception:
pass
elif isinstance(data, str):
msg = json.loads(data)
if msg.get("type") == "join":
clients[client_id]["user"] = msg.get("user")
# Send current document state
ws.send(document["content"].encode("utf-8"))
# Send current lock state
ws.send(json.dumps({"type": "lock", "holder": lock_holder}))
# Broadcast updated peer list
broadcast_peers()
elif msg.get("type") == "presence":
clients[client_id]["user"] = msg
broadcast_peers()
except Exception:
pass
finally:
del clients[client_id]
broadcast_peers()
def broadcast_json(msg):
data = json.dumps(msg)
for client in clients.values():
try:
client["ws"].send(data)
except Exception:
pass
def broadcast_peers():
peers = [c["user"] for c in clients.values() if c["user"]]
broadcast_json({"type": "peers", "peers": peers})
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5000)
+1
View File
@@ -0,0 +1 @@
/tmp/ribbit/dist/ribbit
+188
View File
@@ -0,0 +1,188 @@
<!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>
+23
View File
@@ -0,0 +1,23 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testPathIgnorePatterns: ['/node_modules/', '/test/integration/'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
strict: true,
target: 'ES2018',
module: 'CommonJS',
moduleResolution: 'node',
esModuleInterop: true,
lib: ['ES2019', 'DOM'],
types: ['jest'],
},
}],
},
};
+6025 -932
View File
File diff suppressed because it is too large Load Diff
+17 -10
View File
@@ -2,25 +2,32 @@
"name": "ribbit", "name": "ribbit",
"version": "1.0.0", "version": "1.0.0",
"description": "Zero-dependency WYSIWYG markdown editor for the browser", "description": "Zero-dependency WYSIWYG markdown editor for the browser",
"main": "dist/ribbit.js", "main": "dist/ribbit/ribbit.js",
"types": "dist/ribbit.d.ts",
"files": [ "files": [
"dist/", "dist/ribbit/"
"src/"
], ],
"scripts": { "scripts": {
"build": "npm run build:check && npm run build:js && npm run build:min", "build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:core && npm run build:core-min && npm run build:css",
"build:check": "tsc --noEmit", "build:check": "tsc --noEmit",
"build:js": "esbuild src/ribbit-editor.ts --bundle --format=iife --sourcemap --outfile=dist/ribbit.js", "build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit.js",
"build:min": "esbuild src/ribbit-editor.ts --bundle --format=iife --minify --outfile=dist/ribbit.min.js", "build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit.min.js",
"test": "npm run build && node test/test_hopdown.js" "build:core": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit-core.js",
"build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js",
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
"dev": "npm run build && node test/integration/dev-server.js",
"test": "npm run build && jest --verbose",
"test:integration": "npm run build && node test/integration/test.js && node test/integration/test_wysiwyg.js",
"test:coverage": "npm run build && jest --coverage"
}, },
"license": "MIT", "license": "MIT",
"author": "evilchili", "author": "evilchili",
"devDependencies": { "devDependencies": {
"@types/jsdom": "^28.0.1", "@types/jest": "^29.5.14",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"jsdom": "^20.0.3", "happy-dom": "^14.12.3",
"jest": "^29.7.0",
"selenium-webdriver": "^4.43.0",
"ts-jest": "^29.4.9",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
} }
-292
View File
@@ -1,292 +0,0 @@
/*
* hopdown.ts — configurable markdown↔HTML converter.
*
* Usage:
* const converter = new HopDown();
* const converter = new HopDown({ exclude: ['table'] });
* const converter = new HopDown({ tags: { ...defaultTags, 'DEL,S,STRIKE': strikethrough } });
*
* converter.toHTML('**bold**');
* converter.toMarkdown('<strong>bold</strong>');
*/
import type { Converter, MatchContext, Tag } from './types';
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags';
export type TagMap = Record<string, Tag>;
export interface HopDownOptions {
tags?: TagMap;
exclude?: string[];
}
/**
* A configurable markdown↔HTML converter.
*
* By default includes all standard tags. Pass options to customize:
* - tags: a mapping of HTML selectors to Tag definitions
* - exclude: remove specific tags by name from the defaults
*/
export class HopDown {
private blockTags: Tag[];
private inlineTags: Tag[];
private tags: Map<string, Tag>;
constructor(options: HopDownOptions = {}) {
let tagMap: TagMap;
if (options.tags) {
tagMap = options.tags;
} else if (options.exclude) {
const excluded = new Set(options.exclude);
tagMap = Object.fromEntries(
Object.entries(defaultTags).filter(([, tag]) => !excluded.has(tag.name))
);
} else {
tagMap = defaultTags;
}
const allTags = Object.values(tagMap);
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name));
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name));
this.blockTags = allTags.filter(tag =>
defaultBlockNames.has(tag.name) ||
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern)
);
this.inlineTags = allTags.filter(tag =>
defaultInlineNames.has(tag.name) || (tag as any).pattern
);
this.tags = new Map();
for (const [selector, tag] of Object.entries(tagMap)) {
for (const sel of selector.split(',').map(s => s.trim()).filter(Boolean)) {
if (sel.startsWith('_')) {
continue;
}
const existing = this.tags.get(sel);
if (existing && existing !== tag) {
throw new Error(
`HTML tag "${sel}" is claimed by both "${existing.name}" and "${tag.name}". ` +
`Use the exclude option to remove one before adding the other.`
);
}
this.tags.set(sel, tag);
}
}
this.validateInlineTags();
}
/**
* Verify that no two inline tags have colliding delimiters without
* correct precedence ordering. If delimiter A is a prefix of delimiter B,
* B must have lower (earlier) precedence so the longer match wins.
*/
private validateInlineTags(): void {
const withDelimiters = this.inlineTags
.filter(tag => (tag as any).delimiter)
.map(tag => ({
name: tag.name,
delimiter: (tag as any).delimiter as string,
precedence: (tag as any).precedence as number ?? 50,
}));
for (let i = 0; i < withDelimiters.length; i++) {
for (let j = i + 1; j < withDelimiters.length; j++) {
const a = withDelimiters[i];
const b = withDelimiters[j];
const aPrefix = b.delimiter.startsWith(a.delimiter);
const bPrefix = a.delimiter.startsWith(b.delimiter);
if (!aPrefix && !bPrefix) {
continue;
}
const longer = a.delimiter.length > b.delimiter.length ? a : b;
const shorter = a.delimiter.length > b.delimiter.length ? b : a;
if (longer.precedence >= shorter.precedence) {
throw new Error(
`Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` +
`lower precedence than "${shorter.name}" (delimiter "${shorter.delimiter}") ` +
`because its delimiter is a prefix match. ` +
`Got ${longer.name}=${longer.precedence}, ${shorter.name}=${shorter.precedence}.`
);
}
}
}
}
/**
* Convert a markdown string to HTML.
*/
toHTML(md: string): string {
return this.processBlocks(md);
}
/**
* Convert an HTML string back to markdown.
*/
toMarkdown(html: string): string {
const container = document.createElement('div');
container.innerHTML = html;
return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
}
private processBlocks(md: string): string {
const lines = md.replace(/\r\n/g, '\n').split('\n');
const output: string[] = [];
let index = 0;
while (index < lines.length) {
if (/^\s*$/.test(lines[index])) {
index++;
continue;
}
let matched = false;
for (const tag of this.blockTags) {
const context: MatchContext = {
lines,
index,
text: '',
offset: 0,
};
const token = tag.match(context);
if (!token) continue;
if (tag.name === 'list') {
const result = parseListBlock(lines, index, 0, (source) => this.processInline(source));
output.push(result.html);
index = result.end;
} else {
output.push(tag.toHTML(token, this.makeConverter()));
index += token.consumed;
}
matched = true;
break;
}
if (!matched) {
index++;
}
}
return output.join('\n');
}
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;
// Pass 1: extract links and non-recursive tags into placeholders before escaping
for (const tag of sorted) {
const recursive = (tag as any).recursive ?? true;
if (tag.name === 'link') {
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
// Process link text: restore earlier placeholders, then run inline on any remaining markdown
let inner = linkText;
// Check if link text contains placeholders (already-processed content)
const hasPlaceholders = /\x00P\d+\x00/.test(inner);
if (hasPlaceholders) {
inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
} else {
inner = this.processInline(inner);
}
placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
return '\x00P' + (placeholders.length - 1) + '\x00';
});
} else if (!recursive && (tag as any).pattern) {
const globalPattern = (tag as any).pattern as RegExp;
globalPattern.lastIndex = 0;
text = text.replace(globalPattern, (_, content: string) => {
placeholders.push(tag.toHTML(
{ content, raw: '', consumed: 0 },
this.makeConverter(),
));
return '\x00P' + (placeholders.length - 1) + '\x00';
});
}
}
text = escapeHtml(text);
// Pass 2: apply recursive tags in precedence order (longest delimiter first).
// Content matched here is already HTML-escaped and has had earlier
// passes applied, so we wrap directly without re-processing.
for (const tag of sorted) {
const recursive = (tag as any).recursive ?? true;
if (tag.name === 'link' || !recursive) {
continue;
}
const globalPattern = (tag as any).pattern as RegExp | undefined;
if (globalPattern) {
globalPattern.lastIndex = 0;
text = text.replace(globalPattern, (_, content: string) => {
// Restore any placeholders in the captured content
const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
const htmlTag = (tag as any).name === 'boldItalic'
? null
: ((tag.selector as string) || '').split(',')[0].toLowerCase();
if (tag.name === 'boldItalic') {
return '<em><strong>' + restored + '</strong></em>';
}
return `<${htmlTag}>${restored}</${htmlTag}>`;
});
}
}
// Restore placeholders
text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
return text;
}
private nodeToMd(node: Node): string {
if (node.nodeType === 3) {
return node.textContent || '';
}
if (node.nodeType !== 1) {
return '';
}
const element = node as HTMLElement;
const tag = this.tags.get(element.nodeName);
if (tag) {
return tag.toMarkdown(element, this.makeConverter());
}
return this.childrenToMd(node);
}
private childrenToMd(node: Node): string {
return Array.from(node.childNodes).map(child => this.nodeToMd(child)).join('');
}
private makeConverter(): Converter {
return {
inline: (source) => this.processInline(source),
block: (md) => this.processBlocks(md),
children: (node) => this.childrenToMd(node),
node: (node) => this.nodeToMd(node),
};
}
}
/**
* 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;
-114
View File
@@ -1,114 +0,0 @@
/*
* ribbit-editor.ts — WYSIWYG editing extension for Ribbit.
*/
import hopdown from './hopdown';
import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
/**
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
*
* Extends Ribbit with contentEditable support and bidirectional
* markdown↔HTML conversion on mode switches.
*
* Usage:
* const editor = new RibbitEditor({ editorId: 'my-element' });
* editor.run();
* editor.wysiwyg(); // switch to WYSIWYG mode
* editor.edit(); // switch to source editing mode
* editor.view(); // switch to read-only view
*/
export class RibbitEditor extends Ribbit {
run(): void {
this.states = {
VIEW: 'view',
EDIT: 'edit',
WYSIWYG: 'wysiwyg'
};
this.#bindEvents();
this.plugins().forEach(plugin => {
plugin.setEditable();
});
this.element.classList.add('loaded');
this.view();
}
#bindEvents(): void {
this.element.addEventListener('input', () => {
if (this.state !== this.states.VIEW) {
this.changed = true;
}
});
}
htmlToMarkdown(html?: string): string {
return hopdown.toMarkdown(html || this.element.innerHTML);
}
getMarkdown(): string {
if (this.getState() === this.states.EDIT) {
let html = this.element.innerHTML;
html = html.replace(/<(?:div|br)>/ig, '');
html = html.replace(/<\/div>/ig, '\n');
this.cachedMarkdown = decodeHtmlEntities(html);
} else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
}
if (!this.cachedMarkdown) {
this.cachedMarkdown = this.element.textContent || '';
}
return this.cachedMarkdown;
}
wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return;
this.changed = false;
this.element.contentEditable = 'true';
this.element.innerHTML = this.getHTML();
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
const macroEl = el as HTMLElement;
if (macroEl.dataset.editable === 'false') {
macroEl.contentEditable = 'false';
macroEl.style.opacity = '0.5';
}
});
this.setState(this.states.WYSIWYG);
}
edit(): void {
if (this.state === this.states.EDIT) return;
this.changed = false;
this.element.contentEditable = 'true';
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
this.setState(this.states.EDIT);
}
insertAtCursor(node: Node): void {
const sel = window.getSelection()!;
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(node);
range.setStartAfter(node);
this.element.focus();
sel.removeAllRanges();
sel.addRange(range);
}
}
// Attach public API to window for <script> tag usage.
(window as any).HopDown = HopDown;
(window as any).hopdown = hopdown;
(window as any).inlineTag = inlineTag;
(window as any).defaultTags = defaultTags;
(window as any).defaultBlockTags = defaultBlockTags;
(window as any).defaultInlineTags = defaultInlineTags;
(window as any).Ribbit = Ribbit;
(window as any).RibbitEditor = RibbitEditor;
(window as any).RibbitPlugin = RibbitPlugin;
(window as any).camelCase = camelCase;
(window as any).decodeHtmlEntities = decodeHtmlEntities;
(window as any).encodeHtmlEntities = encodeHtmlEntities;
-58
View File
@@ -1,58 +0,0 @@
/*
* ribbit.css — editor styles for the ribbit WYSIWYG markdown editor.
*
* Provides base content formatting and editor state styles.
* Override with your own theme CSS for custom look and feel.
*/
/* ── Content formatting ──────────────────────────────── */
a { text-decoration: none; }
q, blockquote {
margin-left: 30px;
font-size: 1.3em;
font-style: italic;
color: #555;
}
table { width: 100%; }
th { border-bottom: 1px solid #000; padding: 3px; }
th, td { padding: 2px; }
table td table { max-width: 95%; }
pre {
border: 1px dashed black;
border-radius: 5px;
padding: 10px;
margin: 5px;
background: #EEE;
}
code {
display: inline-block;
border: 1px dashed black;
border-radius: 5px;
padding: 5px;
background: #EEE;
margin: 3px;
}
/* ── Editor states ───────────────────────────────────── */
#ribbit {
display: none;
}
#ribbit.loaded {
display: block;
}
#ribbit.edit {
font-family: monospace;
white-space: pre;
}
#ribbit.wysiwyg .md {
opacity: 0.5;
}
-152
View File
@@ -1,152 +0,0 @@
/*
* ribbit.ts — core editor classes for the ribbit WYSIWYG markdown editor.
*/
import hopdown from './hopdown';
export interface RibbitSettings {
api?: unknown;
editorId?: string;
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
}
/**
* Base class for editor plugins. Subclass and override toHTML/toMarkdown
* to add custom processing hooks.
*/
export class RibbitPlugin {
name: string;
wiki: Ribbit;
precedence: number;
constructor(settings: { name: string; wiki: Ribbit }) {
this.name = settings.name;
this.wiki = settings.wiki;
this.precedence = 50;
}
setEditable(): void {
}
toMarkdown(html: string): string {
return html;
}
toHTML(md: string): string {
return md;
}
}
/**
* Read-only markdown viewer. Renders markdown content into an HTML element.
*
* Usage:
* const viewer = new Ribbit({ editorId: 'my-element' });
* viewer.run();
*/
export class Ribbit {
api: unknown;
element: HTMLElement;
states: Record<string, string>;
cachedHTML: string | null;
cachedMarkdown: string | null;
state: string | null;
changed: boolean;
enabledPlugins: Record<string, RibbitPlugin>;
constructor(settings: RibbitSettings) {
this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!;
this.states = {
VIEW: 'view',
};
this.cachedHTML = null;
this.cachedMarkdown = null;
this.state = null;
this.changed = false;
this.enabledPlugins = {};
(settings.plugins || []).forEach(plugin => {
this.enabledPlugins[plugin.name] = new plugin({
name: plugin.name,
wiki: this,
});
});
}
run(): void {
this.element.classList.add('loaded');
this.view();
}
plugins(): RibbitPlugin[] {
return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence);
}
getState(): string | null {
return this.state;
}
setState(newState: string): void {
this.state = newState;
Object.values(this.states).forEach(state => {
if (state === newState) {
this.element.classList.add(state);
} else {
this.element.classList.remove(state);
}
});
}
markdownToHTML(md: string): string {
return hopdown.toHTML(md);
}
getHTML(): string {
if (this.changed || !this.cachedHTML) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
}
return this.cachedHTML;
}
getMarkdown(): string {
if (!this.cachedMarkdown) {
this.cachedMarkdown = this.element.textContent || '';
}
return this.cachedMarkdown;
}
view(): void {
if (this.getState() === this.states.VIEW) return;
this.element.innerHTML = this.getHTML();
this.setState(this.states.VIEW);
this.element.contentEditable = 'false';
}
}
/**
* Convert a string to title case, splitting on whitespace.
* Returns an array of capitalized words.
*/
export function camelCase(words: string): string[] {
return words.trim().split(/\s+/g).map(word => {
const lc = word.toLowerCase();
return lc.charAt(0).toUpperCase() + lc.slice(1);
});
}
/**
* Decode HTML entities in a string using a textarea element.
*/
export function decodeHtmlEntities(html: string): string {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
}
/**
* Encode HTML-significant characters as numeric entities.
*/
export function encodeHtmlEntities(str: string): string {
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
}
+72
View File
@@ -0,0 +1,72 @@
/*
* ribbit-core.css — functional editor styles. Always load this.
* These styles control editor state visibility and behavior.
* They should not be overridden by themes.
*/
#ribbit {
display: none;
}
#ribbit.loaded {
display: block;
}
#ribbit.edit {
font-family: monospace;
white-space: pre;
}
#ribbit.wysiwyg .md {
opacity: 0.5;
}
.ribbit-editing::before,
.ribbit-editing::after {
opacity: 0.3;
font-weight: normal;
font-style: normal;
font-family: monospace;
font-size: 0.85em;
}
[data-speculative]::before,
[data-speculative]::after {
content: none !important;
}
#ribbit.wysiwyg strong.ribbit-editing::before,
#ribbit.wysiwyg strong.ribbit-editing::after {
content: "**";
}
#ribbit.wysiwyg em.ribbit-editing::before,
#ribbit.wysiwyg em.ribbit-editing::after {
content: "*";
}
#ribbit.wysiwyg code.ribbit-editing::before,
#ribbit.wysiwyg code.ribbit-editing::after {
content: "\`";
}
#ribbit.wysiwyg h1.ribbit-editing::before { content: "# "; font-size: 0.5em; }
#ribbit.wysiwyg h2.ribbit-editing::before { content: "## "; font-size: 0.5em; }
#ribbit.wysiwyg h3.ribbit-editing::before { content: "### "; font-size: 0.5em; }
#ribbit.wysiwyg h4.ribbit-editing::before { content: "#### "; font-size: 0.5em; }
#ribbit.wysiwyg h5.ribbit-editing::before { content: "##### "; font-size: 0.5em; }
#ribbit.wysiwyg h6.ribbit-editing::before { content: "###### "; font-size: 0.5em; }
#ribbit.wysiwyg blockquote.ribbit-editing::before {
content: "> ";
}
#ribbit.vim-normal {
cursor: default;
caret-color: transparent;
border-left: 3px solid #4af;
}
#ribbit.vim-insert {
border-left: 3px solid #4f4;
}
@@ -0,0 +1,52 @@
/*
* default.css — the default ribbit theme.
* Provides basic aesthetic styling for rendered markdown content.
* Replace this file with your own theme to customize the look.
*/
@import "../../ribbit-core.css";
a {
text-decoration: none;
}
blockquote {
margin-left: 30px;
font-size: 1.3em;
font-style: italic;
color: #555;
}
table {
width: 100%;
}
th {
border-bottom: 1px solid #000;
padding: 3px;
}
th, td {
padding: 2px;
}
table td table {
max-width: 95%;
}
pre {
border: 1px dashed black;
border-radius: 5px;
padding: 10px;
margin: 5px;
background: #EEE;
}
code {
display: inline-block;
border: 1px dashed black;
border-radius: 5px;
padding: 5px;
background: #EEE;
margin: 3px;
}
-479
View File
@@ -1,479 +0,0 @@
/*
* tags.ts — tag definitions for the hopdown converter.
*
* Each Tag is a self-contained definition of a markdown element,
* with rules for matching, converting to HTML, and converting back.
*/
import type { Tag, MatchContext, SourceToken, Converter, ListItem, ListResult, InlineTagDef } from './types';
/**
* Create a Tag from a shorthand inline definition.
*
* Most inline markdown elements follow the same pattern: a delimiter wraps
* content, it maps to an HTML element, and the reverse is just unwrapping.
* This factory builds a full Tag from that pattern.
*
* Usage:
* inlineTag({ name: 'bold', delimiter: '**', htmlTag: 'strong', aliases: 'B' })
* inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 })
* inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' })
*/
export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recursive: boolean; pattern: RegExp; delimiter: string } {
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
const upperTag = def.htmlTag.toUpperCase();
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
const recursive = def.recursive !== false;
return {
name: def.name,
precedence: def.precedence ?? 50,
recursive,
pattern: globalPattern,
delimiter: def.delimiter,
match: (context) => {
const matched = context.text.slice(context.offset).match(matchPattern);
if (!matched) {
return null;
}
return {
content: matched[1],
raw: matched[0],
consumed: matched[0].length,
};
},
toHTML: (token, convert) => {
const inner = recursive ? convert.inline(token.content) : escapeHtml(token.content);
return `<${def.htmlTag}>${inner}</${def.htmlTag}>`;
},
selector,
toMarkdown: (element, convert) => {
if (!recursive && element.parentNode?.nodeName === 'PRE') {
return convert.children(element);
}
return def.delimiter + convert.children(element) + def.delimiter;
},
};
}
/**
* Escape HTML special characters in a string.
*/
export function escapeHtml(source: string): string {
return source.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Generate a camelCase ID from heading text, for use as an anchor.
*/
export function camelId(text: string): string {
return text.trim().split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join('');
}
/**
* Recursively parse a markdown list into HTML, handling nested sublists
* at arbitrary depth and mixed list types (ul/ol).
*/
export function parseListBlock(lines: string[], start: number, indent: number, inlineConvert: (s: string) => string): ListResult {
const prefix = new RegExp('^' + ' '.repeat(indent) + '([\\*\\-]|\\d+\\.)\\s');
const isOl = /^\d+\./.test(lines[start].trim());
const tag = isOl ? 'ol' : 'ul';
const items: ListItem[] = [];
let i = start;
while (i < lines.length) {
const line = lines[i];
if (/^\s*$/.test(line)) break;
const lineIndent = line.match(/^(\s*)/)![1].length;
if (lineIndent < indent) break;
if (lineIndent > indent) {
const sub = parseListBlock(lines, i, lineIndent, inlineConvert);
items[items.length - 1].sub = sub.html;
i = sub.end;
continue;
}
if (!prefix.test(line)) break;
items.push({
text: line.replace(prefix, ''),
sub: '',
});
i++;
}
const html = '<' + tag + '>' + items.map(item =>
'<li>' + inlineConvert(item.text) + item.sub + '</li>'
).join('') + '</' + tag + '>';
return { html, end: i };
}
/**
* Convert an HTML list element back to markdown, recursing into
* nested sublists with 2-space indentation per depth level.
*/
export function listToMd(node: HTMLElement, depth: number, convert: Converter): string {
const isOl = node.nodeName === 'OL';
const indent = ' '.repeat(depth);
const lines: string[] = [];
Array.from(node.children).forEach((listItem, index) => {
const marker = isOl ? (index + 1) + '. ' : '- ';
let text = '';
let sublist = '';
Array.from(listItem.childNodes).forEach(child => {
if (child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')) {
sublist += listToMd(child as HTMLElement, depth + 1, convert);
} else {
text += convert.node(child);
}
});
lines.push(indent + marker + text.trim());
if (sublist) lines.push(sublist);
});
const result = lines.join('\n');
return depth === 0 ? '\n\n' + result + '\n\n' : result;
}
/**
* Test whether a line begins a block-level element (used to detect
* paragraph boundaries).
*/
export function isBlockStart(lines: string[], index: number): boolean {
const line = lines[index];
if (/^(`{3,})/.test(line)) return true;
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true;
if (/^#{1,6}\s/.test(line)) return true;
if (/^>\s?/.test(line)) return true;
if (/^[*\-]\s/.test(line) || /^\d+\.\s/.test(line)) return true;
if (line.indexOf('|') !== -1 && index + 1 < lines.length &&
/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(lines[index + 1])) return true;
return false;
}
function parseTableRow(line: string): string[] {
return line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim());
}
function parseAligns(line: string): (string | null)[] {
return parseTableRow(line).map(cell => {
if (/^:-+:$/.test(cell)) return 'center';
if (/^-+:$/.test(cell)) return 'right';
return null;
});
}
/**
* The default set of block-level tags, matched in order.
* Paragraph is the catch-all and must be last.
*/
export const defaultBlockTags: Record<string, Tag> = {
'PRE': {
/*
* ```lang
* code here
* ```
*/
name: 'fencedCode',
match: (context) => {
const matched = context.lines[context.index].match(/^(`{3,})(.*)/);
if (!matched) return null;
const fence = matched[1], lang = matched[2].trim();
const code: string[] = [];
let i = context.index + 1;
while (i < context.lines.length && !context.lines[i].startsWith(fence))
code.push(context.lines[i++]);
return {
content: code.join('\n'),
raw: '',
consumed: i + 1 - context.index,
meta: {
lang,
},
};
},
toHTML: (token) =>
'<pre><code' + (token.meta?.lang ? ` class="language-${escapeHtml(token.meta.lang)}"` : '') +
'>' + escapeHtml(token.content) + '</code></pre>',
selector: 'PRE',
toMarkdown: (element) => {
const code = element.querySelector('code');
const lang = (code?.getAttribute('class') || '').match(/language-(\S+)/)?.[1] || '';
return '\n\n```' + lang + '\n' + (code?.textContent || element.textContent || '') + '\n```\n\n';
},
},
'HR': {
/*
* ***
* ---
* ___
*/
name: 'hr',
match: (context) => {
if (!/^(\*{3,}|-{3,}|_{3,})\s*$/.test(context.lines[context.index])) {
return null;
}
return {
content: '',
raw: '',
consumed: 1,
};
},
toHTML: () => '<hr>',
selector: 'HR',
toMarkdown: () => '\n\n---\n\n',
},
'H1,H2,H3,H4,H5,H6': {
/*
* # Heading 1
* ## Heading 2
* ### Heading 3
*/
name: 'heading',
match: (context) => {
const matched = context.lines[context.index].match(/^(#{1,6})\s+(.*)/);
if (!matched) return null;
return {
content: matched[2].trim(),
raw: '',
consumed: 1,
meta: {
level: String(matched[1].length),
},
};
},
toHTML: (token, convert) =>
`<h${token.meta!.level} id='${camelId(token.content)}'>${convert.inline(token.content)}</h${token.meta!.level}>`,
selector: 'H1,H2,H3,H4,H5,H6',
toMarkdown: (element, convert) =>
'\n\n' + '#'.repeat(parseInt(element.nodeName[1])) + ' ' + convert.children(element) + '\n\n',
},
'BLOCKQUOTE': {
/*
* > quoted text
* > more quoted text
*/
name: 'blockquote',
match: (context) => {
if (!/^>\s?/.test(context.lines[context.index])) return null;
const lines: string[] = [];
let i = context.index;
while (i < context.lines.length && /^>\s?/.test(context.lines[i]))
lines.push(context.lines[i++].replace(/^>\s?/, ''));
return {
content: lines.join('\n'),
raw: '',
consumed: i - context.index,
};
},
toHTML: (token, convert) => '<blockquote>' + convert.block(token.content) + '</blockquote>',
selector: 'BLOCKQUOTE',
toMarkdown: (element, convert) =>
'\n\n' + convert.children(element).trim().split('\n').map(line => '> ' + line).join('\n') + '\n\n',
},
'UL,OL': {
/*
* - unordered item
* - unordered item
* - nested item
*
* 1. ordered item
* 2. ordered item
*/
name: 'list',
match: (context) => {
const line = context.lines[context.index];
if (!/^[*\-]\s/.test(line) && !/^\d+\.\s/.test(line)) return null;
return {
content: '',
raw: '',
consumed: 0,
};
},
toHTML: (token) => token.raw,
selector: 'UL,OL',
toMarkdown: (element, convert) =>
listToMd(element, 0, convert),
},
'TABLE': {
/*
* | head 1 | head 2 |
* |--------|--------|
* | cell 1 | cell 2 |
*/
name: 'table',
match: (context) => {
const { lines, index } = context;
if (lines[index].indexOf('|') === -1 || index + 1 >= lines.length) return null;
if (!/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(lines[index + 1])) return null;
const headers = parseTableRow(lines[index]);
const aligns = parseAligns(lines[index + 1]);
const rows: string[][] = [];
let i = index + 2;
while (i < lines.length && lines[i].indexOf('|') !== -1 && !/^\s*$/.test(lines[i]))
rows.push(parseTableRow(lines[i++]));
return {
content: '',
raw: '',
consumed: i - index,
meta: {
headers: JSON.stringify(headers),
aligns: JSON.stringify(aligns),
rows: JSON.stringify(rows),
},
};
},
toHTML: (token, convert) => {
const headers: string[] = JSON.parse(token.meta!.headers);
const aligns: (string | null)[] = JSON.parse(token.meta!.aligns);
const rows: string[][] = JSON.parse(token.meta!.rows);
function cell(tag: string, text: string, index: number): string {
const align = aligns[index] ? ` style="text-align:${aligns[index]}"` : '';
return `<${tag}${align}>${convert.inline(text)}</${tag}>`;
}
const head = '<thead><tr>' + headers.map((text, i) => cell('th', text, i)).join('') + '</tr></thead>';
const body = rows.map(row =>
'<tr>' + row.map((text, i) => cell('td', text, i)).join('') + '</tr>'
).join('');
return '<table>' + head + '<tbody>' + body + '</tbody></table>';
},
selector: 'TABLE',
toMarkdown: (element, convert) => {
const rows = Array.from(element.querySelectorAll('tr'));
if (!rows.length) return '';
const headers = Array.from(rows[0].querySelectorAll('th,td')).map(cell => convert.children(cell).trim());
const separator = headers.map(() => '---');
const output = [
'| ' + headers.join(' | ') + ' |',
'| ' + separator.join(' | ') + ' |',
];
rows.slice(1).forEach(row => {
const cells = Array.from(row.querySelectorAll('td,th')).map(cell => convert.children(cell).trim());
output.push('| ' + cells.join(' | ') + ' |');
});
return '\n\n' + output.join('\n') + '\n\n';
},
},
'P': {
/*
* Any text that doesn't match another block tag
* becomes a paragraph.
*/
name: 'paragraph',
match: (context) => {
const collected: string[] = [];
let i = context.index;
while (i < context.lines.length && !/^\s*$/.test(context.lines[i]) && !isBlockStart(context.lines, i))
collected.push(context.lines[i++]);
return collected.length
? {
content: collected.join('\n'),
raw: '',
consumed: i - context.index,
}
: null;
},
toHTML: (token, convert) => '<p>' + convert.inline(token.content) + '</p>',
selector: 'P',
toMarkdown: (element, convert) => '\n\n' + convert.children(element) + '\n\n',
},
};
/**
* The default set of inline tags, matched in precedence order.
* Tags created with inlineTag() carry a precedence field; lower values run first.
*/
export const defaultInlineTags: Record<string, Tag> = {
'CODE': inlineTag({
/*
* `inline code`
*/
name: 'code',
delimiter: '`',
htmlTag: 'code',
precedence: 10,
recursive: false,
}),
'A': {
/*
* [link text](http://example.com)
*/
name: 'link',
match: (context) => {
const matched = context.text.slice(context.offset).match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (!matched) {
return null;
}
return {
content: matched[1],
raw: matched[0],
consumed: matched[0].length,
meta: {
href: matched[2],
},
};
},
toHTML: (token, convert) =>
'<a href="' + escapeHtml(token.meta!.href) + '">' + convert.inline(token.content) + '</a>',
selector: 'A',
toMarkdown: (element, convert) =>
'[' + convert.children(element) + '](' + (element.getAttribute('href') || '') + ')',
},
'_boldItalic': {
/*
* ***bold and italic***
*/
...inlineTag({
name: 'boldItalic',
delimiter: '***',
htmlTag: 'em',
precedence: 30,
}),
toHTML: (token, convert) =>
'<em><strong>' + convert.inline(token.content) + '</strong></em>',
selector: ((element: HTMLElement) => false) as string | ((element: HTMLElement) => boolean),
toMarkdown: () => '',
},
'STRONG,B': inlineTag({
/*
* **bold text**
*/
name: 'bold',
delimiter: '**',
htmlTag: 'strong',
aliases: 'B',
precedence: 40,
}),
'EM,I': inlineTag({
/*
* *italic text*
*/
name: 'italic',
delimiter: '*',
htmlTag: 'em',
aliases: 'I',
precedence: 50,
}),
};
/**
* All default tags: block tags merged with inline tags.
*/
export const defaultTags: Record<string, Tag> = {
...defaultBlockTags,
...defaultInlineTags,
};
+373
View File
@@ -0,0 +1,373 @@
/*
* collaboration.ts — real-time collaboration manager for ribbit.
*
* Manages document sync, presence, locking, and revision creation
* through consumer-provided interfaces. Ribbit never makes network
* calls — the consumer owns the network layer.
*/
import type {
DocumentTransport, PresenceChannel, PeerInfo,
CollaborationSettings, RevisionProvider, Revision, RevisionMetadata,
} from './types';
/** Milliseconds to buffer rapid remote updates before applying the latest. */
const THROTTLE_DELAY_MS = 150;
/** Default milliseconds before a peer is considered idle. */
const DEFAULT_IDLE_TIMEOUT_MS = 30000;
/** Peer status values used in presence tracking. */
const PEER_STATUS = {
ACTIVE: 'active' as const,
EDITING: 'editing' as const,
IDLE: 'idle' as const,
};
/** Auto-revision metadata when saving remote state before source mode merge. */
const AUTO_REVISION_AUTHOR = 'auto';
const AUTO_REVISION_SUMMARY = 'Auto-saved before source mode merge';
/**
* Manages real-time collaboration for a ribbit editor instance.
*
* Handles document sync, peer presence, document locking, and
* revision management through consumer-provided transport interfaces.
*
* @example
* const collab = new CollaborationManager(settings, {
* onRemoteUpdate: (content) => editor.setContent(content),
* onPeersChange: (peers) => updateUserList(peers),
* onLockChange: (holder) => updateLockUI(holder),
* onRemoteActivity: (count) => showBadge(count),
* });
* collab.connect();
*/
export class CollaborationManager {
private transport: DocumentTransport;
private presence?: PresenceChannel;
private revisions?: RevisionProvider;
private user: PeerInfo;
private peers: PeerInfo[];
private connected: boolean;
private paused: boolean;
private remoteChangeCount: number;
private latestRemoteContent: string | null;
private idleTimeout: number;
private lockHolder: PeerInfo | null;
private onRemoteUpdate: (content: string) => void;
private onPeersChange: (peers: PeerInfo[]) => void;
private onLockChange: (holder: PeerInfo | null) => void;
private onRemoteActivity: (count: number) => void;
private receiveBuffer: Uint8Array[];
private throttleTimer?: number;
constructor(
settings: CollaborationSettings,
callbacks: {
onRemoteUpdate: (content: string) => void;
onPeersChange: (peers: PeerInfo[]) => void;
onLockChange: (holder: PeerInfo | null) => void;
onRemoteActivity: (count: number) => void;
},
) {
this.transport = settings.transport;
this.presence = settings.presence;
this.revisions = settings.revisions;
this.user = settings.user;
this.peers = [];
this.connected = false;
this.paused = false;
this.remoteChangeCount = 0;
this.latestRemoteContent = null;
this.idleTimeout = settings.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS;
this.lockHolder = null;
this.onRemoteUpdate = callbacks.onRemoteUpdate;
this.onPeersChange = callbacks.onPeersChange;
this.onLockChange = callbacks.onLockChange;
this.onRemoteActivity = callbacks.onRemoteActivity;
this.receiveBuffer = [];
this.transport.onReceive((update) => {
this.handleRemoteUpdate(update);
});
if (this.presence) {
this.presence.onUpdate((peers) => {
this.peers = this.applyIdleStatus(peers);
this.onPeersChange(this.peers);
});
}
if (this.transport.onLockChange) {
this.transport.onLockChange((holder) => {
this.lockHolder = holder;
this.onLockChange(holder);
});
}
}
/**
* Open the transport connection and begin receiving updates.
*
* @example
* collab.connect();
*/
connect(): void {
if (this.connected) {
return;
}
this.transport.connect();
this.connected = true;
this.remoteChangeCount = 0;
this.latestRemoteContent = null;
}
/**
* Close the transport connection and clear peer state.
*
* @example
* collab.disconnect();
*/
disconnect(): void {
if (!this.connected) {
return;
}
this.transport.disconnect();
this.connected = false;
this.peers = [];
this.paused = false;
}
/**
* Pause applying remote updates (e.g. when entering source mode).
* Updates are still received and counted so the UI can show a badge.
*
* @example
* collab.pause(editor.getMarkdown());
*/
pause(currentContent: string): void {
this.paused = true;
this.remoteChangeCount = 0;
this.latestRemoteContent = null;
}
/**
* Resume applying remote updates (e.g. when leaving source mode).
* If remote changes arrived while paused, creates a revision of
* the remote version before applying local content (last-write-wins).
*
* @example
* await collab.resume(editor.getMarkdown());
*/
async resume(localContent: string): Promise<void> {
if (this.paused && this.latestRemoteContent && this.revisions) {
await this.revisions.create(this.latestRemoteContent, {
author: AUTO_REVISION_AUTHOR,
summary: AUTO_REVISION_SUMMARY,
});
}
this.paused = false;
this.remoteChangeCount = 0;
this.latestRemoteContent = null;
this.sendUpdate(localContent);
}
/**
* Broadcast local content to connected peers.
*
* @example
* collab.sendUpdate(editor.getMarkdown());
*/
sendUpdate(markdown: string): void {
if (!this.connected || this.paused) {
return;
}
const encoded = new TextEncoder().encode(markdown);
this.transport.send(encoded);
}
/**
* Broadcast cursor position to connected peers.
*
* @example
* collab.sendCursor(selection.anchorOffset);
*/
sendCursor(position: number): void {
if (!this.connected || !this.presence) {
return;
}
this.presence.send({
...this.user,
status: this.paused ? PEER_STATUS.EDITING : PEER_STATUS.ACTIVE,
lastActive: Date.now(),
cursor: position,
});
}
/**
* Request an exclusive document lock.
*
* @example
* const acquired = await collab.lock();
*/
async lock(): Promise<boolean> {
if (!this.transport.lock) {
return false;
}
return this.transport.lock();
}
/**
* Release the document lock.
*
* @example
* collab.unlock();
*/
unlock(): void {
this.transport.unlock?.();
}
/**
* Force-acquire the lock, overriding any existing holder.
*
* @example
* const acquired = await collab.forceLock();
*/
async forceLock(): Promise<boolean> {
if (!this.transport.forceLock) {
return false;
}
return this.transport.forceLock();
}
/**
* Return the peer currently holding the document lock, or null.
*
* @example
* const holder = collab.getLockHolder();
*/
getLockHolder(): PeerInfo | null {
return this.lockHolder;
}
/**
* Return the list of currently connected peers.
*
* @example
* const peers = collab.getPeers();
*/
getPeers(): PeerInfo[] {
return this.peers;
}
/**
* Return the number of remote changes received while paused.
*
* @example
* const count = collab.getRemoteChangeCount();
*/
getRemoteChangeCount(): number {
return this.remoteChangeCount;
}
/**
* Whether the transport connection is open.
*
* @example
* if (collab.isConnected()) { ... }
*/
isConnected(): boolean {
return this.connected;
}
/**
* Whether remote updates are currently paused.
*
* @example
* if (collab.isPaused()) { ... }
*/
isPaused(): boolean {
return this.paused;
}
/**
* List all stored revisions via the consumer's RevisionProvider.
*
* @example
* const revisions = await collab.listRevisions();
*/
async listRevisions(): Promise<Revision[]> {
if (!this.revisions) {
return [];
}
return this.revisions.list();
}
/**
* Retrieve a specific revision by ID.
*
* @example
* const revision = await collab.getRevision('abc123');
*/
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
if (!this.revisions) {
return null;
}
return this.revisions.get(id);
}
/**
* Create a new revision with the given content and metadata.
*
* @example
* await collab.createRevision(markdown, { author: 'user1', summary: 'Draft' });
*/
async createRevision(content: string, metadata?: RevisionMetadata): Promise<Revision | null> {
if (!this.revisions) {
return null;
}
return this.revisions.create(content, metadata);
}
/**
* Buffers rapid remote updates and applies only the latest after
* a throttle delay. When paused, counts changes without applying.
*/
private handleRemoteUpdate(update: Uint8Array): void {
const content = new TextDecoder().decode(update);
if (this.paused) {
this.remoteChangeCount++;
this.latestRemoteContent = content;
this.onRemoteActivity(this.remoteChangeCount);
return;
}
this.receiveBuffer.push(update);
if (this.throttleTimer !== undefined) {
return;
}
this.throttleTimer = window.setTimeout(() => {
this.throttleTimer = undefined;
if (this.receiveBuffer.length === 0) {
return;
}
const latest = this.receiveBuffer[this.receiveBuffer.length - 1];
this.receiveBuffer = [];
this.onRemoteUpdate(new TextDecoder().decode(latest));
}, THROTTLE_DELAY_MS);
}
/** Marks peers as idle when their lastActive exceeds the timeout. */
private applyIdleStatus(peers: PeerInfo[]): PeerInfo[] {
const now = Date.now();
return peers.map(peer => ({
...peer,
status: peer.status === PEER_STATUS.EDITING
? PEER_STATUS.EDITING
: (now - peer.lastActive > this.idleTimeout ? PEER_STATUS.IDLE : PEER_STATUS.ACTIVE),
}));
}
}
+26
View File
@@ -0,0 +1,26 @@
/*
* default-theme.ts — the default ribbit theme.
*
* Enables all default tags and all editor features.
*/
import type { RibbitTheme } from './types';
import { defaultTags } from './tags';
/** Theme name used as the built-in default across ribbit. */
const DEFAULT_THEME_NAME = 'ribbit-default';
/**
* The built-in ribbit theme. Enables all default tags and source mode.
*
* @example
* import { defaultTheme } from './default-theme';
* const editor = new RibbitEditor({ theme: defaultTheme });
*/
export const defaultTheme: RibbitTheme = {
name: DEFAULT_THEME_NAME,
tags: defaultTags,
features: {
sourceMode: true,
},
};
+165
View File
@@ -0,0 +1,165 @@
/*
* events.ts — typed event emitter for the ribbit editor.
*/
import type { RibbitTheme, PeerInfo, Revision } from './types';
export interface ContentPayload {
markdown: string;
html: string;
}
export interface ModeChangePayload {
current: string;
previous: string | null;
}
export interface ThemeChangePayload {
current: RibbitTheme;
previous: RibbitTheme;
}
export interface ReadyPayload {
markdown: string;
html: string;
mode: string;
theme: RibbitTheme;
}
export interface RibbitEventMap {
/*
* Content was modified. Fires on every edit.
*
* editor.on('change', ({ markdown }) => {
* localStorage.setItem('draft', markdown);
* });
*/
change: (payload: ContentPayload) => void;
/*
* Save requested via editor.save(), toolbar button, or Ctrl+S.
*
* editor.on('save', ({ markdown, html }) => {
* fetch('/api/save', { method: 'POST', body: markdown });
* });
*/
save: (payload: ContentPayload) => void;
/*
* Editor mode switched between view, edit, and wysiwyg.
*
* editor.on('modeChange', ({ current, previous }) => {
* toolbar.toggle(current !== 'view');
* main.classList.toggle('editing', current !== 'view');
* });
*/
modeChange: (payload: ModeChangePayload) => void;
/*
* Theme switched via editor.themes.set().
*
* editor.on('themeChange', ({ current, previous }) => {
* analytics.track('theme_switch', { from: previous.name, to: current.name });
* });
*/
themeChange: (payload: ThemeChangePayload) => void;
/*
* Editor initialized and first render complete.
*
* editor.on('ready', ({ mode, theme }) => {
* console.log(`Editor ready in ${mode} mode with ${theme.name} theme`);
* });
*/
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;
/**
* 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 {
private listeners: Map<string, Set<Function>>;
constructor() {
this.listeners = new Map();
}
/**
* Register a callback for an event.
*
* @example
* emitter.on('save', ({ markdown }) => saveDraft(markdown));
*/
on<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
/**
* Remove a previously registered callback.
*
* @example
* emitter.off('save', savedCallback);
*/
off<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
this.listeners.get(event)?.delete(callback);
}
/**
* 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 {
for (const callback of this.listeners.get(event) || []) {
callback(...args);
}
}
}
+741
View File
@@ -0,0 +1,741 @@
/*
* hopdown.ts — configurable markdown↔HTML converter.
*
* HopDown orchestrates markdown↔HTML conversion using a tokenizer for
* inline parsing and a serializer for HTML→markdown. Block-level parsing
* uses Tag definitions directly. The tokenizer/serializer architecture
* ensures correct round-trips by separating structural delimiters from
* literal text at the type level.
*/
import type { Converter, MatchContext, Tag, DelimiterMatch } from './types';
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml } from './tags';
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros';
import { InlineTokenizer, type InlineToken, type DelimiterDef } from './tokenizer';
import { MarkdownSerializer, type SerializerTagDef } from './serializer';
export type TagMap = Record<string, Tag>;
export interface HopDownOptions {
tags?: TagMap;
exclude?: string[];
macros?: MacroDef[];
}
/**
* Configurable markdown↔HTML converter. Uses a tokenizer for inline
* parsing (markdown→HTML) and a serializer for HTML→markdown. Block
* parsing delegates to Tag definitions.
*
* const converter = new HopDown();
* converter.toHTML('**bold**');
* converter.toMarkdown('<strong>bold</strong>');
*/
export class HopDown {
private blockTags: Tag[];
private inlineTags: Tag[];
private tags: Map<string, Tag>;
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 = {}) {
let tagMap: TagMap;
if (options.tags) {
tagMap = options.tags;
} else if (options.exclude) {
const excluded = new Set(options.exclude);
tagMap = Object.fromEntries(
Object.entries(defaultTags).filter(([, tag]) => !excluded.has(tag.name))
);
} else {
tagMap = defaultTags;
}
this.macroMap = new Map();
this.referenceLinks = new Map();
if (options.macros && options.macros.length > 0) {
const { blockTag, selectorTag, macroMap } = buildMacroTags(options.macros);
this.macroMap = macroMap;
tagMap['[data-macro]'] = selectorTag;
tagMap['_macro'] = blockTag;
}
const allTags = Object.values(tagMap);
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(tag => tag.name));
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(tag => tag.name));
this.blockTags = allTags.filter(tag =>
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
(!defaultInlineNames.has(tag.name) && !tag.pattern)
);
// Macro block tag must run after fencedCode (so code blocks aren't
// parsed as macros) but before paragraph (the catch-all)
this.blockTags.sort((a, b) => {
const order = (tag: Tag) => {
if (tag.name === 'fencedCode') {
return 0;
}
if (tag.name === 'macro') {
return 1;
}
if (tag.name === 'paragraph') {
return 99;
}
return 50;
};
return order(a) - order(b);
});
this.inlineTags = allTags.filter(tag =>
defaultInlineNames.has(tag.name) || tag.pattern
);
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)) {
const parts = selector.split(',').map(part => part.trim()).filter(Boolean);
for (const part of parts) {
if (part.startsWith('_')) {
continue;
}
const existing = this.tags.get(part);
if (existing && existing !== tag) {
throw new Error(
`HTML tag "${part}" is claimed by both "${existing.name}" and "${tag.name}". ` +
`Use the exclude option to remove one before adding the other.`
);
}
this.tags.set(part, tag);
}
}
}
private validateInlineTags(): void {
const withDelimiters = this.inlineTags
.filter(tag => tag.delimiter)
.map(tag => ({
name: tag.name,
delimiter: tag.delimiter as string,
precedence: tag.precedence as number ?? 50,
}));
for (let outer = 0; outer < withDelimiters.length; outer++) {
for (let inner = outer + 1; inner < withDelimiters.length; inner++) {
const first = withDelimiters[outer];
const second = withDelimiters[inner];
const firstIsPrefix = second.delimiter.startsWith(first.delimiter);
const secondIsPrefix = first.delimiter.startsWith(second.delimiter);
if (!firstIsPrefix && !secondIsPrefix) {
continue;
}
const longer = first.delimiter.length > second.delimiter.length ? first : second;
const shorter = first.delimiter.length > second.delimiter.length ? second : first;
if (longer.precedence >= shorter.precedence) {
throw new Error(
`Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` +
`lower precedence than "${shorter.name}" (delimiter "${shorter.delimiter}") ` +
`because its delimiter is a prefix match. ` +
`Got ${longer.name}=${longer.precedence}, ${shorter.name}=${shorter.precedence}.`
);
}
}
}
}
/**
* Convert a markdown string to HTML.
*
* converter.toHTML('# Hello\n\n**bold** text')
*/
toHTML(markdown: string): string {
return this.processBlocks(markdown);
}
/**
* Convert an HTML string back to markdown. Uses the serializer
* which produces correctly-escaped output via typed tokens.
*
* converter.toMarkdown('<h1>Hello</h1><p><strong>bold</strong> text</p>')
*/
toMarkdown(html: string): string {
const container = document.createElement('div');
container.innerHTML = html;
return this.serializeNode(container).replace(/\n{3,}/g, '\n\n').trim();
}
/**
* The registered block-level tags. Used by the WYSIWYG editor
* to detect block syntax patterns during live editing.
*
* converter.getBlockTags().forEach(tag => console.log(tag.name))
*/
getBlockTags(): Tag[] {
return this.blockTags;
}
/**
* The registered inline tags. Used by the WYSIWYG editor to
* build delimiter regexes for speculative rendering.
*
* converter.getInlineTags().filter(tag => tag.delimiter)
*/
getInlineTags(): Tag[] {
return this.inlineTags;
}
/**
* Find the first complete delimiter pair in the text.
*
* converter.findCompletePair('hello **world** end')
*/
findCompletePair(text: string): DelimiterMatch | null {
for (const entry of this.delimiterRegexes) {
const match = text.match(entry.complete);
if (match && match.index !== undefined) {
return {
tag: entry.tag,
htmlTag: entry.htmlTag,
content: match[1],
index: match.index,
length: match[0].length,
delimiter: entry.tag.delimiter!,
};
}
}
return null;
}
/**
* Find the first unclosed delimiter opener in the text.
*
* converter.findUnmatchedOpener('hello **world')
*/
findUnmatchedOpener(text: string): DelimiterMatch | null {
for (const entry of this.delimiterRegexes) {
const match = text.match(entry.open);
if (match && match.index !== undefined) {
const before = text.slice(0, match.index);
if (before.endsWith('<') || before.endsWith('/')) {
continue;
}
return {
tag: entry.tag,
htmlTag: entry.htmlTag,
content: match[1],
index: match.index,
length: match[0].length,
delimiter: entry.tag.delimiter!,
};
}
}
return null;
}
/**
* Look up the Tag definition for an HTML element by its tag name.
*
* converter.getTagForElement(strongElement)
*/
getTagForElement(element: HTMLElement): Tag | null {
const tag = this.tags.get(element.tagName);
if (tag && tag.delimiter) {
return tag;
}
return null;
}
/**
* CSS selector string matching all elements that should show
* editing context.
*
* element.matches(converter.getEditableSelector())
*/
getEditableSelector(): string {
return this.editableSelectorCache;
}
/**
* Split markdown into lines, match each against block tags in
* priority order, and concatenate the resulting HTML.
*/
private processBlocks(markdown: string): string {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const output: string[] = [];
const blankLine = /^\s*$/;
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
let lineIndex = 0;
// Collect reference link definitions
this.referenceLinks = new Map();
for (const line of lines) {
const match = line.match(refDefinition);
if (match?.groups) {
this.referenceLinks.set(
match.groups.label.toLowerCase(),
{
url: match.groups.url,
title: match.groups.title,
},
);
}
}
while (lineIndex < lines.length) {
if (blankLine.test(lines[lineIndex]) || refDefinition.test(lines[lineIndex])) {
lineIndex++;
continue;
}
let matched = false;
for (const tag of this.blockTags) {
const context: MatchContext = {
lines,
index: lineIndex,
text: '',
offset: 0,
};
const token = tag.match(context);
if (!token) {
continue;
}
output.push(tag.toHTML(token, this.cachedConverter));
lineIndex += token.consumed;
matched = true;
break;
}
if (!matched) {
lineIndex++;
}
}
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 {
let text = source;
// Process inline macros before tokenizing — they produce HTML
// that should pass through without further parsing
if (this.macroMap.size > 0) {
const placeholders: string[] = [];
text = processInlineMacros(text, this.macroMap, this.cachedConverter, placeholders);
// Restore placeholders to their HTML content
const placeholderPattern = /\x00P(?<index>\d+)\x00/g;
text = text.replace(placeholderPattern, (_, index: string) =>
placeholders[parseInt(index)]
);
}
// Resolve reference links before tokenizing
text = this.resolveReferenceLinks(text);
// Normalize _ emphasis to *
text = this.normalizeUnderscores(text);
const tokens = this.tokenizer.tokenize(text);
return this.tokensToHTML(tokens);
}
/**
* Replace [text][ref] and [text][] with [text](url) using the
* reference definitions collected during block parsing.
*/
private resolveReferenceLinks(text: string): string {
if (this.referenceLinks.size === 0) {
return text;
}
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
return text.replace(refLink, (...args) => {
const groups = args[args.length - 1] as Record<string, string>;
const label = (groups.label || groups.text).toLowerCase();
const ref = this.referenceLinks.get(label);
if (!ref) {
return args[0];
}
const titlePart = ref.title ? ` "${ref.title}"` : '';
return `[${groups.text}](${ref.url}${titlePart})`;
});
}
/**
* Normalize flanking underscore runs to asterisks so the tokenizer
* only needs to handle * delimiters for emphasis.
*/
private normalizeUnderscores(text: string): string {
// Protect backslash-escaped underscores from normalization
const escapePlaceholder = '\x00U\x00';
const safeText = text.replace(/\\_/g, escapePlaceholder);
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
const openRun = new RegExp(
`(?<=^|${punctuation})` + // preceded by start, space, or punctuation
`(_+)` + // one or more underscores
`(?=\\S)`, // followed by non-whitespace
'g'
);
const closeRun = new RegExp(
`(?<=\\S)` + // preceded by non-whitespace
`(_+)` + // one or more underscores
`(?=$|${punctuation})`, // followed by end, space, or punctuation
'g'
);
const toAsterisks = (_: string, run: string) => '*'.repeat(run.length);
const normalized = safeText
.replace(openRun, toAsterisks)
.replace(closeRun, toAsterisks);
return normalized.replace(/\x00U\x00/g, '\\_');
}
/**
* Convert a token stream to HTML. Matches open/close delimiter
* pairs and wraps their content in the appropriate HTML tags.
* Unmatched delimiters are emitted as literal text.
*/
private tokensToHTML(tokens: InlineToken[]): string {
// Build a map from delimiter string to tag info
const delimiterToTag = new Map<string, { htmlTag: string; name: string }>();
for (const tag of this.inlineTags) {
if (tag.delimiter) {
const htmlTag = tag.name === 'boldItalic'
? 'em'
: (tag.selector as string).split(',')[0].toLowerCase();
delimiterToTag.set(tag.delimiter, {
htmlTag,
name: tag.name,
});
}
}
// First pass: match open/close pairs using a stack
const paired = this.pairDelimiters(tokens);
// Second pass: build HTML from paired tokens
let html = '';
for (const token of paired) {
switch (token.role) {
case 'text':
html += escapeHtml(token.value);
break;
case 'open': {
const info = delimiterToTag.get(token.delimiter!);
if (info) {
if (info.name === 'boldItalic') {
html += '<em><strong>';
} else {
html += `<${info.htmlTag}>`;
}
} else {
html += escapeHtml(token.value);
}
break;
}
case 'close': {
const info = delimiterToTag.get(token.delimiter!);
if (info) {
if (info.name === 'boldItalic') {
html += '</strong></em>';
} else {
html += `</${info.htmlTag}>`;
}
} else {
html += escapeHtml(token.value);
}
break;
}
case 'code':
html += `<code>${escapeHtml(token.content || '')}</code>`;
break;
case 'link': {
const titleAttr = token.title
? ` title="${escapeHtml(token.title)}"`
: '';
// Process link text for nested inline formatting
const innerTokens = this.tokenizer.tokenize(token.value);
const innerHtml = this.tokensToHTML(innerTokens);
// Strip any nested <a> tags (links can't contain links)
const nestedLink = /<a[^>]*>|<\/a>/g;
const cleanInner = innerHtml.replace(nestedLink, '');
html += `<a href="${escapeHtml(token.href!)}"${titleAttr}>${cleanInner}</a>`;
break;
}
case 'autolink':
html += `<a href="${escapeHtml(token.href!)}">${escapeHtml(token.value)}</a>`;
break;
case 'html':
html += token.value;
break;
case 'break':
html += '<br>';
break;
default:
html += escapeHtml(token.value);
}
}
return html;
}
/**
* Match open/close delimiter pairs in a token stream. Unmatched
* openers/closers are converted to text tokens so they render
* as literal characters.
*/
private pairDelimiters(tokens: InlineToken[]): InlineToken[] {
const openStack: number[] = [];
const result = [...tokens];
// Track which delimiter types are currently open to prevent
// forbidden nesting (e.g. <del> inside <del>, <em> inside <em>)
const openDelimiters = new Set<string>();
for (let index = 0; index < result.length; index++) {
const token = result[index];
if (token.role === 'open') {
// Don't open a delimiter that's already open (prevents nesting)
if (openDelimiters.has(token.delimiter!)) {
result[index] = {
role: 'text',
value: token.value,
};
continue;
}
openStack.push(index);
openDelimiters.add(token.delimiter!);
} else if (token.role === 'close') {
let matched = false;
for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex--) {
const openerIndex = openStack[stackIndex];
if (result[openerIndex].delimiter === token.delimiter) {
openStack.splice(stackIndex, 1);
openDelimiters.delete(token.delimiter!);
matched = true;
break;
}
}
if (!matched) {
result[index] = {
role: 'text',
value: token.value,
};
}
}
}
// Any remaining unmatched openers become literal text
for (const openerIndex of openStack) {
result[openerIndex] = {
role: 'text',
value: result[openerIndex].value,
};
}
return result;
}
/**
* Serialize a DOM node to markdown using the serializer for inline
* content and custom logic for block-level elements.
*/
private serializeNode(node: Node): string {
if (node.nodeType === 3) {
return this.serializer.serialize(node);
}
if (node.nodeType !== 1) {
return '';
}
const element = node as HTMLElement;
// CSS selectors (e.g. [data-macro]) are more specific
const cssSelectorMatch = this.matchCssSelector(element);
if (cssSelectorMatch) {
return cssSelectorMatch.toMarkdown(element, this.cachedConverter);
}
// 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 {
if (element.matches(normalized)) {
return tag;
}
} catch {
// Invalid selector — skip
}
}
return null;
}
private serializeChildren(node: Node): string {
return Array.from(node.childNodes)
.map(child => this.serializeNode(child))
.join('');
}
/**
* Build the inline tokenizer from registered delimiter-based tags.
*/
private buildTokenizer(): InlineTokenizer {
const hasCodeTag = this.inlineTags.some(tag => tag.name === 'code');
const delimiterDefs: DelimiterDef[] = this.inlineTags
.filter(tag => tag.delimiter && tag.name !== 'code')
.map(tag => ({
delimiter: tag.delimiter!,
htmlTag: tag.name === 'boldItalic'
? 'em'
: (tag.selector as string).split(',')[0].toLowerCase(),
recursive: tag.recursive !== false,
precedence: tag.precedence ?? 50,
}));
return new InlineTokenizer(delimiterDefs, { codeSpans: hasCodeTag });
}
/**
* Build the markdown serializer from registered tags. Maps HTML
* element names to their serialization strategy (delimiter wrap
* or custom function).
*/
private buildSerializer(): MarkdownSerializer {
const tagMap = new Map<string, SerializerTagDef>();
const delimiterChars = new Set<string>();
for (const [selector, tag] of this.tags.entries()) {
if (tag.delimiter) {
delimiterChars.add(tag.delimiter[0]);
// Delimiter-based tags: emit delimiter + children + delimiter
for (const part of selector.split(',').map(part => part.trim())) {
tagMap.set(part, { delimiter: tag.delimiter });
}
} else if (tag.name === 'link') {
tagMap.set('A', {
serialize: (element, children) => {
const href = element.getAttribute('href') || '';
const title = element.getAttribute('title');
const titlePart = title ? ` "${title}"` : '';
return '[' + children() + '](' + href + titlePart + ')';
},
});
} else if (tag.name === 'hardBreak') {
tagMap.set('BR', {
serialize: () => ' \n',
});
} else if (tag.name === 'fencedCode') {
tagMap.set('PRE', {
serialize: (element) => {
const code = element.querySelector('code');
const langMatch = (code?.getAttribute('class') || '').match(/language-(\S+)/);
const lang = langMatch ? langMatch[1] : '';
const content = code?.textContent || element.textContent || '';
return '\n\n```' + lang + '\n' + content + '\n```\n\n';
},
});
}
}
// CODE gets a custom serializer because its content is literal
tagMap.set('CODE', {
serialize: (element) => {
// Code inside <pre> is handled by the PRE serializer
if (element.parentNode?.nodeName === 'PRE') {
return element.textContent || '';
}
return '`' + (element.textContent || '') + '`';
},
});
return new MarkdownSerializer(tagMap, delimiterChars);
}
private buildDelimiterRegexes(): { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[] {
const escapeRegex = /[.*+?^${}()|[\]\\]/g;
const sorted = this.inlineTags
.filter(tag => tag.delimiter)
.sort((first, second) => (first.precedence ?? 50) - (second.precedence ?? 50));
return sorted.map(tag => {
const delimiter = tag.delimiter!;
const escaped = delimiter.replace(escapeRegex, '\\$&');
const escapedChar = delimiter[0].replace(escapeRegex, '\\$&');
const htmlTag = tag.name === 'boldItalic'
? 'em'
: (tag.selector as string).split(',')[0].toLowerCase();
return {
tag,
htmlTag,
complete: new RegExp(
`(?<!${escapedChar})` +
`${escaped}` +
`(?!${escapedChar})` +
`([^\\x01\\x02]+?)` +
`(?<!${escapedChar})` +
`${escaped}`
),
open: new RegExp(
`(?<!${escapedChar})` +
`${escaped}` +
`(?!${escapedChar})` +
`([^\\x01\\x02]+)$`
),
};
});
}
private buildEditableSelector(): string {
return [
...this.inlineTags,
...this.blockTags,
].filter(tag => typeof tag.selector === 'string')
.map(tag => (tag.selector as string).toLowerCase())
.join(', ');
}
private makeConverter(): Converter {
return {
inline: (source) => this.processInline(source),
block: (markdown) => this.processBlocks(markdown),
children: (node) => this.serializeChildren(node),
node: (node) => this.serializeNode(node),
};
}
}
+397
View File
@@ -0,0 +1,397 @@
/*
* macros.ts — macro parsing and Tag generation for ribbit.
*
* Macros use @name(...) syntax. Ribbit automatically wraps macro output
* in an element with data- attributes that preserve the original source.
* Round-tripping is handled generically — consumers only write toHTML.
*
* Syntax:
* @user — bare, no args
* @user() — empty parens, same as bare
* @npc(Goblin King) — self-closing with keywords
* @toc(depth="3") — self-closing with params
* @style(box center — block: newline after args = content
* **Bold** content here.
* )
* @style(box verbatim — verbatim block
* Literal <b>content</b>.
* )
*/
import type { Tag, Converter, ToolbarButton } from './types';
import { escapeHtml } from './tags';
/* ── Constants ─────────────────────────────────────────────────── */
const VERBATIM_KEYWORD = 'verbatim';
const VERBATIM_DATA_VALUE = 'true';
const DATASET_PARAM_PREFIX = 'param';
const DATASET_PARAM_PREFIX_LENGTH = 5;
const PLACEHOLDER_SENTINEL = '\x00P';
const PLACEHOLDER_TERMINATOR = '\x00';
/* Named regex for key="value" pairs inside macro argument strings */
const PARAM_PATTERN = /(?<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 {
name: string;
/**
* Render the macro's inner HTML. Ribbit wraps the result in an
* element with data- attributes for round-tripping.
*
* { name: 'user', toHTML: () => '<a href="/User/gsb">gsb</a>' }
* { name: 'style', toHTML: ({ keywords, content }) =>
* `<div class="${keywords.join(' ')}">${content}</div>` }
*/
toHTML: (context: {
keywords: string[];
params: Record<string, string>;
content?: string;
convert: Converter;
}) => string;
/**
* Toolbar button. Set to false to hide from the macros dropdown.
* Default: auto-generated from the macro name.
*/
button?: ToolbarButton | false;
}
/** Internal representation of a fully parsed macro invocation. */
interface ParsedMacro {
name: string;
keywords: string[];
params: Record<string, string>;
verbatim: boolean;
content?: string;
/** Number of source lines consumed by this macro (for block advancement). */
consumed: number;
}
/* ── Module-level helpers ──────────────────────────────────────── */
/**
* Parse the argument string from a macro invocation into keywords,
* key="value" params, and a verbatim flag.
*
* @example
* ```ts
* parseArgs('box center depth="3"')
* // { keywords: ['box', 'center'], params: { depth: '3' }, verbatim: false }
* ```
*/
function parseArgs(argumentString: string | undefined): {
keywords: string[];
params: Record<string, string>;
verbatim: boolean;
} {
if (!argumentString || !argumentString.trim()) {
return {
keywords: [],
params: {},
verbatim: false,
};
}
const params: Record<string, string> = {};
/* Strip key="value" pairs, collecting them into params */
const withoutParams = argumentString.replace(
new RegExp(PARAM_PATTERN.source, 'g'),
(_match, paramKey, paramValue) => {
params[paramKey] = paramValue;
return '';
},
);
const allKeywords = withoutParams.trim().split(/\s+/).filter(Boolean);
const verbatim = allKeywords.includes(VERBATIM_KEYWORD);
const keywords = allKeywords.filter(keyword => keyword !== VERBATIM_KEYWORD);
return {
keywords,
params,
verbatim,
};
}
function macroError(name: string): string {
return `<span class="ribbit-error">Unknown macro: @${escapeHtml(name)}</span>`;
}
/**
* Wrap a macro's rendered HTML with data- attributes for round-tripping.
* Block macros (with content) use `<div>`, inline macros use `<span>`.
*/
function wrapMacro(
name: string,
keywords: string[],
params: Record<string, string>,
verbatim: boolean,
hasContent: boolean,
innerHtml: string,
): string {
const tag = hasContent ? 'div' : 'span';
let attrs = ` data-macro="${escapeHtml(name)}"`;
if (keywords.length) {
attrs += ` data-keywords="${escapeHtml(keywords.join(' '))}"`;
}
for (const [paramKey, paramValue] of Object.entries(params)) {
attrs += ` data-param-${escapeHtml(paramKey)}="${escapeHtml(paramValue)}"`;
}
if (verbatim) {
attrs += ` data-verbatim="${VERBATIM_DATA_VALUE}"`;
}
return `<${tag}${attrs}>${innerHtml}</${tag}>`;
}
/**
* Reconstruct macro source from a DOM element's data- attributes.
* This is the generic toMarkdown for all macros — it reads the
* data- attributes that wrapMacro wrote and rebuilds the @name(...)
* syntax so the document can round-trip without per-macro logic.
*/
function macroToMarkdown(element: HTMLElement, convert: Converter): string {
const name = element.dataset.macro || '';
const keywords = element.dataset.keywords || '';
const verbatim = element.dataset.verbatim === VERBATIM_DATA_VALUE;
const paramParts: string[] = [];
for (const [datasetKey, datasetValue] of Object.entries(element.dataset)) {
if (datasetKey.startsWith(DATASET_PARAM_PREFIX) && datasetKey.length > DATASET_PARAM_PREFIX_LENGTH) {
const paramName = datasetKey.slice(DATASET_PARAM_PREFIX_LENGTH).toLowerCase();
paramParts.push(`${paramName}="${datasetValue}"`);
}
}
const allKeywords = verbatim
? [keywords, VERBATIM_KEYWORD].filter(Boolean).join(' ')
: keywords;
const args = [allKeywords, paramParts.join(' ')].filter(Boolean).join(' ');
const isBlock = element.tagName === 'DIV';
if (isBlock) {
const content = convert.children(element);
return `\n\n@${name}(${args}\n${content}\n)\n\n`;
}
return args ? `@${name}(${args})` : `@${name}`;
}
/**
* Try to parse a block macro starting at the given line index.
* Returns null if the line doesn't start a block macro or the
* closing paren is never found (unclosed macro).
*/
function parseBlockMacro(lines: string[], lineIndex: number): ParsedMacro | null {
const line = lines[lineIndex];
const openMatch = BLOCK_MACRO_OPEN.exec(line);
if (!openMatch || !openMatch.groups) {
return null;
}
const name = openMatch.groups.macroName;
const { keywords, params, verbatim } = parseArgs(openMatch.groups.macroArgs);
const contentLines: string[] = [];
let scanIndex = lineIndex + 1;
let nestingDepth = 1;
while (scanIndex < lines.length && nestingDepth > 0) {
if (BLOCK_CLOSE_LINE.test(lines[scanIndex])) {
nestingDepth--;
if (nestingDepth === 0) {
break;
}
}
if (NESTED_BLOCK_OPEN.test(lines[scanIndex])) {
nestingDepth++;
}
contentLines.push(lines[scanIndex]);
scanIndex++;
}
/* Unclosed macro — treat as plain text */
if (nestingDepth !== 0) {
return null;
}
return {
name,
keywords,
params,
verbatim,
content: contentLines.join('\n'),
consumed: scanIndex + 1 - lineIndex,
};
}
/* ── Public API ────────────────────────────────────────────────── */
/**
* Build Tags from an array of macro definitions.
*
* Returns a block-level Tag for parsing `@name(args\ncontent\n)` syntax,
* a selector Tag for HTML→markdown round-tripping, and a lookup map
* for inline macro processing.
*
* @example
* ```ts
* const { blockTag, selectorTag, macroMap } = buildMacroTags([
* { name: 'user', toHTML: () => '<a href="/User/gsb">gsb</a>' },
* ]);
* ```
*/
export function buildMacroTags(
macros: MacroDef[],
): { blockTag: Tag; selectorTag: Tag; macroMap: Map<string, MacroDef> } {
const macroMap = new Map<string, MacroDef>();
for (const macro of macros) {
macroMap.set(macro.name, macro);
}
const blockTag: Tag = {
name: 'macro',
match: (context) => {
const parsed = parseBlockMacro(context.lines, context.index);
if (!parsed) {
return null;
}
return {
content: parsed.content || '',
raw: JSON.stringify(parsed),
consumed: parsed.consumed,
};
},
toHTML: (token, convert) => {
const parsed: ParsedMacro = JSON.parse(token.raw);
const macro = macroMap.get(parsed.name);
if (!macro) {
return macroError(parsed.name);
}
let content = parsed.content;
if (content !== undefined) {
if (parsed.verbatim) {
content = escapeHtml(content.trim()).replace(/\n/g, '<br>\n');
} else {
content = convert.block(content);
}
}
const innerHtml = macro.toHTML({
keywords: parsed.keywords,
params: parsed.params,
content,
convert,
});
return wrapMacro(
parsed.name, parsed.keywords, parsed.params,
parsed.verbatim, true, innerHtml,
);
},
selector: '[data-macro]',
toMarkdown: () => '',
};
/**
* Generic selector tag — matches any element with data-macro
* and reconstructs the macro source from data- attributes.
* Separate from blockTag so the selector-based HTML→markdown
* path can find macro elements independently.
*/
const selectorTag: Tag = {
name: 'macro:generic',
match: () => null,
toHTML: () => '',
selector: '[data-macro]',
toMarkdown: macroToMarkdown,
};
return {
blockTag,
selectorTag,
macroMap,
};
}
/**
* Process inline macros in a text string, replacing them with rendered HTML.
*
* Inline macros are replaced with placeholder tokens so that subsequent
* inline parsing (bold, italic, etc.) doesn't mangle the HTML output.
* The caller restores placeholders after all inline processing is done.
*
* @example
* ```ts
* const placeholders: string[] = [];
* const result = processInlineMacros(
* 'Hello @user!',
* macroMap,
* convert,
* placeholders,
* );
* ```
*/
export function processInlineMacros(
text: string,
macroMap: Map<string, MacroDef>,
convert: Converter,
placeholders: string[],
): string {
return text.replace(
INLINE_MACRO_GLOBAL,
(match, ...args) => {
/* Named groups are the last non-offset argument from replace() */
const groups = args[args.length - 1] as { inlineName: string; inlineArgs?: string };
const macroName = groups.inlineName;
const macro = macroMap.get(macroName);
if (!macro) {
placeholders.push(macroError(macroName));
return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
}
const { keywords, params } = parseArgs(groups.inlineArgs);
const innerHtml = macro.toHTML({
keywords,
params,
convert,
});
const wrapped = wrapMacro(macroName, keywords, params, false, false, innerHtml);
placeholders.push(wrapped);
return PLACEHOLDER_SENTINEL + (placeholders.length - 1) + PLACEHOLDER_TERMINATOR;
},
);
}
+21
View File
@@ -0,0 +1,21 @@
/*
* ribbit-core.ts — lightweight entry point without optional features (vim).
*
* Same API as ribbit-editor.ts but excludes VimHandler.
*/
import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme';
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { type MacroDef } from './macros';
export { RibbitEditor as Editor } from './ribbit-editor';
export { Ribbit as Viewer };
export { HopDown };
export { inlineTag };
export { defaultTags, defaultBlockTags, defaultInlineTags };
export { defaultTheme };
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
export { ToolbarManager } from './toolbar';
export type { MacroDef };
File diff suppressed because it is too large Load Diff
+436
View File
@@ -0,0 +1,436 @@
/*
* ribbit.ts — core editor classes for the ribbit WYSIWYG markdown editor.
*/
import { HopDown } from './hopdown';
import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager';
import { RibbitEmitter, type RibbitEventMap } from './events';
import { CollaborationManager } from './collaboration';
import { type MacroDef } from './macros';
import { ToolbarManager } from './toolbar';
import type { RibbitTheme, ToolbarSlot, CollaborationSettings, PeerInfo, Revision, RevisionMetadata } from './types';
export interface RibbitSettings {
api?: unknown;
editorId?: string;
currentTheme?: string;
themes?: RibbitTheme[];
themesPath?: string;
macros?: MacroDef[];
toolbar?: ToolbarSlot[];
/** Set to false to prevent auto-rendering the toolbar. Default true. */
autoToolbar?: boolean;
/** Collaboration settings. Omit to disable. */
collaboration?: CollaborationSettings;
on?: Partial<RibbitEventMap>;
}
/**
* Base class providing read-only markdown rendering. RibbitEditor extends
* 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 {
api: unknown;
element: HTMLElement;
states: Record<string, string>;
cachedHTML: string | null;
cachedMarkdown: string | null;
state: string | null;
theme: RibbitTheme;
themes: ThemeManager;
converter: HopDown;
themesPath: string;
toolbar: ToolbarManager;
collaboration?: CollaborationManager;
protected autoToolbar: boolean;
private emitter: RibbitEmitter;
private macros: MacroDef[];
constructor(settings: RibbitSettings) {
this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!;
this.themesPath = settings.themesPath || './themes';
this.emitter = new RibbitEmitter();
this.macros = settings.macros || [];
this.states = {
VIEW: 'view',
};
this.cachedHTML = null;
this.cachedMarkdown = null;
this.state = null;
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
this.theme = theme;
this.converter = theme.tags
? new HopDown({ tags: theme.tags, macros: this.macros })
: new HopDown({ macros: this.macros });
this.cachedHTML = null;
this.emitter.emit('themeChange', {
current: theme,
previous,
});
if (this.getState() === this.states.VIEW) {
this.state = null;
this.view();
}
});
(settings.themes || []).forEach(theme => {
this.themes.add(theme);
});
const activeName = settings.currentTheme || defaultTheme.name;
this.themes.set(activeName);
this.theme = this.themes.current();
this.converter = this.theme.tags
? new HopDown({ tags: this.theme.tags, macros: this.macros })
: new HopDown({ macros: this.macros });
if (settings.on) {
for (const [event, handler] of Object.entries(settings.on)) {
if (handler) {
this.on(event as keyof RibbitEventMap, handler as any);
}
}
}
this.toolbar = new ToolbarManager(
this,
this.theme.tags || {},
this.macros,
settings.toolbar,
);
this.autoToolbar = settings.autoToolbar !== false;
if (settings.collaboration) {
this.collaboration = new CollaborationManager(
settings.collaboration,
{
onRemoteUpdate: (content) => {
this.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 {
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 {
this.emitter.off(event, callback);
}
protected emitReady(): void {
this.emitter.emit('ready', {
markdown: this.getMarkdown(),
html: this.getHTML(),
mode: this.state || 'view',
theme: this.theme,
});
}
/**
* 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 {
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 {
const previous = this.state;
if (previous) {
this.element.classList.remove(previous);
}
this.state = newState;
this.element.classList.add(newState);
this.emitter.emit('modeChange', {
current: newState,
previous,
});
}
/**
* One-shot markdown→HTML conversion using the current theme's tags.
*
* 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 {
if (this.cachedHTML === null) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
}
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 {
if (this.cachedMarkdown === null) {
this.cachedMarkdown = this.element.textContent || '';
}
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 {
this.emitter.emit('save', {
markdown: this.getMarkdown(),
html: this.getHTML(),
});
}
/**
* Switch to read-only view mode. Renders markdown to HTML and
* disables contentEditable. Disconnects collaboration if active.
*
* editor.view();
*/
view(): void {
if (this.getState() === this.states.VIEW) return;
this.invalidateCache();
this.collaboration?.disconnect();
this.element.innerHTML = this.getHTML();
this.setState(this.states.VIEW);
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 {
this.cachedMarkdown = 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 {
const markdown = this.getMarkdown();
this.collaboration?.sendUpdate(markdown);
this.emitter.emit('change', {
markdown,
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[] {
return words.trim().split(/\s+/g).map(word => {
const lc = word.toLowerCase();
return lc.charAt(0).toUpperCase() + lc.slice(1);
});
}
/**
* 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 {
const txt = document.createElement('textarea');
txt.innerHTML = html;
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 {
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
}
+198
View File
@@ -0,0 +1,198 @@
/*
* serializer.ts — DOM to markdown serializer.
*
* Converts an HTML DOM tree back to markdown by walking the tree and
* producing a typed token stream. Text tokens are escaped during final
* serialization; delimiter tokens pass through verbatim. This separation
* is what makes round-trip correctness possible — the serializer always
* knows which characters are structural and which are literal.
*
* const serializer = new MarkdownSerializer(tagMap, delimiterChars);
* serializer.serialize(document.getElementById('content'))
* // '**bold** and *italic*'
*/
import type { InlineToken } from './tokenizer';
/**
* Maps HTML element names to their markdown serialization.
* Each entry defines how to convert an element back to markdown tokens.
*/
export interface SerializerTagDef {
/** The canonical delimiter (e.g. '**' for bold). */
delimiter?: string;
/** Custom serializer for elements that aren't simple delimiter wraps
* (e.g. links, code blocks, headings). Returns the full markdown
* string for the element and its children. */
serialize?: (element: HTMLElement, children: () => string) => string;
}
/**
* Converts a DOM tree to markdown. Walks the tree producing inline
* tokens, then serializes the token stream to a string with correct
* escaping.
*
* const serializer = new MarkdownSerializer(tagMap, new Set(['*', '`', '~', '[', '_']));
* const markdown = serializer.serialize(containerElement);
*/
export class MarkdownSerializer {
private tagMap: Map<string, SerializerTagDef>;
private delimiterChars: Set<string>;
constructor(
tagMap: Map<string, SerializerTagDef>,
delimiterChars: Set<string>,
) {
this.tagMap = tagMap;
this.delimiterChars = delimiterChars;
}
/**
* Serialize a DOM tree to a markdown string.
*
* serializer.serialize(document.querySelector('article'))
*/
serialize(node: Node): string {
const tokens = this.nodeToTokens(node);
return this.tokensToString(tokens);
}
/**
* Convert a DOM node to a stream of inline tokens.
* Text nodes become text tokens; elements with known tags
* become delimiter-wrapped token sequences; unknown elements
* recurse into their children.
*/
private nodeToTokens(node: Node): InlineToken[] {
if (node.nodeType === 3) {
return [{
role: 'text',
value: node.textContent || '',
}];
}
if (node.nodeType !== 1) {
return [];
}
const element = node as HTMLElement;
const tagDef = this.tagMap.get(element.nodeName);
// Custom serializer handles the entire element
if (tagDef?.serialize) {
const childrenMarkdown = () => this.serializeChildren(element);
const markdown = tagDef.serialize(element, childrenMarkdown);
// Custom serializers return raw markdown strings — wrap
// in a single text token that won't be escaped (it's already
// correctly formatted)
return [{
role: 'html',
value: markdown,
}];
}
// Delimiter-based element: emit open + children + close
if (tagDef?.delimiter) {
const delimiter = tagDef.delimiter;
return [
{
role: 'open',
value: delimiter,
delimiter,
},
...this.childrenToTokens(element),
{
role: 'close',
value: delimiter,
delimiter,
},
];
}
// Unknown element: just recurse into children
return this.childrenToTokens(element);
}
/**
* Collect tokens from all child nodes of an element.
*/
private childrenToTokens(element: HTMLElement): InlineToken[] {
const tokens: InlineToken[] = [];
for (const child of Array.from(element.childNodes)) {
tokens.push(...this.nodeToTokens(child));
}
return tokens;
}
/**
* Serialize an element's children directly to a markdown string.
* Used by custom serializers (links, headings, etc.) that need
* the children as a string, not as tokens.
*/
private serializeChildren(element: HTMLElement): string {
const tokens = this.childrenToTokens(element);
return this.tokensToString(tokens);
}
/**
* Convert a token stream to a markdown string. This is where
* escaping happens: text tokens have their delimiter characters
* backslash-escaped; all other token types pass through verbatim.
*/
private tokensToString(tokens: InlineToken[]): string {
let result = '';
for (const token of tokens) {
switch (token.role) {
case 'text':
result += this.escapeText(token.value);
break;
case 'open':
case 'close':
case 'html':
case 'break':
// Structural tokens are never escaped
result += token.value;
break;
case 'code':
result += token.value;
break;
case 'link':
result += token.value;
break;
case 'autolink':
result += token.value;
break;
default:
result += token.value;
}
}
return result;
}
/**
* Escape characters in literal text that would be misinterpreted
* as markdown syntax on re-parse. Only escapes characters that are
* registered as delimiter characters, plus `\`, `[`, `_`, and `<`
* before letters (HTML passthrough prevention).
*/
private escapeText(text: string): string {
let result = '';
for (let position = 0; position < text.length; position++) {
const character = text[position];
if (character === '\\') {
result += '\\\\';
} else if (character === '_') {
result += '\\_';
} else if (character === '[') {
result += '\\[';
} else if (character === '<' && position + 1 < text.length && /[a-zA-Z/]/.test(text[position + 1])) {
// Only escape < when it would start an HTML tag
result += '\\<';
} else if (this.delimiterChars.has(character)) {
result += '\\' + character;
} else {
result += character;
}
}
return result;
}
}
+1305
View File
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
/*
* theme-manager.ts — manages theme registration and activation for a Ribbit instance.
*/
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 {
private registered: Map<string, RibbitTheme>;
private disabled: Set<string>;
private active: RibbitTheme;
private themeLink: HTMLLinkElement | null;
private themesPath: string;
private onSwitch: (theme: RibbitTheme, previous: RibbitTheme) => void;
constructor(initial: RibbitTheme, themesPath: string, onSwitch: (theme: RibbitTheme, previous: RibbitTheme) => void) {
this.registered = new Map();
this.disabled = new Set();
this.themeLink = null;
this.themesPath = themesPath;
this.onSwitch = onSwitch;
this.active = initial;
this.add(initial);
}
/**
* Register a theme. Themes must be added before they can be activated.
*
* @example
* themes.add({ name: 'dark', tags: darkTags });
*/
add(theme: RibbitTheme): void {
this.registered.set(theme.name, theme);
}
/**
* Unregister a theme by name. Cannot remove the active theme.
*
* @example
* themes.remove('dark');
*/
remove(name: string): void {
if (this.active.name === name) {
throw new Error(`Cannot remove the active theme "${name}".`);
}
this.registered.delete(name);
}
/**
* Return the names of all registered and enabled themes.
*
* @example
* const available = themes.list(); // ['ribbit-default', 'dark']
*/
list(): string[] {
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
}
/**
* Get a registered theme by name, or undefined if not found.
*
* @example
* const theme = themes.get('dark');
*/
get(name: string): RibbitTheme | undefined {
return this.registered.get(name);
}
/**
* Return the currently active theme.
*
* @example
* const active = themes.current();
*/
current(): RibbitTheme {
return this.active;
}
/**
* Switch to a registered theme by name. The theme must be
* registered and enabled. Loads the theme's CSS and notifies
* the editor to rebuild its converter.
*
* @example
* themes.set('dark');
*/
set(name: string): void {
const theme = this.registered.get(name);
if (!theme) {
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
}
if (this.disabled.has(name)) {
throw new Error(`Theme "${name}" is disabled. Call enable() first.`);
}
const previous = this.active;
this.active = theme;
// Only load CSS when actually switching to a different theme
if (previous !== theme) {
this.loadCSS(name);
}
this.onSwitch(theme, previous);
}
/**
* Mark a theme as available for selection via set().
* Themes are enabled by default when added.
*
* @example
* themes.enable('dark');
*/
enable(name: string): void {
if (!this.registered.has(name)) {
throw new Error(`Theme "${name}" is not registered. Call add() first.`);
}
this.disabled.delete(name);
}
/**
* Mark a theme as unavailable for selection via set().
* Does not affect the current theme if it is already active.
*
* @example
* themes.disable('dark');
*/
disable(name: string): void {
if (!this.registered.has(name)) {
throw new Error(`Theme "${name}" is not registered.`);
}
this.disabled.add(name);
}
private loadCSS(name: string): void {
if (this.themeLink) {
this.themeLink.remove();
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `${this.themesPath}/${name}/${THEME_CSS_FILENAME}`;
document.head.appendChild(link);
this.themeLink = link;
}
}
+447
View File
@@ -0,0 +1,447 @@
/*
* tokenizer.ts — Inline markdown tokenizer.
*
* Scans markdown text left-to-right producing a typed token stream.
* Tokens carry their semantic role (delimiter, text, code, link, etc.)
* so downstream consumers can make correct escaping and pairing
* decisions without regex heuristics.
*
* const tokenizer = new InlineTokenizer(delimiterDefs);
* const tokens = tokenizer.tokenize('hello **bold** end');
* // [text "hello "] [open "**"] [text "bold"] [close "**"] [text " end"]
*/
/**
* A single token in the inline token stream. The `role` field
* distinguishes structural markers from literal content, which
* is the key insight that makes round-trip escaping correct.
*/
export interface InlineToken {
role: 'text' | 'open' | 'close' | 'code' | 'link' | 'autolink' | 'html' | 'break';
value: string;
/** For link tokens: the href and optional title. */
href?: string;
title?: string;
/** For delimiter tokens: which delimiter this is (e.g. '**'). */
delimiter?: string;
/** For code tokens: the raw content (not HTML-escaped). */
content?: string;
}
/**
* A delimiter definition used by the tokenizer to recognize
* opening and closing delimiter runs.
*/
export interface DelimiterDef {
/** The delimiter string, e.g. '**', '*', '~~', '`'. */
delimiter: string;
/** The HTML tag name to emit, e.g. 'strong', 'em', 'del'. */
htmlTag: string;
/** Whether content inside this delimiter is parsed for further
* inline markup. False for code spans. */
recursive: boolean;
/** Lower values are matched first. Ensures *** matches before **. */
precedence: number;
}
/**
* Characters that count as punctuation for flanking delimiter rules.
* A delimiter is left-flanking if preceded by whitespace/punctuation
* and followed by non-whitespace. Right-flanking is the reverse.
*/
const PUNCTUATION = new Set(
' \t\n\u00A0.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('')
);
/**
* Characters that can be backslash-escaped in markdown.
*/
const ESCAPABLE = new Set(
'\\`*_{}[]()#+-.!~|><'.split('')
);
/**
* Named HTML entities recognized by the tokenizer.
*/
const NAMED_ENTITIES: Record<string, string> = {
'amp': '&',
'lt': '<',
'gt': '>',
'quot': '"',
'apos': "'",
'nbsp': '\u00A0',
};
/**
* Scans markdown text into a stream of typed tokens. Handles
* backslash escapes, entities, flanking rules, code spans, links,
* autolinks, HTML tags, and hard line breaks.
*
* const tokenizer = new InlineTokenizer([
* { delimiter: '**', htmlTag: 'strong', recursive: true, precedence: 40 },
* { delimiter: '*', htmlTag: 'em', recursive: true, precedence: 50 },
* ]);
* const tokens = tokenizer.tokenize('**bold**');
*/
export class InlineTokenizer {
private delimiters: DelimiterDef[];
private codeSpansEnabled: boolean;
constructor(delimiters: DelimiterDef[], options?: { codeSpans?: boolean }) {
this.codeSpansEnabled = options?.codeSpans !== false;
// Sort by delimiter length descending so longer delimiters
// are tried first (*** before ** before *)
this.delimiters = [...delimiters].sort(
(first, second) => second.delimiter.length - first.delimiter.length
);
}
/**
* Tokenize a markdown string into an inline token stream.
*
* tokenizer.tokenize('hello **world**')
* // [text "hello "] [open "**"] [text "world"] [close "**"]
*/
tokenize(source: string): InlineToken[] {
const tokens: InlineToken[] = [];
let position = 0;
let textBuffer = '';
const flushText = () => {
if (textBuffer.length > 0) {
tokens.push({
role: 'text',
value: textBuffer,
});
textBuffer = '';
}
};
while (position < source.length) {
const remaining = source.slice(position);
// Backslash escape: \X → literal X
if (source[position] === '\\' && position + 1 < source.length) {
const nextChar = source[position + 1];
if (ESCAPABLE.has(nextChar)) {
textBuffer += nextChar;
position += 2;
continue;
}
// \ before newline is a hard break
if (nextChar === '\n') {
flushText();
tokens.push({ role: 'break', value: '<br>' });
position += 2;
continue;
}
}
// Hard line break: two+ trailing spaces before newline
if (source[position] === ' ') {
const spaceMatch = remaining.match(/^(?<spaces> {2,})\n/);
if (spaceMatch?.groups) {
flushText();
tokens.push({ role: 'break', value: '<br>' });
position += spaceMatch[0].length;
continue;
}
}
// HTML entity resolution: &name; or &#digits; or &#xhex;
if (source[position] === '&') {
const resolved = this.resolveEntity(remaining);
if (resolved) {
textBuffer += resolved.character;
position += resolved.length;
continue;
}
}
// Code span: `content` — not parsed for further inline markup
if (this.codeSpansEnabled && source[position] === '`') {
const codeSpan = this.matchCodeSpan(source, position);
if (codeSpan) {
flushText();
tokens.push({
role: 'code',
value: codeSpan.raw,
content: codeSpan.content,
});
position += codeSpan.raw.length;
continue;
}
}
// Link: [text](url) or [text](url "title")
if (source[position] === '[') {
const link = this.matchLink(source, position);
if (link) {
flushText();
tokens.push({
role: 'link',
value: link.text,
href: link.href,
title: link.title,
});
position += link.length;
continue;
}
}
// Autolink: <url>
if (source[position] === '<') {
const autolink = this.matchAutolink(remaining);
if (autolink) {
flushText();
tokens.push({
role: 'autolink',
value: autolink.url,
href: autolink.url,
});
position += autolink.length;
continue;
}
// HTML tag passthrough
const htmlTagMatch = this.matchHtmlTag(remaining);
if (htmlTagMatch) {
flushText();
tokens.push({
role: 'html',
value: htmlTagMatch.tag,
});
position += htmlTagMatch.length;
continue;
}
}
// Bare URL autolink: https://...
if (remaining.startsWith('http://') || remaining.startsWith('https://')) {
const bareUrl = this.matchBareUrl(remaining);
if (bareUrl) {
flushText();
tokens.push({
role: 'autolink',
value: bareUrl.url,
href: bareUrl.url,
});
position += bareUrl.length;
continue;
}
}
// Delimiter: check each registered delimiter
const delimiterMatch = this.matchDelimiter(source, position);
if (delimiterMatch) {
flushText();
tokens.push(delimiterMatch.token);
position += delimiterMatch.length;
continue;
}
// Plain character
textBuffer += source[position];
position++;
}
flushText();
return tokens;
}
/**
* Try to resolve an HTML entity at the start of the string.
* Returns the resolved character and the length consumed, or null.
*/
private resolveEntity(text: string): { character: string; length: number } | null {
const namedPattern = /^&(?<name>[a-zA-Z]+);/;
const numericPattern = /^&#(?<code>\d+);/;
const hexPattern = /^&#x(?<hex>[0-9a-fA-F]+);/;
const named = text.match(namedPattern);
if (named?.groups) {
const resolved = NAMED_ENTITIES[named.groups.name.toLowerCase()];
if (resolved) {
return {
character: resolved,
length: named[0].length,
};
}
}
const numeric = text.match(numericPattern);
if (numeric?.groups) {
return {
character: String.fromCharCode(parseInt(numeric.groups.code, 10)),
length: numeric[0].length,
};
}
const hex = text.match(hexPattern);
if (hex?.groups) {
return {
character: String.fromCharCode(parseInt(hex.groups.hex, 16)),
length: hex[0].length,
};
}
return null;
}
/**
* Match a code span starting at the given position.
* Handles single backtick delimiters only (not multi-backtick).
*/
private matchCodeSpan(
source: string,
position: number,
): { content: string; raw: string } | null {
if (source[position] !== '`') {
return null;
}
const closeIndex = source.indexOf('`', position + 1);
if (closeIndex === -1) {
return null;
}
const content = source.slice(position + 1, closeIndex);
return {
content,
raw: source.slice(position, closeIndex + 1),
};
}
/**
* Match a markdown link [text](url) or [text](url "title")
* starting at the given position. Disallows [ in link text
* to prevent nested link ambiguity.
*/
private matchLink(
source: string,
position: number,
): { text: string; href: string; title?: string; length: number } | null {
const linkPattern = /^\[(?<text>[^\[\]]+)\]\((?<href>[^\s)]+)(?:\s+"(?<title>[^"]*)")?\)/;
const match = source.slice(position).match(linkPattern);
if (!match?.groups) {
return null;
}
return {
text: match.groups.text,
href: match.groups.href,
title: match.groups.title,
length: match[0].length,
};
}
/**
* Match an angle-bracket autolink <url> at the start of the string.
*/
private matchAutolink(text: string): { url: string; length: number } | null {
const pattern = /^<(?<url>https?:\/\/[^\s>]+)>/;
const match = text.match(pattern);
if (!match?.groups) {
return null;
}
return {
url: match.groups.url,
length: match[0].length,
};
}
/**
* Match a bare URL (https://...) at the start of the string.
*/
private matchBareUrl(text: string): { url: string; length: number } | null {
const pattern = /^https?:\/\/[^\s<>\x00]+/;
const match = text.match(pattern);
if (!match) {
return null;
}
return {
url: match[0],
length: match[0].length,
};
}
/**
* Match an HTML tag at the start of the string.
*/
private matchHtmlTag(text: string): { tag: string; length: number } | null {
const pattern = /^<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?\s*\/?>/;
const match = text.match(pattern);
if (!match) {
return null;
}
return {
tag: match[0],
length: match[0].length,
};
}
/**
* Try to match a delimiter at the given position. For runs of the
* same character (e.g. *** = 3 asterisks), the run is split into
* the longest registered delimiter that fits, then the remainder.
* This handles cases like **bold***italic* where *** must split
* into ** (close bold) + * (open italic).
*/
private matchDelimiter(
source: string,
position: number,
): { token: InlineToken; length: number } | null {
// Count the full run of the same character
const runChar = source[position];
let runLength = 0;
while (position + runLength < source.length && source[position + runLength] === runChar) {
runLength++;
}
if (runLength === 0) {
return null;
}
// Find registered delimiters that use this character
const candidates = this.delimiters.filter(
definition => definition.delimiter[0] === runChar
);
if (candidates.length === 0) {
return null;
}
// Try each candidate delimiter length (longest first, already sorted)
for (const definition of candidates) {
const delimiter = definition.delimiter;
if (delimiter.length > runLength) {
continue;
}
const charBefore = position > 0 ? source[position - 1] : '\n';
const charAfter = source[position + delimiter.length];
const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0';
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
if (leftFlanking) {
return {
token: {
role: 'open',
value: delimiter,
delimiter,
},
length: delimiter.length,
};
}
if (rightFlanking) {
return {
token: {
role: 'close',
value: delimiter,
delimiter,
},
length: delimiter.length,
};
}
}
return null;
}
}
+535
View File
@@ -0,0 +1,535 @@
/*
* toolbar.ts — toolbar manager for the ribbit editor.
*
* Resolves tags and macros into toolbar buttons. Renders the toolbar
* DOM. Manages button state (active/disabled/visible).
*
* Usage:
* const toolbar = editor.toolbar;
* toolbar.buttons.get('bold').click();
* toolbar.buttons.get('table').hide();
* document.body.prepend(toolbar.render());
*/
import type { Tag, ToolbarSlot, Button } from './types';
import type { MacroDef } from './macros';
const CSS_CLASS_ACTIVE = 'active';
const CSS_CLASS_DISABLED = 'disabled';
const CSS_CLASS_TOOLBAR = 'ribbit-toolbar';
const CSS_CLASS_SPACER = 'spacer';
const CSS_CLASS_GROUP = 'ribbit-btn-group';
const CSS_CLASS_DROPDOWN = 'ribbit-dropdown';
const CSS_DISPLAY_NONE = 'none';
const MACRO_ID_PREFIX = 'macro:';
const DROPDOWN_INDICATOR = ' ▾';
/** IDs of buttons that belong in the utility section, not the tag/macro area. */
const UTILITY_BUTTON_IDS = ['save', '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 {
id: string;
label: string;
icon?: string;
shortcut?: string;
action: 'wrap' | 'prefix' | 'insert' | 'custom';
delimiter?: string;
template?: string;
replaceSelection: boolean;
visible: boolean;
element?: HTMLElement;
handler?: () => void;
constructor(definition: Partial<Button> & { id: string }) {
this.id = definition.id;
this.label = definition.label || definition.id;
this.icon = definition.icon;
this.shortcut = definition.shortcut;
this.action = definition.action || 'insert';
this.delimiter = definition.delimiter;
this.template = definition.template;
this.replaceSelection = definition.replaceSelection ?? true;
this.visible = definition.visible ?? true;
this.handler = definition.handler;
}
/**
* Programmatically trigger this button's click event.
*
* @example
* toolbar.buttons.get('bold')?.click();
*/
click(): void {
this.element?.click();
}
/**
* Hide this button from the toolbar.
*
* @example
* toolbar.buttons.get('table')?.hide();
*/
hide(): void {
this.visible = false;
if (this.element) {
this.element.style.display = CSS_DISPLAY_NONE;
}
}
/**
* Show this button in the toolbar.
*
* @example
* toolbar.buttons.get('table')?.show();
*/
show(): void {
this.visible = true;
if (this.element) {
this.element.style.display = '';
}
}
}
/**
* Manages the editor toolbar: registers buttons from tags and macros,
* renders the toolbar DOM, handles keyboard shortcuts, and tracks
* active/disabled state.
*
* @example
* const manager = new ToolbarManager(editor, tags, macros);
* document.body.prepend(manager.render());
* manager.updateActiveState(['bold', 'italic']);
*/
export class ToolbarManager {
buttons: Map<string, Button>;
private layout: ToolbarSlot[];
private editor: any;
constructor(editor: any, tags: Record<string, Tag>, macros: MacroDef[], layout?: ToolbarSlot[]) {
this.editor = editor;
this.buttons = new Map();
this.registerTagButtons(tags);
this.registerHeadingButtons();
this.registerListButtons();
this.registerMacroButtons(macros);
this.registerUtilityButtons();
this.layout = layout || this.buildDefaultLayout();
this.bindShortcuts();
}
/** Register buttons for tags that have button config enabled. */
private registerTagButtons(tags: Record<string, Tag>): void {
for (const tag of Object.values(tags)) {
if (!tag.button || !tag.button.show) {
continue;
}
this.register(tag.name, {
label: tag.button.label,
icon: tag.button.icon,
shortcut: tag.button.shortcut,
action: tag.delimiter ? 'wrap' : 'insert',
delimiter: tag.delimiter,
template: tag.template,
replaceSelection: tag.replaceSelection,
});
}
}
/** Heading levels are derived from a single pattern rather than repeated blocks. */
private registerHeadingButtons(): void {
for (let level = 1; level <= MAX_HEADING_LEVEL; level++) {
this.register(`h${level}`, {
label: `H${level}`,
shortcut: `Ctrl+${level}`,
action: 'prefix',
delimiter: '#'.repeat(level) + ' ',
replaceSelection: true,
});
}
}
private registerListButtons(): void {
const listDefinitions: Array<{ id: string; label: string; shortcut: string; template: string }> = [
{
id: 'ul',
label: 'Bullet List',
shortcut: 'Ctrl+Shift+8',
template: '- Item 1\n- Item 2\n- Item 3',
},
{
id: 'ol',
label: 'Numbered List',
shortcut: 'Ctrl+Shift+7',
template: '1. Item 1\n2. Item 2\n3. Item 3',
},
];
for (const definition of listDefinitions) {
this.register(definition.id, {
label: definition.label,
shortcut: definition.shortcut,
action: 'insert',
template: definition.template,
replaceSelection: false,
});
}
}
private registerMacroButtons(macros: MacroDef[]): void {
for (const macro of macros) {
if (macro.button === false) {
continue;
}
const buttonConfig = typeof macro.button === 'object' ? macro.button : null;
const capitalizedName = macro.name.charAt(0).toUpperCase() + macro.name.slice(1);
this.register(`${MACRO_ID_PREFIX}${macro.name}`, {
label: buttonConfig?.label || capitalizedName,
icon: buttonConfig?.icon,
action: 'insert',
template: `@${macro.name}`,
replaceSelection: false,
});
}
}
private registerUtilityButtons(): void {
this.register('save', {
label: 'Save',
shortcut: 'Ctrl+S',
action: 'custom',
handler: () => this.editor.save(),
});
this.register('toggle', {
label: 'Edit',
shortcut: 'Ctrl+Shift+V',
action: 'custom',
handler: () => {
if (this.editor.getState() === EDITOR_STATE_VIEW) {
this.editor.wysiwyg();
} else {
this.editor.view();
}
},
});
this.register('markdown', {
label: 'Source',
shortcut: 'Ctrl+/',
action: 'custom',
handler: () => {
if (this.editor.getState() === EDITOR_STATE_EDIT) {
this.editor.wysiwyg();
} else {
this.editor.edit();
}
},
});
}
/**
* Builds a keyboard shortcut lookup and dispatches matching
* button actions on keydown events.
*/
private bindShortcuts(): void {
const shortcutMap = new Map<string, Button>();
for (const button of this.buttons.values()) {
if (button.shortcut) {
shortcutMap.set(button.shortcut.toLowerCase(), button);
}
}
document.addEventListener('keydown', (event: KeyboardEvent) => {
const combo = this.buildKeyCombo(event);
const button = shortcutMap.get(combo);
if (button) {
event.preventDefault();
this.executeAction(button);
}
});
}
/** Normalizes a KeyboardEvent into a comparable shortcut string like "ctrl+shift+b". */
private buildKeyCombo(event: KeyboardEvent): string {
const parts: string[] = [];
if (event.ctrlKey || event.metaKey) {
parts.push('ctrl');
}
if (event.shiftKey) {
parts.push('shift');
}
if (event.altKey) {
parts.push('alt');
}
// Special keys pass through as-is; letter keys are lowercased
const specialKeys = ['/', '.', '-'];
const key = specialKeys.includes(event.key) ? event.key : event.key.toLowerCase();
parts.push(key);
return parts.join('+');
}
private register(id: string, definition: Partial<Button>): void {
if (this.buttons.has(id)) {
return;
}
this.buttons.set(id, new ButtonImpl({ id, ...definition }));
}
private buildDefaultLayout(): ToolbarSlot[] {
const tagIds: string[] = [];
const macroIds: string[] = [];
for (const id of this.buttons.keys()) {
if (UTILITY_BUTTON_IDS.includes(id)) {
continue;
}
if (id.startsWith(MACRO_ID_PREFIX)) {
macroIds.push(id);
} else {
tagIds.push(id);
}
}
const slots: ToolbarSlot[] = [...tagIds];
if (macroIds.length > 0) {
slots.push('');
slots.push({
group: 'Macros',
items: macroIds,
});
}
slots.push('', 'markdown', 'save', 'toggle');
return slots;
}
/**
* Toggle the active CSS class on buttons whose IDs appear in the
* given list of currently-active tag names.
*
* @example
* manager.updateActiveState(['bold', 'italic']);
*/
updateActiveState(activeTagNames: string[]): void {
for (const [id, button] of this.buttons) {
button.element?.classList.toggle(CSS_CLASS_ACTIVE, activeTagNames.includes(id));
}
}
/**
* Enable all toolbar buttons by removing the disabled CSS class.
*
* @example
* manager.enable();
*/
enable(): void {
for (const button of this.buttons.values()) {
button.element?.classList.remove(CSS_CLASS_DISABLED);
}
}
/**
* Disable all toolbar buttons by adding the disabled CSS class.
*
* @example
* manager.disable();
*/
disable(): void {
for (const button of this.buttons.values()) {
button.element?.classList.add(CSS_CLASS_DISABLED);
}
}
/**
* Build the toolbar DOM tree and return the root element.
* The caller is responsible for inserting it into the document.
*
* @example
* document.body.prepend(manager.render());
*/
render(): HTMLElement {
const nav = document.createElement('nav');
nav.className = CSS_CLASS_TOOLBAR;
const list = document.createElement('ul');
for (const slot of this.layout) {
const element = this.renderSlot(slot);
if (element) {
list.appendChild(element);
}
}
nav.appendChild(list);
return nav;
}
/** Dispatches a single layout slot to the appropriate renderer. */
private renderSlot(slot: ToolbarSlot): HTMLElement | null {
if (slot === '') {
return this.renderSpacer();
}
if (typeof slot === 'string') {
return this.renderStringSlot(slot);
}
return this.renderGroupSlot(slot);
}
private renderSpacer(): HTMLElement {
const listItem = document.createElement('li');
listItem.className = CSS_CLASS_SPACER;
return listItem;
}
private renderStringSlot(slot: string): HTMLElement | null {
if (slot === 'macros') {
const items = [...this.buttons.values()].filter(button => button.id.startsWith(MACRO_ID_PREFIX));
if (items.length > 0) {
return this.renderGroup({
label: 'Macros',
items,
});
}
return null;
}
const button = this.buttons.get(slot);
if (button) {
return this.renderButton(button);
}
return null;
}
private renderGroupSlot(slot: { group: string; items: string[] }): HTMLElement | null {
const items = slot.items
.map(id => this.buttons.get(id))
.filter((button): button is Button => button !== undefined);
if (items.length > 0) {
return this.renderGroup({
label: slot.group,
items,
});
}
return null;
}
private renderButton(button: Button): HTMLElement {
const listItem = document.createElement('li');
const buttonElement = document.createElement('button');
buttonElement.className = `ribbit-btn-${button.id}`;
buttonElement.textContent = button.label;
buttonElement.setAttribute('aria-label', button.label);
buttonElement.title = button.shortcut
? `${button.label} (${button.shortcut})`
: button.label;
if (!button.visible) {
listItem.style.display = CSS_DISPLAY_NONE;
}
buttonElement.addEventListener('click', () => this.executeAction(button));
button.element = buttonElement;
listItem.appendChild(buttonElement);
return listItem;
}
private renderGroup(group: { label: string; items: Button[] }): HTMLElement {
const listItem = document.createElement('li');
const toggle = document.createElement('button');
toggle.className = CSS_CLASS_GROUP;
toggle.textContent = group.label + DROPDOWN_INDICATOR;
toggle.setAttribute('aria-label', group.label);
toggle.title = group.label;
const menu = document.createElement('div');
menu.className = CSS_CLASS_DROPDOWN;
menu.style.display = CSS_DISPLAY_NONE;
for (const button of group.items) {
const buttonElement = this.renderDropdownItem(button, menu);
menu.appendChild(buttonElement);
}
toggle.addEventListener('click', () => {
menu.style.display = menu.style.display === CSS_DISPLAY_NONE ? '' : CSS_DISPLAY_NONE;
});
listItem.appendChild(toggle);
listItem.appendChild(menu);
return listItem;
}
/** Creates a single button element inside a dropdown menu. */
private renderDropdownItem(button: Button, menu: HTMLElement): HTMLElement {
const buttonElement = document.createElement('button');
buttonElement.className = `ribbit-btn-${button.id}`;
buttonElement.setAttribute('aria-label', button.label);
buttonElement.title = button.label;
buttonElement.textContent = button.label;
if (!button.visible) {
buttonElement.style.display = CSS_DISPLAY_NONE;
}
buttonElement.addEventListener('click', () => {
this.executeAction(button);
menu.style.display = CSS_DISPLAY_NONE;
});
button.element = buttonElement;
return buttonElement;
}
private executeAction(button: Button): void {
if (!button.visible) {
return;
}
if (button.handler) {
button.handler();
this.editor.element.focus();
return;
}
if (button.action === 'wrap' && button.delimiter) {
this.wrapSelection(button.delimiter);
} else if (button.action === 'insert' && button.template) {
this.insertText(button.template, button.replaceSelection);
}
this.editor.invalidateCache();
this.editor.element.focus();
}
/** Wraps the current selection with the given delimiter on both sides. */
private wrapSelection(delimiter: string): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
const text = range.toString();
range.deleteContents();
range.insertNode(document.createTextNode(delimiter + text + delimiter));
}
/** Inserts text at the cursor, optionally replacing the current selection. */
private insertText(text: string, replaceSelection: boolean): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
if (replaceSelection) {
range.deleteContents();
} else {
range.collapse(false);
}
range.insertNode(document.createTextNode(text));
}
}
+260
View File
@@ -0,0 +1,260 @@
/*
* types.ts — shared type definitions for the ribbit editor.
*
* All interfaces used across multiple modules live here to avoid
* circular imports. Module-specific types stay in their own files.
*/
/**
* The result of a Tag's match() call. Carries the matched content,
* the raw matched text, how many source lines were consumed, and
* optional metadata (e.g. heading level, link href).
*/
export interface SourceToken {
content: string;
raw: string;
consumed: number;
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 {
inline: (text: string) => string;
block: (markdown: string) => string;
children: (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 {
lines: string[];
index: number;
text: string;
offset: number;
}
/**
* Configuration for a toolbar button's appearance and shortcut.
*/
export interface ToolbarButton {
show: boolean;
label: string;
icon?: string;
shortcut?: string;
}
/**
* A Tag is the core abstraction: it knows how to match markdown syntax,
* convert it to HTML, and convert the HTML back to markdown. Tags are
* registered by HTML selector (e.g. 'STRONG,B') so the converter can
* look them up during HTML→markdown conversion.
*/
export interface Tag {
name: string;
match: (context: MatchContext) => SourceToken | null;
toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean);
toMarkdown: (element: HTMLElement, convert: Converter) => string;
delimiter?: string;
precedence?: number;
recursive?: boolean;
pattern?: RegExp;
template?: string;
replaceSelection?: boolean;
button?: ToolbarButton;
/** Keyboard event handlers for WYSIWYG mode. Keys are event names
* like 'onEnter', 'onBackspace', 'onTab'. The handler receives
* the tag's element, the current selection, and the editor instance. */
eventHandlers?: Record<string, (element: HTMLElement, selection: Selection, editor: any) => boolean>;
/** Dispatch a keydown event to the appropriate handler in
* eventHandlers. Provided by BaseTag; override for custom logic. */
handleKeydown?: (element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any) => boolean;
}
/**
* A single item in a parsed list, with optional nested sublist HTML.
*/
export interface ListItem {
text: 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 {
html: string;
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 {
name: string;
delimiter: string;
htmlTag: string;
aliases?: string;
precedence?: number;
recursive?: boolean;
button?: ToolbarButton | false;
}
export interface RibbitThemeFeatures {
sourceMode?: boolean;
vim?: boolean;
collaboration?: boolean;
}
/**
* Transport for syncing document changes between clients.
* The consumer implements this with their choice of network layer
* (WebSocket, WebRTC, HTTP polling, etc.). Ribbit never makes
* network calls itself.
*
* const transport: DocumentTransport = {
* connect() { socket.open(); },
* disconnect() { socket.close(); },
* send(update) { socket.send(update); },
* onReceive(callback) { socket.onmessage = (event) => callback(event.data); },
* };
*/
export interface DocumentTransport {
connect(): void;
disconnect(): void;
send(update: Uint8Array): void;
onReceive(callback: (update: Uint8Array) => void): void;
lock?(): Promise<boolean>;
unlock?(): void;
forceLock?(): Promise<boolean>;
onLockChange?(callback: (holder: PeerInfo | null) => void): void;
}
/**
* Channel for broadcasting cursor position and user presence.
* Optional — collaboration works without it, but users won't see
* each other's cursors.
*
* const presence: PresenceChannel = {
* send(info) { socket.send(JSON.stringify(info)); },
* onUpdate(callback) { socket.onmessage = (event) => callback(JSON.parse(event.data)); },
* };
*/
export interface PresenceChannel {
send(info: PeerInfo): void;
onUpdate(callback: (peers: PeerInfo[]) => void): void;
}
export interface PeerInfo {
userId: string;
displayName: string;
cursor?: number;
color?: string;
status: 'active' | 'editing' | 'idle';
lastActive: number;
}
export interface CollaborationSettings {
transport: DocumentTransport;
presence?: PresenceChannel;
user: PeerInfo;
/** Milliseconds before a peer is considered idle. Default 30000. */
idleTimeout?: number;
/** Provider for revision storage. Required for auto-revision on source mode exit. */
revisions?: RevisionProvider;
}
/**
* Storage backend for document revisions. The consumer implements
* this with their persistence layer (database, API, localStorage, etc.).
*/
export interface RevisionProvider {
list(): Promise<Revision[]>;
get(id: string): Promise<Revision & { content: string }>;
create(content: string, metadata?: RevisionMetadata): Promise<Revision>;
}
export interface Revision {
id: string;
timestamp: string;
author: string;
summary?: string;
}
export interface RevisionMetadata {
summary?: string;
author: string;
}
/**
* A slot in the toolbar layout. Strings reference tag names or
* special values; objects define dropdown groups.
*
* 'bold' — single button
* '' — spacer
* 'macros' — auto-populated macro dropdown
* { group: 'Heading', items: ['h1', ...] } — dropdown group
*/
export type ToolbarSlot =
| string
| {
group: string;
items: string[];
};
/**
* A resolved toolbar button with DOM element and interaction methods.
*/
export interface Button {
id: string;
label: string;
icon?: string;
shortcut?: string;
action: 'wrap' | 'prefix' | 'insert' | 'custom';
delimiter?: string;
template?: string;
replaceSelection: boolean;
visible: boolean;
element?: HTMLElement;
handler?: () => void;
click(): void;
hide(): void;
show(): void;
}
export interface RibbitTheme {
name: string;
tags?: Record<string, Tag>;
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;
}
+337
View File
@@ -0,0 +1,337 @@
/*
* vim.ts — vim keybinding handler for ribbit source edit mode.
*
* Two modes: normal and insert. Activated in source (edit) mode only.
* Esc enters normal mode, i/a/o/O enter insert mode.
*
* Normal mode commands:
* h/j/k/l — cursor movement
* w/b — word forward/back
* 0/$ — line start/end
* gg/G — document start/end
* i — insert before cursor
* a — insert after cursor
* o — new line below, insert
* O — new line above, insert
* x — delete char under cursor
* dd — delete line
* u — undo
* Ctrl+r — redo
*/
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 {
mode: VimMode;
private element: HTMLElement | null;
private listener: ((event: KeyboardEvent) => void) | null;
private pending: string;
private count: string;
private onModeChange: (mode: VimMode) => void;
constructor(onModeChange: (mode: VimMode) => void) {
this.mode = 'insert';
this.element = null;
this.listener = null;
this.pending = '';
this.count = '';
this.onModeChange = onModeChange;
}
/**
* Bind vim keybindings to a DOM element.
*
* @example
* vim.attach(document.getElementById('editor'));
*/
attach(element: HTMLElement): void {
this.detach();
this.element = element;
this.pending = '';
this.listener = (event: KeyboardEvent) => this.handleKey(event);
this.element.addEventListener('keydown', this.listener);
this.setMode('insert');
}
/**
* Remove vim keybindings from the current element.
*
* @example
* vim.detach();
*/
detach(): void {
if (this.element && this.listener) {
this.element.removeEventListener('keydown', this.listener);
this.element.classList.remove('vim-normal', 'vim-insert');
}
this.element = null;
this.listener = null;
this.mode = 'insert';
this.pending = '';
}
private setMode(mode: VimMode): void {
this.mode = mode;
this.pending = '';
this.count = '';
this.onModeChange(mode);
}
/**
* 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 (event.key === 'Escape') {
event.preventDefault();
this.setMode('normal');
}
return;
}
// Suppress default text input in normal mode
event.preventDefault();
if (event.ctrlKey) {
if (event.key === 'r') {
document.execCommand('redo');
}
return;
}
const key = event.key;
// Accumulate count prefix — 0 as first char is line-start, not count
if (DIGIT_PATTERN.test(key) && (this.count || key !== '0')) {
this.count += key;
return;
}
const repeat = parseInt(this.count || DEFAULT_REPEAT_COUNT, DECIMAL_RADIX);
this.count = '';
if (this.pending) {
const combo = this.pending + key;
this.pending = '';
for (let step = 0; step < repeat; step++) {
this.handlePending(combo);
}
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) {
case 'i':
this.setMode('insert');
break;
case 'a':
this.moveCursor(DIRECTION.RIGHT);
this.setMode('insert');
break;
case 'o':
this.endOfLine();
this.insertNewline();
this.setMode('insert');
break;
case 'O':
this.startOfLine();
this.insertNewline();
this.moveCursor(DIRECTION.UP);
this.setMode('insert');
break;
case 'h':
for (let step = 0; step < repeat; step++) {
this.moveCursor(DIRECTION.LEFT);
}
break;
case 'j':
for (let step = 0; step < repeat; step++) {
this.moveCursor(DIRECTION.DOWN);
}
break;
case 'k':
for (let step = 0; step < repeat; step++) {
this.moveCursor(DIRECTION.UP);
}
break;
case 'l':
for (let step = 0; step < repeat; step++) {
this.moveCursor(DIRECTION.RIGHT);
}
break;
case 'w':
for (let step = 0; step < repeat; step++) {
this.wordForward();
}
break;
case 'b':
for (let step = 0; step < repeat; step++) {
this.wordBack();
}
break;
case '0':
this.startOfLine();
break;
case '$':
this.endOfLine();
break;
case 'G':
this.endOfDocument();
break;
case 'x':
for (let step = 0; step < repeat; step++) {
this.deleteChar();
}
break;
case 'u':
for (let step = 0; step < repeat; step++) {
document.execCommand('undo');
}
break;
// Two-char commands — preserve count for the second key
case 'd':
case 'g':
this.pending = key;
if (repeat > 1) {
this.count = String(repeat);
}
break;
}
}
private handlePending(combo: string): void {
switch (combo) {
case 'dd':
this.deleteLine();
break;
case 'gg':
this.startOfDocument();
break;
}
}
private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void {
const selection = window.getSelection();
if (!selection) {
return;
}
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 {
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.WORD);
}
private wordBack(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.WORD);
}
private startOfLine(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.BACKWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
}
private endOfLine(): void {
window.getSelection()?.modify('move', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
}
private startOfDocument(): void {
const selection = window.getSelection();
if (!selection || !this.element) {
return;
}
const range = document.createRange();
range.setStart(this.element, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
private endOfDocument(): void {
const selection = window.getSelection();
if (!selection || !this.element) {
return;
}
const range = document.createRange();
range.selectNodeContents(this.element);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
private deleteChar(): void {
document.execCommand('forwardDelete');
}
private deleteLine(): void {
this.startOfLine();
window.getSelection()?.modify('extend', SELECTION_DIRECTION.FORWARD, SELECTION_GRANULARITY.LINE_BOUNDARY);
document.execCommand('delete');
// Remove the trailing newline left after deleting line content
document.execCommand('forwardDelete');
}
private insertNewline(): void {
document.execCommand('insertLineBreak');
}
}
-56
View File
@@ -1,56 +0,0 @@
/*
* types.ts — shared types for the hopdown converter.
*/
export interface SourceToken {
content: string;
raw: string;
consumed: number;
meta?: Record<string, string>;
}
export interface Converter {
inline: (text: string) => string;
block: (md: string) => string;
children: (node: Node) => string;
node: (node: Node) => string;
}
export interface MatchContext {
lines: string[];
index: number;
text: string;
offset: number;
}
export interface Tag {
name: string;
match: (context: MatchContext) => SourceToken | null;
toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean);
toMarkdown: (element: HTMLElement, convert: Converter) => string;
}
export interface ListItem {
text: string;
sub: string;
}
export interface ListResult {
html: string;
end: number;
}
export interface InlineTagDef {
name: string;
/** The markdown delimiter, e.g. '**' or '`' or '~~' */
delimiter: string;
/** The HTML tag to wrap with, e.g. 'strong' or 'code' */
htmlTag: string;
/** Additional HTML selectors for reverse matching, e.g. 'B' for bold */
aliases?: string;
/** Lower runs first. Default 50. */
precedence?: number;
/** Process inner content for nested markdown? Default true. False for code spans. */
recursive?: boolean;
}
+491
View File
@@ -0,0 +1,491 @@
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);
});
});
});
+109
View File
@@ -0,0 +1,109 @@
import { ribbit, resetDOM } from './setup';
const lib = ribbit();
describe('Custom block tags', () => {
const spoiler = {
name: 'spoiler',
match: (context: any) => {
const fencePattern = /^\|{3,}/;
if (!fencePattern.test(context.lines[context.index])) {
return null;
}
const content: string[] = [];
let lineIndex = context.index + 1;
while (lineIndex < context.lines.length && !fencePattern.test(context.lines[lineIndex])) {
content.push(context.lines[lineIndex++]);
}
return {
content: content.join('\n'),
raw: '',
consumed: lineIndex + 1 - context.index,
};
},
toHTML: (token: any, convert: any) => '<details>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
};
const converter = new lib.HopDown({
tags: {
'DETAILS': spoiler,
...lib.defaultTags,
},
});
it('renders', () => expect(converter.toHTML('|||\nhidden\n|||')).toContain('<details>'));
it('nested md', () => expect(converter.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
});
describe('HopDown({ exclude })', () => {
it('excludes table', () => {
const converter = new lib.HopDown({ exclude: ['table'] });
expect(converter.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
});
it('excludes code', () => {
const converter = new lib.HopDown({ exclude: ['code'] });
expect(converter.toHTML('`code`')).toBe('<p>`code`</p>');
});
it('other tags still work', () => {
const converter = new lib.HopDown({ exclude: ['table'] });
expect(converter.toHTML('**bold**')).toContain('<strong>bold</strong>');
});
});
describe('Collision detection', () => {
it('delimiter collision throws', () => {
const bad = lib.inlineTag({
name: 'bad',
delimiter: '*',
htmlTag: 'span',
precedence: 10,
});
expect(() => new lib.HopDown({
tags: {
...lib.defaultTags,
'SPAN': bad,
},
})).toThrow();
});
it('selector collision throws', () => {
const dup = {
name: 'dup',
match: () => null,
toHTML: () => '',
selector: 'STRONG',
toMarkdown: () => '',
};
expect(() => new lib.HopDown({
tags: {
...lib.defaultTags,
'STRONG': dup,
},
})).toThrow();
});
it('valid precedence does not throw', () => {
const short = lib.inlineTag({
name: 'short',
delimiter: '~',
htmlTag: 's',
precedence: 50,
});
const long = lib.inlineTag({
name: 'long',
delimiter: '~~',
htmlTag: 'del',
precedence: 40,
});
// Remove default strikethrough to avoid collision with the custom S/DEL tags
const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags;
expect(() => new lib.HopDown({
tags: {
...tagsWithoutStrikethrough,
'S': short,
'DEL': long,
},
})).not.toThrow();
});
});
+268
View File
@@ -0,0 +1,268 @@
import { ribbit, resetDOM } from './setup';
const lib = ribbit();
describe('RibbitEmitter', () => {
beforeEach(() => resetDOM());
it('fires save event', () => {
const editor = new lib.Editor({});
editor.run();
let received: any = null;
editor.on('save', (payload: any) => {
received = payload;
});
editor.save();
expect(received).toHaveProperty('markdown');
expect(received).toHaveProperty('html');
});
it('off removes handler', () => {
const editor = new lib.Editor({});
editor.run();
let count = 0;
const handler = () => {
count++;
};
editor.on('save', handler);
editor.save();
editor.off('save', handler);
editor.save();
expect(count).toBe(1);
});
it('multiple listeners', () => {
const editor = new lib.Editor({});
editor.run();
let count = 0;
editor.on('save', () => {
count++;
});
editor.on('save', () => {
count++;
});
editor.save();
expect(count).toBe(2);
});
});
describe('Ribbit viewer', () => {
beforeEach(() => resetDOM('**bold**'));
it('starts with null state', () => {
const viewer = new lib.Viewer({});
expect(viewer.getState()).toBeNull();
});
it('run sets view state', () => {
const viewer = new lib.Viewer({});
viewer.run();
expect(viewer.getState()).toBe('view');
});
it('renders html', () => {
const viewer = new lib.Viewer({});
viewer.run();
expect(viewer.element.innerHTML).toContain('<strong>bold</strong>');
});
it('getMarkdown returns source', () => {
const viewer = new lib.Viewer({});
expect(viewer.getMarkdown()).toBe('**bold**');
});
});
describe('Ribbit events', () => {
it('ready fires on run', () => {
resetDOM('hello');
let payload: any = null;
const viewer = new lib.Viewer({
on: {
ready: (eventPayload: any) => {
payload = eventPayload;
},
},
});
viewer.run();
expect(payload).toHaveProperty('markdown');
expect(payload).toHaveProperty('mode', 'view');
expect(payload.theme.name).toBe('ribbit-default');
});
});
describe('RibbitEditor modes', () => {
beforeEach(() => resetDOM('**bold**'));
it('starts in view', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.getState()).toBe('view');
});
it('switches to wysiwyg', () => {
const editor = new lib.Editor({});
editor.run();
editor.wysiwyg();
expect(editor.getState()).toBe('wysiwyg');
expect(editor.element.contentEditable).toBe('true');
});
it('switches to edit', () => {
const editor = new lib.Editor({});
editor.run();
editor.wysiwyg();
editor.edit();
expect(editor.getState()).toBe('edit');
});
it('switches back to view', () => {
const editor = new lib.Editor({});
editor.run();
editor.wysiwyg();
editor.view();
expect(editor.getState()).toBe('view');
expect(editor.element.contentEditable).toBe('false');
});
it('fires modeChange events', () => {
const modes: string[] = [];
const editor = new lib.Editor({
on: {
modeChange: ({ current }: any) => {
modes.push(current);
},
},
});
editor.run();
editor.wysiwyg();
editor.edit();
editor.view();
expect(modes).toEqual(['view', 'wysiwyg', 'edit', 'view']);
});
it('sourceMode disabled blocks edit', () => {
resetDOM();
const editor = new lib.Editor({
currentTheme: 'no-source',
themes: [{
name: 'no-source',
features: { sourceMode: false },
}],
});
editor.run();
editor.wysiwyg();
editor.edit();
expect(editor.getState()).toBe('wysiwyg');
});
});
describe('ThemeManager', () => {
beforeEach(() => resetDOM());
it('lists registered themes', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
expect(editor.themes.list()).toContain('ribbit-default');
expect(editor.themes.list()).toContain('dark');
});
it('set switches theme', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.set('dark');
expect(editor.themes.current().name).toBe('dark');
});
it('disable hides from list', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.disable('dark');
expect(editor.themes.list()).not.toContain('dark');
});
it('enable restores to list', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.disable('dark');
editor.themes.enable('dark');
expect(editor.themes.list()).toContain('dark');
});
it('set disabled throws', () => {
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
editor.run();
editor.themes.disable('dark');
expect(() => editor.themes.set('dark')).toThrow();
});
it('set unknown throws', () => {
const editor = new lib.Editor({});
editor.run();
expect(() => editor.themes.set('nonexistent')).toThrow();
});
it('remove active throws', () => {
const editor = new lib.Editor({});
editor.run();
expect(() => editor.themes.remove(editor.themes.current().name)).toThrow();
});
it('fires themeChange', () => {
let payload: any = null;
const editor = new lib.Editor({
themes: [{ name: 'dark' }],
on: {
themeChange: (eventPayload: any) => {
payload = eventPayload;
},
},
});
editor.run();
editor.themes.set('dark');
expect(payload.current.name).toBe('dark');
expect(payload.previous.name).toBe('ribbit-default');
});
});
describe('defaultTheme', () => {
it('has correct shape', () => {
expect(lib.defaultTheme.name).toBe('ribbit-default');
expect(lib.defaultTheme.tags).toBeDefined();
expect(lib.defaultTheme.features.sourceMode).toBe(true);
});
});
describe('Utility functions', () => {
it('encodeHtmlEntities', () => {
expect(lib.encodeHtmlEntities('<')).toBe('&#60;');
expect(lib.encodeHtmlEntities('>')).toBe('&#62;');
expect(lib.encodeHtmlEntities('&')).toBe('&#38;');
});
it('decodeHtmlEntities', () => {
expect(lib.decodeHtmlEntities('&#60;')).toBe('<');
expect(lib.decodeHtmlEntities('&amp;')).toBe('&');
});
it('camelCase', () => {
expect(lib.camelCase('hello').join('')).toBe('Hello');
expect(lib.camelCase('hello world').join(' ')).toBe('Hello World');
});
});
describe('Editor htmlToMarkdown', () => {
beforeEach(() => resetDOM());
it('converts strong', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
});
it('converts em', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
});
});
+551
View File
@@ -0,0 +1,551 @@
import { ribbit } from './setup';
const lib = ribbit();
const hopdown = new lib.HopDown();
const H = (md: string) => hopdown.toHTML(md);
const M = (html: string) => hopdown.toMarkdown(html);
const rt = (md: string) => M(H(md));
describe('Markdown → HTML', () => {
describe('inline formatting', () => {
it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>'));
it('italic', () => expect(H('*italic*')).toBe('<p><em>italic</em></p>'));
it('inline code', () => expect(H('`code`')).toBe('<p><code>code</code></p>'));
it('link', () => expect(H('[t](http://x)')).toBe('<p><a href="http://x">t</a></p>'));
it('bold+italic', () => expect(H('***bi***')).toBe('<p><em><strong>bi</strong></em></p>'));
it('mixed', () => expect(H('a **b** *c* `d`')).toBe('<p>a <strong>b</strong> <em>c</em> <code>d</code></p>'));
it('code before bold', () => expect(H('`a` **b**')).toBe('<p><code>a</code> <strong>b</strong></p>'));
});
describe('headings', () => {
it.each([1, 2, 3, 4, 5, 6])('h%i', (level) => {
const prefix = '#'.repeat(level);
expect(H(`${prefix} Sub`)).toContain(`<h${level}`);
});
it('heading id', () => expect(H('## Hello World')).toContain("id='HelloWorld'"));
it('heading inline md', () => expect(H('## **Bold** text')).toContain('<strong>Bold</strong>'));
});
describe('horizontal rules', () => {
it('***', () => expect(H('***')).toBe('<hr>'));
it('---', () => expect(H('---')).toBe('<hr>'));
it('___', () => expect(H('___')).toBe('<hr>'));
});
describe('lists', () => {
it('ul *', () => expect(H('* a\n* b')).toBe('<ul><li>a</li><li>b</li></ul>'));
it('ul -', () => expect(H('- a\n- b')).toBe('<ul><li>a</li><li>b</li></ul>'));
it('ol', () => expect(H('1. a\n2. b')).toBe('<ol><li>a</li><li>b</li></ol>'));
it('ul inline', () => expect(H('* **bold** item')).toContain('<strong>bold</strong>'));
});
describe('blockquotes', () => {
it('basic', () => expect(H('> text')).toContain('<blockquote>'));
it('content', () => expect(H('> hello')).toContain('hello'));
it('multi-line', () => expect(H('> a\n> b')).toContain('a'));
});
describe('fenced code', () => {
it('basic', () => expect(H('```\nx = 1\n```')).toContain('<pre><code>'));
it('content', () => expect(H('```\nx = 1\n```')).toContain('x = 1'));
it('language', () => expect(H('```js\nvar x;\n```')).toContain('language-js'));
it('escapes html', () => expect(H('```\n<div>\n```')).toContain('&lt;div&gt;'));
it('no lang when none', () => expect(H('```\nplain\n```')).not.toContain('language-'));
});
describe('tables', () => {
const tbl = '| a | b |\n|---|---|\n| 1 | 2 |';
it('table tag', () => expect(H(tbl)).toContain('<table>'));
it('thead', () => expect(H(tbl)).toContain('<thead>'));
it('th cells', () => expect(H(tbl)).toContain('<th>a</th>'));
it('td cells', () => expect(H(tbl)).toContain('<td>1</td>'));
it('center align', () => expect(H('| C |\n|:--:|\n| x |')).toContain('text-align:center'));
it('right align', () => expect(H('| R |\n|--:|\n| x |')).toContain('text-align:right'));
it('inline md', () => expect(H('| **b** |\n|---|\n| x |')).toContain('<strong>b</strong>'));
});
describe('paragraphs', () => {
it('single', () => expect(H('hello')).toBe('<p>hello</p>'));
it('two', () => expect(H('a\n\nb')).toBe('<p>a</p>\n<p>b</p>'));
it('soft break', () => expect(H('a\nb')).toBe('<p>a\nb</p>'));
});
describe('edge cases', () => {
it('empty', () => expect(H('')).toBe(''));
it('whitespace', () => expect(H(' ')).toBe(''));
it('html entities', () => expect(H('a & b < c')).toContain('&amp;'));
it('html in code', () => expect(H('`<div>`')).toContain('&lt;div&gt;'));
it('para then heading', () => expect(H('text\n\n## H')).toContain('<h2'));
it('list then para', () => expect(H('- a\n\ntext')).toContain('<p>text</p>'));
});
});
describe('HTML → Markdown', () => {
it('strong→**', () => expect(M('<p><strong>b</strong></p>')).toBe('**b**'));
it('em→*', () => expect(M('<p><em>i</em></p>')).toBe('*i*'));
it('code→`', () => expect(M('<p><code>c</code></p>')).toBe('`c`'));
it('a→[]', () => expect(M('<a href="http://x">t</a>')).toBe('[t](http://x)'));
it('h1→#', () => expect(M('<h1>T</h1>')).toBe('# T'));
it('hr→---', () => expect(M('<hr>')).toBe('---'));
it('ul→-', () => expect(M('<ul><li>a</li><li>b</li></ul>')).toBe('- a\n- b'));
it('ol→1.', () => expect(M('<ol><li>a</li><li>b</li></ol>')).toBe('1. a\n2. b'));
it('bq→>', () => expect(M('<blockquote><p>q</p></blockquote>')).toContain('> '));
it('pre→```', () => expect(M('<pre><code>x</code></pre>')).toContain('```'));
it('pre lang', () => expect(M('<pre><code class="language-py">x</code></pre>')).toContain('```py'));
it('table→pipes', () => {
const html = '<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>';
expect(M(html)).toContain('| a | b |');
});
});
describe('Round-trips', () => {
it.each([
['paragraph', 'Hello world'],
['bold', '**bold**'],
['italic', '*italic*'],
['code', '`code`'],
['link', '[t](http://x)'],
['h1', '# Title'],
['h2', '## Sub'],
['ul', '- a\n- b'],
['ol', '1. a\n2. b'],
])('%s', (_, md) => expect(rt(md)).toBe(md));
it('hr', () => expect(rt('---')).toBe('---'));
it('blockquote', () => expect(rt('> quoted')).toContain('> '));
it('code block', () => expect(rt('```\nx = 1\n```')).toContain('```'));
it('table', () => expect(rt('| a | b |\n|---|---|\n| 1 | 2 |')).toContain('| a | b |'));
});
describe('Nested inline', () => {
it('bold wraps italic', () => expect(H('**a *b* c**')).toBe('<p><strong>a <em>b</em> c</strong></p>'));
it('italic wraps bold', () => expect(H('*a **b** c*')).toBe('<p><em>a <strong>b</strong> c</em></p>'));
it('bold wraps code', () => expect(H('**a `b` c**')).toBe('<p><strong>a <code>b</code> c</strong></p>'));
it('bold wraps link', () => expect(H('**[t](u)**')).toBe('<p><strong><a href="u">t</a></strong></p>'));
it('link with bold', () => expect(H('[**t**](u)')).toBe('<p><a href="u"><strong>t</strong></a></p>'));
it('link with code', () => expect(H('[`t`](u)')).toBe('<p><a href="u"><code>t</code></a></p>'));
});
describe('Nested blocks', () => {
it('bq > heading', () => expect(H('> # Title')).toContain('<h1'));
it('bq > list', () => expect(H('> - a\n> - b')).toContain('<ul>'));
it('bq > bq', () => expect(H('> > nested')).toContain('<blockquote>'));
it('li > bold', () => expect(H('- **bold**')).toContain('<strong>bold</strong>'));
it('heading > code', () => expect(H('## `code`')).toContain('<code>code</code>'));
it('table > bold', () => expect(H('| **b** |\n|---|\n| x |')).toContain('<strong>b</strong>'));
});
describe('Nested lists', () => {
it('ul > ul', () => expect(H('- a\n - b\n - c\n- d')).toBe('<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>'));
it('ol > ol', () => expect(H('1. a\n 1. b\n 1. c\n2. d')).toBe('<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>'));
it('ul > ol', () => expect(H('- a\n 1. b\n 2. c\n- d')).toBe('<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>'));
it('3-level', () => expect(H('- a\n - b\n - c\n- d')).toBe('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>'));
it('ul>ul rt', () => expect(rt('- a\n - b\n - c\n- d')).toBe('- a\n - b\n - c\n- d'));
});
describe('Tables with nested markdown', () => {
it('td bold', () => expect(H('| h |\n|---|\n| **b** |')).toContain('<td><strong>b</strong></td>'));
it('td link>bold', () => expect(H('| h |\n|---|\n| [**t**](u) |')).toContain('<a href="u"><strong>t</strong></a>'));
it('td bold rt', () => expect(rt('| h |\n|---|\n| **b** |')).toBe('| h |\n| --- |\n| **b** |'));
it('multi-cell rt', () => expect(rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |')).toBe('| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |'));
});
describe('Backslash escapes', () => {
it('escaped asterisk', () => expect(H('\\*not italic\\*')).toBe('<p>*not italic*</p>'));
it('escaped backslash', () => expect(H('a \\\\ b')).toBe('<p>a \\ b</p>'));
it('escaped backtick', () => expect(H('\\`not code\\`')).toBe('<p>`not code`</p>'));
it('round-trip preserves escape', () => {
const html = H('\\*literal\\*');
expect(html).toContain('*literal*');
expect(html).not.toContain('<em>');
});
});
describe('Strikethrough', () => {
it('md→html', () => expect(H('~~deleted~~')).toBe('<p><del>deleted</del></p>'));
it('html→md', () => expect(M('<p><del>gone</del></p>')).toBe('~~gone~~'));
it('round-trip', () => expect(rt('~~struck~~')).toBe('~~struck~~'));
it('mixed with bold', () => expect(H('**bold** and ~~struck~~')).toContain('<del>struck</del>'));
});
describe('Link titles', () => {
it('link with title', () => expect(H('[t](http://x "My Title")')).toBe('<p><a href="http://x" title="My Title">t</a></p>'));
it('title round-trip', () => expect(rt('[t](http://x "My Title")')).toBe('[t](http://x "My Title")'));
});
describe('Reference links', () => {
it('basic reference', () => expect(H('[text][ref]\n\n[ref]: http://x')).toContain('<a href="http://x">text</a>'));
it('shortcut reference', () => expect(H('[ref][]\n\n[ref]: http://x')).toContain('<a href="http://x">ref</a>'));
it('reference with title', () => expect(H('[t][r]\n\n[r]: http://x "T"')).toContain('title="T"'));
it('case insensitive', () => expect(H('[t][REF]\n\n[ref]: http://x')).toContain('<a href="http://x">'));
it('undefined reference passes through', () => expect(H('[t][missing]')).toContain('[t][missing]'));
it('definition not rendered', () => expect(H('[ref]: http://x\n\ntext')).toBe('<p>text</p>'));
});
describe('HTML passthrough', () => {
it('inline html preserved', () => expect(H('a <span class="x">b</span> c')).toContain('<span class="x">b</span>'));
it('self-closing tag', () => expect(H('a <br/> b')).toContain('<br/>'));
it('html not double-escaped', () => expect(H('<em>hi</em>')).not.toContain('&lt;'));
});
describe('Autolinks', () => {
it('angle bracket autolink', () => expect(H('<https://example.com>')).toContain('<a href="https://example.com">'));
it('bare URL', () => expect(H('visit https://example.com today')).toContain('<a href="https://example.com">'));
it('URL not matched inside link', () => {
const html = H('[text](https://example.com)');
// Should have exactly one <a> tag, not nested
const anchorPattern = /<a /g;
const count = (html.match(anchorPattern) || []).length;
expect(count).toBe(1);
});
});
describe('Alternate syntax (parse-only, canonical output)', () => {
describe('underscore emphasis', () => {
it('_italic_ → *italic*', () => {
expect(H('_italic_')).toBe('<p><em>italic</em></p>');
expect(rt('_italic_')).toBe('*italic*');
});
it('__bold__ → **bold**', () => {
expect(H('__bold__')).toBe('<p><strong>bold</strong></p>');
expect(rt('__bold__')).toBe('**bold**');
});
it('___both___ → ***both***', () => {
expect(H('___both___')).toContain('<em><strong>both</strong></em>');
expect(rt('___both___')).toBe('***both***');
});
it('mid-word _ not converted', () => {
expect(H('foo_bar_baz')).toBe('<p>foo_bar_baz</p>');
});
});
describe('setext headings', () => {
it('=== underline → h1', () => {
expect(H('Title\n=====')).toContain('<h1');
expect(H('Title\n=====')).toContain('Title');
});
it('--- underline → h2', () => {
expect(H('Sub\n---')).toContain('<h2');
});
it('round-trips to ATX', () => {
expect(rt('Title\n=====')).toBe('# Title');
expect(rt('Sub\n---')).toBe('## Sub');
});
});
describe('ATX closing hashes', () => {
it('## Title ## → h2', () => {
expect(H('## Title ##')).toContain('<h2');
expect(H('## Title ##')).toContain('Title');
});
it('round-trips without closing', () => {
expect(rt('## Title ##')).toBe('## Title');
});
});
describe('tilde fenced code', () => {
it('~~~ fence accepted', () => {
expect(H('~~~\ncode\n~~~')).toContain('<code>code</code>');
});
it('round-trips to backtick', () => {
expect(rt('~~~\ncode\n~~~')).toContain('```');
});
});
describe('plus list marker', () => {
it('+ item accepted', () => {
expect(H('+ item')).toContain('<li>');
});
it('round-trips to -', () => {
expect(rt('+ item')).toContain('- item');
});
});
});
describe('HopDown delimiter matching API', () => {
describe('findCompletePair', () => {
it('finds bold pair', () => {
const result = hopdown.findCompletePair('hello **world** end');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('strong');
expect(result!.content).toBe('world');
expect(result!.delimiter).toBe('**');
});
it('finds italic pair', () => {
const result = hopdown.findCompletePair('hello *world* end');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('em');
});
it('finds strikethrough pair', () => {
const result = hopdown.findCompletePair('hello ~~gone~~ end');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('del');
});
it('returns null when no pair exists', () => {
expect(hopdown.findCompletePair('hello world')).toBeNull();
});
it('skips sentinel-wrapped content', () => {
expect(hopdown.findCompletePair('hello \x01<strong>world</strong>\x02 end')).toBeNull();
});
it('respects precedence (boldItalic before bold)', () => {
const result = hopdown.findCompletePair('***both***');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('em');
expect(result!.tag.name).toBe('boldItalic');
});
});
describe('findUnmatchedOpener', () => {
it('finds unclosed bold', () => {
const result = hopdown.findUnmatchedOpener('hello **world');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('strong');
expect(result!.content).toBe('world');
});
it('returns null when no opener exists', () => {
expect(hopdown.findUnmatchedOpener('hello world end')).toBeNull();
});
it('returns null for plain text', () => {
expect(hopdown.findUnmatchedOpener('hello world')).toBeNull();
});
});
describe('getTagForElement', () => {
it('returns tag for strong element', () => {
const element = document.createElement('strong');
const tag = hopdown.getTagForElement(element);
expect(tag).not.toBeNull();
expect(tag!.name).toBe('bold');
expect(tag!.delimiter).toBe('**');
});
it('returns tag for em element', () => {
const element = document.createElement('em');
const tag = hopdown.getTagForElement(element);
expect(tag).not.toBeNull();
expect(tag!.name).toBe('italic');
});
it('returns null for div element', () => {
const element = document.createElement('div');
expect(hopdown.getTagForElement(element)).toBeNull();
});
});
describe('getEditableSelector', () => {
it('returns a non-empty string', () => {
const selector = hopdown.getEditableSelector();
expect(selector.length).toBeGreaterThan(0);
});
it('includes inline tag selectors', () => {
const selector = hopdown.getEditableSelector();
expect(selector).toContain('strong');
expect(selector).toContain('em');
expect(selector).toContain('code');
});
it('includes block tag selectors', () => {
const selector = hopdown.getEditableSelector();
expect(selector).toContain('pre');
expect(selector).toContain('blockquote');
});
});
});
describe('Hard line breaks', () => {
it('trailing two spaces', () => {
expect(H('line one \nline two')).toContain('<br>');
});
it('trailing backslash', () => {
expect(H('line one\\\nline two')).toContain('<br>');
});
it('single space does not break', () => {
expect(H('line one \nline two')).not.toContain('<br>');
});
it('round-trip', () => {
const html = H('line one \nline two');
const markdown = M(html);
expect(markdown).toContain(' \n');
});
});
describe('Link nesting prevention', () => {
it('nested brackets prevent link match', () => {
const html = H('[outer [inner](http://b)](http://a)');
// The outer [ prevents matching as a single link — the inner
// link matches instead, and the outer brackets are literal text
expect(html).toContain('<a href="http://b">inner</a>');
});
it('preserves inner link text', () => {
const html = H('[outer [inner](http://b)](http://a)');
expect(html).toContain('inner');
});
it('autolink inside link is stripped', () => {
const html = H('[see <https://b.com>](http://a)');
const anchorPattern = /<a /g;
const linkCount = (html.match(anchorPattern) || []).length;
expect(linkCount).toBe(1);
});
});
describe('Multiple-of-3 emphasis rule', () => {
it('***foo*** is bold-italic', () => {
expect(H('***foo***')).toContain('<em><strong>foo</strong></em>');
});
it('**foo** is bold', () => {
expect(H('**foo**')).toBe('<p><strong>foo</strong></p>');
});
it('*foo* is italic', () => {
expect(H('*foo*')).toBe('<p><em>foo</em></p>');
});
it('*foo** does not match (1+2=3, rule applies)', () => {
const html = H('*foo**');
expect(html).not.toContain('<em>');
expect(html).not.toContain('<strong>');
});
it('**foo* does not match (2+1=3, rule applies)', () => {
const html = H('**foo*');
expect(html).not.toContain('<em>');
expect(html).not.toContain('<strong>');
});
});
describe('HTML entity resolution', () => {
it('&amp; resolves to &', () => {
expect(H('a &amp; b')).toBe('<p>a &amp; b</p>');
});
it('&lt; resolves to <', () => {
expect(H('a &lt; b')).toBe('<p>a &lt; b</p>');
});
it('&gt; resolves to >', () => {
expect(H('a &gt; b')).toBe('<p>a &gt; b</p>');
});
it('&#123; resolves to {', () => {
expect(H('&#123;')).toBe('<p>{</p>');
});
it('&#x7B; resolves to {', () => {
expect(H('&#x7B;')).toBe('<p>{</p>');
});
it('unknown entity passes through', () => {
expect(H('&unknown;')).toContain('&amp;unknown;');
});
});
describe('Nested inline scenarios', () => {
describe('markdown → HTML nesting', () => {
it('strikethrough wraps bold', () => {
expect(H('~~**bold** struck~~')).toBe('<p><del><strong>bold</strong> struck</del></p>');
});
it('bold wraps strikethrough', () => {
expect(H('**~~struck~~ bold**')).toBe('<p><strong><del>struck</del> bold</strong></p>');
});
it('italic wraps link', () => {
expect(H('*[text](http://x)*')).toContain('<em><a href="http://x">text</a></em>');
});
it('code inside strikethrough', () => {
expect(H('~~`code` struck~~')).toContain('<del><code>code</code> struck</del>');
});
it('adjacent bold and italic', () => {
const html = H('**bold***italic*');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<em>italic</em>');
});
});
describe('HTML → markdown → HTML round-trip nesting', () => {
it('bold wraps italic', () => {
const html = '<p><strong>a <em>b</em> c</strong></p>';
expect(H(M(html))).toBe(html);
});
it('italic wraps bold', () => {
const html = '<p><em>a <strong>b</strong> c</em></p>';
expect(H(M(html))).toBe(html);
});
it('bold wraps code', () => {
const html = '<p><strong>a <code>b</code> c</strong></p>';
expect(H(M(html))).toBe(html);
});
it('bold wraps link', () => {
const html = '<p><strong><a href="http://x">t</a></strong></p>';
expect(H(M(html))).toBe(html);
});
it('strikethrough wraps bold', () => {
const html = '<p><del><strong>bold</strong> struck</del></p>';
expect(H(M(html))).toBe(html);
});
it('italic wraps link', () => {
const html = '<p><em><a href="http://x">t</a></em></p>';
expect(H(M(html))).toBe(html);
});
});
describe('literal delimiters in text round-trip', () => {
it('literal * in bold', () => {
const html = '<p><strong>a * b</strong></p>';
expect(H(M(html))).toBe(html);
});
it('literal ~ in strikethrough', () => {
const html = '<p><del>a ~ b</del></p>';
expect(H(M(html))).toBe(html);
});
it('literal ` adjacent to code', () => {
const html = '<p>a ` b <code>c</code></p>';
expect(H(M(html))).toBe(html);
});
it('literal * in plain text', () => {
const html = '<p>hello * world</p>';
expect(H(M(html))).toBe(html);
});
it('literal ** in plain text', () => {
const html = '<p>hello ** world</p>';
expect(H(M(html))).toBe(html);
});
it('literal _ in plain text', () => {
const html = '<p>hello _ world</p>';
expect(H(M(html))).toBe(html);
});
});
});
describe('Backslash-escaped HTML tags', () => {
it('\\<em> does not produce a real em element', () => {
const html = H('\\<em>text');
expect(html).not.toContain('<em>');
expect(html).toContain('&lt;em&gt;');
});
it('\\<b> does not produce a real b element', () => {
const html = H('\\<b>text');
expect(html).not.toContain('<b>');
});
it('round-trip of escaped HTML tag in text', () => {
const html = '<p>~~\\<em>---\\<b></em></p>';
const markdown = M(html);
const rehtml = H(markdown);
const markdown2 = M(rehtml);
const rehtml2 = H(markdown2);
expect(rehtml).toBe(rehtml2);
});
});
describe('Table cell round-trip', () => {
it('inline formatting in cells survives round-trip', () => {
const html = '<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td><strong>bold</strong></td><td><em>italic</em></td></tr></tbody></table>';
expect(H(M(html))).toBe(html);
});
it('code in cells survives round-trip', () => {
const html = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td><code>x</code></td></tr></tbody></table>';
expect(H(M(html))).toBe(html);
});
it('literal * in cells survives round-trip', () => {
const html = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>2 * 3</td></tr></tbody></table>';
expect(H(M(html))).toBe(html);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Development server with livereload.
*
* Serves the test page and ribbit dist files. Watches src/ for
* changes, rebuilds automatically, and notifies connected browsers
* to reload via a simple EventSource stream.
*
* Run: npm run dev
*/
const { createServer } = require('./server');
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const PORT = 8080;
const WATCH_DIRS = [
path.join(__dirname, '..', '..', 'src'),
path.join(__dirname, '..', '..', 'test', 'integration'),
];
const DEBOUNCE_MS = 300;
const server = createServer(PORT);
const reloadClients = [];
// Patch the server to add the livereload endpoint
const originalServer = require('http').createServer;
const httpServer = server._server || (() => {
// Access the internal server by starting and intercepting
let captured = null;
const origListen = require('http').Server.prototype.listen;
require('http').Server.prototype.listen = function (...args) {
captured = this;
return origListen.apply(this, args);
};
server.start();
require('http').Server.prototype.listen = origListen;
return captured;
})();
// Simpler approach: create a standalone livereload server
const reloadServer = require('http').createServer((request, response) => {
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
reloadClients.push(response);
request.on('close', () => {
const index = reloadClients.indexOf(response);
if (index >= 0) {
reloadClients.splice(index, 1);
}
});
});
function notifyReload() {
for (const client of reloadClients) {
client.write('data: reload\n\n');
}
}
function rebuild() {
try {
console.log('\n🔨 Rebuilding...');
execSync('npm run build:js && npm run build:css', {
cwd: path.join(__dirname, '..', '..'),
stdio: 'pipe',
});
console.log('✅ Build complete');
notifyReload();
} catch (error) {
console.error('❌ Build failed:', error.stderr?.toString().slice(0, 500));
}
}
let debounceTimer = null;
function onFileChange(filename) {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
console.log(`📝 Changed: ${filename}`);
debounceTimer = setTimeout(rebuild, DEBOUNCE_MS);
}
// Watch source directories
for (const directory of WATCH_DIRS) {
if (fs.existsSync(directory)) {
fs.watch(directory, { recursive: true }, (eventType, filename) => {
if (filename && !filename.includes('node_modules')) {
onFileChange(filename);
}
});
}
}
server.start().then(() => {
reloadServer.listen(PORT + 1, () => {
console.log(`\n🐸 Ribbit dev server`);
console.log(` Editor: http://localhost:${PORT}`);
console.log(` Livereload: http://localhost:${PORT + 1} (EventSource)`);
console.log(` Watching: src/, test/integration/`);
console.log(`\n Add this to the page to enable livereload:`);
console.log(` <script>new EventSource('http://localhost:${PORT + 1}').onmessage = () => location.reload()</script>\n`);
});
});
+51
View File
@@ -0,0 +1,51 @@
<!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>
<script>
// Livereload — connects to dev server's EventSource endpoint.
// Silently fails if the dev server isn't running.
try { new EventSource('http://localhost:8081').onmessage = () => location.reload(); } catch(e) {}
</script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
/**
* Minimal static file server for e2e tests.
* Serves the test page and ribbit dist files.
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const MIME = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.map': 'application/json',
};
function createServer(port = 9999) {
const distDir = path.join(__dirname, '..', '..', 'dist', 'ribbit');
const testDir = __dirname;
const server = http.createServer((req, res) => {
let filePath;
if (req.url === '/' || req.url === '/index.html') {
filePath = path.join(testDir, 'index.html');
} else if (req.url.startsWith('/ribbit/')) {
filePath = path.join(distDir, req.url.replace('/ribbit/', ''));
} else {
res.writeHead(404);
res.end('Not found');
return;
}
const ext = path.extname(filePath);
const mime = MIME[ext] || 'application/octet-stream';
try {
const content = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime });
res.end(content);
} catch {
res.writeHead(404);
res.end('Not found');
}
});
return {
start() {
return new Promise((resolve) => {
server.listen(port, () => resolve());
});
},
stop() {
return new Promise((resolve) => {
server.close(() => resolve());
});
},
url: `http://localhost:${port}`,
};
}
module.exports = { createServer };
+307
View File
@@ -0,0 +1,307 @@
/**
* Integration tests for the ribbit editor using Selenium + Firefox.
*
* Run: npm run test:e2e
*/
const { Builder, By, Key, until } = require('selenium-webdriver');
const firefox = require('selenium-webdriver/firefox');
const { createServer } = require('./server');
let server;
let driver;
async function setup() {
server = createServer(9999);
await server.start();
const options = new firefox.Options().addArguments('--headless');
driver = await new Builder()
.forBrowser('firefox')
.setFirefoxOptions(options)
.build();
await driver.get(server.url);
// Wait for ribbit to initialize
await driver.wait(async () => {
return driver.executeScript('return window.__ribbitReady === true');
}, 10000).catch(async () => {
const logs = await driver.manage().logs().get('browser').catch(() => []);
console.log('Browser logs:', logs.map(l => l.message));
const ready = await driver.executeScript('return { ready: window.__ribbitReady, ribbit: typeof window.ribbit, editor: typeof window.__ribbitEditor }');
console.log('State:', ready);
throw new Error('Editor did not become ready');
});
}
async function teardown() {
if (driver) await driver.quit();
if (server) await server.stop();
}
// Test helpers
async function getEditorHTML() {
return driver.executeScript('return document.getElementById("ribbit").innerHTML');
}
async function getEditorText() {
return driver.executeScript('return document.getElementById("ribbit").textContent');
}
async function getState() {
return driver.executeScript('return window.__ribbitEditor.getState()');
}
async function clickButton(label) {
const buttons = await driver.findElements(By.css('.ribbit-toolbar button'));
for (const btn of buttons) {
const text = await btn.getText();
if (text === label) {
await btn.click();
return;
}
}
throw new Error(`Button "${label}" not found`);
}
async function clickEditor() {
const editor = await driver.findElement(By.id('ribbit'));
await editor.click();
}
// Test runner
let passed = 0;
let failed = 0;
const errors = [];
async function test(name, fn) {
try {
await fn();
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` ${e.message}`);
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || 'Assertion failed');
}
// Tests
async function runTests() {
console.log('\nRibbit Integration Tests\n');
await test('page loads', async () => {
const title = await driver.getTitle();
assert(title === 'Ribbit Integration Test Page', `Title: ${title}`);
});
await test('editor renders in view mode', async () => {
const state = await getState();
assert(state === 'view', `State: ${state}`);
});
await test('editor renders markdown as HTML', async () => {
const html = await getEditorHTML();
assert(html.includes('<strong>bold</strong>'), 'Missing bold');
assert(html.includes('<em>italic</em>'), 'Missing italic');
assert(html.includes('<code>code</code>'), 'Missing code');
});
await test('editor renders headings', async () => {
const html = await getEditorHTML();
assert(html.includes('<h2'), 'Missing h2');
});
await test('editor renders lists', async () => {
const html = await getEditorHTML();
assert(html.includes('<ul>'), 'Missing ul');
assert(html.includes('<li>'), 'Missing li');
});
await test('editor renders tables', async () => {
const html = await getEditorHTML();
assert(html.includes('<table>'), 'Missing table');
});
await test('editor renders blockquotes', async () => {
const html = await getEditorHTML();
assert(html.includes('<blockquote>'), 'Missing blockquote');
});
await test('toolbar is rendered', async () => {
const toolbar = await driver.findElements(By.css('.ribbit-toolbar'));
assert(toolbar.length > 0, 'No toolbar found');
});
await test('toolbar has buttons with labels', async () => {
const buttons = await driver.findElements(By.css('.ribbit-toolbar button'));
assert(buttons.length > 5, `Only ${buttons.length} buttons`);
const text = await buttons[0].getText();
assert(text.length > 0, 'Button has no label');
});
await test('toggle button switches to wysiwyg', async () => {
await clickButton('Edit');
const state = await getState();
assert(state === 'wysiwyg', `State: ${state}`);
});
await test('editor is contentEditable in wysiwyg', async () => {
const editable = await driver.executeScript(
'return document.getElementById("ribbit").contentEditable'
);
assert(editable === 'true', `contentEditable: ${editable}`);
});
await test('can type in wysiwyg mode', async () => {
await clickEditor();
// Move to end and type
await driver.actions().keyDown(Key.CONTROL).sendKeys(Key.END).keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys('\nhello from selenium').perform();
const text = await getEditorText();
assert(text.includes('hello from selenium'), 'Typed text not found');
});
await test('source button switches to edit mode', async () => {
await clickButton('Source');
const state = await getState();
assert(state === 'edit', `State: ${state}`);
});
await test('edit mode shows raw markdown', async () => {
const text = await getEditorText();
assert(text.includes('**bold**'), 'Missing raw markdown');
});
await test('toggle back to view mode', async () => {
await clickButton('Edit');
const state = await getState();
assert(state === 'view', `State: ${state}`);
});
await test('view mode renders HTML again', async () => {
const html = await getEditorHTML();
assert(html.includes('<strong>bold</strong>'), 'Not rendered as HTML');
});
await test('save button fires save event', async () => {
await driver.executeScript('window.__saved = false; window.__ribbitEditor.on("save", () => { window.__saved = true; })');
await clickButton('Edit');
await clickButton('Save');
const saved = await driver.executeScript('return window.__saved');
assert(saved === true, 'Save event not fired');
});
await test('enter key creates new line in wysiwyg', async () => {
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
// Clear and type two lines
await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys(Key.DELETE).perform();
await driver.actions().sendKeys('line one').perform();
await driver.actions().sendKeys(Key.ENTER).perform();
await driver.actions().sendKeys('line two').perform();
const text = await getEditorText();
assert(text.includes('line one'), `Missing "line one" in: ${text}`);
assert(text.includes('line two'), `Missing "line two" in: ${text}`);
// Check that they're on separate lines (not concatenated)
const html = await getEditorHTML();
const hasBreak = html.includes('<br') || html.includes('<div') || html.includes('<p');
assert(hasBreak, `No line break in HTML: ${html}`);
});
await test('enter key in wysiwyg produces valid markdown', async () => {
// Get the markdown from the content typed above
const md = await driver.executeScript('return window.__ribbitEditor.getMarkdown()');
assert(md.includes('line one'), `Missing "line one" in markdown: ${md}`);
assert(md.includes('line two'), `Missing "line two" in markdown: ${md}`);
// Lines should be separate (not on same line)
const lines = md.split('\n').filter(l => l.trim());
const hasLineOne = lines.some(l => l.includes('line one'));
const hasLineTwo = lines.some(l => l.includes('line two'));
assert(hasLineOne, `"line one" not on its own line in: ${md}`);
assert(hasLineTwo, `"line two" not on its own line in: ${md}`);
});
await test('multiple enters create blank lines in wysiwyg', async () => {
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys(Key.DELETE).perform();
await driver.actions().sendKeys('para one').perform();
await driver.actions().sendKeys(Key.ENTER, Key.ENTER).perform();
await driver.actions().sendKeys('para two').perform();
const text = await getEditorText();
assert(text.includes('para one'), `Missing "para one" in: ${text}`);
assert(text.includes('para two'), `Missing "para two" in: ${text}`);
});
await test('enter after heading in wysiwyg', async () => {
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys(Key.DELETE).perform();
await driver.actions().sendKeys('## My Heading').perform();
await driver.actions().sendKeys(Key.ENTER).perform();
await driver.actions().sendKeys('paragraph text').perform();
const md = await driver.executeScript('return window.__ribbitEditor.getMarkdown()');
assert(md.includes('Heading') || md.includes('heading'), `Missing heading in: ${md}`);
assert(md.includes('paragraph'), `Missing paragraph in: ${md}`);
});
await test('typing heading prefix in wysiwyg', async () => {
// Start fresh
await driver.executeScript(`
var e = window.__ribbitEditor;
e.wysiwyg();
e.element.innerHTML = '<p><br></p>';
`);
await clickEditor();
await driver.sleep(100);
// Type "# Hello"
await driver.actions().sendKeys('# Hello').perform();
await driver.sleep(100);
const html = await getEditorHTML();
console.log(' HTML:', html.slice(0, 200));
assert(html.includes('<h1'), `Expected <h1> in HTML: ${html.slice(0, 200)}`);
});
await test('Ctrl+B shortcut works in wysiwyg', async () => {
// Switch to wysiwyg
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
// Type and select
await driver.actions().sendKeys('test text').perform();
await driver.actions()
.keyDown(Key.SHIFT)
.sendKeys(Key.ARROW_LEFT, Key.ARROW_LEFT, Key.ARROW_LEFT, Key.ARROW_LEFT)
.keyUp(Key.SHIFT)
.perform();
// Ctrl+B
await driver.actions().keyDown(Key.CONTROL).sendKeys('b').keyUp(Key.CONTROL).perform();
const html = await getEditorHTML();
assert(html.includes('**'), 'Bold delimiter not inserted');
});
}
(async () => {
try {
await setup();
await runTests();
} catch (e) {
console.error('Setup failed:', e.message);
failed++;
} finally {
console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`);
if (errors.length) {
console.log('\nFailed:');
errors.forEach(e => console.log(`${e}`));
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
}
})();
+471
View File
@@ -0,0 +1,471 @@
/**
* WYSIWYG fuzz test.
*
* Generates random keystroke sequences, types them char-by-char,
* and checks structural invariants after every keystroke. When a
* failure is found, the seed is logged for deterministic replay
* and the sequence is shrunk to a minimal reproducing case.
*
* Run:
* node test/integration/test_fuzz.js
* node test/integration/test_fuzz.js --seed 12345
* node test/integration/test_fuzz.js --rounds 200
* node test/integration/test_fuzz.js --seed 12345 --shrink
*/
const { Builder, By, Key } = require('selenium-webdriver');
const firefox = require('selenium-webdriver/firefox');
const { createServer } = require('./server');
let server, driver;
const DELAY = 20;
/* ── Seeded PRNG (mulberry32) ── */
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* ── Keystroke generation ── */
const PRINTABLE = 'abcdefghijklmnopqrstuvwxyz 0123456789.,!?';
const DELIMITERS = ['*', '**', '***', '`', '~~', '_', '__', '___'];
const BLOCK_PREFIXES = ['# ', '## ', '### ', '- ', '+ ', '1. ', '> ', '---', '~~~'];
const SPECIAL_KEYS = [
{ name: 'Enter', keys: Key.ENTER, isSpecial: true },
{ name: 'Backspace', keys: Key.BACK_SPACE, isSpecial: true },
{ name: 'ArrowLeft', keys: Key.ARROW_LEFT, isSpecial: true },
{ name: 'ArrowRight', keys: Key.ARROW_RIGHT, isSpecial: true },
];
/**
* Generate a random keystroke sequence.
* Returns array of { name, keys } where keys is a string or Key constant.
*/
function generateSequence(random, length) {
const sequence = [];
for (let i = 0; i < length; i++) {
const roll = random();
if (roll < 0.50) {
/* printable character */
const character = PRINTABLE[Math.floor(random() * PRINTABLE.length)];
sequence.push({ name: character === ' ' ? 'Space' : character, keys: character });
} else if (roll < 0.70) {
/* delimiter */
const delimiter = DELIMITERS[Math.floor(random() * DELIMITERS.length)];
sequence.push({ name: delimiter, keys: delimiter });
} else if (roll < 0.80) {
/* special key */
const special = SPECIAL_KEYS[Math.floor(random() * SPECIAL_KEYS.length)];
sequence.push(special);
} else if (roll < 0.88) {
/* block prefix (only useful at line start, but fuzz doesn't care) */
const prefix = BLOCK_PREFIXES[Math.floor(random() * BLOCK_PREFIXES.length)];
sequence.push({ name: `"${prefix.trim()}"`, keys: prefix });
} else if (roll < 0.94) {
/* repeated delimiter (stress test) */
const count = 2 + Math.floor(random() * 4);
const delimiters = ['*', '_', '~'];
const character = delimiters[Math.floor(random() * delimiters.length)];
sequence.push({ name: character.repeat(count), keys: character.repeat(count) });
} else if (roll < 0.97) {
/* backslash sequences */
const escaped = ['\\*', '\\_', '\\`', '\\~', '\\\\', '\\'];
const fragment = escaped[Math.floor(random() * escaped.length)];
sequence.push({ name: fragment, keys: fragment });
} else {
/* angle bracket / HTML-like content */
const fragments = ['<', '>', '<div>', '</div>', '<b>', '&amp;'];
const fragment = fragments[Math.floor(random() * fragments.length)];
sequence.push({ name: fragment, keys: fragment });
}
}
return sequence;
}
/* ── Invariant checks ── */
/**
* Valid direct children of the editor element.
* Everything the WYSIWYG produces must be one of these.
*/
const VALID_BLOCK_TAGS = new Set([
'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'HR', 'TABLE',
]);
/**
* Valid inline elements that can appear inside block content.
*/
const VALID_INLINE_TAGS = new Set([
'STRONG', 'B', 'EM', 'I', 'CODE', 'A', 'BR',
]);
/**
* Elements that can only contain specific children.
*/
const REQUIRED_CHILDREN = {
'UL': ['LI'],
'OL': ['LI'],
'TABLE': ['THEAD', 'TBODY', 'TR', 'CAPTION', 'COLGROUP'],
'THEAD': ['TR'],
'TBODY': ['TR'],
'TR': ['TH', 'TD'],
};
/**
* Elements that must not contain certain descendants.
*/
const FORBIDDEN_NESTING = {
'LI': ['TABLE'],
'A': ['A'],
'STRONG': ['STRONG', 'B'],
'B': ['STRONG', 'B'],
'EM': ['EM', 'I'],
'I': ['EM', 'I'],
'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'],
};
/**
* Run all invariant checks on the current editor state.
* Returns null if all pass, or a string describing the violation.
*/
async function checkInvariants() {
return driver.executeScript(function () {
var editor = document.getElementById('ribbit');
if (!editor) { return 'Editor element not found'; }
if (editor.contentEditable !== 'true') { return 'contentEditable is not true'; }
/* Invariant 1: all direct children are valid block elements */
for (var i = 0; i < editor.childNodes.length; i++) {
var child = editor.childNodes[i];
if (child.nodeType === 3) {
if (child.textContent.replace(/[\u200B\s]/g, '').length > 0) {
return 'Bare text node in editor: "' + child.textContent.slice(0, 40) + '"';
}
continue;
}
if (child.nodeType !== 1) { continue; }
var validBlocks = ['P','H1','H2','H3','H4','H5','H6','UL','OL','BLOCKQUOTE','PRE','HR','TABLE'];
if (validBlocks.indexOf(child.nodeName) === -1) {
return 'Invalid block element: <' + child.nodeName.toLowerCase() + '>';
}
}
/* Invariant 2: no nested speculative elements */
var specs = editor.querySelectorAll('[data-speculative]');
for (var s = 0; s < specs.length; s++) {
if (specs[s].querySelector('[data-speculative]')) {
return 'Nested speculative elements';
}
}
/* Invariant 3: required children (UL must contain LI, etc.) */
var parentChildRules = {
'UL': ['LI'], 'OL': ['LI'],
'TABLE': ['THEAD','TBODY','TR','CAPTION','COLGROUP'],
'THEAD': ['TR'], 'TBODY': ['TR'], 'TR': ['TH','TD'],
};
function checkChildren(element) {
var allowed = parentChildRules[element.nodeName];
if (!allowed) { return null; }
for (var c = 0; c < element.children.length; c++) {
if (allowed.indexOf(element.children[c].nodeName) === -1) {
return '<' + element.children[c].nodeName.toLowerCase() +
'> inside <' + element.nodeName.toLowerCase() +
'> (allowed: ' + allowed.join(', ') + ')';
}
}
for (var c = 0; c < element.children.length; c++) {
var result = checkChildren(element.children[c]);
if (result) { return result; }
}
return null;
}
var childViolation = checkChildren(editor);
if (childViolation) { return 'Invalid nesting: ' + childViolation; }
/* Invariant 4: forbidden nesting (no <strong> inside <strong>, etc.) */
var forbiddenRules = {
'STRONG': ['STRONG','B'], 'B': ['STRONG','B'],
'EM': ['EM','I'], 'I': ['EM','I'],
'CODE': ['CODE','STRONG','B','EM','I','A','DEL'],
'DEL': ['DEL','S','STRIKE'], 'S': ['DEL','S','STRIKE'], 'STRIKE': ['DEL','S','STRIKE'],
'A': ['A'],
};
var allElements = editor.querySelectorAll('*');
for (var e = 0; e < allElements.length; e++) {
var el = allElements[e];
var forbidden = forbiddenRules[el.nodeName];
if (!forbidden) { continue; }
for (var f = 0; f < forbidden.length; f++) {
if (el.querySelector(forbidden[f].toLowerCase() + ',' + forbidden[f])) {
return 'Forbidden nesting: <' + forbidden[f].toLowerCase() +
'> inside <' + el.nodeName.toLowerCase() + '>';
}
}
}
/* Invariant 5: getMarkdown() must not throw */
try {
window.__ribbitEditor.getMarkdown();
} catch (err) {
return 'getMarkdown() threw: ' + err.message;
}
/* Invariant 6: rendered HTML is stable through markdown round-trip.
md → toHTML → toMarkdown → toHTML must eventually stabilize.
The first round-trip may change the HTML (e.g. literal <strong>
in text becomes a real element via HTML passthrough, then
serializes as **). But the second round-trip must be stable.
Skip if there are speculative elements (in-progress editing). */
var hasSpeculative = editor.querySelector('[data-speculative]');
if (!hasSpeculative) {
try {
var md = window.__ribbitEditor.getMarkdown();
var converter = window.__ribbitEditor.converter;
// Two round-trips: allow the first to normalize, check
// that the second produces identical HTML
var html1 = converter.toHTML(md);
var md2 = converter.toMarkdown(html1);
var html2 = converter.toHTML(md2);
var md3 = converter.toMarkdown(html2);
var html3 = converter.toHTML(md3);
var normalize = function(html) {
return html
.replace(/\s*id='[^']*'/g, '')
.replace(/\s+/g, ' ')
.trim();
};
if (normalize(html2) !== normalize(html3)) {
return 'Round-trip HTML not stable after 2 passes:\n pass2: "' + normalize(html2).slice(0, 80) +
'"\n pass3: "' + normalize(html3).slice(0, 80) + '"';
}
} catch (err) {
return 'Round-trip check threw: ' + err.message;
}
}
/* Invariant 7: only valid inline elements inside block content */
var validInline = ['STRONG','B','EM','I','CODE','A','BR','DEL','S','STRIKE'];
var blocks = editor.querySelectorAll('p,h1,h2,h3,h4,h5,h6,li,blockquote,td,th');
for (var b = 0; b < blocks.length; b++) {
var inlineEls = blocks[b].querySelectorAll('*');
for (var ie = 0; ie < inlineEls.length; ie++) {
var inEl = inlineEls[ie];
/* Skip nested block elements (blockquote can contain blocks) */
if (inEl.parentElement !== blocks[b] && inEl.closest('blockquote,ul,ol,table,pre') !== blocks[b]) {
continue;
}
if (validInline.indexOf(inEl.nodeName) === -1 &&
['P','H1','H2','H3','H4','H5','H6','UL','OL','BLOCKQUOTE','PRE','HR','TABLE','LI','THEAD','TBODY','TR','TH','TD','CAPTION','COLGROUP'].indexOf(inEl.nodeName) === -1) {
return 'Invalid inline element <' + inEl.nodeName.toLowerCase() +
'> inside <' + blocks[b].nodeName.toLowerCase() + '>';
}
}
}
return null;
});
}
/* ── Test runner ── */
async function setup() {
server = createServer(9996);
await server.start();
const options = new firefox.Options().addArguments('--headless');
driver = await new Builder().forBrowser('firefox').setFirefoxOptions(options).build();
await driver.get(server.url);
await driver.wait(async () => driver.executeScript('return window.__ribbitReady === true'), 10000);
}
async function teardown() {
if (driver) { await driver.quit(); }
if (server) { await server.stop(); }
}
async function resetEditor() {
await driver.executeScript(`
var e = window.__ribbitEditor;
e.wysiwyg();
e.element.innerHTML = '<p><br></p>';
`);
await driver.findElement(By.id('ribbit')).click();
await driver.sleep(50);
}
async function typeKeystroke(keystroke) {
const keys = keystroke.keys;
if (typeof keys !== 'string') {
throw new Error('Invalid keystroke: ' + JSON.stringify(keystroke));
}
if (keys.length === 1 || keystroke.isSpecial) {
await driver.actions().sendKeys(keys).perform();
await driver.sleep(DELAY);
} else {
/* Multi-char string: type char by char */
for (const character of keys) {
await driver.actions().sendKeys(character).perform();
await driver.sleep(DELAY);
}
}
}
function formatSequence(sequence, upTo) {
return sequence.slice(0, upTo + 1).map(s => s.name).join(' ');
}
/**
* Replay a sequence and return the index of the first invariant failure,
* or -1 if no failure.
*/
async function replaySequence(sequence) {
await resetEditor();
for (let i = 0; i < sequence.length; i++) {
await typeKeystroke(sequence[i]);
const violation = await checkInvariants();
if (violation) { return { index: i, violation }; }
}
return null;
}
/**
* Shrink a failing sequence to find the minimal reproducing prefix.
* Uses binary search on the sequence length.
*/
async function shrinkSequence(sequence, failIndex) {
let lo = 0;
let hi = failIndex;
let bestSequence = sequence.slice(0, failIndex + 1);
let bestViolation = '';
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
const candidate = sequence.slice(0, mid + 1);
const result = await replaySequence(candidate);
if (result) {
hi = mid;
bestSequence = candidate;
bestViolation = result.violation;
} else {
lo = mid + 1;
}
}
/* Try removing individual keystrokes from the beginning */
let shrunk = true;
while (shrunk) {
shrunk = false;
for (let i = 0; i < bestSequence.length - 1; i++) {
const candidate = [...bestSequence.slice(0, i), ...bestSequence.slice(i + 1)];
const result = await replaySequence(candidate);
if (result) {
bestSequence = candidate;
bestViolation = result.violation;
shrunk = true;
break;
}
}
}
return { sequence: bestSequence, violation: bestViolation };
}
async function runFuzz(options) {
const { rounds, minLength, maxLength, seed: baseSeed, doShrink } = options;
let totalKeystrokes = 0;
let failures = 0;
console.log(`\nWYSIWYG Fuzz Test — ${rounds} rounds, seed ${baseSeed}\n`);
for (let round = 0; round < rounds; round++) {
const roundSeed = baseSeed + round;
const random = mulberry32(roundSeed);
const length = minLength + Math.floor(random() * (maxLength - minLength));
const sequence = generateSequence(random, length);
await resetEditor();
let failed = false;
for (let i = 0; i < sequence.length; i++) {
await typeKeystroke(sequence[i]);
const violation = await checkInvariants();
if (violation) {
failures++;
failed = true;
const html = await driver.executeScript('return document.getElementById("ribbit").innerHTML');
console.log(` ✗ Round ${round + 1} [seed=${roundSeed}] — keystroke ${i + 1}/${length}`);
console.log(` Invariant: ${violation}`);
console.log(` Sequence: ${formatSequence(sequence, i)}`);
console.log(` HTML: ${html.slice(0, 200)}`);
if (doShrink) {
console.log(` Shrinking...`);
const shrunk = await shrinkSequence(sequence, i);
console.log(` Minimal (${shrunk.sequence.length} keystrokes): ${shrunk.sequence.map(s => s.name).join(' ')}`);
console.log(` Violation: ${shrunk.violation}`);
}
console.log(` Replay: node test/integration/test_fuzz.js --seed ${roundSeed}\n`);
break;
}
}
if (!failed) {
totalKeystrokes += length;
if ((round + 1) % 10 === 0 || round === rounds - 1) {
process.stdout.write(`${round + 1}/${rounds} rounds (${totalKeystrokes} keystrokes)\r`);
}
}
}
console.log(`\n\n${rounds - failures}/${rounds} rounds passed — ${totalKeystrokes} keystrokes checked`);
if (failures > 0) {
console.log(`${failures} failure(s) found`);
}
return failures;
}
/* ── CLI ── */
function parseArgs() {
const args = process.argv.slice(2);
const options = {
rounds: 50,
minLength: 20,
maxLength: 80,
seed: Date.now() % 100000,
doShrink: true,
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--seed' && args[i + 1]) { options.seed = parseInt(args[i + 1]); i++; }
if (args[i] === '--rounds' && args[i + 1]) { options.rounds = parseInt(args[i + 1]); i++; }
if (args[i] === '--min' && args[i + 1]) { options.minLength = parseInt(args[i + 1]); i++; }
if (args[i] === '--max' && args[i + 1]) { options.maxLength = parseInt(args[i + 1]); i++; }
if (args[i] === '--no-shrink') { options.doShrink = false; }
if (args[i] === '--shrink') { options.doShrink = true; }
}
return options;
}
(async () => {
const options = parseArgs();
try {
await setup();
const failures = await runFuzz(options);
process.exitCode = failures > 0 ? 1 : 0;
} catch (error) {
console.error('Setup failed:', error.message);
process.exitCode = 1;
} finally {
await teardown();
}
})();
+684
View File
@@ -0,0 +1,684 @@
/**
* 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}`);
});
await test('enter inside blockquote adds new line', async () => {
await resetEditor();
await typeString('> first line');
let html = await getHTML();
assert(html.includes('<blockquote'), `No blockquote: ${html}`);
await typeChar(Key.ENTER);
await typeString('second line');
html = await getHTML();
// Both lines must be inside the same blockquote
const blockquoteCount = (html.match(/<blockquote/g) || []).length;
assert(blockquoteCount === 1, `Expected 1 blockquote, got ${blockquoteCount}: ${html}`);
assert(html.includes('first line'), `Missing first line: ${html}`);
assert(html.includes('second line'), `Missing second line: ${html}`);
// The two lines must be separate — not merged into one string
assert(!html.includes('first linesecond'), `Lines merged without break: ${html}`);
// Markdown should be "> foo\n> bar" — continuation, no blank lines
const markdown = await getMarkdown();
assert(markdown.includes('> first line'), `Missing > first line in markdown: ${markdown}`);
assert(markdown.includes('> second line'), `Missing > second line in markdown: ${markdown}`);
assert(!markdown.includes('>\n>'), `Unwanted blank > line in markdown: ${markdown}`);
});
await test('blockquote paragraphs survive mode round-trip', async () => {
await resetEditor();
await typeString('> foo');
await typeChar(Key.ENTER);
await typeString('bar');
await driver.sleep(50);
// Switch to source and back to wysiwyg twice
await driver.executeScript('window.__ribbitEditor.edit()');
await driver.sleep(50);
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await driver.sleep(50);
await driver.executeScript('window.__ribbitEditor.edit()');
await driver.sleep(50);
const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent');
assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`);
assert(markdown.includes('> bar'), `Missing > bar — second line lost its prefix: ${markdown}`);
});
await test('double enter exits blockquote', async () => {
await resetEditor();
await typeString('> quoted');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('after');
const html = await getHTML();
assert(html.includes('<blockquote'), `No blockquote: ${html}`);
assert(html.includes('quoted'), `Missing quoted text: ${html}`);
assert(html.includes('after'), `Missing text after blockquote: ${html}`);
// "after" should NOT be inside the blockquote
const afterBlockquote = html.indexOf('</blockquote');
const afterText = html.indexOf('after');
assert(afterText > afterBlockquote, `"after" is inside blockquote: ${html}`);
});
// ── Horizontal rule ──
console.log(' Horizontal rule:');
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}`);
});
console.log(' Enter behavior:');
await test('block pattern after Enter splits and transforms', async () => {
await resetEditor();
await typeString('foo');
await typeChar(Key.ENTER);
await typeString('> bar');
await driver.sleep(50);
const html = await getHTML();
assert(html.includes('<blockquote'), `> after Enter did not create blockquote: ${html}`);
assert(html.includes('foo'), `Lost content before split: ${html}`);
assert(html.includes('bar'), `Lost content after split: ${html}`);
});
await test('single Enter in paragraph inserts line break', async () => {
await resetEditor();
await typeString('line one');
await typeChar(Key.ENTER);
await typeString('line two');
const markdown = await getMarkdown();
// Single Enter = one \n, not \n\n
assert(markdown.includes('line one'), `Missing line one: ${markdown}`);
assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`);
});
await test('double Enter in paragraph creates new block', async () => {
await resetEditor();
await typeString('first paragraph');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('second paragraph');
const html = await getHTML();
// Double Enter = new <p>, so two separate paragraphs
const paragraphCount = (html.match(/<p[\s>]/g) || []).length;
assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`);
});
await test('backspace at start of line after Enter joins lines', async () => {
await resetEditor();
await typeString('foo');
await typeChar(Key.ENTER);
await typeChar(Key.BACK_SPACE);
await driver.sleep(50);
const html = await getHTML();
// The <br> should be removed, cursor at end of "foo"
assert(!html.includes('<br'), `<br> not removed: ${html}`);
assert(html.includes('foo'), `Content lost: ${html}`);
});
await test('single Enter in list item inserts line break', async () => {
await resetEditor();
await typeString('- line one');
await typeChar(Key.ENTER);
await typeString('line two');
const markdown = await getMarkdown();
// Both lines in the same list item
assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`);
assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
// Should NOT create a second list item
const markerCount = (markdown.match(/^- /gm) || []).length;
assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`);
});
await test('double Enter in list item creates new item', async () => {
await resetEditor();
await typeString('- first');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('second');
const markdown = await getMarkdown();
const markerCount = (markdown.match(/^- /gm) || []).length;
assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`);
assert(markdown.includes('- first'), `Missing first item: ${markdown}`);
assert(markdown.includes('- second'), `Missing second item: ${markdown}`);
});
await test('double Enter on empty list item exits list', async () => {
await resetEditor();
await typeString('- item');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('after list');
const html = await getHTML();
assert(html.includes('after list'), `Missing text after list: ${html}`);
// "after list" should NOT be inside the <ul>
const ulEnd = html.indexOf('</ul>');
const afterPos = html.indexOf('after list');
assert(afterPos > ulEnd, `"after list" is inside the list: ${html}`);
});
// ── Complex document ──
console.log(' Complex document:');
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 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(' Underscore emphasis:');
await test('_text_ transforms to italic (shows * delimiters)', async () => {
await resetEditor();
await typeString('_hello_');
const html = await getHTML();
assert(html.includes('<em'), `No <em> after _hello_: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
});
await test('_text shows speculative italic', async () => {
await resetEditor();
await typeString('_hel');
const html = await getHTML();
assert(html.includes('<em'), `No <em> after _hel: ${html}`);
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
});
await test('__text__ transforms to bold', async () => {
await resetEditor();
await typeString('__hello__');
const html = await getHTML();
assert(html.includes('<strong'), `No <strong> after __hello__: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
});
console.log(' Backslash escapes:');
await test('backslash is just a character in WYSIWYG', async () => {
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);
}
})();
+100
View File
@@ -0,0 +1,100 @@
import { ribbit } from './setup';
const lib = ribbit();
const spacePattern = / /g;
const macros = [
{
name: 'user',
toHTML: () => '<a href="/user">TestUser</a>',
},
{
name: 'npc',
toHTML: ({ keywords }: any) => {
const name = keywords.join(' ');
return '<a href="/NPC/' + name.replace(spacePattern, '') + '">' + name + '</a>';
},
},
{
name: 'style',
toHTML: ({ keywords, content }: any) => '<div class="' + keywords.join(' ') + '">' + (content || '') + '</div>',
},
{
name: 'toc',
toHTML: ({ params }: any) => '<aside class="toc" data-depth="' + (params.depth || '3') + '"></aside>',
},
];
const converter = new lib.HopDown({ macros });
const H = (md: string) => converter.toHTML(md);
const M = (html: string) => converter.toMarkdown(html);
describe('Macros', () => {
describe('self-closing', () => {
it('bare name renders', () => expect(H('hello @user world')).toContain('<a href="/user">TestUser</a>'));
it('bare name wrapped', () => expect(H('hello @user world')).toContain('data-macro="user"'));
it('empty parens', () => expect(H('hello @user() world')).toContain('data-macro="user"'));
it('keywords', () => expect(H('@npc(Goblin King)')).toContain('Goblin King'));
it('keywords in data attr', () => expect(H('@npc(Goblin King)')).toContain('data-keywords="Goblin King"'));
it('params', () => expect(H('@toc(depth="2")')).toContain('data-param-depth="2"'));
});
describe('unknown macros', () => {
it('renders error', () => expect(H('@bogus')).toContain('ribbit-error'));
it('shows name', () => expect(H('@bogus')).toContain('@bogus'));
it('block error', () => expect(H('@bogus(args\ncontent\n)')).toContain('ribbit-error'));
});
it('email not matched', () => expect(H('user@example.com')).toBe('<p>user@example.com</p>'));
describe('block macros', () => {
it('content processed', () => expect(H('@style(box\n**bold**\n)')).toContain('<strong>bold</strong>'));
it('wrapped with data-macro', () => expect(H('@style(box\ncontent\n)')).toContain('data-macro="style"'));
it('keywords in data attr', () => expect(H('@style(box center\ncontent\n)')).toContain('data-keywords="box center"'));
});
describe('verbatim', () => {
it('skips markdown', () => expect(H('@style(box verbatim\n**bold**\n)')).toContain('**bold**'));
it('no strong tag', () => expect(H('@style(box verbatim\n**bold**\n)')).not.toContain('<strong>'));
it('escapes html', () => expect(H('@style(box verbatim\n<b>tag</b>\n)')).toContain('&lt;b&gt;'));
it('preserves newlines', () => expect(H('@style(box verbatim\nline1\nline2\n)')).toContain('line1<br>'));
it('data-verbatim set', () => expect(H('@style(box verbatim\ncontent\n)')).toContain('data-verbatim="true"'));
it('keyword stripped from data-keywords', () => {
const html = H('@style(box verbatim\ncontent\n)');
expect(html).toContain('data-keywords="box"');
const verbatimKeywordPattern = /data-keywords="[^"]*verbatim/;
expect(html).not.toMatch(verbatimKeywordPattern);
});
});
describe('nesting', () => {
it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('<strong>'));
it('block contains list', () => expect(H('@style(box\n- item 1\n- item 2\n)')).toContain('<ul>'));
it('inline inside block', () => expect(H('@style(box\nhello @user world\n)')).toContain('data-macro="user"'));
});
describe('fenced code protection', () => {
it('not in code block', () => expect(H('```\n@user\n```')).not.toContain('data-macro'));
it('literal in code block', () => expect(H('```\n@user\n```')).toContain('@user'));
it('not in inline code', () => expect(H('`@user`')).not.toContain('data-macro'));
});
describe('generic round-trip via data- attributes', () => {
it('inline macro', () => expect(M(H('hello @user world'))).toBe('hello @user world'));
it('inline with keywords', () => expect(M(H('@npc(Goblin King)'))).toBe('@npc(Goblin King)'));
it('inline with params', () => expect(M(H('@toc(depth="2")'))).toBe('@toc(depth="2")'));
it('block macro', () => {
const md = '@style(box\n**bold** content\n)';
const result = M(H(md)).trim();
expect(result).toContain('@style(box');
expect(result).toContain('**bold** content');
expect(result).toContain(')');
});
it('verbatim round-trip preserves keyword', () => {
const md = '@style(box verbatim\n<b>literal</b>\n)';
const result = M(H(md)).trim();
expect(result).toContain('@style(box verbatim');
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { Window } from 'happy-dom';
import * as fs from 'fs';
import * as path from 'path';
let _window: any;
export function getWindow(): any {
if (!_window) {
_window = new Window({ url: 'http://localhost' });
(global as any).window = _window;
(global as any).document = _window.document;
(global as any).HTMLElement = _window.HTMLElement;
(global as any).Node = _window.Node;
(global as any).NodeFilter = _window.NodeFilter;
(global as any).TextEncoder = _window.TextEncoder || require('util').TextEncoder;
(global as any).TextDecoder = _window.TextDecoder || require('util').TextDecoder;
const { TextEncoder, TextDecoder } = require('util');
_window.TextEncoder = TextEncoder;
_window.TextDecoder = TextDecoder;
const bundle = fs.readFileSync(
path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8'
);
_window.eval(bundle.replace('var ribbit =', 'window.ribbit ='));
}
return _window;
}
export function ribbit(): any {
const browserWindow = getWindow();
const lib = browserWindow.ribbit;
lib.window = browserWindow;
return lib;
}
export function resetDOM(content = 'test'): void {
getWindow().document.body.innerHTML = `<article id="ribbit">${content}</article>`;
}
-416
View File
@@ -1,416 +0,0 @@
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
// Set up a DOM environment and load the bundle
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'http://localhost',
pretendToBeVisual: true,
});
global.window = dom.window;
global.document = dom.window.document;
global.HTMLElement = dom.window.HTMLElement;
global.Node = dom.window.Node;
// Load the compiled bundle (attaches globals to window)
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit.js'), 'utf8');
dom.window.eval(bundle);
const hopdown = dom.window.hopdown;
const H = hopdown.toHTML.bind(hopdown);
const M = hopdown.toMarkdown.bind(hopdown);
function rt(md) { return M(H(md)); }
// Test harness
let passed = 0, failed = 0, errors = [];
function norm(s) { return (s || '').replace(/\r\n/g, '\n').trim(); }
function eq(name, actual, expected) {
const a = norm(actual), e = norm(expected);
if (a === e) {
passed++;
} else {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` expected: ${e}`);
console.log(` actual: ${a}`);
}
}
function has(name, actual, sub) {
if (norm(actual).indexOf(norm(sub)) !== -1) {
passed++;
} else {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` expected to contain: ${sub}`);
console.log(` actual: ${actual}`);
}
}
function not(name, actual, sub) {
if (norm(actual).indexOf(norm(sub)) === -1) {
passed++;
} else {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` should NOT contain: ${sub}`);
console.log(` actual: ${actual}`);
}
}
function section(n) { /* silent */ }
// ── 1. Inline formatting ────────────────────────────────
section('1. Inline Formatting → HTML');
eq('bold', H('**bold**'), '<p><strong>bold</strong></p>');
eq('italic', H('*italic*'), '<p><em>italic</em></p>');
eq('inline code', H('`code`'), '<p><code>code</code></p>');
eq('link', H('[t](http://x)'), '<p><a href="http://x">t</a></p>');
eq('bold+italic', H('***bi***'), '<p><em><strong>bi</strong></em></p>');
eq('mixed inline', H('a **b** *c* `d`'), '<p>a <strong>b</strong> <em>c</em> <code>d</code></p>');
eq('code before bold', H('`a` **b**'), '<p><code>a</code> <strong>b</strong></p>');
// ── 2. Headings ─────────────────────────────────────────
eq('h1', H('# Title'), "<h1 id='Title'>Title</h1>");
eq('h2', H('## Sub'), "<h2 id='Sub'>Sub</h2>");
eq('h3', H('### Sub3'), "<h3 id='Sub3'>Sub3</h3>");
eq('h4', H('#### Sub4'), "<h4 id='Sub4'>Sub4</h4>");
eq('h5', H('##### Sub5'), "<h5 id='Sub5'>Sub5</h5>");
eq('h6', H('###### Sub6'), "<h6 id='Sub6'>Sub6</h6>");
has('heading id multi-word', H('## Hello World'), "id='HelloWorld'");
has('heading inline md', H('## **Bold** text'), '<strong>Bold</strong>');
// ── 3. Horizontal rules ─────────────────────────────────
eq('*** rule', H('***'), '<hr>');
eq('--- rule', H('---'), '<hr>');
eq('___ rule', H('___'), '<hr>');
// ── 4. Lists ────────────────────────────────────────────
eq('ul *', H('* a\n* b'), '<ul><li>a</li><li>b</li></ul>');
eq('ul -', H('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
eq('ol', H('1. a\n2. b'),'<ol><li>a</li><li>b</li></ol>');
has('ul inline', H('* **bold** item'), '<strong>bold</strong>');
has('ol inline', H('1. *em* item'), '<em>em</em>');
// ── 5. Blockquotes ──────────────────────────────────────
has('blockquote', H('> text'), '<blockquote>');
has('bq content', H('> hello'), 'hello');
has('multi-line bq', H('> a\n> b'), 'a');
// ── 6. Fenced code blocks ───────────────────────────────
has('code block', H('```\nx = 1\n```'), '<pre><code>');
has('code content', H('```\nx = 1\n```'), 'x = 1');
has('lang class', H('```js\nvar x;\n```'), 'language-js');
has('html escaped', H('```\n<div>\n```'), '&lt;div&gt;');
not('no lang attr when none', H('```\nplain\n```'), 'language-');
// ── 7. Tables ───────────────────────────────────────────
var tbl = '| a | b |\n|---|---|\n| 1 | 2 |';
has('table tag', H(tbl), '<table>');
has('thead', H(tbl), '<thead>');
has('tbody', H(tbl), '<tbody>');
has('th cells', H(tbl), '<th>a</th>');
has('td cells', H(tbl), '<td>1</td>');
var aligned = '| L | C | R |\n|:--|:--:|--:|\n| a | b | c |';
has('left align (default)', H(aligned), '<td>a</td>');
has('center align', H(aligned), 'text-align:center');
has('right align', H(aligned), 'text-align:right');
has('table inline md', H('| **b** | *i* |\n|---|---|\n| x | y |'), '<strong>b</strong>');
// ── 8. Paragraphs ───────────────────────────────────────
eq('single para', H('hello'), '<p>hello</p>');
eq('two paras', H('a\n\nb'), '<p>a</p>\n<p>b</p>');
eq('soft line break', H('a\nb'), '<p>a\nb</p>');
// ── 9. HTML → Markdown ──────────────────────────────────
eq('strong→**', M('<p><strong>b</strong></p>'), '**b**');
eq('em→*', M('<p><em>i</em></p>'), '*i*');
eq('code→`', M('<p><code>c</code></p>'), '`c`');
eq('a→[]', M('<a href="http://x">t</a>'), '[t](http://x)');
eq('p→text', M('<p>hello</p>'), 'hello');
eq('h1→#', M('<h1>T</h1>'), '# T');
eq('h2→##', M('<h2>T</h2>'), '## T');
eq('h3→###', M('<h3>T</h3>'), '### T');
eq('hr→---', M('<hr>'), '---');
eq('ul→-', M('<ul><li>a</li><li>b</li></ul>'), '- a\n- b');
eq('ol→1.', M('<ol><li>a</li><li>b</li></ol>'), '1. a\n2. b');
has('bq→>', M('<blockquote><p>q</p></blockquote>'), '> ');
has('pre→```', M('<pre><code>x</code></pre>'), '```');
has('pre content', M('<pre><code>x = 1</code></pre>'), 'x = 1');
has('pre lang', M('<pre><code class="language-py">x</code></pre>'), '```py');
var tableHtml = '<table><thead><tr><th>a</th><th>b</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>';
has('table→pipes', M(tableHtml), '| a | b |');
has('table separator', M(tableHtml), '| --- | --- |');
has('table body', M(tableHtml), '| 1 | 2 |');
// ── 10. Round-trip ──────────────────────────────────────
eq('para rt', rt('Hello world'), 'Hello world');
eq('bold rt', rt('**bold**'), '**bold**');
eq('italic rt', rt('*italic*'), '*italic*');
eq('code rt', rt('`code`'), '`code`');
eq('link rt', rt('[t](http://x)'), '[t](http://x)');
eq('h1 rt', rt('# Title'), '# Title');
eq('h2 rt', rt('## Sub'), '## Sub');
eq('hr rt', rt('---'), '---');
eq('ul rt', rt('- a\n- b'), '- a\n- b');
eq('ol rt', rt('1. a\n2. b'), '1. a\n2. b');
has('bq rt', rt('> quoted'), '> ');
has('code block rt', rt('```\nx = 1\n```'), '```');
has('code block rt content', rt('```\nx = 1\n```'), 'x = 1');
has('table rt', rt('| a | b |\n|---|---|\n| 1 | 2 |'), '| a | b |');
// ── 11. Edge cases ──────────────────────────────────────
eq('empty string', H(''), '');
eq('whitespace only', H(' '), '');
has('html entities', H('a & b < c'), '&amp;');
has('html in code', H('`<div>`'), '&lt;div&gt;');
eq('empty html→md', M(''), '');
has('para then heading', H('text\n\n## H'), '<h2');
has('list then para', H('- a\n\ntext'), '<p>text</p>');
has('table no leading pipe', H('a | b\n---|---\n1 | 2'), '<table>');
// ── 12. Complex document ────────────────────────────────
var doc = '# Title\n\nSome **bold** and *italic* text with `code`.\n\n## Section One\n\n- item 1\n- item 2\n\n## Section Two\n\n| Col A | Col B |\n|-------|-------|\n| 1 | 2 |\n\n> A blockquote\n\n```js\nvar x = 1;\n```\n\n[A link](http://example.com)\n\n---';
var html = H(doc);
has('doc: h1', html, "<h1 id='Title'>Title</h1>");
has('doc: bold', html, '<strong>bold</strong>');
has('doc: italic', html, '<em>italic</em>');
has('doc: code', html, '<code>code</code>');
has('doc: h2', html, '<h2');
has('doc: ul', html, '<ul>');
has('doc: table', html, '<table>');
has('doc: blockquote', html, '<blockquote>');
has('doc: pre', html, '<pre>');
has('doc: link', html, '<a href="http://example.com">');
has('doc: hr', html, '<hr>');
var md = M(html);
has('doc rt: heading', md, '# Title');
has('doc rt: bold', md, '**bold**');
has('doc rt: italic', md, '*italic*');
has('doc rt: code', md, '`code`');
has('doc rt: list', md, '- item 1');
has('doc rt: table', md, '| Col A | Col B |');
has('doc rt: bq', md, '> ');
has('doc rt: fenced', md, '```');
has('doc rt: link', md, '[A link](http://example.com)');
has('doc rt: hr', md, '---');
// ── 13. Nested Inline ───────────────────────────────────
eq('bold wraps italic', H('**a *b* c**'), '<p><strong>a <em>b</em> c</strong></p>');
eq('italic wraps bold', H('*a **b** c*'), '<p><em>a <strong>b</strong> c</em></p>');
eq('bold wraps code', H('**a `b` c**'), '<p><strong>a <code>b</code> c</strong></p>');
eq('italic wraps code', H('*a `b` c*'), '<p><em>a <code>b</code> c</em></p>');
eq('bold wraps link', H('**[t](u)**'), '<p><strong><a href="u">t</a></strong></p>');
eq('italic wraps link', H('*[t](u)*'), '<p><em><a href="u">t</a></em></p>');
eq('link with bold text', H('[**t**](u)'), '<p><a href="u"><strong>t</strong></a></p>');
eq('link with italic text', H('[*t*](u)'), '<p><a href="u"><em>t</em></a></p>');
eq('link with code text', H('[`t`](u)'), '<p><a href="u"><code>t</code></a></p>');
eq('bold>italic>code', H('***`x`***'), '<p><em><strong><code>x</code></strong></em></p>');
eq('bold wraps bold-italic', H('**a ***b*** c**'), '<p><strong>a <em><strong>b</strong></em> c</strong></p>');
// ── 14. Nested Blocks ───────────────────────────────────
has('bq > heading', H('> # Title'), '<h1');
has('bq > heading content', H('> # Title'), 'Title');
has('bq > list', H('> - a\n> - b'), '<ul>');
has('bq > list items', H('> - a\n> - b'), '<li>a</li>');
has('bq > inline md', H('> **bold**'), '<strong>bold</strong>');
has('bq > code', H('> `code`'), '<code>code</code>');
has('bq > link', H('> [t](u)'), '<a href="u">');
has('bq > bq', H('> > nested'), '<blockquote>');
has('bq > fenced code', H('> ```\n> x\n> ```'), '<code>');
has('li > bold', H('- **bold**'), '<strong>bold</strong>');
has('li > italic', H('- *italic*'), '<em>italic</em>');
has('li > code', H('- `code`'), '<code>code</code>');
has('li > link', H('- [t](u)'), '<a href="u">');
has('heading > link', H('## [t](u)'), '<a href="u">');
has('heading > code', H('## `code`'), '<code>code</code>');
has('table > bold', H('| **b** |\n|---|\n| x |'), '<strong>b</strong>');
has('table > italic', H('| *i* |\n|---|\n| x |'), '<em>i</em>');
has('table > code', H('| `c` |\n|---|\n| x |'), '<code>c</code>');
has('table > link', H('| [t](u) |\n|---|\n| x |'), '<a href="u">');
// ── 15. Nested Round-Trips ──────────────────────────────
eq('bold>italic rt', rt('**a *b* c**'), '**a *b* c**');
eq('italic>bold rt', rt('*a **b** c*'), '*a **b** c*');
eq('bold>code rt', rt('**a `b` c**'), '**a `b` c**');
eq('bold>link rt', rt('**[t](u)**'), '**[t](u)**');
eq('link>bold rt', rt('[**t**](u)'), '[**t**](u)');
has('bq>heading rt', rt('> # Title'), '> ');
has('bq>heading rt title', rt('> # Title'), '# Title');
has('bq>list rt', rt('> - a\n> - b'), '> ');
has('li>bold rt', rt('- **bold**'), '**bold**');
has('heading>code rt', rt('## `code`'), '`code`');
// ── 16. Nested Lists ────────────────────────────────────
eq('ul > ul', H('- a\n - b\n - c\n- d'), '<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>');
eq('ol > ol', H('1. a\n 1. b\n 1. c\n2. d'), '<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>');
eq('ul > ol', H('- a\n 1. b\n 2. c\n- d'), '<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>');
eq('ol > ul', H('1. a\n - b\n - c\n2. d'), '<ol><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ol>');
eq('3-level nesting', H('- a\n - b\n - c\n- d'), '<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>');
has('nested li > bold', H('- a\n - **bold**'), '<strong>bold</strong>');
has('nested li > link', H('- a\n - [t](u)'), '<a href="u">');
eq('ul>ul → md', M('<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>'), '- a\n - b\n - c\n- d');
eq('ol>ol → md', M('<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>'), '1. a\n 1. b\n 2. c\n2. d');
eq('ul>ol → md', M('<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>'), '- a\n 1. b\n 2. c\n- d');
eq('3-level → md', M('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>'), '- a\n - b\n - c\n- d');
eq('ul>ul rt', rt('- a\n - b\n - c\n- d'), '- a\n - b\n - c\n- d');
eq('ol>ol rt', rt('1. a\n 1. b\n 1. c\n2. d'), '1. a\n 1. b\n 2. c\n2. d');
eq('ul>ol rt', rt('- a\n 1. b\n 2. c\n- d'), '- a\n 1. b\n 2. c\n- d');
eq('3-level rt', rt('- a\n - b\n - c\n- d'), '- a\n - b\n - c\n- d');
// ── 17. Tables with nested markdown ─────────────────────
has('td bold', H('| h |\n|---|\n| **b** |'), '<td><strong>b</strong></td>');
has('td italic', H('| h |\n|---|\n| *i* |'), '<td><em>i</em></td>');
has('td code', H('| h |\n|---|\n| `c` |'), '<td><code>c</code></td>');
has('td link', H('| h |\n|---|\n| [t](u) |'), '<td><a href="u">t</a></td>');
has('td bold+italic', H('| h |\n|---|\n| ***bi*** |'), '<td><em><strong>bi</strong></em></td>');
has('td bold>italic', H('| h |\n|---|\n| **a *b* c** |'), '<strong>a <em>b</em> c</strong>');
has('td link>bold', H('| h |\n|---|\n| [**t**](u) |'), '<a href="u"><strong>t</strong></a>');
has('td link>code', H('| h |\n|---|\n| [`c`](u) |'), '<a href="u"><code>c</code></a>');
has('multi-cell bold+italic', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '<strong>a</strong>');
has('multi-cell code+link', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '<a href="e">d</a>');
eq('td bold → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><strong>b</strong></td></tr></tbody></table>'), '| h |\n| --- |\n| **b** |');
eq('td italic → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><em>i</em></td></tr></tbody></table>'), '| h |\n| --- |\n| *i* |');
eq('td code → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><code>c</code></td></tr></tbody></table>'), '| h |\n| --- |\n| `c` |');
eq('td link → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><a href="u">t</a></td></tr></tbody></table>'), '| h |\n| --- |\n| [t](u) |');
eq('td bold rt', rt('| h |\n|---|\n| **b** |'), '| h |\n| --- |\n| **b** |');
eq('td italic rt', rt('| h |\n|---|\n| *i* |'), '| h |\n| --- |\n| *i* |');
eq('td code rt', rt('| h |\n|---|\n| `c` |'), '| h |\n| --- |\n| `c` |');
eq('td link rt', rt('| h |\n|---|\n| [t](u) |'), '| h |\n| --- |\n| [t](u) |');
eq('td bold+italic rt', rt('| h |\n|---|\n| ***bi*** |'), '| h |\n| --- |\n| ***bi*** |');
eq('td link>bold rt', rt('| h |\n|---|\n| [**t**](u) |'), '| h |\n| --- |\n| [**t**](u) |');
eq('multi-cell rt', rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |');
// ── 18. inlineTag() factory ─────────────────────────────
const strikethrough = dom.window.inlineTag({
name: 'strikethrough',
delimiter: '~~',
htmlTag: 'del',
aliases: 'S,STRIKE',
precedence: 45,
});
const customInline = new dom.window.HopDown({
tags: { ...dom.window.defaultTags, 'DEL,S,STRIKE': strikethrough },
});
eq('factory: md→html', customInline.toHTML('~~struck~~'), '<p><del>struck</del></p>');
has('factory: html→md', customInline.toMarkdown('<p><del>struck</del></p>'), '~~struck~~');
eq('factory: round-trip', customInline.toMarkdown(customInline.toHTML('~~struck~~')), '~~struck~~');
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<del>struck</del>');
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<strong>bold</strong>');
eq('factory: non-recursive', dom.window.inlineTag({
name: 'test',
delimiter: '%%',
htmlTag: 'mark',
recursive: false,
}).toHTML({ content: '<b>x</b>', raw: '', consumed: 0 }, { inline: s => s, block: s => s, children: n => '', node: n => '' }),
'<mark>&lt;b&gt;x&lt;/b&gt;</mark>');
// ── 19. Custom block tag ────────────────────────────────
const spoiler = {
name: 'spoiler',
match: (context) => {
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
const content = [];
let i = context.index + 1;
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i])) content.push(context.lines[i++]);
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
},
toHTML: (token, convert) => '<details><summary>Spoiler</summary>' + convert.block(token.content) + '</details>',
selector: 'DETAILS',
toMarkdown: (element, convert) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
};
const customBlock = new dom.window.HopDown({
tags: { 'DETAILS': spoiler, ...dom.window.defaultTags },
});
has('custom block: md→html', customBlock.toHTML('|||\nhidden\n|||'), '<details>');
has('custom block: content', customBlock.toHTML('|||\nhidden\n|||'), 'hidden');
has('custom block: html→md', customBlock.toMarkdown('<details><summary>Spoiler</summary><p>hidden</p></details>'), '|||');
has('custom block: nested md', customBlock.toHTML('|||\n**bold** inside\n|||'), '<strong>bold</strong>');
// ── 20. HopDown({ exclude }) ────────────────────────────
const noTables = new dom.window.HopDown({ exclude: ['table'] });
// With table excluded, pipe lines fall through to paragraph but isBlockStart
// still detects table-like patterns, so lines are split across paragraphs.
has('exclude: table not rendered', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<p>');
not('exclude: no table tag', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<table>');
has('exclude: bold still works', noTables.toHTML('**bold**'), '<strong>bold</strong>');
const noCode = new dom.window.HopDown({ exclude: ['code'] });
eq('exclude: code not processed', noCode.toHTML('`code`'), '<p>`code`</p>');
has('exclude: bold still works', noCode.toHTML('**bold**'), '<strong>bold</strong>');
// ── 21. Collision detection: delimiter ───────────────────
let threw = false;
try {
const bad = dom.window.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'SPAN': bad } });
} catch (e) {
threw = true;
}
eq('delimiter collision throws', String(threw), 'true');
threw = false;
try {
// Same delimiter, higher precedence than existing — should throw
const bad = dom.window.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 });
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'SPAN': bad } });
} catch (e) {
threw = true;
}
eq('duplicate delimiter collision throws', String(threw), 'true');
// ── 22. Collision detection: selector ───────────────────
threw = false;
try {
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'STRONG': dup } });
} catch (e) {
threw = true;
}
eq('selector collision throws', String(threw), 'true');
// ── 23. Precedence ordering ─────────────────────────────
// Longer delimiter with lower precedence should win
const tilde = dom.window.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 });
const doubleTilde = dom.window.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 });
const precTest = new dom.window.HopDown({
tags: { ...dom.window.defaultTags, 'S': tilde, 'DEL': doubleTilde },
});
has('precedence: ~~ matches before ~', precTest.toHTML('~~struck~~'), '<del>struck</del>');
has('precedence: ~ still works', precTest.toHTML('~light~'), '<s>light</s>');
// Valid: longer delimiter has lower precedence
threw = false;
try {
const short = dom.window.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': short, 'DEL': long } });
} catch (e) {
threw = true;
}
eq('valid precedence does not throw', String(threw), 'false');
// Invalid: longer delimiter has higher precedence
threw = false;
try {
const short = dom.window.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 });
const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 });
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': short, 'DEL': long } });
} catch (e) {
threw = true;
}
eq('invalid precedence throws', String(threw), 'true');
// ── Results ─────────────────────────────────────────────
const total = passed + failed;
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
if (errors.length) {
console.log('\nFailed:');
errors.forEach(e => console.log(`${e}`));
}
process.exit(failed > 0 ? 1 : 0);
+322
View File
@@ -0,0 +1,322 @@
import { ribbit, getWindow } from './setup';
import { InlineTokenizer, type InlineToken } from '../src/ts/tokenizer';
import { MarkdownSerializer, type SerializerTagDef } from '../src/ts/serializer';
// Set up DOM globals before any tests run
getWindow();
const boldDef = {
delimiter: '**',
htmlTag: 'strong',
recursive: true,
precedence: 40,
};
const italicDef = {
delimiter: '*',
htmlTag: 'em',
recursive: true,
precedence: 50,
};
const strikeDef = {
delimiter: '~~',
htmlTag: 'del',
recursive: true,
precedence: 45,
};
const codeDef = {
delimiter: '`',
htmlTag: 'code',
recursive: false,
precedence: 10,
};
const tokenizer = new InlineTokenizer([boldDef, italicDef, strikeDef, codeDef]);
function roles(tokens: InlineToken[]): string[] {
return tokens.map(token => token.role);
}
function values(tokens: InlineToken[]): string[] {
return tokens.map(token => token.value);
}
describe('InlineTokenizer', () => {
describe('plain text', () => {
it('produces a single text token', () => {
const tokens = tokenizer.tokenize('hello world');
expect(roles(tokens)).toEqual(['text']);
expect(values(tokens)).toEqual(['hello world']);
});
});
describe('bold', () => {
it('tokenizes **bold**', () => {
const tokens = tokenizer.tokenize('**bold**');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
expect(tokens[0].delimiter).toBe('**');
expect(tokens[1].value).toBe('bold');
});
it('tokenizes text **bold** text', () => {
const tokens = tokenizer.tokenize('hello **bold** end');
expect(roles(tokens)).toEqual(['text', 'open', 'text', 'close', 'text']);
});
});
describe('italic', () => {
it('tokenizes *italic*', () => {
const tokens = tokenizer.tokenize('*italic*');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
expect(tokens[0].delimiter).toBe('*');
});
});
describe('strikethrough', () => {
it('tokenizes ~~struck~~', () => {
const tokens = tokenizer.tokenize('~~struck~~');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
expect(tokens[0].delimiter).toBe('~~');
});
});
describe('code spans', () => {
it('tokenizes `code`', () => {
const tokens = tokenizer.tokenize('`code`');
expect(roles(tokens)).toEqual(['code']);
expect(tokens[0].content).toBe('code');
});
it('does not parse delimiters inside code', () => {
const tokens = tokenizer.tokenize('`**not bold**`');
expect(roles(tokens)).toEqual(['code']);
expect(tokens[0].content).toBe('**not bold**');
});
});
describe('backslash escapes', () => {
it('\\* becomes literal *', () => {
const tokens = tokenizer.tokenize('\\*hello');
expect(roles(tokens)).toEqual(['text']);
expect(tokens[0].value).toBe('*hello');
});
it('\\\\ becomes literal \\', () => {
const tokens = tokenizer.tokenize('\\\\');
expect(roles(tokens)).toEqual(['text']);
expect(tokens[0].value).toBe('\\');
});
it('\\n at end of line is a hard break', () => {
const tokens = tokenizer.tokenize('hello\\\nworld');
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
});
});
describe('hard line breaks', () => {
it('two trailing spaces before newline', () => {
const tokens = tokenizer.tokenize('hello \nworld');
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
});
it('single space does not break', () => {
const tokens = tokenizer.tokenize('hello \nworld');
const breakTokens = tokens.filter(token => token.role === 'break');
expect(breakTokens.length).toBe(0);
});
});
describe('entity resolution', () => {
it('&amp; becomes &', () => {
const tokens = tokenizer.tokenize('a &amp; b');
expect(tokens[0].value).toBe('a & b');
});
it('&#123; becomes {', () => {
const tokens = tokenizer.tokenize('&#123;');
expect(tokens[0].value).toBe('{');
});
it('&#x7B; becomes {', () => {
const tokens = tokenizer.tokenize('&#x7B;');
expect(tokens[0].value).toBe('{');
});
});
describe('links', () => {
it('tokenizes [text](url)', () => {
const tokens = tokenizer.tokenize('[click](http://x)');
expect(roles(tokens)).toEqual(['link']);
expect(tokens[0].href).toBe('http://x');
expect(tokens[0].value).toBe('click');
});
it('tokenizes [text](url "title")', () => {
const tokens = tokenizer.tokenize('[click](http://x "My Title")');
expect(tokens[0].title).toBe('My Title');
});
it('disallows [ in link text', () => {
const tokens = tokenizer.tokenize('[outer [inner](b)](a)');
// Should not match as a single link
const linkTokens = tokens.filter(token => token.role === 'link');
expect(linkTokens.length).toBeLessThanOrEqual(1);
});
});
describe('autolinks', () => {
it('tokenizes <url>', () => {
const tokens = tokenizer.tokenize('<https://example.com>');
expect(roles(tokens)).toEqual(['autolink']);
expect(tokens[0].href).toBe('https://example.com');
});
it('tokenizes bare URL', () => {
const tokens = tokenizer.tokenize('visit https://example.com today');
expect(tokens.some(token => token.role === 'autolink')).toBe(true);
});
});
describe('HTML passthrough', () => {
it('tokenizes HTML tags', () => {
const tokens = tokenizer.tokenize('a <span>b</span> c');
const htmlTokens = tokens.filter(token => token.role === 'html');
expect(htmlTokens.length).toBe(2);
expect(htmlTokens[0].value).toBe('<span>');
expect(htmlTokens[1].value).toBe('</span>');
});
});
describe('flanking rules', () => {
it('mid-word * is not a delimiter', () => {
const tokens = tokenizer.tokenize('2*3*4');
expect(roles(tokens)).toEqual(['text']);
});
it('* at word boundary is a delimiter', () => {
const tokens = tokenizer.tokenize('*hello*');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
});
});
describe('nested delimiters', () => {
it('bold inside italic', () => {
const tokens = tokenizer.tokenize('*hello **world***');
const openTokens = tokens.filter(token => token.role === 'open');
expect(openTokens.length).toBe(2);
});
});
});
describe('MarkdownSerializer', () => {
const tagMap = new Map<string, SerializerTagDef>([
['STRONG', { delimiter: '**' }],
['B', { delimiter: '**' }],
['EM', { delimiter: '*' }],
['I', { delimiter: '*' }],
['DEL', { delimiter: '~~' }],
['CODE', {
serialize: (element) => '`' + (element.textContent || '') + '`',
}],
['A', {
serialize: (element, children) => {
const href = element.getAttribute('href') || '';
const title = element.getAttribute('title');
const titlePart = title ? ` "${title}"` : '';
return '[' + children() + '](' + href + titlePart + ')';
},
}],
['BR', {
serialize: () => ' \n',
}],
]);
const delimiterChars = new Set(['*', '`', '~']);
const serializer = new MarkdownSerializer(tagMap, delimiterChars);
it('serializes plain text', () => {
const div = document.createElement('div');
div.textContent = 'hello world';
expect(serializer.serialize(div)).toBe('hello world');
});
it('serializes bold', () => {
const div = document.createElement('div');
div.innerHTML = '<strong>bold</strong>';
expect(serializer.serialize(div)).toBe('**bold**');
});
it('serializes italic', () => {
const div = document.createElement('div');
div.innerHTML = '<em>italic</em>';
expect(serializer.serialize(div)).toBe('*italic*');
});
it('escapes * in text nodes', () => {
const div = document.createElement('div');
div.textContent = 'hello * world';
expect(serializer.serialize(div)).toBe('hello \\* world');
});
it('escapes _ in text nodes', () => {
const div = document.createElement('div');
div.textContent = 'hello_world';
expect(serializer.serialize(div)).toBe('hello\\_world');
});
it('escapes \\ in text nodes', () => {
const div = document.createElement('div');
div.textContent = 'back\\slash';
expect(serializer.serialize(div)).toBe('back\\\\slash');
});
it('escapes < before letters', () => {
const div = document.createElement('div');
div.textContent = 'a <b> c';
expect(serializer.serialize(div)).toBe('a \\<b> c');
});
it('does not escape < before non-letters', () => {
const div = document.createElement('div');
div.textContent = '1 < 2';
expect(serializer.serialize(div)).toBe('1 < 2');
});
it('does not escape * inside delimiters', () => {
const div = document.createElement('div');
div.innerHTML = '<strong>bold</strong>';
const result = serializer.serialize(div);
// The ** are delimiter tokens, not escaped
expect(result).toBe('**bold**');
expect(result).not.toContain('\\*');
});
it('escapes * in text adjacent to delimiters', () => {
const div = document.createElement('div');
div.innerHTML = '<strong>bold</strong> * text';
const result = serializer.serialize(div);
expect(result).toContain('\\*');
});
it('serializes link', () => {
const div = document.createElement('div');
div.innerHTML = '<a href="http://x">click</a>';
expect(serializer.serialize(div)).toBe('[click](http://x)');
});
it('serializes link with title', () => {
const div = document.createElement('div');
div.innerHTML = '<a href="http://x" title="T">click</a>';
expect(serializer.serialize(div)).toBe('[click](http://x "T")');
});
it('serializes code', () => {
const div = document.createElement('div');
div.innerHTML = '<code>x</code>';
expect(serializer.serialize(div)).toBe('`x`');
});
it('serializes hard break', () => {
const div = document.createElement('div');
div.innerHTML = 'hello<br>world';
expect(serializer.serialize(div)).toBe('hello \nworld');
});
});
+343
View File
@@ -0,0 +1,343 @@
import { ribbit, resetDOM } from './setup';
const lib = ribbit();
describe('ToolbarManager', () => {
beforeEach(() => resetDOM('**bold** text'));
describe('button registration', () => {
it('registers tag buttons', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('bold')).toBeDefined();
expect(editor.toolbar.buttons.get('italic')).toBeDefined();
expect(editor.toolbar.buttons.get('code')).toBeDefined();
});
it('registers editor actions', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('save')).toBeDefined();
expect(editor.toolbar.buttons.get('toggle')).toBeDefined();
expect(editor.toolbar.buttons.get('markdown')).toBeDefined();
});
it('registers macro buttons', () => {
const editor = new lib.Editor({
macros: [{
name: 'user',
toHTML: () => 'u',
}],
});
editor.run();
expect(editor.toolbar.buttons.get('macro:user')).toBeDefined();
});
it('skips macros with button: false', () => {
const editor = new lib.Editor({
macros: [{
name: 'hidden',
toHTML: () => '',
button: false,
}],
});
editor.run();
expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined();
});
it('skips tags without button', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined();
});
});
describe('button properties', () => {
it('bold has correct label and shortcut', () => {
const editor = new lib.Editor({});
editor.run();
const bold = editor.toolbar.buttons.get('bold')!;
expect(bold.label).toBe('Bold');
expect(bold.shortcut).toBe('Ctrl+B');
});
it('bold action is wrap', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap');
});
it('save action is custom', () => {
const editor = new lib.Editor({});
editor.run();
expect(editor.toolbar.buttons.get('save')!.action).toBe('custom');
});
it('table has template', () => {
const editor = new lib.Editor({});
editor.run();
const table = editor.toolbar.buttons.get('table')!;
expect(table.template).toContain('Header');
expect(table.replaceSelection).toBe(false);
});
it('macro button has insert action', () => {
const editor = new lib.Editor({
macros: [{
name: 'toc',
toHTML: () => '',
}],
});
editor.run();
const btn = editor.toolbar.buttons.get('macro:toc')!;
expect(btn.action).toBe('insert');
expect(btn.template).toBe('@toc');
});
});
describe('button.hide() and button.show()', () => {
it('hide sets visible false', () => {
const editor = new lib.Editor({});
editor.run();
const bold = editor.toolbar.buttons.get('bold')!;
expect(bold.visible).toBe(true);
bold.hide();
expect(bold.visible).toBe(false);
});
it('show restores visible', () => {
const editor = new lib.Editor({});
editor.run();
const bold = editor.toolbar.buttons.get('bold')!;
bold.hide();
bold.show();
expect(bold.visible).toBe(true);
});
});
describe('render()', () => {
it('returns an HTMLElement', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.tagName).toBe('NAV');
expect(toolbar.className).toBe('ribbit-toolbar');
});
it('contains buttons', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelector('.ribbit-btn-bold')).not.toBeNull();
expect(toolbar.querySelector('.ribbit-btn-save')).not.toBeNull();
});
it('buttons have aria-label', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.getAttribute('aria-label')).toBe('Bold');
});
it('buttons have title with shortcut', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)');
});
it('renders spacers', () => {
const editor = new lib.Editor({
autoToolbar: false,
toolbar: ['bold', '', 'save'],
});
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelector('.spacer')).not.toBeNull();
});
it('renders dropdown groups', () => {
const editor = new lib.Editor({
autoToolbar: false,
toolbar: [{
group: 'Test',
items: ['bold', 'italic'],
}],
});
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelector('.ribbit-dropdown')).not.toBeNull();
});
});
describe('auto-render', () => {
it('inserts toolbar before editor by default', () => {
resetDOM();
const editor = new lib.Editor({});
editor.run();
const toolbarElement = editor.element.previousElementSibling;
expect(toolbarElement?.className).toBe('ribbit-toolbar');
});
it('does not insert when autoToolbar is false', () => {
resetDOM();
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbarElement = editor.element.previousElementSibling;
expect(toolbarElement?.className || '').not.toBe('ribbit-toolbar');
});
});
describe('custom layout', () => {
it('respects custom toolbar order', () => {
const editor = new lib.Editor({
autoToolbar: false,
toolbar: ['save', 'bold'],
});
editor.run();
const toolbar = editor.toolbar.render();
const buttons = toolbar.querySelectorAll('button');
expect(buttons[0]?.className).toBe('ribbit-btn-save');
expect(buttons[1]?.className).toBe('ribbit-btn-bold');
});
it('auto-generates layout when not specified', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
expect(toolbar.querySelectorAll('button').length).toBeGreaterThan(3);
});
});
describe('enable/disable', () => {
it('disable adds disabled class', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
editor.toolbar.disable();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.classList.contains('disabled')).toBe(true);
});
it('enable removes disabled class', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const toolbar = editor.toolbar.render();
editor.toolbar.disable();
editor.toolbar.enable();
const bold = toolbar.querySelector('.ribbit-btn-bold');
expect(bold?.classList.contains('disabled')).toBe(false);
});
});
describe('updateActiveState', () => {
it('sets active class on matching buttons', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
editor.toolbar.updateActiveState(['bold']);
expect(editor.toolbar.buttons.get('bold')!.element?.classList.contains('active')).toBe(true);
expect(editor.toolbar.buttons.get('italic')!.element?.classList.contains('active')).toBe(false);
});
it('clears active when not in list', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
editor.toolbar.updateActiveState(['bold']);
editor.toolbar.updateActiveState([]);
expect(editor.toolbar.buttons.get('bold')!.element?.classList.contains('active')).toBe(false);
});
});
describe('heading and list buttons', () => {
it('registers h1-h6', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
for (let level = 1; level <= 6; level++) {
const btn = editor.toolbar.buttons.get(`h${level}`);
expect(btn).toBeDefined();
expect(btn!.label).toBe(`H${level}`);
expect(btn!.shortcut).toBe(`Ctrl+${level}`);
expect(btn!.action).toBe('prefix');
}
});
it('registers ul and ol', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8');
expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7');
});
});
describe('keyboard shortcuts', () => {
it('all formatting buttons have shortcuts', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
const expected = ['bold', 'italic', 'code', 'link', 'save'];
for (const id of expected) {
expect(editor.toolbar.buttons.get(id)!.shortcut).toBeDefined();
}
});
it('block buttons have shortcuts', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
expect(editor.toolbar.buttons.get('fencedCode')!.shortcut).toBe('Ctrl+Shift+E');
expect(editor.toolbar.buttons.get('blockquote')!.shortcut).toBe('Ctrl+Shift+.');
expect(editor.toolbar.buttons.get('table')!.shortcut).toBe('Ctrl+Shift+T');
expect(editor.toolbar.buttons.get('hr')!.shortcut).toBe('Ctrl+Shift+-');
});
it('editor actions have shortcuts', () => {
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V');
expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/');
});
});
describe('save button', () => {
it('triggers editor.save()', () => {
resetDOM();
let saved = false;
const editor = new lib.Editor({
autoToolbar: false,
on: {
save: () => {
saved = true;
},
},
});
editor.run();
editor.toolbar.render();
editor.toolbar.buttons.get('save')!.click();
expect(saved).toBe(true);
});
});
describe('toggle button', () => {
it('switches from view to wysiwyg', () => {
resetDOM();
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
expect(editor.getState()).toBe('view');
editor.toolbar.buttons.get('toggle')!.click();
expect(editor.getState()).toBe('wysiwyg');
});
it('switches from wysiwyg to view', () => {
resetDOM();
const editor = new lib.Editor({ autoToolbar: false });
editor.run();
editor.wysiwyg();
editor.toolbar.render();
editor.toolbar.buttons.get('toggle')!.click();
expect(editor.getState()).toBe('view');
});
});
});
+150
View File
@@ -0,0 +1,150 @@
import { ribbit, resetDOM } from './setup';
const lib = ribbit();
describe('VimHandler', () => {
beforeEach(() => resetDOM('hello world'));
it('starts in insert mode', () => {
const editor = new lib.Editor({
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run();
editor.edit();
expect(editor.element.classList.contains('vim-insert')).toBe(true);
});
it('Esc enters normal mode', () => {
const editor = new lib.Editor({
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run();
editor.edit();
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
expect(editor.element.classList.contains('vim-normal')).toBe(true);
expect(editor.element.classList.contains('vim-insert')).toBe(false);
});
it('i returns to insert mode', () => {
const editor = new lib.Editor({
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run();
editor.edit();
// Enter normal mode
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
// Back to insert
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
expect(editor.element.classList.contains('vim-insert')).toBe(true);
expect(editor.element.classList.contains('vim-normal')).toBe(false);
});
it('disables toolbar in normal mode', () => {
const editor = new lib.Editor({
autoToolbar: false,
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run();
editor.toolbar.render();
editor.edit();
editor.toolbar.enable();
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
const bold = editor.toolbar.buttons.get('bold');
expect(bold?.element?.classList.contains('disabled')).toBe(true);
});
it('re-enables toolbar in insert mode', () => {
const editor = new lib.Editor({
autoToolbar: false,
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run();
editor.toolbar.render();
editor.edit();
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
const bold = editor.toolbar.buttons.get('bold');
expect(bold?.element?.classList.contains('disabled')).toBe(false);
});
it('detaches when leaving edit mode', () => {
const editor = new lib.Editor({
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run();
editor.edit();
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
expect(editor.element.classList.contains('vim-normal')).toBe(true);
editor.wysiwyg();
// vim classes should be gone after mode switch
expect(editor.element.classList.contains('vim-normal')).toBe(false);
expect(editor.element.classList.contains('vim-insert')).toBe(false);
});
it('only activates in edit mode', () => {
const editor = new lib.Editor({
currentTheme: 'vim',
themes: [{
name: 'vim',
features: {
sourceMode: true,
vim: true,
},
tags: lib.defaultTags,
}],
});
editor.run();
editor.wysiwyg();
// Esc in wysiwyg should not add vim classes
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
expect(editor.element.classList.contains('vim-normal')).toBe(false);
});
});
+4 -3
View File
@@ -1,14 +1,15 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"target": "ES2017", "target": "ES2018",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src/ts",
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
"lib": ["ES2019", "DOM"] "lib": ["ES2019", "DOM"]
}, },
"include": ["src/**/*.ts"] "include": ["src/ts/**/*.ts"],
"exclude": ["test/**/*"]
} }