Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95f6ffcf86 | ||
|
|
c1dc49f0b3 | ||
|
|
818ee418d5 | ||
|
|
bfc20f56bf | ||
|
|
24560a4d21 | ||
|
|
5b2bd94388 | ||
|
|
d41716c8b2 | ||
|
|
005db2f431 | ||
|
|
3e8d3388f6 | ||
|
|
1198791505 | ||
|
|
2e28598243 | ||
|
|
3368e719fd | ||
|
|
8bef75e59f | ||
|
|
1f523cbc0f | ||
|
|
98719ec8cd | ||
|
|
2b88d2c10b | ||
|
|
4237a3f6a2 | ||
|
|
86d59877f1 |
@@ -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.
|
|
||||||
|
|||||||
@@ -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">> </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`.
|
||||||
@@ -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: `&` → 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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
/tmp/ribbit/dist/ribbit
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Ribbit Collaboration Example</title>
|
||||||
|
<link rel="stylesheet" href="/static/ribbit/themes/ribbit-default/theme.css">
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; }
|
||||||
|
#peers { padding: 8px; background: #f0f0f0; border-radius: 4px; margin-bottom: 10px; font-size: 14px; }
|
||||||
|
#peers .peer { display: inline-block; padding: 2px 8px; border-radius: 3px; margin-right: 4px; color: white; }
|
||||||
|
.examples { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; }
|
||||||
|
.example { border: 1px solid #ddd; border-radius: 4px; padding: 12px; }
|
||||||
|
.example h3 { margin: 0 0 8px 0; font-size: 13px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
#status { font-size: 12px; color: #666; margin-bottom: 10px; }
|
||||||
|
#revisions { margin-top: 20px; }
|
||||||
|
#revisions button { margin: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Ribbit Collaboration Example</h1>
|
||||||
|
<div id="peers">No peers connected</div>
|
||||||
|
<div id="status"></div>
|
||||||
|
<article id="ribbit">{{ content }}</article>
|
||||||
|
<div id="revisions">
|
||||||
|
<h3>Revisions</h3>
|
||||||
|
<div id="revision-list">Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/ribbit/ribbit.js"></script>
|
||||||
|
<script>
|
||||||
|
const userId = 'user-' + Math.random().toString(36).slice(2, 6);
|
||||||
|
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#1abc9c'];
|
||||||
|
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://${location.host}/ws`);
|
||||||
|
|
||||||
|
const transport = {
|
||||||
|
connect() {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'join',
|
||||||
|
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
disconnect() {},
|
||||||
|
send(update) { if (ws.readyState === 1) ws.send(update); },
|
||||||
|
onReceive(callback) {
|
||||||
|
ws.addEventListener('message', (e) => {
|
||||||
|
if (e.data instanceof Blob) {
|
||||||
|
e.data.arrayBuffer().then(buf => callback(new Uint8Array(buf)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async lock() {
|
||||||
|
const res = await fetch('/api/lock', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId, displayName: userId }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
},
|
||||||
|
unlock() { fetch('/api/lock', { method: 'DELETE' }); },
|
||||||
|
async forceLock() {
|
||||||
|
const res = await fetch('/api/lock/force', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId, displayName: userId }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
},
|
||||||
|
onLockChange(callback) {
|
||||||
|
ws.addEventListener('message', (e) => {
|
||||||
|
if (typeof e.data === 'string') {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'lock') callback(msg.holder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const presence = {
|
||||||
|
send(info) {
|
||||||
|
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'presence', ...info }));
|
||||||
|
},
|
||||||
|
onUpdate(callback) {
|
||||||
|
ws.addEventListener('message', (e) => {
|
||||||
|
if (typeof e.data === 'string') {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'peers') callback(msg.peers);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const revisions = {
|
||||||
|
async list() {
|
||||||
|
return (await fetch('/api/revisions')).json();
|
||||||
|
},
|
||||||
|
async get(id) {
|
||||||
|
return (await fetch(`/api/revisions/${id}`)).json();
|
||||||
|
},
|
||||||
|
async create(content, metadata) {
|
||||||
|
const res = await fetch('/api/revisions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content, ...metadata }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = new ribbit.Editor({
|
||||||
|
macros: [
|
||||||
|
{
|
||||||
|
name: 'block',
|
||||||
|
block: true,
|
||||||
|
toHTML: ({ keywords, content }) => {
|
||||||
|
const className = keywords.join(' ');
|
||||||
|
const classAttr = className ? ' class="' + className + '"' : '';
|
||||||
|
return '<div' + classAttr + '>' + (content || '') + '</div>';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
presence,
|
||||||
|
revisions,
|
||||||
|
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
peerChange({ peers }) {
|
||||||
|
const el = document.getElementById('peers');
|
||||||
|
if (peers.length === 0) {
|
||||||
|
el.innerHTML = 'No peers connected';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = peers.map(p =>
|
||||||
|
`<span class="peer" style="background:${p.color || '#999'}">${p.displayName} (${p.status})</span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lockChange({ holder }) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = holder ? `🔒 Locked by ${holder.displayName}` : '';
|
||||||
|
},
|
||||||
|
remoteActivity({ count }) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = `⚡ ${count} remote change${count > 1 ? 's' : ''} while in source mode`;
|
||||||
|
},
|
||||||
|
save({ markdown }) {
|
||||||
|
revisions.create(markdown, { author: userId, summary: 'Manual save' }).then(refreshRevisions);
|
||||||
|
},
|
||||||
|
revisionCreated() {
|
||||||
|
refreshRevisions();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.run();
|
||||||
|
|
||||||
|
async function refreshRevisions() {
|
||||||
|
const list = await editor.listRevisions();
|
||||||
|
const el = document.getElementById('revision-list');
|
||||||
|
if (list.length === 0) {
|
||||||
|
el.innerHTML = '<em>No revisions yet. Click Save to create one.</em>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = list.map(r =>
|
||||||
|
`<button onclick="restore('${r.id}')">${r.timestamp} by ${r.author}${r.summary ? ': ' + r.summary : ''}</button>`
|
||||||
|
).join('<br>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.restore = async function(id) {
|
||||||
|
await editor.restoreRevision(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshRevisions();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/test'],
|
||||||
|
testPathIgnorePatterns: ['/node_modules/', '/test/integration/'],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': ['ts-jest', {
|
||||||
|
tsconfig: {
|
||||||
|
strict: true,
|
||||||
|
target: 'ES2018',
|
||||||
|
module: 'CommonJS',
|
||||||
|
moduleResolution: 'node',
|
||||||
|
esModuleInterop: true,
|
||||||
|
lib: ['ES2019', 'DOM'],
|
||||||
|
types: ['jest'],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+6429
-1462
File diff suppressed because it is too large
Load Diff
+20
-8
@@ -2,26 +2,38 @@
|
|||||||
"name": "ribbit",
|
"name": "ribbit",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Zero-dependency WYSIWYG markdown editor for the browser",
|
"description": "Zero-dependency WYSIWYG markdown editor for the browser",
|
||||||
"main": "dist/ribbit.js",
|
"main": "dist/ribbit/ribbit.js",
|
||||||
"types": "dist/ribbit.d.ts",
|
|
||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/ribbit/"
|
||||||
"src/"
|
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:css",
|
"build": "mkdir -p dist/ribbit && npm run build:check && npm run build:js && npm run build:min && npm run build:core && npm run build:core-min && npm run build:css",
|
||||||
"build:check": "tsc --noEmit",
|
"build:check": "tsc --noEmit",
|
||||||
"build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit.js",
|
"build:js": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit.js",
|
||||||
"build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit.min.js",
|
"build:min": "esbuild src/ts/ribbit-editor.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit.min.js",
|
||||||
|
"build:core": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit-core.js",
|
||||||
|
"build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js",
|
||||||
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
|
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
|
||||||
"test": "npm run build && node test/test_hopdown.js"
|
"dev": "npm run build && node test/integration/dev-server.js",
|
||||||
|
"test": "npm run build && jest --verbose",
|
||||||
|
"test:integration": "npm run build && node test/integration/test.js && node test/integration/test_wysiwyg.js",
|
||||||
|
"test:coverage": "npm run build && jest --coverage"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "evilchili",
|
"author": "evilchili",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jest": "^29.5.14",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"jsdom": "^20.0.3",
|
"happy-dom": "^20.9.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"live-server": "^1.2.0",
|
||||||
|
"node-watch": "^0.7.4",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
|
"selenium-webdriver": "^4.43.0",
|
||||||
|
"ts-jest": "^29.4.9",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bootstrap-icons": "^1.13.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './ts';
|
||||||
+137
-5
@@ -1,9 +1,18 @@
|
|||||||
/*
|
/*
|
||||||
* ribbit-core.css — functional editor styles. Always load this.
|
* ribbit-core.css — functional editor styles. Always load this.
|
||||||
* These styles control editor state visibility and behavior.
|
*
|
||||||
* They should not be overridden by themes.
|
* These styles control editor state visibility and the styled-source
|
||||||
|
* rendering. They should not be overridden by themes.
|
||||||
|
*
|
||||||
|
* Two CSS states (not modes):
|
||||||
|
* .wysiwyg — contentEditable, delimiters revealed on cursor focus
|
||||||
|
* .view — read-only, all delimiters hidden, full block styling
|
||||||
|
*
|
||||||
|
* The DOM is identical in both states; only CSS changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* ── Visibility ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
#ribbit {
|
#ribbit {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -12,11 +21,134 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ribbit.edit {
|
/* ── Delimiter visibility ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Delimiters are always present in the DOM as text nodes inside
|
||||||
|
* .md-delim spans. In view state they are hidden; in wysiwyg state
|
||||||
|
* they are hidden by default and revealed only for the span the
|
||||||
|
* cursor is currently inside (.ribbit-editing).
|
||||||
|
*
|
||||||
|
* This means getMarkdown() = element.textContent at all times —
|
||||||
|
* no conversion is needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.md-delim {
|
||||||
|
display:inline;
|
||||||
|
opacity: 0.3;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ribbit-editing {
|
||||||
|
background: #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ribbit-editing > .md-delim {
|
||||||
|
display: inline;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List prefixes use a separate class so CSS can replace them with
|
||||||
|
real list bullets in view state while keeping them in textContent */
|
||||||
|
.md-list-prefix {
|
||||||
|
display: inline;
|
||||||
|
opacity: 0.8;
|
||||||
|
/*
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.view .md-list-prefix {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inline formatting ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.md-bold,
|
||||||
|
.md-bold-italic {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-italic,
|
||||||
|
.md-bold-italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-code {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-link-text {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Block-level styling ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Block divs use .md-{name} classes. In view state they render as
|
||||||
|
* their visual equivalents. In wysiwyg state they use monospace so
|
||||||
|
* the user can see the raw markdown while the formatting is applied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ribbit.wysiwyg {
|
||||||
|
/* white-space: pre-wrap; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-h1 { font-size: 2em; font-weight: bold; }
|
||||||
|
.md-h2 { font-size: 1.5em; font-weight: bold; }
|
||||||
|
.md-h3 { font-size: 1.17em; font-weight: bold; }
|
||||||
|
.md-h4 { font-size: 1em; font-weight: bold; }
|
||||||
|
.md-h5 { font-size: 0.83em; font-weight: bold; }
|
||||||
|
.md-h6 { font-size: 0.67em; font-weight: bold; }
|
||||||
|
|
||||||
|
.md-blockquote {
|
||||||
|
border-left: 3px solid currentColor;
|
||||||
|
opacity: 0.7;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* List items: in wysiwyg state the .md-list-prefix span shows the
|
||||||
|
* raw markdown marker ("- " or "1. "). In view state we hide the
|
||||||
|
* prefix and use display:list-item to get a real browser bullet.
|
||||||
|
*/
|
||||||
|
#ribbit.view .md-list-item {
|
||||||
|
display: list-item;
|
||||||
|
margin-left: 1.5em;
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.view .md-ol-list-item {
|
||||||
|
display: list-item;
|
||||||
|
margin-left: 1.5em;
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-pre {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ribbit.wysiwyg .md {
|
/* ── Vim mode indicators ────────────────────────────────────────────────────── */
|
||||||
opacity: 0.5;
|
|
||||||
|
#ribbit.vim-normal {
|
||||||
|
cursor: default;
|
||||||
|
caret-color: transparent;
|
||||||
|
border-left: 3px solid #4af;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.vim-insert {
|
||||||
|
border-left: 3px solid #4f4;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../../node_modules/bootstrap-icons/icons
|
||||||
@@ -4,7 +4,12 @@
|
|||||||
* Replace this file with your own theme to customize the look.
|
* Replace this file with your own theme to customize the look.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@import "../ribbit-core.css";
|
@import "../../ribbit-core.css";
|
||||||
|
|
||||||
|
body { font-family: sans-serif; margin: 20px; }
|
||||||
|
main { max-width: 960px; margin: auto }
|
||||||
|
|
||||||
|
#ribbit { border: 1px solid #ccc; border-radius: 4px; padding: 20px; min-height: 200px; }
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -50,3 +55,80 @@ code {
|
|||||||
background: #EEE;
|
background: #EEE;
|
||||||
margin: 3px;
|
margin: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ribbit-toolbar {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ccc; border-radius: 4px; padding: 4px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.ribbit-toolbar ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ribbit-toolbar button {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 1rem 1rem;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
.ribbit-toolbar button:hover {
|
||||||
|
background-color: #DDD;
|
||||||
|
background-blend-mode: darken;
|
||||||
|
}
|
||||||
|
.ribbit-toolbar button.disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.ribbit-btn-fencedCode { background-image: url("icons/code-square.svg"); }
|
||||||
|
.ribbit-btn-blockquote { background-image: url("icons/blockquote-left.svg"); }
|
||||||
|
.ribbit-btn-hr { background-image: url("icons/hr.svg"); }
|
||||||
|
.ribbit-btn-table { background-image: url("icons/table.svg"); }
|
||||||
|
.ribbit-btn-code { background-image: url("icons/code.svg"); }
|
||||||
|
.ribbit-btn-link { background-image: url("icons/link.svg"); }
|
||||||
|
.ribbit-btn-boldItalic { background-image: url("icons/type-bold.svg"); }
|
||||||
|
.ribbit-btn-bold { background-image: url("icons/type-bold.svg"); }
|
||||||
|
.ribbit-btn-italic { background-image: url("icons/type-italic.svg"); }
|
||||||
|
.ribbit-btn-strikethrough { background-image: url("icons/type-strikethrough.svg"); }
|
||||||
|
.ribbit-btn-h1 { background-image: url("icons/type-h1.svg"); }
|
||||||
|
.ribbit-btn-h2 { background-image: url("icons/type-h2.svg"); }
|
||||||
|
.ribbit-btn-h3 { background-image: url("icons/type-h3.svg"); }
|
||||||
|
.ribbit-btn-h4 { background-image: url("icons/type-h4.svg"); }
|
||||||
|
.ribbit-btn-h5 { background-image: url("icons/type-h5.svg"); }
|
||||||
|
.ribbit-btn-h6 { background-image: url("icons/type-h6.svg"); }
|
||||||
|
.ribbit-btn-ul { background-image: url("icons/list-ul.svg"); }
|
||||||
|
.ribbit-btn-ol { background-image: url("icons/list-ol.svg"); }
|
||||||
|
.ribbit-btn-edit { background-image: url("icons/pen.svg"); }
|
||||||
|
.ribbit-btn-save { background-image: url("icons/floppy.svg"); }
|
||||||
|
.ribbit-btn-toggle { background-image: url("icons/toggle-off.svg"); }
|
||||||
|
|
||||||
|
|
||||||
|
.ribbit-toolbar .spacer {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
.ribbit-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.ribbit-dropdown button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
margin: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/*
|
||||||
|
* collaboration.ts — real-time collaboration manager for ribbit.
|
||||||
|
*
|
||||||
|
* Manages document sync, presence, locking, and revision creation
|
||||||
|
* through consumer-provided interfaces. Ribbit never makes network
|
||||||
|
* calls — the consumer owns the network layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DocumentTransport, PresenceChannel, PeerInfo,
|
||||||
|
CollaborationSettings, RevisionProvider, Revision, RevisionMetadata,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/** Milliseconds to buffer rapid remote updates before applying the latest. */
|
||||||
|
const THROTTLE_DELAY_MS = 150;
|
||||||
|
|
||||||
|
/** Default milliseconds before a peer is considered idle. */
|
||||||
|
const DEFAULT_IDLE_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
/** Peer status values used in presence tracking. */
|
||||||
|
const PEER_STATUS = {
|
||||||
|
ACTIVE: 'active' as const,
|
||||||
|
EDITING: 'editing' as const,
|
||||||
|
IDLE: 'idle' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Auto-revision metadata when saving remote state before source mode merge. */
|
||||||
|
const AUTO_REVISION_AUTHOR = 'auto';
|
||||||
|
const AUTO_REVISION_SUMMARY = 'Auto-saved before source mode merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages real-time collaboration for a ribbit editor instance.
|
||||||
|
*
|
||||||
|
* Handles document sync, peer presence, document locking, and
|
||||||
|
* revision management through consumer-provided transport interfaces.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const collab = new CollaborationManager(settings, {
|
||||||
|
* onRemoteUpdate: (content) => editor.setContent(content),
|
||||||
|
* onPeersChange: (peers) => updateUserList(peers),
|
||||||
|
* onLockChange: (holder) => updateLockUI(holder),
|
||||||
|
* onRemoteActivity: (count) => showBadge(count),
|
||||||
|
* });
|
||||||
|
* collab.connect();
|
||||||
|
*/
|
||||||
|
export class CollaborationManager {
|
||||||
|
private transport: DocumentTransport;
|
||||||
|
private presence?: PresenceChannel;
|
||||||
|
private revisions?: RevisionProvider;
|
||||||
|
private user: PeerInfo;
|
||||||
|
private peers: PeerInfo[];
|
||||||
|
private connected: boolean;
|
||||||
|
private paused: boolean;
|
||||||
|
private remoteChangeCount: number;
|
||||||
|
private latestRemoteContent: string | null;
|
||||||
|
private idleTimeout: number;
|
||||||
|
private lockHolder: PeerInfo | null;
|
||||||
|
private onRemoteUpdate: (content: string) => void;
|
||||||
|
private onPeersChange: (peers: PeerInfo[]) => void;
|
||||||
|
private onLockChange: (holder: PeerInfo | null) => void;
|
||||||
|
private onRemoteActivity: (count: number) => void;
|
||||||
|
private receiveBuffer: Uint8Array[];
|
||||||
|
private throttleTimer?: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
settings: CollaborationSettings,
|
||||||
|
callbacks: {
|
||||||
|
onRemoteUpdate: (content: string) => void;
|
||||||
|
onPeersChange: (peers: PeerInfo[]) => void;
|
||||||
|
onLockChange: (holder: PeerInfo | null) => void;
|
||||||
|
onRemoteActivity: (count: number) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.transport = settings.transport;
|
||||||
|
this.presence = settings.presence;
|
||||||
|
this.revisions = settings.revisions;
|
||||||
|
this.user = settings.user;
|
||||||
|
this.peers = [];
|
||||||
|
this.connected = false;
|
||||||
|
this.paused = false;
|
||||||
|
this.remoteChangeCount = 0;
|
||||||
|
this.latestRemoteContent = null;
|
||||||
|
this.idleTimeout = settings.idleTimeout ?? DEFAULT_IDLE_TIMEOUT_MS;
|
||||||
|
this.lockHolder = null;
|
||||||
|
this.onRemoteUpdate = callbacks.onRemoteUpdate;
|
||||||
|
this.onPeersChange = callbacks.onPeersChange;
|
||||||
|
this.onLockChange = callbacks.onLockChange;
|
||||||
|
this.onRemoteActivity = callbacks.onRemoteActivity;
|
||||||
|
this.receiveBuffer = [];
|
||||||
|
|
||||||
|
this.transport.onReceive((update) => {
|
||||||
|
this.handleRemoteUpdate(update);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.presence) {
|
||||||
|
this.presence.onUpdate((peers) => {
|
||||||
|
this.peers = this.applyIdleStatus(peers);
|
||||||
|
this.onPeersChange(this.peers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.transport.onLockChange) {
|
||||||
|
this.transport.onLockChange((holder) => {
|
||||||
|
this.lockHolder = holder;
|
||||||
|
this.onLockChange(holder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the transport connection and begin receiving updates.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.connect();
|
||||||
|
*/
|
||||||
|
connect(): void {
|
||||||
|
if (this.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.transport.connect();
|
||||||
|
this.connected = true;
|
||||||
|
this.remoteChangeCount = 0;
|
||||||
|
this.latestRemoteContent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the transport connection and clear peer state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.disconnect();
|
||||||
|
*/
|
||||||
|
disconnect(): void {
|
||||||
|
if (!this.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.transport.disconnect();
|
||||||
|
this.connected = false;
|
||||||
|
this.peers = [];
|
||||||
|
this.paused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause applying remote updates (e.g. when entering source mode).
|
||||||
|
* Updates are still received and counted so the UI can show a badge.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.pause(editor.getMarkdown());
|
||||||
|
*/
|
||||||
|
pause(currentContent: string): void {
|
||||||
|
this.paused = true;
|
||||||
|
this.remoteChangeCount = 0;
|
||||||
|
this.latestRemoteContent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume applying remote updates (e.g. when leaving source mode).
|
||||||
|
* If remote changes arrived while paused, creates a revision of
|
||||||
|
* the remote version before applying local content (last-write-wins).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await collab.resume(editor.getMarkdown());
|
||||||
|
*/
|
||||||
|
async resume(localContent: string): Promise<void> {
|
||||||
|
if (this.paused && this.latestRemoteContent && this.revisions) {
|
||||||
|
await this.revisions.create(this.latestRemoteContent, {
|
||||||
|
author: AUTO_REVISION_AUTHOR,
|
||||||
|
summary: AUTO_REVISION_SUMMARY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.paused = false;
|
||||||
|
this.remoteChangeCount = 0;
|
||||||
|
this.latestRemoteContent = null;
|
||||||
|
this.sendUpdate(localContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast local content to connected peers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.sendUpdate(editor.getMarkdown());
|
||||||
|
*/
|
||||||
|
sendUpdate(markdown: string): void {
|
||||||
|
if (!this.connected || this.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const encoded = new TextEncoder().encode(markdown);
|
||||||
|
this.transport.send(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast cursor position to connected peers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.sendCursor(selection.anchorOffset);
|
||||||
|
*/
|
||||||
|
sendCursor(position: number): void {
|
||||||
|
if (!this.connected || !this.presence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.presence.send({
|
||||||
|
...this.user,
|
||||||
|
status: this.paused ? PEER_STATUS.EDITING : PEER_STATUS.ACTIVE,
|
||||||
|
lastActive: Date.now(),
|
||||||
|
cursor: position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request an exclusive document lock.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const acquired = await collab.lock();
|
||||||
|
*/
|
||||||
|
async lock(): Promise<boolean> {
|
||||||
|
if (!this.transport.lock) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.transport.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the document lock.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* collab.unlock();
|
||||||
|
*/
|
||||||
|
unlock(): void {
|
||||||
|
this.transport.unlock?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-acquire the lock, overriding any existing holder.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const acquired = await collab.forceLock();
|
||||||
|
*/
|
||||||
|
async forceLock(): Promise<boolean> {
|
||||||
|
if (!this.transport.forceLock) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.transport.forceLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the peer currently holding the document lock, or null.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const holder = collab.getLockHolder();
|
||||||
|
*/
|
||||||
|
getLockHolder(): PeerInfo | null {
|
||||||
|
return this.lockHolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the list of currently connected peers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const peers = collab.getPeers();
|
||||||
|
*/
|
||||||
|
getPeers(): PeerInfo[] {
|
||||||
|
return this.peers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the number of remote changes received while paused.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const count = collab.getRemoteChangeCount();
|
||||||
|
*/
|
||||||
|
getRemoteChangeCount(): number {
|
||||||
|
return this.remoteChangeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the transport connection is open.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* if (collab.isConnected()) { ... }
|
||||||
|
*/
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether remote updates are currently paused.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* if (collab.isPaused()) { ... }
|
||||||
|
*/
|
||||||
|
isPaused(): boolean {
|
||||||
|
return this.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all stored revisions via the consumer's RevisionProvider.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const revisions = await collab.listRevisions();
|
||||||
|
*/
|
||||||
|
async listRevisions(): Promise<Revision[]> {
|
||||||
|
if (!this.revisions) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.revisions.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a specific revision by ID.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const revision = await collab.getRevision('abc123');
|
||||||
|
*/
|
||||||
|
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
|
||||||
|
if (!this.revisions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.revisions.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new revision with the given content and metadata.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await collab.createRevision(markdown, { author: 'user1', summary: 'Draft' });
|
||||||
|
*/
|
||||||
|
async createRevision(content: string, metadata?: RevisionMetadata): Promise<Revision | null> {
|
||||||
|
if (!this.revisions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.revisions.create(content, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buffers rapid remote updates and applies only the latest after
|
||||||
|
* a throttle delay. When paused, counts changes without applying.
|
||||||
|
*/
|
||||||
|
private handleRemoteUpdate(update: Uint8Array): void {
|
||||||
|
const content = new TextDecoder().decode(update);
|
||||||
|
|
||||||
|
if (this.paused) {
|
||||||
|
this.remoteChangeCount++;
|
||||||
|
this.latestRemoteContent = content;
|
||||||
|
this.onRemoteActivity(this.remoteChangeCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.receiveBuffer.push(update);
|
||||||
|
if (this.throttleTimer !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.throttleTimer = window.setTimeout(() => {
|
||||||
|
this.throttleTimer = undefined;
|
||||||
|
if (this.receiveBuffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latest = this.receiveBuffer[this.receiveBuffer.length - 1];
|
||||||
|
this.receiveBuffer = [];
|
||||||
|
this.onRemoteUpdate(new TextDecoder().decode(latest));
|
||||||
|
}, THROTTLE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks peers as idle when their lastActive exceeds the timeout. */
|
||||||
|
private applyIdleStatus(peers: PeerInfo[]): PeerInfo[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return peers.map(peer => ({
|
||||||
|
...peer,
|
||||||
|
status: peer.status === PEER_STATUS.EDITING
|
||||||
|
? PEER_STATUS.EDITING
|
||||||
|
: (now - peer.lastActive > this.idleTimeout ? PEER_STATUS.IDLE : PEER_STATUS.ACTIVE),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -7,8 +7,18 @@
|
|||||||
import type { RibbitTheme } from './types';
|
import type { RibbitTheme } from './types';
|
||||||
import { defaultTags } from './tags';
|
import { defaultTags } from './tags';
|
||||||
|
|
||||||
|
/** Theme name used as the built-in default across ribbit. */
|
||||||
|
const DEFAULT_THEME_NAME = 'ribbit-default';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The built-in ribbit theme. Enables all default tags and source mode.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import { defaultTheme } from './default-theme';
|
||||||
|
* const editor = new RibbitEditor({ theme: defaultTheme });
|
||||||
|
*/
|
||||||
export const defaultTheme: RibbitTheme = {
|
export const defaultTheme: RibbitTheme = {
|
||||||
name: 'ribbit-default',
|
name: DEFAULT_THEME_NAME,
|
||||||
tags: defaultTags,
|
tags: defaultTags,
|
||||||
features: {
|
features: {
|
||||||
sourceMode: true,
|
sourceMode: true,
|
||||||
|
|||||||
+55
-1
@@ -2,7 +2,7 @@
|
|||||||
* events.ts — typed event emitter for the ribbit editor.
|
* events.ts — typed event emitter for the ribbit editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RibbitTheme } from './types';
|
import type { RibbitTheme, PeerInfo, Revision } from './types';
|
||||||
|
|
||||||
export interface ContentPayload {
|
export interface ContentPayload {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
@@ -72,10 +72,55 @@ export interface RibbitEventMap {
|
|||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
ready: (payload: ReadyPayload) => void;
|
ready: (payload: ReadyPayload) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remote users connected, disconnected, or moved their cursors.
|
||||||
|
*
|
||||||
|
* editor.on('peerChange', ({ peers }) => {
|
||||||
|
* updateUserList(peers);
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
peerChange: (payload: { peers: PeerInfo[] }) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Document lock acquired or released.
|
||||||
|
*
|
||||||
|
* editor.on('lockChange', ({ holder }) => {
|
||||||
|
* if (holder) showBanner(`Locked by ${holder.displayName}`);
|
||||||
|
* else hideBanner();
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
lockChange: (payload: { holder: PeerInfo | null }) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remote changes received while in source mode.
|
||||||
|
*
|
||||||
|
* editor.on('remoteActivity', ({ count }) => {
|
||||||
|
* statusBar.textContent = `${count} remote changes`;
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
remoteActivity: (payload: { count: number }) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A revision was created.
|
||||||
|
*
|
||||||
|
* editor.on('revisionCreated', ({ revision }) => {
|
||||||
|
* console.log(`Revision ${revision.id} saved`);
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
revisionCreated: (payload: { revision: Revision }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventName = keyof RibbitEventMap;
|
type EventName = keyof RibbitEventMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed event emitter for ribbit editor lifecycle and collaboration events.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const emitter = new RibbitEmitter();
|
||||||
|
* emitter.on('change', ({ markdown }) => console.log(markdown));
|
||||||
|
* emitter.emit('change', { markdown: '# Hello', html: '<h1>Hello</h1>' });
|
||||||
|
*/
|
||||||
export class RibbitEmitter {
|
export class RibbitEmitter {
|
||||||
private listeners: Map<string, Set<Function>>;
|
private listeners: Map<string, Set<Function>>;
|
||||||
|
|
||||||
@@ -85,6 +130,9 @@ export class RibbitEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a callback for an event.
|
* Register a callback for an event.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* emitter.on('save', ({ markdown }) => saveDraft(markdown));
|
||||||
*/
|
*/
|
||||||
on<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
on<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
||||||
if (!this.listeners.has(event)) {
|
if (!this.listeners.has(event)) {
|
||||||
@@ -95,6 +143,9 @@ export class RibbitEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a previously registered callback.
|
* Remove a previously registered callback.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* emitter.off('save', savedCallback);
|
||||||
*/
|
*/
|
||||||
off<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
off<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
|
||||||
this.listeners.get(event)?.delete(callback);
|
this.listeners.get(event)?.delete(callback);
|
||||||
@@ -102,6 +153,9 @@ export class RibbitEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit an event, calling all registered callbacks with the payload.
|
* Emit an event, calling all registered callbacks with the payload.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* emitter.emit('change', { markdown: '# Title', html: '<h1>Title</h1>' });
|
||||||
*/
|
*/
|
||||||
emit<K extends EventName>(event: K, ...args: Parameters<RibbitEventMap[K]>): void {
|
emit<K extends EventName>(event: K, ...args: Parameters<RibbitEventMap[K]>): void {
|
||||||
for (const callback of this.listeners.get(event) || []) {
|
for (const callback of this.listeners.get(event) || []) {
|
||||||
|
|||||||
+600
-151
@@ -1,36 +1,47 @@
|
|||||||
/*
|
/*
|
||||||
* hopdown.ts — configurable markdown↔HTML converter.
|
* hopdown.ts — configurable markdown↔HTML converter.
|
||||||
*
|
*
|
||||||
* Usage:
|
* HopDown orchestrates markdown↔HTML conversion using a tokenizer for
|
||||||
* const converter = new HopDown();
|
* inline parsing and a serializer for HTML→markdown. Block-level parsing
|
||||||
* const converter = new HopDown({ exclude: ['table'] });
|
* uses Tag definitions directly. The tokenizer/serializer architecture
|
||||||
* const converter = new HopDown({ tags: { ...defaultTags, 'DEL,S,STRIKE': strikethrough } });
|
* ensures correct round-trips by separating structural delimiters from
|
||||||
*
|
* literal text at the type level.
|
||||||
* converter.toHTML('**bold**');
|
|
||||||
* converter.toMarkdown('<strong>bold</strong>');
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Converter, MatchContext, Tag } from './types';
|
import type { Converter, MatchContext, Tag, DelimiterMatch } from './types';
|
||||||
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags';
|
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml } from './tags';
|
||||||
|
import { buildMacroTags, processInlineMacros, type MacroDef } from './macros';
|
||||||
|
import { InlineTokenizer, type InlineToken, type DelimiterDef } from './tokenizer';
|
||||||
|
import { MarkdownSerializer, type SerializerTagDef } from './serializer';
|
||||||
|
|
||||||
export type TagMap = Record<string, Tag>;
|
export type TagMap = Record<string, Tag>;
|
||||||
|
|
||||||
export interface HopDownOptions {
|
export interface HopDownOptions {
|
||||||
tags?: TagMap;
|
tags?: TagMap;
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
|
macros?: MacroDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A configurable markdown↔HTML converter.
|
* Configurable markdown↔HTML converter. Uses a tokenizer for inline
|
||||||
|
* parsing (markdown→HTML) and a serializer for HTML→markdown. Block
|
||||||
|
* parsing delegates to Tag definitions.
|
||||||
*
|
*
|
||||||
* By default includes all standard tags. Pass options to customize:
|
* const converter = new HopDown();
|
||||||
* - tags: a mapping of HTML selectors to Tag definitions
|
* converter.toHTML('**bold**');
|
||||||
* - exclude: remove specific tags by name from the defaults
|
* converter.toMarkdown('<strong>bold</strong>');
|
||||||
*/
|
*/
|
||||||
export class HopDown {
|
export class HopDown {
|
||||||
private blockTags: Tag[];
|
private blockTags: Tag[];
|
||||||
private inlineTags: Tag[];
|
private inlineTags: Tag[];
|
||||||
private tags: Map<string, Tag>;
|
private tags: Map<string, Tag>;
|
||||||
|
private macroMap: Map<string, MacroDef>;
|
||||||
|
private referenceLinks: Map<string, { url: string; title?: string }>;
|
||||||
|
private tokenizer: InlineTokenizer;
|
||||||
|
private serializer: MarkdownSerializer;
|
||||||
|
private cachedConverter: Converter;
|
||||||
|
private delimiterRegexes: { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[];
|
||||||
|
private editableSelectorCache: string;
|
||||||
|
|
||||||
constructor(options: HopDownOptions = {}) {
|
constructor(options: HopDownOptions = {}) {
|
||||||
let tagMap: TagMap;
|
let tagMap: TagMap;
|
||||||
@@ -46,63 +57,96 @@ export class HopDown {
|
|||||||
tagMap = defaultTags;
|
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 allTags = Object.values(tagMap);
|
||||||
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name));
|
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(tag => tag.name));
|
||||||
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name));
|
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(tag => tag.name));
|
||||||
|
|
||||||
this.blockTags = allTags.filter(tag =>
|
this.blockTags = allTags.filter(tag =>
|
||||||
defaultBlockNames.has(tag.name) ||
|
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
|
||||||
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern)
|
(!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 =>
|
this.inlineTags = allTags.filter(tag =>
|
||||||
defaultInlineNames.has(tag.name) || (tag as any).pattern
|
defaultInlineNames.has(tag.name) || tag.pattern
|
||||||
);
|
);
|
||||||
|
|
||||||
this.tags = new Map();
|
this.tags = new Map();
|
||||||
|
this.registerSelectors(tagMap);
|
||||||
|
this.validateInlineTags();
|
||||||
|
|
||||||
|
this.tokenizer = this.buildTokenizer();
|
||||||
|
this.serializer = this.buildSerializer();
|
||||||
|
this.cachedConverter = this.makeConverter();
|
||||||
|
this.delimiterRegexes = this.buildDelimiterRegexes();
|
||||||
|
this.editableSelectorCache = this.buildEditableSelector();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerSelectors(tagMap: TagMap): void {
|
||||||
for (const [selector, tag] of Object.entries(tagMap)) {
|
for (const [selector, tag] of Object.entries(tagMap)) {
|
||||||
for (const sel of selector.split(',').map(s => s.trim()).filter(Boolean)) {
|
const parts = selector.split(',').map(part => part.trim()).filter(Boolean);
|
||||||
if (sel.startsWith('_')) {
|
for (const part of parts) {
|
||||||
|
if (part.startsWith('_')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const existing = this.tags.get(sel);
|
const existing = this.tags.get(part);
|
||||||
if (existing && existing !== tag) {
|
if (existing && existing !== tag) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`HTML tag "${sel}" is claimed by both "${existing.name}" and "${tag.name}". ` +
|
`HTML tag "${part}" is claimed by both "${existing.name}" and "${tag.name}". ` +
|
||||||
`Use the exclude option to remove one before adding the other.`
|
`Use the exclude option to remove one before adding the other.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.tags.set(sel, tag);
|
this.tags.set(part, tag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.validateInlineTags();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify that no two inline tags have colliding delimiters without
|
|
||||||
* correct precedence ordering. If delimiter A is a prefix of delimiter B,
|
|
||||||
* B must have lower (earlier) precedence so the longer match wins.
|
|
||||||
*/
|
|
||||||
private validateInlineTags(): void {
|
private validateInlineTags(): void {
|
||||||
const withDelimiters = this.inlineTags
|
const withDelimiters = this.inlineTags
|
||||||
.filter(tag => (tag as any).delimiter)
|
.filter(tag => tag.delimiter)
|
||||||
.map(tag => ({
|
.map(tag => ({
|
||||||
name: tag.name,
|
name: tag.name,
|
||||||
delimiter: (tag as any).delimiter as string,
|
delimiter: tag.delimiter as string,
|
||||||
precedence: (tag as any).precedence as number ?? 50,
|
precedence: tag.precedence as number ?? 50,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
for (let i = 0; i < withDelimiters.length; i++) {
|
for (let outer = 0; outer < withDelimiters.length; outer++) {
|
||||||
for (let j = i + 1; j < withDelimiters.length; j++) {
|
for (let inner = outer + 1; inner < withDelimiters.length; inner++) {
|
||||||
const a = withDelimiters[i];
|
const first = withDelimiters[outer];
|
||||||
const b = withDelimiters[j];
|
const second = withDelimiters[inner];
|
||||||
const aPrefix = b.delimiter.startsWith(a.delimiter);
|
const firstIsPrefix = second.delimiter.startsWith(first.delimiter);
|
||||||
const bPrefix = a.delimiter.startsWith(b.delimiter);
|
const secondIsPrefix = first.delimiter.startsWith(second.delimiter);
|
||||||
if (!aPrefix && !bPrefix) {
|
if (!firstIsPrefix && !secondIsPrefix) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const longer = a.delimiter.length > b.delimiter.length ? a : b;
|
const longer = first.delimiter.length > second.delimiter.length ? first : second;
|
||||||
const shorter = a.delimiter.length > b.delimiter.length ? b : a;
|
const shorter = first.delimiter.length > second.delimiter.length ? second : first;
|
||||||
if (longer.precedence >= shorter.precedence) {
|
if (longer.precedence >= shorter.precedence) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` +
|
`Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` +
|
||||||
@@ -117,28 +161,145 @@ export class HopDown {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a markdown string to HTML.
|
* Convert a markdown string to HTML.
|
||||||
|
*
|
||||||
|
* converter.toHTML('# Hello\n\n**bold** text')
|
||||||
*/
|
*/
|
||||||
toHTML(md: string): string {
|
toHTML(markdown: string): string {
|
||||||
return this.processBlocks(md);
|
return this.processBlocks(markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an HTML string back to markdown.
|
* Convert an HTML string back to markdown. Uses the serializer
|
||||||
|
* which produces correctly-escaped output via typed tokens.
|
||||||
|
*
|
||||||
|
* converter.toMarkdown('<h1>Hello</h1><p><strong>bold</strong> text</p>')
|
||||||
*/
|
*/
|
||||||
toMarkdown(html: string): string {
|
toMarkdown(html: string): string {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
|
return this.serializeNode(container).replace(/\n{3,}/g, '\n\n').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private processBlocks(md: string): string {
|
/**
|
||||||
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
* The registered block-level tags. Used by the WYSIWYG editor
|
||||||
const output: string[] = [];
|
* to detect block syntax patterns during live editing.
|
||||||
let index = 0;
|
*
|
||||||
|
* converter.getBlockTags().forEach(tag => console.log(tag.name))
|
||||||
|
*/
|
||||||
|
getBlockTags(): Tag[] {
|
||||||
|
return this.blockTags;
|
||||||
|
}
|
||||||
|
|
||||||
while (index < lines.length) {
|
/**
|
||||||
if (/^\s*$/.test(lines[index])) {
|
* The registered inline tags. Used by the WYSIWYG editor to
|
||||||
index++;
|
* build delimiter regexes for speculative rendering.
|
||||||
|
*
|
||||||
|
* converter.getInlineTags().filter(tag => tag.delimiter)
|
||||||
|
*/
|
||||||
|
getInlineTags(): Tag[] {
|
||||||
|
return this.inlineTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first complete delimiter pair in the text.
|
||||||
|
*
|
||||||
|
* converter.findCompletePair('hello **world** end')
|
||||||
|
*/
|
||||||
|
findCompletePair(text: string): DelimiterMatch | null {
|
||||||
|
for (const entry of this.delimiterRegexes) {
|
||||||
|
const match = text.match(entry.complete);
|
||||||
|
if (match && match.index !== undefined) {
|
||||||
|
return {
|
||||||
|
tag: entry.tag,
|
||||||
|
htmlTag: entry.htmlTag,
|
||||||
|
content: match[1],
|
||||||
|
index: match.index,
|
||||||
|
length: match[0].length,
|
||||||
|
delimiter: entry.tag.delimiter!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first unclosed delimiter opener in the text.
|
||||||
|
*
|
||||||
|
* converter.findUnmatchedOpener('hello **world')
|
||||||
|
*/
|
||||||
|
findUnmatchedOpener(text: string): DelimiterMatch | null {
|
||||||
|
for (const entry of this.delimiterRegexes) {
|
||||||
|
const match = text.match(entry.open);
|
||||||
|
if (match && match.index !== undefined) {
|
||||||
|
const before = text.slice(0, match.index);
|
||||||
|
if (before.endsWith('<') || before.endsWith('/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tag: entry.tag,
|
||||||
|
htmlTag: entry.htmlTag,
|
||||||
|
content: match[1],
|
||||||
|
index: match.index,
|
||||||
|
length: match[0].length,
|
||||||
|
delimiter: entry.tag.delimiter!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the Tag definition for an HTML element by its tag name.
|
||||||
|
*
|
||||||
|
* converter.getTagForElement(strongElement)
|
||||||
|
*/
|
||||||
|
getTagForElement(element: HTMLElement): Tag | null {
|
||||||
|
const tag = this.tags.get(element.tagName);
|
||||||
|
if (tag && tag.delimiter) {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS selector string matching all elements that should show
|
||||||
|
* editing context.
|
||||||
|
*
|
||||||
|
* element.matches(converter.getEditableSelector())
|
||||||
|
*/
|
||||||
|
getEditableSelector(): string {
|
||||||
|
return this.editableSelectorCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split markdown into lines, match each against block tags in
|
||||||
|
* priority order, and concatenate the resulting HTML.
|
||||||
|
*/
|
||||||
|
private processBlocks(markdown: string): string {
|
||||||
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
const output: string[] = [];
|
||||||
|
const blankLine = /^\s*$/;
|
||||||
|
const refDefinition = /^\[(?<label>[^\]]+)\]:\s+(?<url>\S+)(?:\s+"(?<title>[^"]*)")?$/;
|
||||||
|
let lineIndex = 0;
|
||||||
|
|
||||||
|
// Collect reference link definitions
|
||||||
|
this.referenceLinks = new Map();
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(refDefinition);
|
||||||
|
if (match?.groups) {
|
||||||
|
this.referenceLinks.set(
|
||||||
|
match.groups.label.toLowerCase(),
|
||||||
|
{
|
||||||
|
url: match.groups.url,
|
||||||
|
title: match.groups.title,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (lineIndex < lines.length) {
|
||||||
|
if (blankLine.test(lines[lineIndex]) || refDefinition.test(lines[lineIndex])) {
|
||||||
|
lineIndex++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,147 +307,435 @@ export class HopDown {
|
|||||||
for (const tag of this.blockTags) {
|
for (const tag of this.blockTags) {
|
||||||
const context: MatchContext = {
|
const context: MatchContext = {
|
||||||
lines,
|
lines,
|
||||||
index,
|
index: lineIndex,
|
||||||
text: '',
|
text: '',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
};
|
||||||
const token = tag.match(context);
|
const token = tag.match(context);
|
||||||
if (!token) continue;
|
if (!token) {
|
||||||
|
continue;
|
||||||
if (tag.name === 'list') {
|
|
||||||
const result = parseListBlock(lines, index, 0, (source) => this.processInline(source));
|
|
||||||
output.push(result.html);
|
|
||||||
index = result.end;
|
|
||||||
} else {
|
|
||||||
output.push(tag.toHTML(token, this.makeConverter()));
|
|
||||||
index += token.consumed;
|
|
||||||
}
|
}
|
||||||
|
output.push(tag.toHTML(token, this.cachedConverter));
|
||||||
|
lineIndex += token.consumed;
|
||||||
matched = true;
|
matched = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
index++;
|
lineIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return output.join('\n');
|
return output.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert inline markdown to HTML using the tokenizer.
|
||||||
|
* Tokenizes the source, then walks the token stream to build HTML.
|
||||||
|
* Open/close delimiter pairs are matched using a stack.
|
||||||
|
*/
|
||||||
private processInline(source: string): string {
|
private processInline(source: string): string {
|
||||||
const sorted = [...this.inlineTags].sort((a, b) =>
|
|
||||||
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
|
|
||||||
);
|
|
||||||
|
|
||||||
const placeholders: string[] = [];
|
|
||||||
let text = source;
|
let text = source;
|
||||||
|
|
||||||
// Pass 1: extract links and non-recursive tags into placeholders before escaping
|
// Process inline macros before tokenizing — they produce HTML
|
||||||
for (const tag of sorted) {
|
// that should pass through without further parsing
|
||||||
const recursive = (tag as any).recursive ?? true;
|
if (this.macroMap.size > 0) {
|
||||||
|
const placeholders: string[] = [];
|
||||||
if (tag.name === 'link') {
|
text = processInlineMacros(text, this.macroMap, this.cachedConverter, placeholders);
|
||||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
|
// Restore placeholders to their HTML content
|
||||||
// Process link text: restore earlier placeholders, then run inline on any remaining markdown
|
const placeholderPattern = /\x00P(?<index>\d+)\x00/g;
|
||||||
let inner = linkText;
|
text = text.replace(placeholderPattern, (_, index: string) =>
|
||||||
// Check if link text contains placeholders (already-processed content)
|
placeholders[parseInt(index)]
|
||||||
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);
|
// Resolve reference links before tokenizing
|
||||||
|
text = this.resolveReferenceLinks(text);
|
||||||
// Pass 2: apply recursive tags in precedence order (longest delimiter first).
|
// Normalize _ emphasis to *
|
||||||
// Content matched here is already HTML-escaped and has had earlier
|
text = this.normalizeUnderscores(text);
|
||||||
// passes applied, so we wrap directly without re-processing.
|
const tokens = this.tokenizer.tokenize(text);
|
||||||
for (const tag of sorted) {
|
return this.tokensToHTML(tokens);
|
||||||
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)]);
|
* 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;
|
return text;
|
||||||
}
|
}
|
||||||
|
const refLink = /\[(?<text>[^\[\]]+)\]\[(?<label>[^\]]*)\]/g;
|
||||||
|
return text.replace(refLink, (...args) => {
|
||||||
|
const groups = args[args.length - 1] as Record<string, string>;
|
||||||
|
const label = (groups.label || groups.text).toLowerCase();
|
||||||
|
const ref = this.referenceLinks.get(label);
|
||||||
|
if (!ref) {
|
||||||
|
return args[0];
|
||||||
|
}
|
||||||
|
const titlePart = ref.title ? ` "${ref.title}"` : '';
|
||||||
|
return `[${groups.text}](${ref.url}${titlePart})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private nodeToMd(node: Node): string {
|
/**
|
||||||
|
* Normalize flanking underscore runs to asterisks so the tokenizer
|
||||||
|
* only needs to handle * delimiters for emphasis.
|
||||||
|
*/
|
||||||
|
private normalizeUnderscores(text: string): string {
|
||||||
|
// Protect backslash-escaped underscores from normalization
|
||||||
|
const escapePlaceholder = '\x00U\x00';
|
||||||
|
const safeText = text.replace(/\\_/g, escapePlaceholder);
|
||||||
|
|
||||||
|
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
|
||||||
|
const openRun = new RegExp(
|
||||||
|
`(?<=^|${punctuation})` + // preceded by start, space, or punctuation
|
||||||
|
`(_+)` + // one or more underscores
|
||||||
|
`(?=\\S)`, // followed by non-whitespace
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
const closeRun = new RegExp(
|
||||||
|
`(?<=\\S)` + // preceded by non-whitespace
|
||||||
|
`(_+)` + // one or more underscores
|
||||||
|
`(?=$|${punctuation})`, // followed by end, space, or punctuation
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
const toAsterisks = (_: string, run: string) => '*'.repeat(run.length);
|
||||||
|
const normalized = safeText
|
||||||
|
.replace(openRun, toAsterisks)
|
||||||
|
.replace(closeRun, toAsterisks);
|
||||||
|
|
||||||
|
return normalized.replace(/\x00U\x00/g, '\\_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a token stream to HTML. Matches open/close delimiter
|
||||||
|
* pairs and wraps their content in the appropriate HTML tags.
|
||||||
|
* Unmatched delimiters are emitted as literal text.
|
||||||
|
*/
|
||||||
|
private tokensToHTML(tokens: InlineToken[]): string {
|
||||||
|
// Build a map from delimiter string to tag info
|
||||||
|
const delimiterToTag = new Map<string, { htmlTag: string; name: string }>();
|
||||||
|
for (const tag of this.inlineTags) {
|
||||||
|
if (tag.delimiter) {
|
||||||
|
const htmlTag = tag.name === 'boldItalic'
|
||||||
|
? 'em'
|
||||||
|
: (tag.selector as string).split(',')[0].toLowerCase();
|
||||||
|
delimiterToTag.set(tag.delimiter, {
|
||||||
|
htmlTag,
|
||||||
|
name: tag.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: match open/close pairs using a stack
|
||||||
|
const paired = this.pairDelimiters(tokens);
|
||||||
|
|
||||||
|
// Second pass: build HTML from paired tokens
|
||||||
|
let html = '';
|
||||||
|
for (const token of paired) {
|
||||||
|
switch (token.role) {
|
||||||
|
case 'text':
|
||||||
|
html += escapeHtml(token.value);
|
||||||
|
break;
|
||||||
|
case 'open': {
|
||||||
|
const info = delimiterToTag.get(token.delimiter!);
|
||||||
|
if (info) {
|
||||||
|
if (info.name === 'boldItalic') {
|
||||||
|
html += '<em><strong>';
|
||||||
|
} else {
|
||||||
|
html += `<${info.htmlTag}>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += escapeHtml(token.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'close': {
|
||||||
|
const info = delimiterToTag.get(token.delimiter!);
|
||||||
|
if (info) {
|
||||||
|
if (info.name === 'boldItalic') {
|
||||||
|
html += '</strong></em>';
|
||||||
|
} else {
|
||||||
|
html += `</${info.htmlTag}>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += escapeHtml(token.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'code':
|
||||||
|
html += `<code>${escapeHtml(token.content || '')}</code>`;
|
||||||
|
break;
|
||||||
|
case 'link': {
|
||||||
|
const titleAttr = token.title
|
||||||
|
? ` title="${escapeHtml(token.title)}"`
|
||||||
|
: '';
|
||||||
|
// Process link text for nested inline formatting
|
||||||
|
const innerTokens = this.tokenizer.tokenize(token.value);
|
||||||
|
const innerHtml = this.tokensToHTML(innerTokens);
|
||||||
|
// Strip any nested <a> tags (links can't contain links)
|
||||||
|
const nestedLink = /<a[^>]*>|<\/a>/g;
|
||||||
|
const cleanInner = innerHtml.replace(nestedLink, '');
|
||||||
|
html += `<a href="${escapeHtml(token.href!)}"${titleAttr}>${cleanInner}</a>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'autolink':
|
||||||
|
html += `<a href="${escapeHtml(token.href!)}">${escapeHtml(token.value)}</a>`;
|
||||||
|
break;
|
||||||
|
case 'html':
|
||||||
|
html += token.value;
|
||||||
|
break;
|
||||||
|
case 'break':
|
||||||
|
html += '<br>';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
html += escapeHtml(token.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match open/close delimiter pairs in a token stream. Unmatched
|
||||||
|
* openers/closers are converted to text tokens so they render
|
||||||
|
* as literal characters.
|
||||||
|
*/
|
||||||
|
private pairDelimiters(tokens: InlineToken[]): InlineToken[] {
|
||||||
|
const openStack: number[] = [];
|
||||||
|
const result = [...tokens];
|
||||||
|
|
||||||
|
// Track which delimiter types are currently open to prevent
|
||||||
|
// forbidden nesting (e.g. <del> inside <del>, <em> inside <em>)
|
||||||
|
const openDelimiters = new Set<string>();
|
||||||
|
|
||||||
|
for (let index = 0; index < result.length; index++) {
|
||||||
|
const token = result[index];
|
||||||
|
if (token.role === 'open') {
|
||||||
|
// Don't open a delimiter that's already open (prevents nesting)
|
||||||
|
if (openDelimiters.has(token.delimiter!)) {
|
||||||
|
result[index] = {
|
||||||
|
role: 'text',
|
||||||
|
value: token.value,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
openStack.push(index);
|
||||||
|
openDelimiters.add(token.delimiter!);
|
||||||
|
} else if (token.role === 'close') {
|
||||||
|
let matched = false;
|
||||||
|
for (let stackIndex = openStack.length - 1; stackIndex >= 0; stackIndex--) {
|
||||||
|
const openerIndex = openStack[stackIndex];
|
||||||
|
if (result[openerIndex].delimiter === token.delimiter) {
|
||||||
|
openStack.splice(stackIndex, 1);
|
||||||
|
openDelimiters.delete(token.delimiter!);
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
result[index] = {
|
||||||
|
role: 'text',
|
||||||
|
value: token.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any remaining unmatched openers become literal text
|
||||||
|
for (const openerIndex of openStack) {
|
||||||
|
result[openerIndex] = {
|
||||||
|
role: 'text',
|
||||||
|
value: result[openerIndex].value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a DOM node to markdown using the serializer for inline
|
||||||
|
* content and custom logic for block-level elements.
|
||||||
|
*/
|
||||||
|
private serializeNode(node: Node): string {
|
||||||
if (node.nodeType === 3) {
|
if (node.nodeType === 3) {
|
||||||
return node.textContent || '';
|
return this.serializer.serialize(node);
|
||||||
}
|
}
|
||||||
if (node.nodeType !== 1) {
|
if (node.nodeType !== 1) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const element = node as HTMLElement;
|
const element = node as HTMLElement;
|
||||||
|
|
||||||
|
// 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);
|
const tag = this.tags.get(element.nodeName);
|
||||||
if (tag) {
|
if (tag) {
|
||||||
return tag.toMarkdown(element, this.makeConverter());
|
return tag.toMarkdown(element, this.cachedConverter);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.childrenToMd(node);
|
return this.serializeChildren(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
private childrenToMd(node: Node): string {
|
private matchCssSelector(element: HTMLElement): Tag | null {
|
||||||
return Array.from(node.childNodes).map(child => this.nodeToMd(child)).join('');
|
for (const [selector, tag] of this.tags.entries()) {
|
||||||
|
if (!selector.includes('[') && !selector.includes('.') && !selector.includes('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uppercaseTagName = /^[A-Z]+/;
|
||||||
|
const normalized = selector.replace(uppercaseTagName, part => part.toLowerCase());
|
||||||
|
try {
|
||||||
|
if (element.matches(normalized)) {
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid selector — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeChildren(node: Node): string {
|
||||||
|
return Array.from(node.childNodes)
|
||||||
|
.map(child => this.serializeNode(child))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the inline tokenizer from registered delimiter-based tags.
|
||||||
|
*/
|
||||||
|
private buildTokenizer(): InlineTokenizer {
|
||||||
|
const hasCodeTag = this.inlineTags.some(tag => tag.name === 'code');
|
||||||
|
const delimiterDefs: DelimiterDef[] = this.inlineTags
|
||||||
|
.filter(tag => tag.delimiter && tag.name !== 'code')
|
||||||
|
.map(tag => ({
|
||||||
|
delimiter: tag.delimiter!,
|
||||||
|
htmlTag: tag.name === 'boldItalic'
|
||||||
|
? 'em'
|
||||||
|
: (tag.selector as string).split(',')[0].toLowerCase(),
|
||||||
|
recursive: tag.recursive !== false,
|
||||||
|
precedence: tag.precedence ?? 50,
|
||||||
|
}));
|
||||||
|
return new InlineTokenizer(delimiterDefs, { codeSpans: hasCodeTag });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the markdown serializer from registered tags. Maps HTML
|
||||||
|
* element names to their serialization strategy (delimiter wrap
|
||||||
|
* or custom function).
|
||||||
|
*/
|
||||||
|
private buildSerializer(): MarkdownSerializer {
|
||||||
|
const tagMap = new Map<string, SerializerTagDef>();
|
||||||
|
const delimiterChars = new Set<string>();
|
||||||
|
|
||||||
|
for (const [selector, tag] of this.tags.entries()) {
|
||||||
|
if (tag.delimiter) {
|
||||||
|
delimiterChars.add(tag.delimiter[0]);
|
||||||
|
// Delimiter-based tags: emit delimiter + children + delimiter
|
||||||
|
for (const part of selector.split(',').map(part => part.trim())) {
|
||||||
|
tagMap.set(part, { delimiter: tag.delimiter });
|
||||||
|
}
|
||||||
|
} else if (tag.name === 'link') {
|
||||||
|
tagMap.set('A', {
|
||||||
|
serialize: (element, children) => {
|
||||||
|
const href = element.getAttribute('href') || '';
|
||||||
|
const title = element.getAttribute('title');
|
||||||
|
const titlePart = title ? ` "${title}"` : '';
|
||||||
|
return '[' + children() + '](' + href + titlePart + ')';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (tag.name === 'hardBreak') {
|
||||||
|
tagMap.set('BR', {
|
||||||
|
serialize: () => ' \n',
|
||||||
|
});
|
||||||
|
} else if (tag.name === 'fencedCode') {
|
||||||
|
tagMap.set('PRE', {
|
||||||
|
serialize: (element) => {
|
||||||
|
const code = element.querySelector('code');
|
||||||
|
const langMatch = (code?.getAttribute('class') || '').match(/language-(\S+)/);
|
||||||
|
const lang = langMatch ? langMatch[1] : '';
|
||||||
|
const content = code?.textContent || element.textContent || '';
|
||||||
|
return '\n\n```' + lang + '\n' + content + '\n```\n\n';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CODE gets a custom serializer because its content is literal
|
||||||
|
tagMap.set('CODE', {
|
||||||
|
serialize: (element) => {
|
||||||
|
// Code inside <pre> is handled by the PRE serializer
|
||||||
|
if (element.parentNode?.nodeName === 'PRE') {
|
||||||
|
return element.textContent || '';
|
||||||
|
}
|
||||||
|
return '`' + (element.textContent || '') + '`';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new MarkdownSerializer(tagMap, delimiterChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDelimiterRegexes(): { tag: Tag; htmlTag: string; complete: RegExp; open: RegExp }[] {
|
||||||
|
const escapeRegex = /[.*+?^${}()|[\]\\]/g;
|
||||||
|
const sorted = this.inlineTags
|
||||||
|
.filter(tag => tag.delimiter)
|
||||||
|
.sort((first, second) => (first.precedence ?? 50) - (second.precedence ?? 50));
|
||||||
|
|
||||||
|
return sorted.map(tag => {
|
||||||
|
const delimiter = tag.delimiter!;
|
||||||
|
const escaped = delimiter.replace(escapeRegex, '\\$&');
|
||||||
|
const escapedChar = delimiter[0].replace(escapeRegex, '\\$&');
|
||||||
|
const htmlTag = tag.name === 'boldItalic'
|
||||||
|
? 'em'
|
||||||
|
: (tag.selector as string).split(',')[0].toLowerCase();
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
htmlTag,
|
||||||
|
complete: new RegExp(
|
||||||
|
`(?<!${escapedChar})` +
|
||||||
|
`${escaped}` +
|
||||||
|
`(?!${escapedChar})` +
|
||||||
|
`([^\\x01\\x02]+?)` +
|
||||||
|
`(?<!${escapedChar})` +
|
||||||
|
`${escaped}`
|
||||||
|
),
|
||||||
|
open: new RegExp(
|
||||||
|
`(?<!${escapedChar})` +
|
||||||
|
`${escaped}` +
|
||||||
|
`(?!${escapedChar})` +
|
||||||
|
`([^\\x01\\x02]+)$`
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEditableSelector(): string {
|
||||||
|
return [
|
||||||
|
...this.inlineTags,
|
||||||
|
...this.blockTags,
|
||||||
|
].filter(tag => typeof tag.selector === 'string')
|
||||||
|
.map(tag => (tag.selector as string).toLowerCase())
|
||||||
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
private makeConverter(): Converter {
|
private makeConverter(): Converter {
|
||||||
return {
|
return {
|
||||||
inline: (source) => this.processInline(source),
|
inline: (source) => this.processInline(source),
|
||||||
block: (md) => this.processBlocks(md),
|
block: (markdown) => this.processBlocks(markdown),
|
||||||
children: (node) => this.childrenToMd(node),
|
children: (node) => this.serializeChildren(node),
|
||||||
node: (node) => this.nodeToMd(node),
|
node: (node) => this.serializeNode(node),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A default HopDown instance with all standard tags enabled.
|
|
||||||
* Use this for simple cases where no configuration is needed.
|
|
||||||
*/
|
|
||||||
const hopdown = new HopDown();
|
|
||||||
|
|
||||||
export function toHTML(md: string): string {
|
|
||||||
return hopdown.toHTML(md);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toMarkdown(html: string): string {
|
|
||||||
return hopdown.toMarkdown(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default hopdown;
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./ribbit";
|
||||||
|
export * from "./hopdown";
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
+795
-53
@@ -1,113 +1,855 @@
|
|||||||
/*
|
/*
|
||||||
* ribbit-editor.ts — WYSIWYG editing extension for Ribbit.
|
* ribbit-editor.ts — Styled-source editing extension for Ribbit.
|
||||||
|
*
|
||||||
|
* The editor is always a markdown text editor. There is no separate
|
||||||
|
* WYSIWYG mode — the user edits markdown directly, but CSS styling
|
||||||
|
* makes it look like rendered output. Delimiters (**, *, `, etc.)
|
||||||
|
* are hidden when the cursor is outside their span, and revealed
|
||||||
|
* when the cursor enters it.
|
||||||
|
*
|
||||||
|
* Two CSS states replace the old three-mode system:
|
||||||
|
* editing: contentEditable="true", delimiters revealed on focus
|
||||||
|
* viewing: contentEditable="false", all delimiters hidden
|
||||||
|
*
|
||||||
|
* The DOM is identical in both states — only CSS changes. This
|
||||||
|
* eliminates all conversion-during-editing bugs and removes the
|
||||||
|
* flatten→rebuild pipeline entirely.
|
||||||
|
*
|
||||||
|
* getMarkdown() reads element.textContent directly. Because every
|
||||||
|
* delimiter character lives in a text node inside a .md-delim span,
|
||||||
|
* textContent always equals the original markdown source — no
|
||||||
|
* conversion is needed.
|
||||||
|
*
|
||||||
|
* getHTML() runs the existing HopDown tokenizer on demand (export,
|
||||||
|
* save, API calls) — never during editing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { HopDown } from './hopdown';
|
|
||||||
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
|
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
|
||||||
import { defaultTheme } from './default-theme';
|
import { defaultTheme } from './default-theme';
|
||||||
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
||||||
|
import type { Tag } from './types';
|
||||||
|
import { type MacroDef } from './macros';
|
||||||
|
|
||||||
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// CSS class applied to the formatting span the cursor is currently inside.
|
||||||
|
// CSS uses this to reveal .md-delim children for that span only.
|
||||||
|
const EDITING_CONTEXT_CLASS = 'ribbit-editing';
|
||||||
|
|
||||||
|
// CSS class prefix for all styled-source block divs.
|
||||||
|
const BLOCK_CLASS_PREFIX = 'md-';
|
||||||
|
|
||||||
|
// CSS class applied to all delimiter spans (e.g. the ** in **bold**).
|
||||||
|
const DELIM_CLASS = 'md-delim';
|
||||||
|
|
||||||
|
// CSS class applied to list-item prefix spans (e.g. "- " or "1. ").
|
||||||
|
const LIST_PREFIX_CLASS = 'md-list-prefix';
|
||||||
|
|
||||||
|
// data- attribute on inline formatting spans.
|
||||||
|
const INLINE_SPAN_ATTR = 'data-md-span';
|
||||||
|
|
||||||
|
// Characters this implementation inserts purely as internal bookkeeping
|
||||||
|
// (never something the user typed, never part of real markdown content):
|
||||||
|
// \u200B — placeholder text node so an empty block-prefix line (e.g.
|
||||||
|
// "# " with nothing after it) still has a text node for the
|
||||||
|
// caret to land in.
|
||||||
|
// \u200C — boundary marker inserted by disambiguateAsteriskRuns
|
||||||
|
// between two adjacent closing delimiters that would
|
||||||
|
// otherwise form an ambiguous run (e.g. "***" closing both
|
||||||
|
// an inner "*" and an outer "**").
|
||||||
|
// These are stripped entirely from getMarkdown() output and skipped
|
||||||
|
// (never counted) by caret-offset math in both directions. \u00A0 is
|
||||||
|
// handled separately by normalizeLineText below — it is NOT an
|
||||||
|
// internal marker, since it stands in for a real space the user typed.
|
||||||
|
const INTERNAL_MARKER_CHARS = '\u200B\u200C';
|
||||||
|
const INTERNAL_MARKER_PATTERN = new RegExp(`[${INTERNAL_MARKER_CHARS}]`, 'g');
|
||||||
|
const INTERNAL_MARKER_SINGLE = new RegExp(`^[${INTERNAL_MARKER_CHARS}]$`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
|
* Remove all internal bookkeeping markers from a string.
|
||||||
|
* stripInternalMarkers('**bold *italic*\u200C**') // '**bold *italic***'
|
||||||
|
*/
|
||||||
|
function stripInternalMarkers(text: string): string {
|
||||||
|
return text.replace(INTERNAL_MARKER_PATTERN, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if a single character is purely internal bookkeeping.
|
||||||
|
* isInternalMarker('\u200C') // true
|
||||||
|
*/
|
||||||
|
function isInternalMarker(character: string): boolean {
|
||||||
|
return INTERNAL_MARKER_SINGLE.test(character);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a block's raw textContent into clean markdown source:
|
||||||
|
* convert NBSP (inserted for Chromium caret-stability when a line ends
|
||||||
|
* in a space — never deleted, since it represents a real space the
|
||||||
|
* user typed) back to a plain space, then strip internal bookkeeping
|
||||||
|
* markers (never user content, always deleted entirely).
|
||||||
*
|
*
|
||||||
* Extends Ribbit with contentEditable support and bidirectional
|
* normalizeLineText('#\u00A0Title\u200C') // '# Title'
|
||||||
* markdown↔HTML conversion on mode switches.
|
*/
|
||||||
|
function normalizeLineText(text: string): string {
|
||||||
|
return stripInternalMarkers(text.replace(/\u00A0/g, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Block classification ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface BlockRule {
|
||||||
|
name: string;
|
||||||
|
prefixLength: (line: string) => number | null;
|
||||||
|
isList?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADING_PATTERN = /^(?<hashes>#{1,6}) /;
|
||||||
|
const BLOCKQUOTE_PATTERN = /^> /;
|
||||||
|
const UNORDERED_LIST_PATTERN = /^[-*+] /;
|
||||||
|
const ORDERED_LIST_PATTERN = /^\d+\. /;
|
||||||
|
|
||||||
|
const BLOCK_RULES: BlockRule[] = [
|
||||||
|
{
|
||||||
|
name: 'pre',
|
||||||
|
prefixLength: (line) => {
|
||||||
|
const FENCE_PATTERN = /^(?<fence>`{3,}|~{3,})/;
|
||||||
|
const match = line.match(FENCE_PATTERN);
|
||||||
|
return match ? match[0].length : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'heading',
|
||||||
|
prefixLength: (line) => {
|
||||||
|
const match = line.match(HEADING_PATTERN);
|
||||||
|
return match ? match[0].length : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'blockquote',
|
||||||
|
prefixLength: (line) => BLOCKQUOTE_PATTERN.test(line) ? 2 : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list-item',
|
||||||
|
prefixLength: (line) => {
|
||||||
|
if (!UNORDERED_LIST_PATTERN.test(line)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return line.indexOf(' ') + 1;
|
||||||
|
},
|
||||||
|
isList: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ol-list-item',
|
||||||
|
prefixLength: (line) => {
|
||||||
|
if (!ORDERED_LIST_PATTERN.test(line)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return line.indexOf(' ') + 1;
|
||||||
|
},
|
||||||
|
isList: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Inline delimiter disambiguation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-pass over a line of markdown text that disambiguates runs of 3
|
||||||
|
* consecutive asterisks (e.g. "**bold *italic***") into two separate,
|
||||||
|
* unambiguous tokens, separated by a sentinel.
|
||||||
|
*
|
||||||
|
* Markdown allows * and ** to nest (one inside the other) but this
|
||||||
|
* implementation does not support nesting a delimiter inside itself,
|
||||||
|
* so at most two distinct asterisk-based delimiters can be open at
|
||||||
|
* once. A run of exactly 3 asterisks therefore always means "open or
|
||||||
|
* close the most-recently-opened one first, then the other."
|
||||||
|
*
|
||||||
|
* Handles both directions of the ambiguity:
|
||||||
|
* - closing-side: "**bold *italic***" — the trailing run of 3
|
||||||
|
* closes italic (innermost) then bold.
|
||||||
|
* - opening-side: "***italic* bold**" — the leading run of 3 opens
|
||||||
|
* italic then bold (by convention, single-char delimiter is
|
||||||
|
* treated as inner/most-recently-opened, matching the closing
|
||||||
|
* rule, for symmetry).
|
||||||
|
*
|
||||||
|
* disambiguateAsteriskRuns('**bold *italic***')
|
||||||
|
* // '**bold *italic*\u200C**'
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SENTINEL = '\u200C';
|
||||||
|
|
||||||
|
function disambiguateAsteriskRuns(line: string): string {
|
||||||
|
const ASTERISK_RUN = /\*{1,3}/g;
|
||||||
|
const stack: ('*' | '**' | '***')[] = [];
|
||||||
|
let result = '';
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
const bold = '**';
|
||||||
|
const italic = '*';
|
||||||
|
|
||||||
|
let lastSequence = '';
|
||||||
|
|
||||||
|
while ((match = ASTERISK_RUN.exec(line)) !== null) {
|
||||||
|
result += line.slice(lastIndex, match.index);
|
||||||
|
const sequence = match[0];
|
||||||
|
|
||||||
|
if (sequence.length === 3) {
|
||||||
|
|
||||||
|
// split *** into opening ** and *
|
||||||
|
if (stack.length === 0) {
|
||||||
|
result += sequence;
|
||||||
|
stack.push('***');
|
||||||
|
|
||||||
|
// closing ***, close the stack
|
||||||
|
} else if (stack.length === 1 && stack[0] === sequence) {
|
||||||
|
result = result.replace('***', bold + SENTINEL + italic);
|
||||||
|
result += italic + SENTINEL + bold;
|
||||||
|
stack.pop();
|
||||||
|
|
||||||
|
} else if (stack.length === 2) {
|
||||||
|
const inner = stack.pop();
|
||||||
|
const outer = stack.pop();
|
||||||
|
result += inner + SENTINEL + outer;
|
||||||
|
|
||||||
|
} else if (stack.length === 1) {
|
||||||
|
console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`);
|
||||||
|
}
|
||||||
|
} else if (stack.length) {
|
||||||
|
if (stack[stack.length - 1] === sequence) {
|
||||||
|
result += stack.pop();
|
||||||
|
} else if (stack.length === 1 && stack[0] === '***') {
|
||||||
|
const opener = stack[0].substring(0, stack[0].length - sequence.length);
|
||||||
|
result = result.replace('***', opener + SENTINEL + sequence);
|
||||||
|
result += sequence;
|
||||||
|
stack[0] = opener as '*' | '**';
|
||||||
|
} else {
|
||||||
|
stack.push(sequence as '*' | '**');
|
||||||
|
result += sequence;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stack.push(sequence as '*' | '**');
|
||||||
|
result += sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + sequence.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += line.slice(lastIndex);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RibbitEditor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Styled-source WYSIWYG editor. Extends Ribbit's read-only viewer with
|
||||||
|
* contentEditable support. The user always edits raw markdown; CSS
|
||||||
|
* renders it visually. Per-line incremental DOM update.
|
||||||
*
|
*
|
||||||
* Usage:
|
|
||||||
* const editor = new RibbitEditor({ editorId: 'my-element' });
|
* const editor = new RibbitEditor({ editorId: 'my-element' });
|
||||||
* editor.run();
|
* editor.run();
|
||||||
* editor.wysiwyg(); // switch to WYSIWYG mode
|
* editor.wysiwyg();
|
||||||
* editor.edit(); // switch to source editing mode
|
|
||||||
* editor.view(); // switch to read-only view
|
|
||||||
*/
|
*/
|
||||||
export class RibbitEditor extends Ribbit {
|
export class RibbitEditor extends Ribbit {
|
||||||
|
|
||||||
|
private activeFormattingSpan: HTMLElement | null = null;
|
||||||
|
|
||||||
run(): void {
|
run(): void {
|
||||||
this.states = {
|
this.states = {
|
||||||
VIEW: 'view',
|
VIEW: 'view',
|
||||||
EDIT: 'edit',
|
WYSIWYG: 'wysiwyg',
|
||||||
WYSIWYG: 'wysiwyg'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.#bindEvents();
|
this.#bindEvents();
|
||||||
this.plugins().forEach(plugin => {
|
|
||||||
plugin.setEditable();
|
|
||||||
});
|
|
||||||
this.element.classList.add('loaded');
|
this.element.classList.add('loaded');
|
||||||
|
if (this.autoToolbar) {
|
||||||
|
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
|
||||||
|
}
|
||||||
this.view();
|
this.view();
|
||||||
|
this.emitReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
#bindEvents(): void {
|
#bindEvents(): void {
|
||||||
|
let debounceTimer: number | undefined;
|
||||||
|
|
||||||
this.element.addEventListener('input', () => {
|
this.element.addEventListener('input', () => {
|
||||||
if (this.state !== this.states.VIEW) {
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#updateCurrentBlock();
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = window.setTimeout(() => {
|
||||||
this.notifyChange();
|
this.notifyChange();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.element.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
const children = Array.from(this.element.children);
|
||||||
|
const onlyChild = children.length === 1 ? children[0] as HTMLElement : null;
|
||||||
|
const isEmpty = onlyChild &&
|
||||||
|
!stripInternalMarkers(onlyChild.textContent!).trim() &&
|
||||||
|
onlyChild.querySelector('br');
|
||||||
|
if (isEmpty) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#dispatchKeydown(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.element.addEventListener('keyup', (event: KeyboardEvent) => {
|
||||||
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.startsWith('Arrow')) {
|
||||||
|
this.#updateEditingContext();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
htmlToMarkdown(html?: string): string {
|
document.addEventListener('selectionchange', () => {
|
||||||
return this.converter.toMarkdown(html || this.element.innerHTML);
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.#updateEditingContext();
|
||||||
|
});
|
||||||
|
|
||||||
getMarkdown(): string {
|
document.addEventListener('click', (event: MouseEvent) => {
|
||||||
if (this.getState() === this.states.EDIT) {
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
let html = this.element.innerHTML;
|
return;
|
||||||
html = html.replace(/<(?:div|br)>/ig, '');
|
|
||||||
html = html.replace(/<\/div>/ig, '\n');
|
|
||||||
this.cachedMarkdown = decodeHtmlEntities(html);
|
|
||||||
} else if (this.getState() === this.states.WYSIWYG) {
|
|
||||||
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
|
|
||||||
}
|
}
|
||||||
if (!this.cachedMarkdown) {
|
if (!this.element.contains(event.target as Node)) {
|
||||||
this.cachedMarkdown = this.element.textContent || '';
|
this.#clearEditingContext();
|
||||||
}
|
}
|
||||||
return this.cachedMarkdown;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
wysiwyg(): void {
|
wysiwyg(): void {
|
||||||
if (this.getState() === this.states.WYSIWYG) return;
|
if (this.getState() === this.states.WYSIWYG) {
|
||||||
this.changed = false;
|
return;
|
||||||
this.element.contentEditable = 'true';
|
}
|
||||||
this.element.innerHTML = this.getHTML();
|
const markdown = this.getMarkdown();
|
||||||
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
this.sourceMarkdown = null;
|
||||||
const macroEl = el as HTMLElement;
|
this.collaboration?.connect();
|
||||||
if (macroEl.dataset.editable === 'false') {
|
this.element.innerHTML = '';
|
||||||
macroEl.contentEditable = 'false';
|
this.element.appendChild(this.#markdownToStyledDOM(markdown));
|
||||||
macroEl.style.opacity = '0.5';
|
this.element.contentEditable = 'true';
|
||||||
|
for (const macroElement of Array.from(this.element.querySelectorAll('.macro'))) {
|
||||||
|
const htmlMacro = macroElement as HTMLElement;
|
||||||
|
if (htmlMacro.dataset.editable === 'false') {
|
||||||
|
htmlMacro.contentEditable = 'false';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
this.setState(this.states.WYSIWYG);
|
this.setState(this.states.WYSIWYG);
|
||||||
}
|
}
|
||||||
|
|
||||||
edit(): void {
|
getMarkdown(): string {
|
||||||
if (!this.theme.features?.sourceMode) {
|
if (this.getState() === this.states.WYSIWYG) {
|
||||||
return;
|
return Array.from(this.element.children)
|
||||||
|
.map((block) => this.#blockToMarkdown(block as HTMLElement))
|
||||||
|
.join('\n');
|
||||||
}
|
}
|
||||||
if (this.state === this.states.EDIT) return;
|
return super.getMarkdown();
|
||||||
this.changed = false;
|
|
||||||
this.element.contentEditable = 'true';
|
|
||||||
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
|
||||||
this.setState(this.states.EDIT);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
insertAtCursor(node: Node): void {
|
insertAtCursor(node: Node): void {
|
||||||
const sel = window.getSelection()!;
|
const selection = window.getSelection()!;
|
||||||
const range = sel.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
range.deleteContents();
|
range.deleteContents();
|
||||||
range.insertNode(node);
|
range.insertNode(node);
|
||||||
range.setStartAfter(node);
|
range.setStartAfter(node);
|
||||||
this.element.focus();
|
this.element.focus();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
#markdownToStyledDOM(markdown: string): DocumentFragment {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const line of markdown.split('\n')) {
|
||||||
|
fragment.appendChild(this.#buildBlock(line));
|
||||||
|
}
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildBlock(line: string): HTMLDivElement {
|
||||||
|
const block = document.createElement('div');
|
||||||
|
|
||||||
|
if (line === '') {
|
||||||
|
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
|
||||||
|
block.appendChild(document.createElement('br'));
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
//console.log(`Parsing line '${line}'`);
|
||||||
|
|
||||||
|
for (const rule of BLOCK_RULES) {
|
||||||
|
const prefixLength = rule.prefixLength(line);
|
||||||
|
if (prefixLength === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.name === 'heading') {
|
||||||
|
const match = line.match(HEADING_PATTERN)!;
|
||||||
|
block.className = `${BLOCK_CLASS_PREFIX}h${match.groups!.hashes.length}`;
|
||||||
|
} else {
|
||||||
|
block.className = `${BLOCK_CLASS_PREFIX}${rule.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixSpan = document.createElement('span');
|
||||||
|
prefixSpan.className = rule.isList ? LIST_PREFIX_CLASS : DELIM_CLASS;
|
||||||
|
prefixSpan.textContent = line.slice(0, prefixLength);
|
||||||
|
block.appendChild(prefixSpan);
|
||||||
|
|
||||||
|
const content = line.slice(prefixLength);
|
||||||
|
if (content) {
|
||||||
|
block.appendChild(this.#parseInline(content));
|
||||||
|
} else {
|
||||||
|
block.appendChild(document.createTextNode('\u200B'));
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
block.className = `${BLOCK_CLASS_PREFIX}paragraph`;
|
||||||
|
block.appendChild(this.#parseInline(line));
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#createSpanForMatch(match: RegExpExecArray, className: string): Node {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = className;
|
||||||
|
span.setAttribute(INLINE_SPAN_ATTR, '1');
|
||||||
|
|
||||||
|
if (span.className === 'md-link') {
|
||||||
|
span.appendChild(this.#makeDelimSpan('['));
|
||||||
|
const linkTextNode = document.createElement('span');
|
||||||
|
linkTextNode.className = 'md-link-text';
|
||||||
|
linkTextNode.textContent = match.groups!.linkLabel;
|
||||||
|
span.appendChild(linkTextNode);
|
||||||
|
span.appendChild(this.#makeDelimSpan(`](${match.groups!.linkHref})`));
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.appendChild(this.#makeDelimSpan(match.groups!.delimiter));
|
||||||
|
span.appendChild(this.#parseInline(match.groups!.content));
|
||||||
|
if (match.groups!.closer) {
|
||||||
|
span.appendChild(this.#makeDelimSpan(match.groups!.closer));
|
||||||
|
}
|
||||||
|
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
#parseInline(text: string): DocumentFragment {
|
||||||
|
var t = text;
|
||||||
|
text = disambiguateAsteriskRuns(text);
|
||||||
|
//console.log(`${t} => ${text}`);
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
'**': 'md-bold',
|
||||||
|
'*': 'md-italic',
|
||||||
|
'~~': 'md-strikethrough',
|
||||||
|
'`': 'md-code',
|
||||||
|
};
|
||||||
|
|
||||||
|
const INLINE_PATTERN = new RegExp(
|
||||||
|
'\\[(?<linkLabel>[^\\]]+)\\]\\((?<linkHref>[^)]+)\\)' +
|
||||||
|
'|' + '(?<![*])' +
|
||||||
|
'(?<delimiter>${SENTINEL}\\*{2}|\\*{2}|${SENTINEL}\\*|\\*|~~|`)' +
|
||||||
|
'(?<content>.*)' +
|
||||||
|
`(?<closer>(?:${SENTINEL}\\k<delimiter>|\\k<delimiter>)(?![*]))`,
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = INLINE_PATTERN.exec(text)) !== null) {
|
||||||
|
// console.log(`DELIM: ${match.groups!.delimiter} CONTENT: ${match.groups!.content} CLOSER: ${match.groups!.closer}`)
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
|
||||||
|
}
|
||||||
|
const className = match.groups!.linkLabel !== undefined ? 'md-link' : classes[match.groups!.delimiter];
|
||||||
|
fragment.appendChild(this.#createSpanForMatch(match, className));
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log(`'${t}' => '${text.replace('\u200C', '|')}`, fragment.children);
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
#makeDelimSpan(text: string): HTMLSpanElement {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = DELIM_CLASS;
|
||||||
|
span.textContent = text;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateCurrentBlock(): void {
|
||||||
|
const block = this.#findCurrentBlock();
|
||||||
|
if (!block) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caretOffset = this.#getCaretOffset(block);
|
||||||
|
const lineText = normalizeLineText(block.textContent!);
|
||||||
|
|
||||||
|
console.log(`'${block.textContent}' normalized to '${lineText}'`);
|
||||||
|
|
||||||
|
const newBlock = this.#buildBlock(lineText);
|
||||||
|
block.className = newBlock.className;
|
||||||
|
block.innerHTML = '';
|
||||||
|
while (newBlock.firstChild) {
|
||||||
|
block.appendChild(newBlock.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (lineText.endsWith(' ')) {
|
||||||
|
const lastChild = block.lastChild;
|
||||||
|
if (lastChild && lastChild.nodeType === 3) {
|
||||||
|
lastChild.textContent = lastChild.textContent!.replace(/ $/, '\u00A0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lineText.endsWith(' ')) {
|
||||||
|
// Find the last text node in the block (may be nested inside spans)
|
||||||
|
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||||
|
let lastTextNode: Text | null = null;
|
||||||
|
let node: Text | null;
|
||||||
|
while ((node = walker.nextNode() as Text | null)) {
|
||||||
|
lastTextNode = node;
|
||||||
|
}
|
||||||
|
if (lastTextNode) {
|
||||||
|
lastTextNode.textContent = lastTextNode.textContent!.replace(/ $/, '\u00A0');
|
||||||
|
}
|
||||||
|
console.log(`updating lastTextNode.textContent to '${lastTextNode!.textContent}'`);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
const prefixSpan = block.firstElementChild;
|
||||||
|
const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) ||
|
||||||
|
prefixSpan?.classList.contains(LIST_PREFIX_CLASS))
|
||||||
|
? prefixSpan.textContent!.length : 0;
|
||||||
|
|
||||||
|
console.log(`${block.textContent!}: ${caretOffset} <= ${prefixLen} && ${prefixSpan} ?`);
|
||||||
|
|
||||||
|
if (caretOffset <= prefixLen && prefixSpan) {
|
||||||
|
const sel = window.getSelection()!;
|
||||||
|
const range = document.createRange();
|
||||||
|
const next = prefixSpan.nextSibling;
|
||||||
|
if (next && next.nodeType === 3) {
|
||||||
|
range.setStart(next as Text, 0);
|
||||||
|
} else {
|
||||||
|
range.setStartAfter(prefixSpan);
|
||||||
|
}
|
||||||
|
range.collapse(true);
|
||||||
sel.removeAllRanges();
|
sel.removeAllRanges();
|
||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
|
} else {
|
||||||
|
this.#restoreCaret(block, caretOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#updateEditingContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
#dispatchKeydown(event: KeyboardEvent): void {
|
||||||
|
const block = this.#findCurrentBlock();
|
||||||
|
if (block) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const tagForBlock = this.converter.getBlockTags().find((tag) => {
|
||||||
|
if (typeof tag.selector !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return tag.selector.split(',').some(
|
||||||
|
(selector) => block.tagName === selector.trim()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (tagForBlock?.handleKeydown) {
|
||||||
|
const handled = tagForBlock.handleKeydown(block, event, selection, this);
|
||||||
|
if (handled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.#handleEnter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
if (this.#handleBackspace()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleEnter(): void {
|
||||||
|
const block = this.#findCurrentBlock();
|
||||||
|
if (!block) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = this.#getCaretOffset(block);
|
||||||
|
const text = normalizeLineText(block.textContent!);
|
||||||
|
|
||||||
|
const prefixSpan = block.firstElementChild;
|
||||||
|
const isListPrefix = prefixSpan?.classList.contains(LIST_PREFIX_CLASS);
|
||||||
|
const isBlockquote = prefixSpan?.classList.contains(DELIM_CLASS) &&
|
||||||
|
block.classList.contains('md-blockquote');
|
||||||
|
|
||||||
|
let prefix = '';
|
||||||
|
if (isListPrefix) {
|
||||||
|
const orderedMatch = prefixSpan!.textContent!.match(/^(?<num>\d+)\. /);
|
||||||
|
if (orderedMatch) {
|
||||||
|
const nextNum = parseInt(orderedMatch.groups!.num) + 1;
|
||||||
|
prefix = `${nextNum}. `;
|
||||||
|
} else {
|
||||||
|
prefix = prefixSpan!.textContent!;
|
||||||
|
}
|
||||||
|
} else if (isBlockquote) {
|
||||||
|
prefix = prefixSpan!.textContent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPrefix = stripInternalMarkers(prefixSpan?.textContent ?? '');
|
||||||
|
|
||||||
|
if ((isListPrefix || isBlockquote) &&
|
||||||
|
(text === currentPrefix || text.trim() === currentPrefix.trim())) {
|
||||||
|
const emptyBlock = this.#buildBlock('');
|
||||||
|
block.className = emptyBlock.className;
|
||||||
|
block.innerHTML = '';
|
||||||
|
while (emptyBlock.firstChild) {
|
||||||
|
block.appendChild(emptyBlock.firstChild);
|
||||||
|
}
|
||||||
|
this.#restoreCaret(block, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = text.slice(0, offset);
|
||||||
|
const after = text.slice(offset);
|
||||||
|
|
||||||
|
const firstBlock = this.#buildBlock(before);
|
||||||
|
const secondBlock = this.#buildBlock(after ? prefix + after : prefix);
|
||||||
|
|
||||||
|
block.className = firstBlock.className;
|
||||||
|
block.innerHTML = '';
|
||||||
|
while (firstBlock.firstChild) {
|
||||||
|
block.appendChild(firstBlock.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
block.after(secondBlock);
|
||||||
|
|
||||||
|
const newPrefixSpan = secondBlock.firstElementChild;
|
||||||
|
if (newPrefixSpan && (newPrefixSpan.classList.contains(DELIM_CLASS) ||
|
||||||
|
newPrefixSpan.classList.contains(LIST_PREFIX_CLASS))) {
|
||||||
|
const sel = window.getSelection()!;
|
||||||
|
const range = document.createRange();
|
||||||
|
const next = newPrefixSpan.nextSibling;
|
||||||
|
if (next && next.nodeType === 3) {
|
||||||
|
range.setStart(next as Text, 0);
|
||||||
|
} else {
|
||||||
|
range.setStartAfter(newPrefixSpan);
|
||||||
|
}
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
} else {
|
||||||
|
this.#restoreCaret(secondBlock, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleBackspace(): boolean {
|
||||||
|
const block = this.#findCurrentBlock();
|
||||||
|
if (!block) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = this.#getCaretOffset(block);
|
||||||
|
if (offset !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBlock = block.previousElementSibling as HTMLElement | null;
|
||||||
|
if (!previousBlock) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousLength = stripInternalMarkers(previousBlock.textContent!).length;
|
||||||
|
const merged = stripInternalMarkers(previousBlock.textContent!) +
|
||||||
|
normalizeLineText(block.textContent!);
|
||||||
|
const mergedBlock = this.#buildBlock(merged);
|
||||||
|
|
||||||
|
previousBlock.className = mergedBlock.className;
|
||||||
|
previousBlock.innerHTML = '';
|
||||||
|
while (mergedBlock.firstChild) {
|
||||||
|
previousBlock.appendChild(mergedBlock.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
block.remove();
|
||||||
|
this.#restoreCaret(previousBlock, previousLength);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#findCurrentBlock(): HTMLElement | null {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let node: Node | null = selection.anchorNode;
|
||||||
|
while (node && node !== this.element) {
|
||||||
|
if (node.nodeType === 1 && node.parentNode === this.element) {
|
||||||
|
return node as HTMLElement;
|
||||||
|
}
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#getCaretOffset(block: HTMLElement): number {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(block, 0);
|
||||||
|
range.setEnd(selection.anchorNode!, selection.anchorOffset);
|
||||||
|
return range.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
#restoreCaret(block: HTMLElement, offset: number): void {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = document.createRange();
|
||||||
|
const placed = this.#walkForCaret(block, range, offset);
|
||||||
|
|
||||||
|
if (!placed) {
|
||||||
|
range.selectNodeContents(block);
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walk text nodes counting only "real" characters
|
||||||
|
* toward `remaining` — internal bookkeeping markers are skipped
|
||||||
|
* over entirely and never consume a unit of `remaining`.
|
||||||
|
*/
|
||||||
|
#walkForCaret(node: Node, range: Range, remaining: number): boolean {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
const textNode = node as Text;
|
||||||
|
const text = textNode.textContent!;
|
||||||
|
|
||||||
|
let domOffset = 0;
|
||||||
|
for (const character of text) {
|
||||||
|
if (isInternalMarker(character)) {
|
||||||
|
domOffset += character.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStart(textNode, domOffset);
|
||||||
|
range.collapse(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
domOffset += character.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
range.setStart(textNode, domOffset);
|
||||||
|
range.collapse(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let consumed = 0;
|
||||||
|
for (const child of Array.from(node.childNodes)) {
|
||||||
|
if (child.nodeType === 3) {
|
||||||
|
const textNode = child as Text;
|
||||||
|
const realLength = stripInternalMarkers(textNode.textContent!).length;
|
||||||
|
if (remaining - consumed <= realLength) {
|
||||||
|
const placed = this.#walkForCaret(textNode, range, remaining - consumed);
|
||||||
|
if (placed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
consumed += realLength;
|
||||||
|
} else {
|
||||||
|
const childRealLength = stripInternalMarkers(child.textContent || '').length;
|
||||||
|
if (remaining - consumed <= childRealLength) {
|
||||||
|
const placed = this.#walkForCaret(child, range, remaining - consumed);
|
||||||
|
if (placed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
consumed += childRealLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateEditingContext(): void {
|
||||||
|
this.#clearEditingContext();
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let node: Node | null = selection.anchorNode;
|
||||||
|
while (node && node !== this.element) {
|
||||||
|
if (node.nodeType === 1) {
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
if (element.hasAttribute(INLINE_SPAN_ATTR)) {
|
||||||
|
element.classList.add(EDITING_CONTEXT_CLASS);
|
||||||
|
this.activeFormattingSpan = element;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#clearEditingContext(): void {
|
||||||
|
if (this.activeFormattingSpan) {
|
||||||
|
this.activeFormattingSpan.classList.remove(EDITING_CONTEXT_CLASS);
|
||||||
|
this.activeFormattingSpan = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#blockToMarkdown(block: HTMLElement): string {
|
||||||
|
if (!block.textContent!.trim() && block.querySelector('br')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (block.dataset.macro) {
|
||||||
|
return block.dataset.source || '';
|
||||||
|
}
|
||||||
|
return normalizeLineText(block.textContent!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
|
|
||||||
export { RibbitEditor as Editor };
|
export { RibbitEditor as Editor };
|
||||||
export { Ribbit as Viewer };
|
export { Ribbit as Viewer };
|
||||||
export { RibbitPlugin as Plugin };
|
|
||||||
export { HopDown };
|
|
||||||
export { inlineTag };
|
export { inlineTag };
|
||||||
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
export { defaultTags, defaultBlockTags, defaultInlineTags };
|
||||||
export { defaultTheme };
|
export { defaultTheme };
|
||||||
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
||||||
|
export { ToolbarManager } from './toolbar';
|
||||||
|
export { CollaborationManager } from './collaboration';
|
||||||
|
export type { MacroDef };
|
||||||
|
|||||||
+283
-104
@@ -6,92 +6,71 @@ import { HopDown } from './hopdown';
|
|||||||
import { defaultTheme } from './default-theme';
|
import { defaultTheme } from './default-theme';
|
||||||
import { ThemeManager } from './theme-manager';
|
import { ThemeManager } from './theme-manager';
|
||||||
import { RibbitEmitter, type RibbitEventMap } from './events';
|
import { RibbitEmitter, type RibbitEventMap } from './events';
|
||||||
import type { RibbitTheme } from './types';
|
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 {
|
export interface RibbitSettings {
|
||||||
api?: unknown;
|
api?: unknown;
|
||||||
editorId?: string;
|
editorId?: string;
|
||||||
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
|
|
||||||
currentTheme?: string;
|
currentTheme?: string;
|
||||||
themes?: RibbitTheme[];
|
themes?: RibbitTheme[];
|
||||||
themesPath?: string;
|
themesPath?: string;
|
||||||
|
macros?: MacroDef[];
|
||||||
|
toolbar?: ToolbarSlot[];
|
||||||
|
/** Set to false to prevent auto-rendering the toolbar. Default true. */
|
||||||
|
autoToolbar?: boolean;
|
||||||
|
/** Collaboration settings. Omit to disable. */
|
||||||
|
collaboration?: CollaborationSettings;
|
||||||
on?: Partial<RibbitEventMap>;
|
on?: Partial<RibbitEventMap>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for editor plugins. Subclass and override toHTML/toMarkdown
|
* Base class providing read-only markdown rendering. RibbitEditor extends
|
||||||
* to add custom processing hooks.
|
* this with editing capabilities, so consumers who only need to display
|
||||||
*/
|
* rendered markdown can use Ribbit directly and avoid loading editor code.
|
||||||
export class RibbitPlugin {
|
|
||||||
name: string;
|
|
||||||
wiki: Ribbit;
|
|
||||||
precedence: number;
|
|
||||||
|
|
||||||
constructor(settings: { name: string; wiki: Ribbit }) {
|
|
||||||
this.name = settings.name;
|
|
||||||
this.wiki = settings.wiki;
|
|
||||||
this.precedence = 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditable(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
toMarkdown(html: string): string {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
toHTML(md: string): string {
|
|
||||||
return md;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-only markdown viewer. Renders markdown content into an HTML element.
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* const viewer = new Ribbit({ editorId: 'my-element' });
|
||||||
* const viewer = new Ribbit({
|
|
||||||
* editorId: 'my-element',
|
|
||||||
* on: {
|
|
||||||
* ready: ({ mode, theme }) => console.log(`Ready in ${mode}`),
|
|
||||||
* },
|
|
||||||
* });
|
|
||||||
* viewer.run();
|
* viewer.run();
|
||||||
*/
|
*/
|
||||||
export class Ribbit {
|
export class Ribbit {
|
||||||
api: unknown;
|
api: unknown;
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
states: Record<string, string>;
|
states: Record<string, string>;
|
||||||
cachedHTML: string | null;
|
|
||||||
cachedMarkdown: string | null;
|
|
||||||
state: string | null;
|
state: string | null;
|
||||||
changed: boolean;
|
|
||||||
enabledPlugins: Record<string, RibbitPlugin>;
|
|
||||||
theme: RibbitTheme;
|
theme: RibbitTheme;
|
||||||
themes: ThemeManager;
|
themes: ThemeManager;
|
||||||
converter: HopDown;
|
converter: HopDown;
|
||||||
themesPath: string;
|
themesPath: string;
|
||||||
|
toolbar: ToolbarManager;
|
||||||
|
collaboration?: CollaborationManager;
|
||||||
|
protected autoToolbar: boolean;
|
||||||
private emitter: RibbitEmitter;
|
private emitter: RibbitEmitter;
|
||||||
|
private macros: MacroDef[];
|
||||||
|
|
||||||
|
// The markdown source as it existed before view() rendered it to HTML.
|
||||||
|
// Set by subclasses (RibbitEditor) before overwriting element.innerHTML.
|
||||||
|
// Allows getMarkdown() in view state to return the original source rather
|
||||||
|
// than textContent of the rendered HTML (which strips delimiters).
|
||||||
|
protected sourceMarkdown: string | null = null;
|
||||||
|
|
||||||
constructor(settings: RibbitSettings) {
|
constructor(settings: RibbitSettings) {
|
||||||
this.api = settings.api || null;
|
this.api = settings.api || null;
|
||||||
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
||||||
this.themesPath = settings.themesPath || './themes';
|
this.themesPath = settings.themesPath || './themes';
|
||||||
this.emitter = new RibbitEmitter();
|
this.emitter = new RibbitEmitter();
|
||||||
|
this.macros = settings.macros || [];
|
||||||
this.states = {
|
this.states = {
|
||||||
VIEW: 'view',
|
VIEW: 'view',
|
||||||
};
|
};
|
||||||
this.cachedHTML = null;
|
|
||||||
this.cachedMarkdown = null;
|
|
||||||
this.state = null;
|
this.state = null;
|
||||||
this.changed = false;
|
|
||||||
this.enabledPlugins = {};
|
|
||||||
|
|
||||||
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
this.converter = theme.tags
|
this.converter = theme.tags
|
||||||
? new HopDown({ tags: theme.tags })
|
? new HopDown({ tags: theme.tags, macros: this.macros })
|
||||||
: new HopDown();
|
: new HopDown({ macros: this.macros });
|
||||||
this.cachedHTML = null;
|
|
||||||
this.emitter.emit('themeChange', {
|
this.emitter.emit('themeChange', {
|
||||||
current: theme,
|
current: theme,
|
||||||
previous,
|
previous,
|
||||||
@@ -110,15 +89,8 @@ export class Ribbit {
|
|||||||
this.themes.set(activeName);
|
this.themes.set(activeName);
|
||||||
this.theme = this.themes.current();
|
this.theme = this.themes.current();
|
||||||
this.converter = this.theme.tags
|
this.converter = this.theme.tags
|
||||||
? new HopDown({ tags: this.theme.tags })
|
? new HopDown({ tags: this.theme.tags, macros: this.macros })
|
||||||
: new HopDown();
|
: new HopDown({ macros: this.macros });
|
||||||
|
|
||||||
(settings.plugins || []).forEach(plugin => {
|
|
||||||
this.enabledPlugins[plugin.name] = new plugin({
|
|
||||||
name: plugin.name,
|
|
||||||
wiki: this,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (settings.on) {
|
if (settings.on) {
|
||||||
for (const [event, handler] of Object.entries(settings.on)) {
|
for (const [event, handler] of Object.entries(settings.on)) {
|
||||||
@@ -127,31 +99,70 @@ export class Ribbit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.toolbar = new ToolbarManager(
|
||||||
|
this,
|
||||||
|
this.theme.tags || {},
|
||||||
|
this.macros,
|
||||||
|
settings.toolbar,
|
||||||
|
);
|
||||||
|
this.autoToolbar = settings.autoToolbar !== false;
|
||||||
|
|
||||||
|
if (settings.collaboration) {
|
||||||
|
this.collaboration = new CollaborationManager(
|
||||||
|
settings.collaboration,
|
||||||
|
{
|
||||||
|
onRemoteUpdate: (content) => {
|
||||||
|
this.sourceMarkdown = content;
|
||||||
|
if (this.getState() !== this.states.VIEW) {
|
||||||
|
this.element.innerHTML = this.markdownToHTML(content);
|
||||||
|
}
|
||||||
|
this.emitter.emit('change', {
|
||||||
|
markdown: content,
|
||||||
|
html: this.markdownToHTML(content),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPeersChange: (peers) => {
|
||||||
|
this.emitter.emit('peerChange', { peers });
|
||||||
|
},
|
||||||
|
onLockChange: (holder) => {
|
||||||
|
this.emitter.emit('lockChange', { holder });
|
||||||
|
if (holder && holder.userId !== settings.collaboration!.user.userId) {
|
||||||
|
this.toolbar.disable();
|
||||||
|
} else {
|
||||||
|
this.toolbar.enable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemoteActivity: (count) => {
|
||||||
|
this.emitter.emit('remoteActivity', { count });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a callback for an event.
|
* Subscribe to editor events. Callbacks persist across mode switches.
|
||||||
*
|
*
|
||||||
* editor.on('save', ({ markdown }) => {
|
* editor.on('change', ({ markdown, html }) => console.log(markdown));
|
||||||
* fetch('/api/save', { method: 'POST', body: markdown });
|
* editor.on('save', ({ markdown }) => fetch('/api', { body: markdown }));
|
||||||
* });
|
|
||||||
*/
|
*/
|
||||||
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
||||||
this.emitter.on(event, callback);
|
this.emitter.on(event, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a previously registered callback.
|
* Unsubscribe a previously registered event callback.
|
||||||
*
|
*
|
||||||
* editor.off('change', myHandler);
|
* const handler = (e) => console.log(e);
|
||||||
|
* editor.on('change', handler);
|
||||||
|
* editor.off('change', handler);
|
||||||
*/
|
*/
|
||||||
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
||||||
this.emitter.off(event, callback);
|
this.emitter.off(event, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
run(): void {
|
protected emitReady(): void {
|
||||||
this.element.classList.add('loaded');
|
|
||||||
this.view();
|
|
||||||
this.emitter.emit('ready', {
|
this.emitter.emit('ready', {
|
||||||
markdown: this.getMarkdown(),
|
markdown: this.getMarkdown(),
|
||||||
html: this.getHTML(),
|
html: this.getHTML(),
|
||||||
@@ -160,53 +171,88 @@ export class Ribbit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins(): RibbitPlugin[] {
|
/**
|
||||||
return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence);
|
* Initialize the viewer: render toolbar, switch to view mode, and
|
||||||
|
* fire the ready event. Call once after construction.
|
||||||
|
*
|
||||||
|
* const viewer = new Ribbit({ editorId: 'content' });
|
||||||
|
* viewer.run();
|
||||||
|
*/
|
||||||
|
run(): void {
|
||||||
|
this.element.classList.add('loaded');
|
||||||
|
if (this.autoToolbar) {
|
||||||
|
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
|
||||||
|
}
|
||||||
|
this.view();
|
||||||
|
this.emitReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current mode name ('view' or 'wysiwyg').
|
||||||
|
*
|
||||||
|
* if (editor.getState() === 'wysiwyg') { ... }
|
||||||
|
*/
|
||||||
getState(): string | null {
|
getState(): string | null {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition to a new mode. Updates CSS classes on the editor element
|
||||||
|
* so themes can style each mode differently, and fires modeChange.
|
||||||
|
*
|
||||||
|
* editor.setState('wysiwyg');
|
||||||
|
*/
|
||||||
setState(newState: string): void {
|
setState(newState: string): void {
|
||||||
const previous = this.state;
|
const previous = this.state;
|
||||||
this.state = newState;
|
if (previous) {
|
||||||
Object.values(this.states).forEach(state => {
|
this.element.classList.remove(previous);
|
||||||
if (state === newState) {
|
|
||||||
this.element.classList.add(state);
|
|
||||||
} else {
|
|
||||||
this.element.classList.remove(state);
|
|
||||||
}
|
}
|
||||||
});
|
this.state = newState;
|
||||||
|
this.element.classList.add(newState);
|
||||||
this.emitter.emit('modeChange', {
|
this.emitter.emit('modeChange', {
|
||||||
current: newState,
|
current: newState,
|
||||||
previous,
|
previous,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
markdownToHTML(md: string): string {
|
/**
|
||||||
return this.converter.toHTML(md);
|
* One-shot markdown→HTML conversion using the current theme's tags.
|
||||||
}
|
*
|
||||||
|
* const html = viewer.markdownToHTML('**hello**');
|
||||||
getHTML(): string {
|
*/
|
||||||
if (this.changed || !this.cachedHTML) {
|
markdownToHTML(markdown: string): string {
|
||||||
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
return this.converter.toHTML(markdown);
|
||||||
}
|
|
||||||
return this.cachedHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMarkdown(): string {
|
|
||||||
if (!this.cachedMarkdown) {
|
|
||||||
this.cachedMarkdown = this.element.textContent || '';
|
|
||||||
}
|
|
||||||
return this.cachedMarkdown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request a save. Fires the 'save' event with the current content.
|
* Rendered HTML of the current content.
|
||||||
* The consumer's callback handles persistence.
|
|
||||||
*
|
*
|
||||||
* editor.save(); // triggers on.save({ markdown, html })
|
* document.getElementById('preview').innerHTML = viewer.getHTML();
|
||||||
|
*/
|
||||||
|
getHTML(): string {
|
||||||
|
return this.markdownToHTML(this.getMarkdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw markdown of the current content. In view state reads from
|
||||||
|
* sourceMarkdown if set (preserved before rendering overwrote the
|
||||||
|
* element), otherwise falls back to element.textContent.
|
||||||
|
*
|
||||||
|
* fetch('/save', { body: editor.getMarkdown() });
|
||||||
|
*/
|
||||||
|
getMarkdown(): string {
|
||||||
|
if (this.sourceMarkdown !== null) {
|
||||||
|
return this.sourceMarkdown;
|
||||||
|
}
|
||||||
|
return this.element.textContent || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a save event with the current content. Ribbit never persists
|
||||||
|
* data itself — the consumer handles storage in the callback.
|
||||||
|
*
|
||||||
|
* editor.on('save', ({ markdown }) => localStorage.setItem('doc', markdown));
|
||||||
|
* editor.save();
|
||||||
*/
|
*/
|
||||||
save(): void {
|
save(): void {
|
||||||
this.emitter.emit('save', {
|
this.emitter.emit('save', {
|
||||||
@@ -215,29 +261,155 @@ export class Ribbit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to read-only view mode. Renders markdown to HTML and
|
||||||
|
* disables contentEditable. Disconnects collaboration if active.
|
||||||
|
*
|
||||||
|
* editor.view();
|
||||||
|
*/
|
||||||
view(): void {
|
view(): void {
|
||||||
if (this.getState() === this.states.VIEW) return;
|
if (this.getState() === this.states.VIEW) {
|
||||||
this.element.innerHTML = this.getHTML();
|
return;
|
||||||
|
}
|
||||||
|
// Capture markdown before overwriting the element with rendered HTML.
|
||||||
|
// getMarkdown() on the base class reads element.textContent when
|
||||||
|
// sourceMarkdown is null — correct for the initial load case where
|
||||||
|
// the element contains raw markdown text.
|
||||||
|
this.sourceMarkdown = this.getMarkdown();
|
||||||
|
this.collaboration?.disconnect();
|
||||||
|
this.element.innerHTML = this.markdownToHTML(this.sourceMarkdown);
|
||||||
this.setState(this.states.VIEW);
|
this.setState(this.states.VIEW);
|
||||||
this.element.contentEditable = 'false';
|
this.element.contentEditable = 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify that content has changed. Called internally by the editor
|
* Request an advisory editing lock. Returns false if another user
|
||||||
* on input events. Fires the 'change' event with current content.
|
* holds the lock. Requires a collaboration transport.
|
||||||
|
*
|
||||||
|
* if (await editor.lockForEditing()) { editor.wysiwyg(); }
|
||||||
|
*/
|
||||||
|
async lockForEditing(): Promise<boolean> {
|
||||||
|
if (!this.collaboration) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.collaboration.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the advisory editing lock.
|
||||||
|
*
|
||||||
|
* editor.unlockEditing();
|
||||||
|
* editor.view();
|
||||||
|
*/
|
||||||
|
unlockEditing(): void {
|
||||||
|
this.collaboration?.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steal the lock from another user. Use when an admin needs to
|
||||||
|
* override a stale lock.
|
||||||
|
*
|
||||||
|
* await editor.forceLockEditing();
|
||||||
|
*/
|
||||||
|
async forceLockEditing(): Promise<boolean> {
|
||||||
|
if (!this.collaboration) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.collaboration.forceLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all saved revisions from the revision provider.
|
||||||
|
*
|
||||||
|
* const revisions = await editor.listRevisions();
|
||||||
|
* revisions.forEach(r => console.log(r.id, r.timestamp));
|
||||||
|
*/
|
||||||
|
async listRevisions(): Promise<Revision[]> {
|
||||||
|
if (!this.collaboration) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.collaboration.listRevisions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single revision's content by ID.
|
||||||
|
*
|
||||||
|
* const rev = await editor.getRevision('abc-123');
|
||||||
|
* if (rev) { console.log(rev.content); }
|
||||||
|
*/
|
||||||
|
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
|
||||||
|
if (!this.collaboration) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.collaboration.getRevision(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the editor content with a previous revision and broadcast
|
||||||
|
* the change to collaborators.
|
||||||
|
*
|
||||||
|
* await editor.restoreRevision('abc-123');
|
||||||
|
*/
|
||||||
|
async restoreRevision(id: string): Promise<void> {
|
||||||
|
if (!this.collaboration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const revision = await this.collaboration.getRevision(id);
|
||||||
|
if (!revision) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sourceMarkdown = revision.content;
|
||||||
|
const html = this.markdownToHTML(revision.content);
|
||||||
|
this.collaboration.sendUpdate(revision.content);
|
||||||
|
if (this.getState() !== this.states.VIEW) {
|
||||||
|
this.element.innerHTML = html;
|
||||||
|
}
|
||||||
|
this.emitter.emit('change', {
|
||||||
|
markdown: revision.content,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the current content as a named revision. The revision
|
||||||
|
* provider stores it; ribbit never persists data itself.
|
||||||
|
*
|
||||||
|
* const rev = await editor.createRevision({ label: 'v1.0' });
|
||||||
|
*/
|
||||||
|
async createRevision(metadata?: RevisionMetadata): Promise<Revision | null> {
|
||||||
|
if (!this.collaboration) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const revision = await this.collaboration.createRevision(this.getMarkdown(), metadata);
|
||||||
|
if (revision) {
|
||||||
|
this.emitter.emit('revisionCreated', { revision });
|
||||||
|
}
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast the current content to collaborators and fire the
|
||||||
|
* change event. Called automatically on input; call manually
|
||||||
|
* after programmatic content changes.
|
||||||
|
*
|
||||||
|
* editor.element.innerHTML = '<p>new content</p>';
|
||||||
|
* editor.notifyChange();
|
||||||
*/
|
*/
|
||||||
notifyChange(): void {
|
notifyChange(): void {
|
||||||
this.changed = true;
|
const markdown = this.getMarkdown();
|
||||||
|
this.collaboration?.sendUpdate(markdown);
|
||||||
this.emitter.emit('change', {
|
this.emitter.emit('change', {
|
||||||
markdown: this.getMarkdown(),
|
markdown,
|
||||||
html: this.getHTML(),
|
html: this.getHTML(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a string to title case, splitting on whitespace.
|
* Split a string into words and capitalize each one.
|
||||||
* Returns an array of capitalized words.
|
* Used to generate camelCase IDs for heading anchors.
|
||||||
|
*
|
||||||
|
* camelCase('hello world') // ['Hello', 'World']
|
||||||
*/
|
*/
|
||||||
export function camelCase(words: string): string[] {
|
export function camelCase(words: string): string[] {
|
||||||
return words.trim().split(/\s+/g).map(word => {
|
return words.trim().split(/\s+/g).map(word => {
|
||||||
@@ -247,7 +419,10 @@ export function camelCase(words: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode HTML entities in a string using a textarea element.
|
* Decode HTML entities back to characters. Uses a textarea element
|
||||||
|
* because the browser's HTML parser handles all entity forms.
|
||||||
|
*
|
||||||
|
* decodeHtmlEntities('<b>') // '<b>'
|
||||||
*/
|
*/
|
||||||
export function decodeHtmlEntities(html: string): string {
|
export function decodeHtmlEntities(html: string): string {
|
||||||
const txt = document.createElement('textarea');
|
const txt = document.createElement('textarea');
|
||||||
@@ -256,7 +431,11 @@ export function decodeHtmlEntities(html: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode HTML-significant characters as numeric entities.
|
* Encode characters that would be interpreted as HTML into numeric
|
||||||
|
* entities. Used when displaying raw markdown in contentEditable
|
||||||
|
* so the browser doesn't parse it as markup.
|
||||||
|
*
|
||||||
|
* encodeHtmlEntities('<b>hi</b>') // '<b>hi</b>'
|
||||||
*/
|
*/
|
||||||
export function encodeHtmlEntities(str: string): string {
|
export function encodeHtmlEntities(str: string): string {
|
||||||
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
|
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/*
|
||||||
|
* serializer.ts — DOM to markdown serializer.
|
||||||
|
*
|
||||||
|
* Converts an HTML DOM tree back to markdown by walking the tree and
|
||||||
|
* producing a typed token stream. Text tokens are escaped during final
|
||||||
|
* serialization; delimiter tokens pass through verbatim. This separation
|
||||||
|
* is what makes round-trip correctness possible — the serializer always
|
||||||
|
* knows which characters are structural and which are literal.
|
||||||
|
*
|
||||||
|
* const serializer = new MarkdownSerializer(tagMap, delimiterChars);
|
||||||
|
* serializer.serialize(document.getElementById('content'))
|
||||||
|
* // '**bold** and *italic*'
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { InlineToken } from './tokenizer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps HTML element names to their markdown serialization.
|
||||||
|
* Each entry defines how to convert an element back to markdown tokens.
|
||||||
|
*/
|
||||||
|
export interface SerializerTagDef {
|
||||||
|
/** The canonical delimiter (e.g. '**' for bold). */
|
||||||
|
delimiter?: string;
|
||||||
|
/** Custom serializer for elements that aren't simple delimiter wraps
|
||||||
|
* (e.g. links, code blocks, headings). Returns the full markdown
|
||||||
|
* string for the element and its children. */
|
||||||
|
serialize?: (element: HTMLElement, children: () => string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a DOM tree to markdown. Walks the tree producing inline
|
||||||
|
* tokens, then serializes the token stream to a string with correct
|
||||||
|
* escaping.
|
||||||
|
*
|
||||||
|
* const serializer = new MarkdownSerializer(tagMap, new Set(['*', '`', '~', '[', '_']));
|
||||||
|
* const markdown = serializer.serialize(containerElement);
|
||||||
|
*/
|
||||||
|
export class MarkdownSerializer {
|
||||||
|
private tagMap: Map<string, SerializerTagDef>;
|
||||||
|
private delimiterChars: Set<string>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
tagMap: Map<string, SerializerTagDef>,
|
||||||
|
delimiterChars: Set<string>,
|
||||||
|
) {
|
||||||
|
this.tagMap = tagMap;
|
||||||
|
this.delimiterChars = delimiterChars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a DOM tree to a markdown string.
|
||||||
|
*
|
||||||
|
* serializer.serialize(document.querySelector('article'))
|
||||||
|
*/
|
||||||
|
serialize(node: Node): string {
|
||||||
|
const tokens = this.nodeToTokens(node);
|
||||||
|
return this.tokensToString(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DOM node to a stream of inline tokens.
|
||||||
|
* Text nodes become text tokens; elements with known tags
|
||||||
|
* become delimiter-wrapped token sequences; unknown elements
|
||||||
|
* recurse into their children.
|
||||||
|
*/
|
||||||
|
private nodeToTokens(node: Node): InlineToken[] {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
return [{
|
||||||
|
role: 'text',
|
||||||
|
value: node.textContent || '',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (node.nodeType !== 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const tagDef = this.tagMap.get(element.nodeName);
|
||||||
|
|
||||||
|
// Custom serializer handles the entire element
|
||||||
|
if (tagDef?.serialize) {
|
||||||
|
const childrenMarkdown = () => this.serializeChildren(element);
|
||||||
|
const markdown = tagDef.serialize(element, childrenMarkdown);
|
||||||
|
// Custom serializers return raw markdown strings — wrap
|
||||||
|
// in a single text token that won't be escaped (it's already
|
||||||
|
// correctly formatted)
|
||||||
|
return [{
|
||||||
|
role: 'html',
|
||||||
|
value: markdown,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delimiter-based element: emit open + children + close
|
||||||
|
if (tagDef?.delimiter) {
|
||||||
|
const delimiter = tagDef.delimiter;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'open',
|
||||||
|
value: delimiter,
|
||||||
|
delimiter,
|
||||||
|
},
|
||||||
|
...this.childrenToTokens(element),
|
||||||
|
{
|
||||||
|
role: 'close',
|
||||||
|
value: delimiter,
|
||||||
|
delimiter,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown element: just recurse into children
|
||||||
|
return this.childrenToTokens(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect tokens from all child nodes of an element.
|
||||||
|
*/
|
||||||
|
private childrenToTokens(element: HTMLElement): InlineToken[] {
|
||||||
|
const tokens: InlineToken[] = [];
|
||||||
|
for (const child of Array.from(element.childNodes)) {
|
||||||
|
tokens.push(...this.nodeToTokens(child));
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize an element's children directly to a markdown string.
|
||||||
|
* Used by custom serializers (links, headings, etc.) that need
|
||||||
|
* the children as a string, not as tokens.
|
||||||
|
*/
|
||||||
|
private serializeChildren(element: HTMLElement): string {
|
||||||
|
const tokens = this.childrenToTokens(element);
|
||||||
|
return this.tokensToString(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a token stream to a markdown string. This is where
|
||||||
|
* escaping happens: text tokens have their delimiter characters
|
||||||
|
* backslash-escaped; all other token types pass through verbatim.
|
||||||
|
*/
|
||||||
|
private tokensToString(tokens: InlineToken[]): string {
|
||||||
|
let result = '';
|
||||||
|
for (const token of tokens) {
|
||||||
|
switch (token.role) {
|
||||||
|
case 'text':
|
||||||
|
result += this.escapeText(token.value);
|
||||||
|
break;
|
||||||
|
case 'open':
|
||||||
|
case 'close':
|
||||||
|
case 'html':
|
||||||
|
case 'break':
|
||||||
|
// Structural tokens are never escaped
|
||||||
|
result += token.value;
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
result += token.value;
|
||||||
|
break;
|
||||||
|
case 'link':
|
||||||
|
result += token.value;
|
||||||
|
break;
|
||||||
|
case 'autolink':
|
||||||
|
result += token.value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result += token.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape characters in literal text that would be misinterpreted
|
||||||
|
* as markdown syntax on re-parse. Only escapes characters that are
|
||||||
|
* registered as delimiter characters, plus `\`, `[`, `_`, and `<`
|
||||||
|
* before letters (HTML passthrough prevention).
|
||||||
|
*/
|
||||||
|
private escapeText(text: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (let position = 0; position < text.length; position++) {
|
||||||
|
const character = text[position];
|
||||||
|
if (character === '\\') {
|
||||||
|
result += '\\\\';
|
||||||
|
} else if (character === '_') {
|
||||||
|
result += '\\_';
|
||||||
|
} else if (character === '[') {
|
||||||
|
result += '\\[';
|
||||||
|
} else if (character === '<' && position + 1 < text.length && /[a-zA-Z/]/.test(text[position + 1])) {
|
||||||
|
// Only escape < when it would start an HTML tag
|
||||||
|
result += '\\<';
|
||||||
|
} else if (this.delimiterChars.has(character)) {
|
||||||
|
result += '\\' + character;
|
||||||
|
} else {
|
||||||
|
result += character;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1124
-298
File diff suppressed because it is too large
Load Diff
+43
-3
@@ -3,8 +3,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RibbitTheme } from './types';
|
import type { RibbitTheme } from './types';
|
||||||
import { HopDown } from './hopdown';
|
|
||||||
|
|
||||||
|
/** CSS file name loaded from each theme's directory. */
|
||||||
|
const THEME_CSS_FILENAME = 'theme.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages theme registration, enabling/disabling, and CSS loading
|
||||||
|
* for a ribbit editor instance.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const themes = new ThemeManager(defaultTheme, '/themes', (current, previous) => {
|
||||||
|
* editor.rebuild();
|
||||||
|
* });
|
||||||
|
* themes.add(customTheme);
|
||||||
|
* themes.set('custom');
|
||||||
|
*/
|
||||||
export class ThemeManager {
|
export class ThemeManager {
|
||||||
private registered: Map<string, RibbitTheme>;
|
private registered: Map<string, RibbitTheme>;
|
||||||
private disabled: Set<string>;
|
private disabled: Set<string>;
|
||||||
@@ -24,7 +37,10 @@ export class ThemeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a theme. Themes must be added before they can be enabled.
|
* Register a theme. Themes must be added before they can be activated.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.add({ name: 'dark', tags: darkTags });
|
||||||
*/
|
*/
|
||||||
add(theme: RibbitTheme): void {
|
add(theme: RibbitTheme): void {
|
||||||
this.registered.set(theme.name, theme);
|
this.registered.set(theme.name, theme);
|
||||||
@@ -32,6 +48,9 @@ export class ThemeManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregister a theme by name. Cannot remove the active theme.
|
* Unregister a theme by name. Cannot remove the active theme.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.remove('dark');
|
||||||
*/
|
*/
|
||||||
remove(name: string): void {
|
remove(name: string): void {
|
||||||
if (this.active.name === name) {
|
if (this.active.name === name) {
|
||||||
@@ -42,6 +61,9 @@ export class ThemeManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the names of all registered and enabled themes.
|
* Return the names of all registered and enabled themes.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const available = themes.list(); // ['ribbit-default', 'dark']
|
||||||
*/
|
*/
|
||||||
list(): string[] {
|
list(): string[] {
|
||||||
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
|
return Array.from(this.registered.keys()).filter(name => !this.disabled.has(name));
|
||||||
@@ -49,6 +71,9 @@ export class ThemeManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a registered theme by name, or undefined if not found.
|
* Get a registered theme by name, or undefined if not found.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const theme = themes.get('dark');
|
||||||
*/
|
*/
|
||||||
get(name: string): RibbitTheme | undefined {
|
get(name: string): RibbitTheme | undefined {
|
||||||
return this.registered.get(name);
|
return this.registered.get(name);
|
||||||
@@ -56,6 +81,9 @@ export class ThemeManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the currently active theme.
|
* Return the currently active theme.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const active = themes.current();
|
||||||
*/
|
*/
|
||||||
current(): RibbitTheme {
|
current(): RibbitTheme {
|
||||||
return this.active;
|
return this.active;
|
||||||
@@ -65,6 +93,9 @@ export class ThemeManager {
|
|||||||
* Switch to a registered theme by name. The theme must be
|
* Switch to a registered theme by name. The theme must be
|
||||||
* registered and enabled. Loads the theme's CSS and notifies
|
* registered and enabled. Loads the theme's CSS and notifies
|
||||||
* the editor to rebuild its converter.
|
* the editor to rebuild its converter.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.set('dark');
|
||||||
*/
|
*/
|
||||||
set(name: string): void {
|
set(name: string): void {
|
||||||
const theme = this.registered.get(name);
|
const theme = this.registered.get(name);
|
||||||
@@ -76,13 +107,19 @@ export class ThemeManager {
|
|||||||
}
|
}
|
||||||
const previous = this.active;
|
const previous = this.active;
|
||||||
this.active = theme;
|
this.active = theme;
|
||||||
|
// Only load CSS when actually switching to a different theme
|
||||||
|
if (previous !== theme) {
|
||||||
this.loadCSS(name);
|
this.loadCSS(name);
|
||||||
|
}
|
||||||
this.onSwitch(theme, previous);
|
this.onSwitch(theme, previous);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a theme as available for selection via set().
|
* Mark a theme as available for selection via set().
|
||||||
* Themes are enabled by default when added.
|
* Themes are enabled by default when added.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.enable('dark');
|
||||||
*/
|
*/
|
||||||
enable(name: string): void {
|
enable(name: string): void {
|
||||||
if (!this.registered.has(name)) {
|
if (!this.registered.has(name)) {
|
||||||
@@ -94,6 +131,9 @@ export class ThemeManager {
|
|||||||
/**
|
/**
|
||||||
* Mark a theme as unavailable for selection via set().
|
* Mark a theme as unavailable for selection via set().
|
||||||
* Does not affect the current theme if it is already active.
|
* Does not affect the current theme if it is already active.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* themes.disable('dark');
|
||||||
*/
|
*/
|
||||||
disable(name: string): void {
|
disable(name: string): void {
|
||||||
if (!this.registered.has(name)) {
|
if (!this.registered.has(name)) {
|
||||||
@@ -108,7 +148,7 @@ export class ThemeManager {
|
|||||||
}
|
}
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
link.href = `${this.themesPath}/${name}/theme.css`;
|
link.href = `${this.themesPath}/${name}/${THEME_CSS_FILENAME}`;
|
||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
this.themeLink = link;
|
this.themeLink = link;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
/*
|
||||||
|
* toolbar.ts — toolbar manager for the ribbit editor.
|
||||||
|
*
|
||||||
|
* Resolves tags and macros into toolbar buttons. Renders the toolbar
|
||||||
|
* DOM. Manages button state (active/disabled/visible).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const toolbar = editor.toolbar;
|
||||||
|
* toolbar.buttons.get('bold').click();
|
||||||
|
* toolbar.buttons.get('table').hide();
|
||||||
|
* document.body.prepend(toolbar.render());
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Tag, ToolbarSlot, Button } from './types';
|
||||||
|
import type { MacroDef } from './macros';
|
||||||
|
|
||||||
|
const CSS_CLASS_ACTIVE = 'active';
|
||||||
|
const CSS_CLASS_DISABLED = 'disabled';
|
||||||
|
const CSS_CLASS_TOOLBAR = 'ribbit-toolbar';
|
||||||
|
const CSS_CLASS_SPACER = 'spacer';
|
||||||
|
const CSS_CLASS_GROUP = 'ribbit-btn-group';
|
||||||
|
const CSS_CLASS_DROPDOWN = 'ribbit-dropdown';
|
||||||
|
const CSS_DISPLAY_NONE = 'none';
|
||||||
|
const MACRO_ID_PREFIX = 'macro:';
|
||||||
|
const DROPDOWN_INDICATOR = ' ▾';
|
||||||
|
|
||||||
|
/** IDs of buttons that belong in the utility section, not the tag/macro area. */
|
||||||
|
const UTILITY_BUTTON_IDS = ['save', 'edit'];
|
||||||
|
|
||||||
|
const MAX_HEADING_LEVEL = 6;
|
||||||
|
|
||||||
|
const EDITOR_STATE_VIEW = 'view';
|
||||||
|
const EDITOR_STATE_EDIT = 'edit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concrete implementation of the Button interface.
|
||||||
|
*
|
||||||
|
* Wraps a button definition with DOM element tracking and
|
||||||
|
* visibility toggling. Created internally by ToolbarManager.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const button = new ButtonImpl({ id: 'bold', label: 'Bold', action: 'wrap', delimiter: '**' });
|
||||||
|
* button.hide();
|
||||||
|
* button.show();
|
||||||
|
*/
|
||||||
|
class ButtonImpl implements Button {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
action: 'wrap' | 'prefix' | 'insert' | 'custom';
|
||||||
|
delimiter?: string;
|
||||||
|
template?: string;
|
||||||
|
replaceSelection: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
element?: HTMLElement;
|
||||||
|
handler?: () => void;
|
||||||
|
|
||||||
|
constructor(definition: Partial<Button> & { id: string }) {
|
||||||
|
this.id = definition.id;
|
||||||
|
this.label = definition.label || definition.id;
|
||||||
|
this.icon = definition.icon;
|
||||||
|
this.shortcut = definition.shortcut;
|
||||||
|
this.action = definition.action || 'insert';
|
||||||
|
this.delimiter = definition.delimiter;
|
||||||
|
this.template = definition.template;
|
||||||
|
this.replaceSelection = definition.replaceSelection ?? true;
|
||||||
|
this.visible = definition.visible ?? true;
|
||||||
|
this.handler = definition.handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatically trigger this button's click event.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* toolbar.buttons.get('bold')?.click();
|
||||||
|
*/
|
||||||
|
click(): void {
|
||||||
|
this.element?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide this button from the toolbar.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* toolbar.buttons.get('table')?.hide();
|
||||||
|
*/
|
||||||
|
hide(): void {
|
||||||
|
this.visible = false;
|
||||||
|
if (this.element) {
|
||||||
|
this.element.style.display = CSS_DISPLAY_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show this button in the toolbar.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* toolbar.buttons.get('table')?.show();
|
||||||
|
*/
|
||||||
|
show(): void {
|
||||||
|
this.visible = true;
|
||||||
|
if (this.element) {
|
||||||
|
this.element.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the editor toolbar: registers buttons from tags and macros,
|
||||||
|
* renders the toolbar DOM, handles keyboard shortcuts, and tracks
|
||||||
|
* active/disabled state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const manager = new ToolbarManager(editor, tags, macros);
|
||||||
|
* document.body.prepend(manager.render());
|
||||||
|
* manager.updateActiveState(['bold', 'italic']);
|
||||||
|
*/
|
||||||
|
export class ToolbarManager {
|
||||||
|
buttons: Map<string, Button>;
|
||||||
|
private layout: ToolbarSlot[];
|
||||||
|
private editor: any;
|
||||||
|
|
||||||
|
constructor(editor: any, tags: Record<string, Tag>, macros: MacroDef[], layout?: ToolbarSlot[]) {
|
||||||
|
this.editor = editor;
|
||||||
|
this.buttons = new Map();
|
||||||
|
|
||||||
|
this.registerTagButtons(tags);
|
||||||
|
this.registerHeadingButtons();
|
||||||
|
this.registerListButtons();
|
||||||
|
this.registerMacroButtons(macros);
|
||||||
|
this.registerUtilityButtons();
|
||||||
|
|
||||||
|
this.layout = layout || this.buildDefaultLayout();
|
||||||
|
this.bindShortcuts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register buttons for tags that have button config enabled. */
|
||||||
|
private registerTagButtons(tags: Record<string, Tag>): void {
|
||||||
|
for (const tag of Object.values(tags)) {
|
||||||
|
if (!tag.button || !tag.button.show) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.register(tag.name, {
|
||||||
|
label: tag.button.label,
|
||||||
|
icon: tag.button.icon,
|
||||||
|
shortcut: tag.button.shortcut,
|
||||||
|
action: tag.delimiter ? 'wrap' : 'insert',
|
||||||
|
delimiter: tag.delimiter,
|
||||||
|
template: tag.template,
|
||||||
|
replaceSelection: tag.replaceSelection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heading levels are derived from a single pattern rather than repeated blocks. */
|
||||||
|
private registerHeadingButtons(): void {
|
||||||
|
for (let level = 1; level <= MAX_HEADING_LEVEL; level++) {
|
||||||
|
this.register(`h${level}`, {
|
||||||
|
label: `H${level}`,
|
||||||
|
shortcut: `Ctrl+${level}`,
|
||||||
|
action: 'prefix',
|
||||||
|
delimiter: '#'.repeat(level) + ' ',
|
||||||
|
replaceSelection: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerListButtons(): void {
|
||||||
|
const listDefinitions: Array<{ id: string; label: string; shortcut: string; template: string }> = [
|
||||||
|
{
|
||||||
|
id: 'ul',
|
||||||
|
label: 'Bullet List',
|
||||||
|
shortcut: 'Ctrl+Shift+8',
|
||||||
|
template: '- Item 1\n- Item 2\n- Item 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ol',
|
||||||
|
label: 'Numbered List',
|
||||||
|
shortcut: 'Ctrl+Shift+7',
|
||||||
|
template: '1. Item 1\n2. Item 2\n3. Item 3',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const definition of listDefinitions) {
|
||||||
|
this.register(definition.id, {
|
||||||
|
label: definition.label,
|
||||||
|
shortcut: definition.shortcut,
|
||||||
|
action: 'insert',
|
||||||
|
template: definition.template,
|
||||||
|
replaceSelection: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerMacroButtons(macros: MacroDef[]): void {
|
||||||
|
for (const macro of macros) {
|
||||||
|
if (macro.button === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const buttonConfig = typeof macro.button === 'object' ? macro.button : null;
|
||||||
|
const capitalizedName = macro.name.charAt(0).toUpperCase() + macro.name.slice(1);
|
||||||
|
this.register(`${MACRO_ID_PREFIX}${macro.name}`, {
|
||||||
|
label: buttonConfig?.label || capitalizedName,
|
||||||
|
icon: buttonConfig?.icon,
|
||||||
|
action: 'insert',
|
||||||
|
template: `@${macro.name}`,
|
||||||
|
replaceSelection: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerUtilityButtons(): void {
|
||||||
|
this.register('save', {
|
||||||
|
label: 'Save',
|
||||||
|
shortcut: 'Ctrl+S',
|
||||||
|
action: 'custom',
|
||||||
|
handler: () => this.editor.save(),
|
||||||
|
});
|
||||||
|
this.register('edit', {
|
||||||
|
label: 'Edit',
|
||||||
|
shortcut: 'Ctrl+Shift+V',
|
||||||
|
action: 'custom',
|
||||||
|
handler: () => {
|
||||||
|
if (this.editor.getState() === EDITOR_STATE_VIEW) {
|
||||||
|
this.editor.wysiwyg();
|
||||||
|
} else {
|
||||||
|
this.editor.view();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a keyboard shortcut lookup and dispatches matching
|
||||||
|
* button actions on keydown events.
|
||||||
|
*/
|
||||||
|
private bindShortcuts(): void {
|
||||||
|
const shortcutMap = new Map<string, Button>();
|
||||||
|
for (const button of this.buttons.values()) {
|
||||||
|
if (button.shortcut) {
|
||||||
|
shortcutMap.set(button.shortcut.toLowerCase(), button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
|
const combo = this.buildKeyCombo(event);
|
||||||
|
const button = shortcutMap.get(combo);
|
||||||
|
if (button) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.executeAction(button);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes a KeyboardEvent into a comparable shortcut string like "ctrl+shift+b". */
|
||||||
|
private buildKeyCombo(event: KeyboardEvent): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
parts.push('ctrl');
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
parts.push('shift');
|
||||||
|
}
|
||||||
|
if (event.altKey) {
|
||||||
|
parts.push('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special keys pass through as-is; letter keys are lowercased
|
||||||
|
const specialKeys = ['/', '.', '-'];
|
||||||
|
const key = specialKeys.includes(event.key) ? event.key : event.key.toLowerCase();
|
||||||
|
|
||||||
|
parts.push(key);
|
||||||
|
return parts.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
private register(id: string, definition: Partial<Button>): void {
|
||||||
|
if (this.buttons.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buttons.set(id, new ButtonImpl({ id, ...definition }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDefaultLayout(): ToolbarSlot[] {
|
||||||
|
const tagIds: string[] = [];
|
||||||
|
const macroIds: string[] = [];
|
||||||
|
for (const id of this.buttons.keys()) {
|
||||||
|
if (UTILITY_BUTTON_IDS.includes(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (id.startsWith(MACRO_ID_PREFIX)) {
|
||||||
|
macroIds.push(id);
|
||||||
|
} else {
|
||||||
|
tagIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const slots: ToolbarSlot[] = [...tagIds];
|
||||||
|
if (macroIds.length > 0) {
|
||||||
|
slots.push('');
|
||||||
|
slots.push({
|
||||||
|
group: 'Macros',
|
||||||
|
items: macroIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
slots.push('', 'save', 'edit');
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the active CSS class on buttons whose IDs appear in the
|
||||||
|
* given list of currently-active tag names.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* manager.updateActiveState(['bold', 'italic']);
|
||||||
|
*/
|
||||||
|
updateActiveState(activeTagNames: string[]): void {
|
||||||
|
for (const [id, button] of this.buttons) {
|
||||||
|
button.element?.classList.toggle(CSS_CLASS_ACTIVE, activeTagNames.includes(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable all toolbar buttons by removing the disabled CSS class.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* manager.enable();
|
||||||
|
*/
|
||||||
|
enable(): void {
|
||||||
|
for (const button of this.buttons.values()) {
|
||||||
|
button.element?.classList.remove(CSS_CLASS_DISABLED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable all toolbar buttons by adding the disabled CSS class.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* manager.disable();
|
||||||
|
*/
|
||||||
|
disable(): void {
|
||||||
|
for (const button of this.buttons.values()) {
|
||||||
|
button.element?.classList.add(CSS_CLASS_DISABLED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the toolbar DOM tree and return the root element.
|
||||||
|
* The caller is responsible for inserting it into the document.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* document.body.prepend(manager.render());
|
||||||
|
*/
|
||||||
|
render(): HTMLElement {
|
||||||
|
const nav = document.createElement('nav');
|
||||||
|
nav.className = CSS_CLASS_TOOLBAR;
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
|
||||||
|
for (const slot of this.layout) {
|
||||||
|
const element = this.renderSlot(slot);
|
||||||
|
if (element) {
|
||||||
|
list.appendChild(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.appendChild(list);
|
||||||
|
return nav;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatches a single layout slot to the appropriate renderer. */
|
||||||
|
private renderSlot(slot: ToolbarSlot): HTMLElement | null {
|
||||||
|
if (slot === '') {
|
||||||
|
return this.renderSpacer();
|
||||||
|
}
|
||||||
|
if (typeof slot === 'string') {
|
||||||
|
return this.renderStringSlot(slot);
|
||||||
|
}
|
||||||
|
return this.renderGroupSlot(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSpacer(): HTMLElement {
|
||||||
|
const listItem = document.createElement('li');
|
||||||
|
listItem.className = CSS_CLASS_SPACER;
|
||||||
|
return listItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStringSlot(slot: string): HTMLElement | null {
|
||||||
|
if (slot === 'macros') {
|
||||||
|
const items = [...this.buttons.values()].filter(button => button.id.startsWith(MACRO_ID_PREFIX));
|
||||||
|
if (items.length > 0) {
|
||||||
|
return this.renderGroup({
|
||||||
|
label: 'Macros',
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const button = this.buttons.get(slot);
|
||||||
|
if (button) {
|
||||||
|
return this.renderButton(button);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderGroupSlot(slot: { group: string; items: string[] }): HTMLElement | null {
|
||||||
|
const items = slot.items
|
||||||
|
.map(id => this.buttons.get(id))
|
||||||
|
.filter((button): button is Button => button !== undefined);
|
||||||
|
if (items.length > 0) {
|
||||||
|
return this.renderGroup({
|
||||||
|
label: slot.group,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderButton(button: Button): HTMLElement {
|
||||||
|
const listItem = document.createElement('li');
|
||||||
|
const buttonElement = document.createElement('button');
|
||||||
|
buttonElement.className = `ribbit-btn-${button.id}`;
|
||||||
|
//buttonElement.textContent = button.label;
|
||||||
|
buttonElement.setAttribute('aria-label', button.label);
|
||||||
|
buttonElement.title = button.shortcut
|
||||||
|
? `${button.label} (${button.shortcut})`
|
||||||
|
: button.label;
|
||||||
|
if (!button.visible) {
|
||||||
|
listItem.style.display = CSS_DISPLAY_NONE;
|
||||||
|
}
|
||||||
|
buttonElement.addEventListener('click', () => this.executeAction(button));
|
||||||
|
button.element = buttonElement;
|
||||||
|
listItem.appendChild(buttonElement);
|
||||||
|
return listItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderGroup(group: { label: string; items: Button[] }): HTMLElement {
|
||||||
|
const listItem = document.createElement('li');
|
||||||
|
const toggle = document.createElement('button');
|
||||||
|
toggle.className = CSS_CLASS_GROUP;
|
||||||
|
toggle.textContent = group.label + DROPDOWN_INDICATOR;
|
||||||
|
toggle.setAttribute('aria-label', group.label);
|
||||||
|
toggle.title = group.label;
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = CSS_CLASS_DROPDOWN;
|
||||||
|
menu.style.display = CSS_DISPLAY_NONE;
|
||||||
|
|
||||||
|
for (const button of group.items) {
|
||||||
|
const buttonElement = this.renderDropdownItem(button, menu);
|
||||||
|
menu.appendChild(buttonElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
menu.style.display = menu.style.display === CSS_DISPLAY_NONE ? '' : CSS_DISPLAY_NONE;
|
||||||
|
});
|
||||||
|
|
||||||
|
listItem.appendChild(toggle);
|
||||||
|
listItem.appendChild(menu);
|
||||||
|
return listItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a single button element inside a dropdown menu. */
|
||||||
|
private renderDropdownItem(button: Button, menu: HTMLElement): HTMLElement {
|
||||||
|
const buttonElement = document.createElement('button');
|
||||||
|
buttonElement.className = `ribbit-btn-${button.id}`;
|
||||||
|
buttonElement.setAttribute('aria-label', button.label);
|
||||||
|
buttonElement.title = button.label;
|
||||||
|
buttonElement.textContent = button.label;
|
||||||
|
if (!button.visible) {
|
||||||
|
buttonElement.style.display = CSS_DISPLAY_NONE;
|
||||||
|
}
|
||||||
|
buttonElement.addEventListener('click', () => {
|
||||||
|
this.executeAction(button);
|
||||||
|
menu.style.display = CSS_DISPLAY_NONE;
|
||||||
|
});
|
||||||
|
button.element = buttonElement;
|
||||||
|
return buttonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeAction(button: Button): void {
|
||||||
|
if (!button.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button.handler) {
|
||||||
|
button.handler();
|
||||||
|
this.editor.element.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button.action === 'wrap' && button.delimiter) {
|
||||||
|
this.wrapSelection(button.delimiter);
|
||||||
|
} else if (button.action === 'insert' && button.template) {
|
||||||
|
this.insertText(button.template, button.replaceSelection);
|
||||||
|
}
|
||||||
|
this.editor.invalidateCache();
|
||||||
|
this.editor.element.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps the current selection with the given delimiter on both sides. */
|
||||||
|
private wrapSelection(delimiter: string): void {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const text = range.toString();
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(document.createTextNode(delimiter + text + delimiter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserts text at the cursor, optionally replacing the current selection. */
|
||||||
|
private insertText(text: string, replaceSelection: boolean): void {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (replaceSelection) {
|
||||||
|
range.deleteContents();
|
||||||
|
} else {
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
range.insertNode(document.createTextNode(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
-7
@@ -1,7 +1,15 @@
|
|||||||
/*
|
/*
|
||||||
* types.ts — shared types for the hopdown converter.
|
* types.ts — shared type definitions for the ribbit editor.
|
||||||
|
*
|
||||||
|
* All interfaces used across multiple modules live here to avoid
|
||||||
|
* circular imports. Module-specific types stay in their own files.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of a Tag's match() call. Carries the matched content,
|
||||||
|
* the raw matched text, how many source lines were consumed, and
|
||||||
|
* optional metadata (e.g. heading level, link href).
|
||||||
|
*/
|
||||||
export interface SourceToken {
|
export interface SourceToken {
|
||||||
content: string;
|
content: string;
|
||||||
raw: string;
|
raw: string;
|
||||||
@@ -9,13 +17,23 @@ export interface SourceToken {
|
|||||||
meta?: Record<string, string>;
|
meta?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversion functions passed to Tag.toHTML and Tag.toMarkdown so
|
||||||
|
* tags can recursively convert their children without knowing about
|
||||||
|
* the HopDown instance.
|
||||||
|
*/
|
||||||
export interface Converter {
|
export interface Converter {
|
||||||
inline: (text: string) => string;
|
inline: (text: string) => string;
|
||||||
block: (md: string) => string;
|
block: (markdown: string) => string;
|
||||||
children: (node: Node) => string;
|
children: (node: Node) => string;
|
||||||
node: (node: Node) => string;
|
node: (node: Node) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to Tag.match() during block-level scanning.
|
||||||
|
* `lines` and `index` are for block matching; `text` and `offset`
|
||||||
|
* are for inline matching within a single line.
|
||||||
|
*/
|
||||||
export interface MatchContext {
|
export interface MatchContext {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
index: number;
|
index: number;
|
||||||
@@ -23,40 +41,196 @@ export interface MatchContext {
|
|||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a toolbar button's appearance and shortcut.
|
||||||
|
*/
|
||||||
|
export interface ToolbarButton {
|
||||||
|
show: boolean;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Tag is the core abstraction: it knows how to match markdown syntax,
|
||||||
|
* convert it to HTML, and convert the HTML back to markdown. Tags are
|
||||||
|
* registered by HTML selector (e.g. 'STRONG,B') so the converter can
|
||||||
|
* look them up during HTML→markdown conversion.
|
||||||
|
*/
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
name: string;
|
name: string;
|
||||||
match: (context: MatchContext) => SourceToken | null;
|
match: (context: MatchContext) => SourceToken | null;
|
||||||
toHTML: (token: SourceToken, convert: Converter) => string;
|
toHTML: (token: SourceToken, convert: Converter) => string;
|
||||||
selector: string | ((element: HTMLElement) => boolean);
|
selector: string | ((element: HTMLElement) => boolean);
|
||||||
toMarkdown: (element: HTMLElement, convert: Converter) => string;
|
toMarkdown: (element: HTMLElement, convert: Converter) => string;
|
||||||
|
delimiter?: string;
|
||||||
|
precedence?: number;
|
||||||
|
recursive?: boolean;
|
||||||
|
pattern?: RegExp;
|
||||||
|
template?: string;
|
||||||
|
replaceSelection?: boolean;
|
||||||
|
button?: ToolbarButton;
|
||||||
|
/** Keyboard event handlers for WYSIWYG mode. Keys are event names
|
||||||
|
* like 'onEnter', 'onBackspace', 'onTab'. The handler receives
|
||||||
|
* the tag's element, the current selection, and the editor instance. */
|
||||||
|
eventHandlers?: Record<string, (element: HTMLElement, selection: Selection, editor: any) => boolean>;
|
||||||
|
/** Dispatch a keydown event to the appropriate handler in
|
||||||
|
* eventHandlers. Provided by BaseTag; override for custom logic. */
|
||||||
|
handleKeydown?: (element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single item in a parsed list, with optional nested sublist HTML.
|
||||||
|
*/
|
||||||
export interface ListItem {
|
export interface ListItem {
|
||||||
text: string;
|
text: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of parsing a list block: the generated HTML and the line
|
||||||
|
* index where the list ends (so the caller can advance past it).
|
||||||
|
*/
|
||||||
export interface ListResult {
|
export interface ListResult {
|
||||||
html: string;
|
html: string;
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand definition for creating inline tags via the inlineTag()
|
||||||
|
* factory. Covers the common case where a delimiter wraps content
|
||||||
|
* and maps to a single HTML element.
|
||||||
|
*/
|
||||||
export interface InlineTagDef {
|
export interface InlineTagDef {
|
||||||
name: string;
|
name: string;
|
||||||
/** The markdown delimiter, e.g. '**' or '`' or '~~' */
|
|
||||||
delimiter: string;
|
delimiter: string;
|
||||||
/** The HTML tag to wrap with, e.g. 'strong' or 'code' */
|
|
||||||
htmlTag: string;
|
htmlTag: string;
|
||||||
/** Additional HTML selectors for reverse matching, e.g. 'B' for bold */
|
|
||||||
aliases?: string;
|
aliases?: string;
|
||||||
/** Lower runs first. Default 50. */
|
|
||||||
precedence?: number;
|
precedence?: number;
|
||||||
/** Process inner content for nested markdown? Default true. False for code spans. */
|
|
||||||
recursive?: boolean;
|
recursive?: boolean;
|
||||||
|
button?: ToolbarButton | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RibbitThemeFeatures {
|
export interface RibbitThemeFeatures {
|
||||||
sourceMode?: boolean;
|
sourceMode?: boolean;
|
||||||
|
vim?: boolean;
|
||||||
|
collaboration?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport for syncing document changes between clients.
|
||||||
|
* The consumer implements this with their choice of network layer
|
||||||
|
* (WebSocket, WebRTC, HTTP polling, etc.). Ribbit never makes
|
||||||
|
* network calls itself.
|
||||||
|
*
|
||||||
|
* const transport: DocumentTransport = {
|
||||||
|
* connect() { socket.open(); },
|
||||||
|
* disconnect() { socket.close(); },
|
||||||
|
* send(update) { socket.send(update); },
|
||||||
|
* onReceive(callback) { socket.onmessage = (event) => callback(event.data); },
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export interface DocumentTransport {
|
||||||
|
connect(): void;
|
||||||
|
disconnect(): void;
|
||||||
|
send(update: Uint8Array): void;
|
||||||
|
onReceive(callback: (update: Uint8Array) => void): void;
|
||||||
|
lock?(): Promise<boolean>;
|
||||||
|
unlock?(): void;
|
||||||
|
forceLock?(): Promise<boolean>;
|
||||||
|
onLockChange?(callback: (holder: PeerInfo | null) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel for broadcasting cursor position and user presence.
|
||||||
|
* Optional — collaboration works without it, but users won't see
|
||||||
|
* each other's cursors.
|
||||||
|
*
|
||||||
|
* const presence: PresenceChannel = {
|
||||||
|
* send(info) { socket.send(JSON.stringify(info)); },
|
||||||
|
* onUpdate(callback) { socket.onmessage = (event) => callback(JSON.parse(event.data)); },
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export interface PresenceChannel {
|
||||||
|
send(info: PeerInfo): void;
|
||||||
|
onUpdate(callback: (peers: PeerInfo[]) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerInfo {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
cursor?: number;
|
||||||
|
color?: string;
|
||||||
|
status: 'active' | 'editing' | 'idle';
|
||||||
|
lastActive: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollaborationSettings {
|
||||||
|
transport: DocumentTransport;
|
||||||
|
presence?: PresenceChannel;
|
||||||
|
user: PeerInfo;
|
||||||
|
/** Milliseconds before a peer is considered idle. Default 30000. */
|
||||||
|
idleTimeout?: number;
|
||||||
|
/** Provider for revision storage. Required for auto-revision on source mode exit. */
|
||||||
|
revisions?: RevisionProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage backend for document revisions. The consumer implements
|
||||||
|
* this with their persistence layer (database, API, localStorage, etc.).
|
||||||
|
*/
|
||||||
|
export interface RevisionProvider {
|
||||||
|
list(): Promise<Revision[]>;
|
||||||
|
get(id: string): Promise<Revision & { content: string }>;
|
||||||
|
create(content: string, metadata?: RevisionMetadata): Promise<Revision>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Revision {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
author: string;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisionMetadata {
|
||||||
|
summary?: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slot in the toolbar layout. Strings reference tag names or
|
||||||
|
* special values; objects define dropdown groups.
|
||||||
|
*
|
||||||
|
* 'bold' — single button
|
||||||
|
* '' — spacer
|
||||||
|
* 'macros' — auto-populated macro dropdown
|
||||||
|
* { group: 'Heading', items: ['h1', ...] } — dropdown group
|
||||||
|
*/
|
||||||
|
export type ToolbarSlot =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
group: string;
|
||||||
|
items: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolved toolbar button with DOM element and interaction methods.
|
||||||
|
*/
|
||||||
|
export interface Button {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
action: 'wrap' | 'prefix' | 'insert' | 'custom';
|
||||||
|
delimiter?: string;
|
||||||
|
template?: string;
|
||||||
|
replaceSelection: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
element?: HTMLElement;
|
||||||
|
handler?: () => void;
|
||||||
|
click(): void;
|
||||||
|
hide(): void;
|
||||||
|
show(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RibbitTheme {
|
export interface RibbitTheme {
|
||||||
@@ -64,3 +238,23 @@ export interface RibbitTheme {
|
|||||||
tags?: Record<string, Tag>;
|
tags?: Record<string, Tag>;
|
||||||
features?: RibbitThemeFeatures;
|
features?: RibbitThemeFeatures;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of finding a complete delimiter pair (e.g. **bold**) or
|
||||||
|
* an unclosed opener (e.g. **bold) in a text string. Used by the
|
||||||
|
* WYSIWYG editor to transform inline formatting in-place.
|
||||||
|
*/
|
||||||
|
export interface DelimiterMatch {
|
||||||
|
/** The Tag definition that matched. */
|
||||||
|
tag: Tag;
|
||||||
|
/** The HTML element name to use (e.g. 'strong', 'em'). */
|
||||||
|
htmlTag: string;
|
||||||
|
/** The matched content between delimiters. */
|
||||||
|
content: string;
|
||||||
|
/** Start index of the full match in the source string. */
|
||||||
|
index: number;
|
||||||
|
/** Length of the full match including delimiters. */
|
||||||
|
length: number;
|
||||||
|
/** The delimiter string (e.g. '**', '*', '`'). */
|
||||||
|
delimiter: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
import { HopDown } from '../src';
|
||||||
|
|
||||||
|
const lib = ribbit();
|
||||||
|
|
||||||
|
describe('Custom block tags', () => {
|
||||||
|
const spoiler = {
|
||||||
|
name: 'spoiler',
|
||||||
|
match: (context: any) => {
|
||||||
|
const fencePattern = /^\|{3,}/;
|
||||||
|
if (!fencePattern.test(context.lines[context.index])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const content: string[] = [];
|
||||||
|
let lineIndex = context.index + 1;
|
||||||
|
while (lineIndex < context.lines.length && !fencePattern.test(context.lines[lineIndex])) {
|
||||||
|
content.push(context.lines[lineIndex++]);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: content.join('\n'),
|
||||||
|
raw: '',
|
||||||
|
consumed: lineIndex + 1 - context.index,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toHTML: (token: any, convert: any) => '<details>' + convert.block(token.content) + '</details>',
|
||||||
|
selector: 'DETAILS',
|
||||||
|
toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
|
||||||
|
};
|
||||||
|
const converter = new HopDown({
|
||||||
|
tags: {
|
||||||
|
'DETAILS': spoiler,
|
||||||
|
...lib.defaultTags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders', () => expect(converter.toHTML('|||\nhidden\n|||')).toContain('<details>'));
|
||||||
|
it('nested md', () => expect(converter.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HopDown({ exclude })', () => {
|
||||||
|
it('excludes table', () => {
|
||||||
|
const converter = new HopDown({ exclude: ['table'] });
|
||||||
|
expect(converter.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
|
||||||
|
});
|
||||||
|
it('excludes code', () => {
|
||||||
|
const converter = new HopDown({ exclude: ['code'] });
|
||||||
|
expect(converter.toHTML('`code`')).toBe('<p>`code`</p>');
|
||||||
|
});
|
||||||
|
it('other tags still work', () => {
|
||||||
|
const converter = new HopDown({ exclude: ['table'] });
|
||||||
|
expect(converter.toHTML('**bold**')).toContain('<strong>bold</strong>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Collision detection', () => {
|
||||||
|
it('delimiter collision throws', () => {
|
||||||
|
const bad = lib.inlineTag({
|
||||||
|
name: 'bad',
|
||||||
|
delimiter: '*',
|
||||||
|
htmlTag: 'span',
|
||||||
|
precedence: 10,
|
||||||
|
});
|
||||||
|
expect(() => new HopDown({
|
||||||
|
tags: {
|
||||||
|
...lib.defaultTags,
|
||||||
|
'SPAN': bad,
|
||||||
|
},
|
||||||
|
})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selector collision throws', () => {
|
||||||
|
const dup = {
|
||||||
|
name: 'dup',
|
||||||
|
match: () => null,
|
||||||
|
toHTML: () => '',
|
||||||
|
selector: 'STRONG',
|
||||||
|
toMarkdown: () => '',
|
||||||
|
};
|
||||||
|
expect(() => new HopDown({
|
||||||
|
tags: {
|
||||||
|
...lib.defaultTags,
|
||||||
|
'STRONG': dup,
|
||||||
|
},
|
||||||
|
})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('valid precedence does not throw', () => {
|
||||||
|
const short = lib.inlineTag({
|
||||||
|
name: 'short',
|
||||||
|
delimiter: '~',
|
||||||
|
htmlTag: 's',
|
||||||
|
precedence: 50,
|
||||||
|
});
|
||||||
|
const long = lib.inlineTag({
|
||||||
|
name: 'long',
|
||||||
|
delimiter: '~~',
|
||||||
|
htmlTag: 'del',
|
||||||
|
precedence: 40,
|
||||||
|
});
|
||||||
|
// Remove default strikethrough to avoid collision with the custom S/DEL tags
|
||||||
|
const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags;
|
||||||
|
expect(() => new HopDown({
|
||||||
|
tags: {
|
||||||
|
...tagsWithoutStrikethrough,
|
||||||
|
'S': short,
|
||||||
|
'DEL': long,
|
||||||
|
},
|
||||||
|
})).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
function disambiguateAsteriskRuns(line: string): string {
|
||||||
|
const SENTINEL = '\u200C';
|
||||||
|
const ASTERISK_RUN = /\*{1,3}/g;
|
||||||
|
const stack: ('*' | '**')[] = [];
|
||||||
|
let result = '';
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
console.log(`DISAMBIGUATE: '${line}'`);
|
||||||
|
|
||||||
|
while ((match = ASTERISK_RUN.exec(line)) !== null) {
|
||||||
|
|
||||||
|
result += line.slice(lastIndex, match.index);
|
||||||
|
const run = match[0];
|
||||||
|
|
||||||
|
console.log(`DISAMBIGUATE: run == '${run}', stack == ${stack}`);
|
||||||
|
|
||||||
|
if (run.length === 3 && stack.length === 2) {
|
||||||
|
const innerCloser = stack.pop()!;
|
||||||
|
const outerCloser = stack.pop()!;
|
||||||
|
result += innerCloser + SENTINEL + outerCloser;
|
||||||
|
} else if (run.length === 3 && stack.length === 0) {
|
||||||
|
if (match.index == 0) {
|
||||||
|
result += '***';
|
||||||
|
} else {
|
||||||
|
result += '*' + SENTINEL + '**';
|
||||||
|
stack.push('**');
|
||||||
|
stack.push('*');
|
||||||
|
}
|
||||||
|
} else if (run.length === 3) {
|
||||||
|
result += run;
|
||||||
|
} else if (stack.length > 0 && stack[stack.length - 1] === run) {
|
||||||
|
stack.pop();
|
||||||
|
result += run;
|
||||||
|
} else {
|
||||||
|
stack.push(run as '*' | '**');
|
||||||
|
result += run;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + run.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += line.slice(lastIndex);
|
||||||
|
console.log(`DISAMBIGUATE: '${result}'`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) { throw new Error(message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewrite(line: string): string {
|
||||||
|
const SENTINEL = '|' //'\u200C';
|
||||||
|
const ASTERISK_RUN = /\*{1,3}/g;
|
||||||
|
const stack: ('*' | '**' | '***')[] = [];
|
||||||
|
let result = '';
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
const bold = '**';
|
||||||
|
const italic = '*';
|
||||||
|
|
||||||
|
let lastSequence = '';
|
||||||
|
|
||||||
|
while ((match = ASTERISK_RUN.exec(line)) !== null) {
|
||||||
|
result += line.slice(lastIndex, match.index);
|
||||||
|
const sequence = match[0];
|
||||||
|
|
||||||
|
if (sequence.length === 3) {
|
||||||
|
|
||||||
|
// split *** into opening ** and *
|
||||||
|
if (stack.length === 0) {
|
||||||
|
result += sequence;
|
||||||
|
stack.push('***');
|
||||||
|
|
||||||
|
// closing ***, close the stack
|
||||||
|
} else if (stack.length === 1 && stack[0] === sequence) {
|
||||||
|
result = result.replace('***', bold + SENTINEL + italic);
|
||||||
|
result += italic + SENTINEL + bold;
|
||||||
|
stack.pop();
|
||||||
|
|
||||||
|
} else if (stack.length === 2) {
|
||||||
|
const inner = stack.pop();
|
||||||
|
const outer = stack.pop();
|
||||||
|
result += inner + SENTINEL + outer;
|
||||||
|
|
||||||
|
} else if (stack.length === 1) {
|
||||||
|
console.warn(`Cannot parsed line '${line}': invalid sequence ${sequence} with stack ${stack}!`);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.warn(`UNHANDLED '${sequence}', last sequence '${lastSequence}'`);
|
||||||
|
}
|
||||||
|
} else if (stack.length) {
|
||||||
|
if (stack[stack.length - 1] === sequence) {
|
||||||
|
result += stack.pop();
|
||||||
|
} else if (stack.length === 1 && stack[0] === '***') {
|
||||||
|
const opener = stack[0].substring(0, stack[0].length - sequence.length);
|
||||||
|
result = result.replace('***', opener + SENTINEL + sequence);
|
||||||
|
result += sequence;
|
||||||
|
stack[0] = opener as '*' | '**';
|
||||||
|
} else {
|
||||||
|
result += sequence;
|
||||||
|
stack.push(sequence as '*' | '**');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stack.push(sequence as '*' | '**');
|
||||||
|
result += sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + sequence.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += line.slice(lastIndex);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function test_it() {
|
||||||
|
console.log(rewrite('*italic **bold***'));
|
||||||
|
let cases = [
|
||||||
|
{
|
||||||
|
'input': '***bold-italic***',
|
||||||
|
'expected': '**|*bold-italic*|**'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input': '***bold** italic*',
|
||||||
|
'expected': '*|**bold** italic*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input': '***italic* bold**',
|
||||||
|
'expected': '**|*italic* bold**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input': '**bold, *italic***',
|
||||||
|
'expected': '**bold, *italic*|**'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'input': '*italic, **bold***',
|
||||||
|
'expected': '*italic, **bold**|*'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
let result = rewrite(testCase.input);
|
||||||
|
assert(result == testCase.expected, `'${result}' (${result.length}) != '${testCase.expected}' (${testCase.expected.length})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test_it();
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
|
const lib = ribbit();
|
||||||
|
|
||||||
|
describe('RibbitEmitter', () => {
|
||||||
|
beforeEach(() => resetDOM());
|
||||||
|
|
||||||
|
it('fires save event', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
let received: any = null;
|
||||||
|
editor.on('save', (payload: any) => {
|
||||||
|
received = payload;
|
||||||
|
});
|
||||||
|
editor.save();
|
||||||
|
expect(received).toHaveProperty('markdown');
|
||||||
|
expect(received).toHaveProperty('html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('off removes handler', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
let count = 0;
|
||||||
|
const handler = () => {
|
||||||
|
count++;
|
||||||
|
};
|
||||||
|
editor.on('save', handler);
|
||||||
|
editor.save();
|
||||||
|
editor.off('save', handler);
|
||||||
|
editor.save();
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple listeners', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
let count = 0;
|
||||||
|
editor.on('save', () => {
|
||||||
|
count++;
|
||||||
|
});
|
||||||
|
editor.on('save', () => {
|
||||||
|
count++;
|
||||||
|
});
|
||||||
|
editor.save();
|
||||||
|
expect(count).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ribbit viewer', () => {
|
||||||
|
beforeEach(() => resetDOM('**bold**'));
|
||||||
|
|
||||||
|
it('starts with null state', () => {
|
||||||
|
const viewer = new lib.Viewer({});
|
||||||
|
expect(viewer.getState()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('run sets view state', () => {
|
||||||
|
const viewer = new lib.Viewer({});
|
||||||
|
viewer.run();
|
||||||
|
expect(viewer.getState()).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders html', () => {
|
||||||
|
const viewer = new lib.Viewer({});
|
||||||
|
viewer.run();
|
||||||
|
expect(viewer.element.innerHTML).toContain('<strong>bold</strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMarkdown returns source', () => {
|
||||||
|
const viewer = new lib.Viewer({});
|
||||||
|
expect(viewer.getMarkdown()).toBe('**bold**');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ribbit events', () => {
|
||||||
|
it('ready fires on run', () => {
|
||||||
|
resetDOM('hello');
|
||||||
|
let payload: any = null;
|
||||||
|
const viewer = new lib.Viewer({
|
||||||
|
on: {
|
||||||
|
ready: (eventPayload: any) => {
|
||||||
|
payload = eventPayload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
viewer.run();
|
||||||
|
expect(payload).toHaveProperty('markdown');
|
||||||
|
expect(payload).toHaveProperty('mode', 'view');
|
||||||
|
expect(payload.theme.name).toBe('ribbit-default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RibbitEditor modes', () => {
|
||||||
|
beforeEach(() => resetDOM('**bold**'));
|
||||||
|
|
||||||
|
it('starts in view', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.getState()).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to wysiwyg', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
expect(editor.getState()).toBe('wysiwyg');
|
||||||
|
expect(editor.element.contentEditable).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches back to view', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
editor.view();
|
||||||
|
expect(editor.getState()).toBe('view');
|
||||||
|
expect(editor.element.contentEditable).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires modeChange events', () => {
|
||||||
|
const modes: string[] = [];
|
||||||
|
const editor = new lib.Editor({
|
||||||
|
on: {
|
||||||
|
modeChange: ({ current }: any) => {
|
||||||
|
modes.push(current);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
editor.view();
|
||||||
|
expect(modes).toEqual(['view', 'wysiwyg', 'view']);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeManager', () => {
|
||||||
|
beforeEach(() => resetDOM());
|
||||||
|
|
||||||
|
it('lists registered themes', () => {
|
||||||
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
|
editor.run();
|
||||||
|
expect(editor.themes.list()).toContain('ribbit-default');
|
||||||
|
expect(editor.themes.list()).toContain('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('set switches theme', () => {
|
||||||
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
|
editor.run();
|
||||||
|
editor.themes.set('dark');
|
||||||
|
expect(editor.themes.current().name).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disable hides from list', () => {
|
||||||
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
|
editor.run();
|
||||||
|
editor.themes.disable('dark');
|
||||||
|
expect(editor.themes.list()).not.toContain('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enable restores to list', () => {
|
||||||
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
|
editor.run();
|
||||||
|
editor.themes.disable('dark');
|
||||||
|
editor.themes.enable('dark');
|
||||||
|
expect(editor.themes.list()).toContain('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('set disabled throws', () => {
|
||||||
|
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||||
|
editor.run();
|
||||||
|
editor.themes.disable('dark');
|
||||||
|
expect(() => editor.themes.set('dark')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('set unknown throws', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(() => editor.themes.set('nonexistent')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove active throws', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(() => editor.themes.remove(editor.themes.current().name)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires themeChange', () => {
|
||||||
|
let payload: any = null;
|
||||||
|
const editor = new lib.Editor({
|
||||||
|
themes: [{ name: 'dark' }],
|
||||||
|
on: {
|
||||||
|
themeChange: (eventPayload: any) => {
|
||||||
|
payload = eventPayload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.themes.set('dark');
|
||||||
|
expect(payload.current.name).toBe('dark');
|
||||||
|
expect(payload.previous.name).toBe('ribbit-default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('defaultTheme', () => {
|
||||||
|
it('has correct shape', () => {
|
||||||
|
expect(lib.defaultTheme.name).toBe('ribbit-default');
|
||||||
|
expect(lib.defaultTheme.tags).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Utility functions', () => {
|
||||||
|
it('encodeHtmlEntities', () => {
|
||||||
|
expect(lib.encodeHtmlEntities('<')).toBe('<');
|
||||||
|
expect(lib.encodeHtmlEntities('>')).toBe('>');
|
||||||
|
expect(lib.encodeHtmlEntities('&')).toBe('&');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodeHtmlEntities', () => {
|
||||||
|
expect(lib.decodeHtmlEntities('<')).toBe('<');
|
||||||
|
expect(lib.decodeHtmlEntities('&')).toBe('&');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('camelCase', () => {
|
||||||
|
expect(lib.camelCase('hello').join('')).toBe('Hello');
|
||||||
|
expect(lib.camelCase('hello world').join(' ')).toBe('Hello World');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Editor htmlToMarkdown', () => {
|
||||||
|
it('returns markdown in view state', () => {
|
||||||
|
resetDOM('**bold**');
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.getMarkdown()).toBe('**bold**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns markdown in wysiwyg state', () => {
|
||||||
|
resetDOM('**bold**');
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
expect(editor.getMarkdown()).toBe('**bold**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips inline formatting', () => {
|
||||||
|
resetDOM('hello **world** and *italic*');
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
expect(editor.getMarkdown()).toBe('hello **world** and *italic*');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
import { ribbit } from './setup';
|
||||||
|
|
||||||
|
const lib = ribbit();
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
const hopdown = editor.converter;
|
||||||
|
|
||||||
|
const H = (md: string) => hopdown.toHTML(md);
|
||||||
|
const M = (html: string) => hopdown.toMarkdown(html);
|
||||||
|
const rt = (md: string) => M(H(md));
|
||||||
|
|
||||||
|
|
||||||
|
describe('Markdown → HTML', () => {
|
||||||
|
describe('inline formatting', () => {
|
||||||
|
it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>'));
|
||||||
|
it('italic', () => expect(H('*italic*')).toBe('<p><em>italic</em></p>'));
|
||||||
|
it('inline code', () => expect(H('`code`')).toBe('<p><code>code</code></p>'));
|
||||||
|
it('link', () => expect(H('[t](http://x)')).toBe('<p><a href="http://x">t</a></p>'));
|
||||||
|
it('bold+italic', () => expect(H('***bi***')).toBe('<p><em><strong>bi</strong></em></p>'));
|
||||||
|
it('mixed', () => expect(H('a **b** *c* `d`')).toBe('<p>a <strong>b</strong> <em>c</em> <code>d</code></p>'));
|
||||||
|
it('code before bold', () => expect(H('`a` **b**')).toBe('<p><code>a</code> <strong>b</strong></p>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headings', () => {
|
||||||
|
it.each([1, 2, 3, 4, 5, 6])('h%i', (level) => {
|
||||||
|
const prefix = '#'.repeat(level);
|
||||||
|
expect(H(`${prefix} Sub`)).toContain(`<h${level}`);
|
||||||
|
});
|
||||||
|
it('heading id', () => expect(H('## Hello World')).toContain("id='HelloWorld'"));
|
||||||
|
it('heading inline md', () => expect(H('## **Bold** text')).toContain('<strong>Bold</strong>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('horizontal rules', () => {
|
||||||
|
it('***', () => expect(H('***')).toBe('<hr>'));
|
||||||
|
it('---', () => expect(H('---')).toBe('<hr>'));
|
||||||
|
it('___', () => expect(H('___')).toBe('<hr>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lists', () => {
|
||||||
|
it('ul *', () => expect(H('* a\n* b')).toBe('<ul><li>a</li><li>b</li></ul>'));
|
||||||
|
it('ul -', () => expect(H('- a\n- b')).toBe('<ul><li>a</li><li>b</li></ul>'));
|
||||||
|
it('ol', () => expect(H('1. a\n2. b')).toBe('<ol><li>a</li><li>b</li></ol>'));
|
||||||
|
it('ul inline', () => expect(H('* **bold** item')).toContain('<strong>bold</strong>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockquotes', () => {
|
||||||
|
it('basic', () => expect(H('> text')).toContain('<blockquote>'));
|
||||||
|
it('content', () => expect(H('> hello')).toContain('hello'));
|
||||||
|
it('multi-line', () => expect(H('> a\n> b')).toContain('a'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fenced code', () => {
|
||||||
|
it('basic', () => expect(H('```\nx = 1\n```')).toContain('<pre><code>'));
|
||||||
|
it('content', () => expect(H('```\nx = 1\n```')).toContain('x = 1'));
|
||||||
|
it('language', () => expect(H('```js\nvar x;\n```')).toContain('language-js'));
|
||||||
|
it('escapes html', () => expect(H('```\n<div>\n```')).toContain('<div>'));
|
||||||
|
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('&'));
|
||||||
|
it('html in code', () => expect(H('`<div>`')).toContain('<div>'));
|
||||||
|
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('<'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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('& resolves to &', () => {
|
||||||
|
expect(H('a & b')).toBe('<p>a & b</p>');
|
||||||
|
});
|
||||||
|
it('< resolves to <', () => {
|
||||||
|
expect(H('a < b')).toBe('<p>a < b</p>');
|
||||||
|
});
|
||||||
|
it('> resolves to >', () => {
|
||||||
|
expect(H('a > b')).toBe('<p>a > b</p>');
|
||||||
|
});
|
||||||
|
it('{ resolves to {', () => {
|
||||||
|
expect(H('{')).toBe('<p>{</p>');
|
||||||
|
});
|
||||||
|
it('{ resolves to {', () => {
|
||||||
|
expect(H('{')).toBe('<p>{</p>');
|
||||||
|
});
|
||||||
|
it('unknown entity passes through', () => {
|
||||||
|
expect(H('&unknown;')).toContain('&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('<em>');
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
var liveServer = require("live-server");
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
port: 5023,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
open: true,
|
||||||
|
root: "test/integration",
|
||||||
|
mount: [
|
||||||
|
['/static', 'dist/ribbit'],
|
||||||
|
['/test', 'test/integration'],
|
||||||
|
],
|
||||||
|
logLevel: 2, // 0 = errors only, 1 = some, 2 = lots
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
console.log(`\n🐸 Ribbit dev server running on http://localhost:${params['port']}`);
|
||||||
|
liveServer.start(params);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Ribbit Integration Test Page</title>
|
||||||
|
<link rel="stylesheet" href="/static/themes/ribbit-default/theme.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<article id="ribbit">
|
||||||
|
|
||||||
|
| Type | To Get |
|
||||||
|
|------|--------|
|
||||||
|
| `*emphasis*` | *emphasis* |
|
||||||
|
| `**bold**` | **bold** |
|
||||||
|
| `abel](/link/address)` | [link label](/link/address) |
|
||||||
|
| ``inline`` | `inline` |
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/ribbit.js"></script>
|
||||||
|
<script>
|
||||||
|
const editor = new ribbit.Editor({
|
||||||
|
on: {
|
||||||
|
ready: () => { window.__ribbitReady = true; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
window.__ribbitEditor = editor;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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>', '&'];
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
/**
|
||||||
|
* test_wysiwyg.js — Styled-source WYSIWYG integration tests.
|
||||||
|
*
|
||||||
|
* Tests the new styled-source editor implementation. Key differences
|
||||||
|
* from the old test suite:
|
||||||
|
*
|
||||||
|
* - No data-speculative, no <strong>/<em>/<del> DOM elements.
|
||||||
|
* The editor always stores raw markdown; CSS renders it visually.
|
||||||
|
* - Inline formatting uses .md-bold, .md-italic, .md-code spans
|
||||||
|
* with .md-delim children holding the delimiter characters.
|
||||||
|
* - getMarkdown() reads textContent directly — always returns the
|
||||||
|
* original markdown source, never converted HTML.
|
||||||
|
* - Block structure uses <div class="md-*"> elements, not <p>/<h1> etc.
|
||||||
|
*
|
||||||
|
* Run headless: node test/integration/test_wysiwyg.js
|
||||||
|
* Run against dev server: node test/integration/test_wysiwyg.js --port=5023
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const { createServer } = require('./server');
|
||||||
|
|
||||||
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const HEADLESS = !process.argv.includes('--headed');
|
||||||
|
const PORT = (() => {
|
||||||
|
const portArg = process.argv.find(arg => arg.startsWith('--port='));
|
||||||
|
return portArg ? parseInt(portArg.split('=')[1]) : 5023;
|
||||||
|
})();
|
||||||
|
const FILTER = (() => {
|
||||||
|
const filterArg = process.argv.find(arg => arg.startsWith('--filter='));
|
||||||
|
return filterArg ? filterArg.split('=')[1] : null;
|
||||||
|
})();
|
||||||
|
const DELAY = 20; // ms between keystrokes
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let browser, page, server;
|
||||||
|
let passed = 0, failed = 0;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getCaretInfo() {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || !sel.rangeCount) return 'no selection';
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
return {
|
||||||
|
container: range.startContainer.nodeType === 3
|
||||||
|
? `text:"${range.startContainer.textContent}"`
|
||||||
|
: `element:${range.startContainer.nodeName}.${range.startContainer.className}`,
|
||||||
|
offset: range.startOffset,
|
||||||
|
parentClass: range.startContainer.parentElement?.className,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isPortInUse(port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const net = require('net');
|
||||||
|
const tester = net.createServer()
|
||||||
|
.once('error', () => resolve(true))
|
||||||
|
.once('listening', () => tester.close(() => resolve(false)))
|
||||||
|
.listen(port, '127.0.0.1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serverStart() {
|
||||||
|
var liveServer = require("live-server");
|
||||||
|
var params = {
|
||||||
|
port: PORT,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
open: true,
|
||||||
|
root: "test/integration",
|
||||||
|
mount: [
|
||||||
|
['/static', 'dist/ribbit'],
|
||||||
|
['/test', 'test/integration'],
|
||||||
|
],
|
||||||
|
logLevel: 2, // 0 = errors only, 1 = some, 2 = lots
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\n🐸 Ribbit dev server running on http://localhost:${params['port']}`);
|
||||||
|
liveServer.start(params);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
|
||||||
|
if (!await isPortInUse(PORT)) {
|
||||||
|
await serverStart();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
browser = await chromium.launch({ headless: HEADLESS, channel: 'chromium' });
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.goto(`http://localhost:${PORT}`);
|
||||||
|
await page.waitForFunction(() => window.__ribbitReady === true, { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teardown() {
|
||||||
|
if (browser) { await browser.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editor helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the editor to an empty state in wysiwyg mode.
|
||||||
|
* Clears the DOM and places the cursor ready for typing.
|
||||||
|
*/
|
||||||
|
async function resetEditor() {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const editor = window.__ribbitEditor;
|
||||||
|
editor.view();
|
||||||
|
editor.sourceMarkdown = '';
|
||||||
|
editor.element.innerHTML = '';
|
||||||
|
editor.wysiwyg();
|
||||||
|
});
|
||||||
|
await page.focus('#ribbit');
|
||||||
|
await page.waitForTimeout(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type a string one character at a time with delay between each.
|
||||||
|
* Matches real user behaviour so block/inline transforms fire correctly.
|
||||||
|
*/
|
||||||
|
async function typeString(text) {
|
||||||
|
for (const character of text) {
|
||||||
|
await page.keyboard.insertText(character);
|
||||||
|
await page.waitForTimeout(DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Press a special key (Enter, Backspace, ArrowRight, etc).
|
||||||
|
*/
|
||||||
|
async function pressKey(key) {
|
||||||
|
await page.keyboard.press(key);
|
||||||
|
await page.waitForTimeout(DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the editor's current innerHTML.
|
||||||
|
*/
|
||||||
|
async function getHTML() {
|
||||||
|
return page.evaluate(() => document.getElementById('ribbit').innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the editor's current markdown via getMarkdown().
|
||||||
|
*/
|
||||||
|
async function getMarkdown() {
|
||||||
|
return page.evaluate(() => window.__ribbitEditor.getMarkdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all CSS classes on block divs inside the editor.
|
||||||
|
*/
|
||||||
|
async function getBlockClasses() {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
Array.from(document.getElementById('ribbit').children)
|
||||||
|
.map(block => block.className)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test runner ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) { throw new Error(message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test(name, fn) {
|
||||||
|
if (FILTER && !name.toLowerCase().includes(FILTER.toLowerCase())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
passed++;
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push({ name, message: error.message });
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
console.log('\nStyled-source WYSIWYG Integration Tests\n');
|
||||||
|
|
||||||
|
// ── Block classification ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('Block classification:');
|
||||||
|
|
||||||
|
await test('# space becomes md-h1', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('#');
|
||||||
|
let classes = await getBlockClasses();
|
||||||
|
assert(!classes.some(c => c.includes('md-h')), `Premature heading after just #: ${classes}`);
|
||||||
|
|
||||||
|
await typeString(' ');
|
||||||
|
classes = await getBlockClasses();
|
||||||
|
assert(classes.some(c => c.includes('md-h1')), `Expected md-h1 after "# ", got: ${classes}`);
|
||||||
|
|
||||||
|
await typeString('Title');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown.includes('# Title'), `Expected "# Title" in markdown: ${markdown}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('plain text becomes md-paragraph', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString("hello\n\n");
|
||||||
|
const classes = await getBlockClasses();
|
||||||
|
assert(classes.some(c => c.includes('md-paragraph')), `Expected md-paragraph, got: ${classes}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('## space becomes md-h2', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('## ');
|
||||||
|
const classes = await getBlockClasses();
|
||||||
|
assert(classes.some(c => c.includes('md-h2')), `Expected md-h2, got: ${classes}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('### space becomes md-h3', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('### ');
|
||||||
|
const classes = await getBlockClasses();
|
||||||
|
assert(classes.some(c => c.includes('md-h3')), `Expected md-h3, got: ${classes}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('> space becomes md-blockquote', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('>');
|
||||||
|
let classes = await getBlockClasses();
|
||||||
|
assert(!classes.some(c => c.includes('md-blockquote')), `Premature blockquote: ${classes}`);
|
||||||
|
|
||||||
|
await typeString(' ');
|
||||||
|
classes = await getBlockClasses();
|
||||||
|
assert(classes.some(c => c.includes('md-blockquote')), `Expected md-blockquote, got: ${classes}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('- space becomes md-list-item', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('-');
|
||||||
|
let classes = await getBlockClasses();
|
||||||
|
assert(!classes.some(c => c.includes('md-list')), `Premature list: ${classes}`);
|
||||||
|
|
||||||
|
await typeString(' ');
|
||||||
|
classes = await getBlockClasses();
|
||||||
|
assert(classes.some(c => c.includes('md-list-item')), `Expected md-list-item, got: ${classes}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('1. space becomes md-ol-list-item', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('1. ');
|
||||||
|
const classes = await getBlockClasses();
|
||||||
|
assert(classes.some(c => c.includes('md-ol-list-item')), `Expected md-ol-list-item, got: ${classes}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Inline formatting ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\nInline formatting:');
|
||||||
|
|
||||||
|
await test('**bold** produces md-bold span', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('**bold**');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('md-bold'), `Expected md-bold span: ${html}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '**bold**', `Expected "**bold**", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('*italic* produces md-italic span', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('*italic*');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('***bold-italic*** produces md-bold anad md-italic spans', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('***both***');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('md-bold'), `Expected md-bold-italic span: ${html}`);
|
||||||
|
assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('nested bold and italic are handled correctly', async() => {
|
||||||
|
const NESTED_CASES = [
|
||||||
|
{
|
||||||
|
name: 'close-side ambiguity, outer=bold (bold *italic***)',
|
||||||
|
markdown: '**bold *italic***',
|
||||||
|
outerClass: 'md-bold',
|
||||||
|
innerClass: 'md-italic',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'close-side ambiguity, outer=italic (*italic **bold***)',
|
||||||
|
markdown: '*italic **bold***',
|
||||||
|
outerClass: 'md-italic',
|
||||||
|
innerClass: 'md-bold',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'open-side ambiguity, outer=bold (***italic* bold**)',
|
||||||
|
markdown: '***italic* bold**',
|
||||||
|
outerClass: 'md-bold',
|
||||||
|
innerClass: 'md-italic',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'open-side ambiguity, outer=italic (***bold** italic*)',
|
||||||
|
markdown: '***bold** italic*',
|
||||||
|
outerClass: 'md-italic',
|
||||||
|
innerClass: 'md-bold',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of NESTED_CASES) {
|
||||||
|
await test(testCase.name, async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString(testCase.markdown);
|
||||||
|
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(
|
||||||
|
markdown === testCase.markdown,
|
||||||
|
`Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
const outerIndex = html.indexOf(testCase.outerClass);
|
||||||
|
const innerIndex = html.indexOf(testCase.innerClass);
|
||||||
|
assert(
|
||||||
|
outerIndex !== -1 && innerIndex !== -1,
|
||||||
|
`Missing expected classes (${testCase.outerClass}, ${testCase.innerClass}) in: ${html}`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
outerIndex < innerIndex,
|
||||||
|
`Expected ${testCase.outerClass} to wrap (appear before) ${testCase.innerClass} in: ${html}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('`code` produces md-code span', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('`code`');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('md-code'), `Expected md-code span: ${html}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '`code`', `Expected "\`code\`", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('~~strike~~ produces md-strikethrough span', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('~~gone~~');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('md-strikethrough'), `Expected md-strikethrough span: ${html}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '~~gone~~', `Expected "~~gone~~", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('delimiters are present in DOM as md-delim spans', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('**bold**');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('md-delim'), `Expected md-delim spans: ${html}`);
|
||||||
|
// The delimiter text ** must appear in the DOM
|
||||||
|
assert(html.includes('**'), `Delimiter text missing from DOM: ${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('mixed inline on one line round-trips correctly', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('hello **world** and *italic*');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(
|
||||||
|
markdown === 'hello **world** and *italic*',
|
||||||
|
`Round-trip failed: "${markdown}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('bold followed by trailing space stays bold', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('**bold** ');
|
||||||
|
const html = await getHTML();
|
||||||
|
assert(html.includes('md-bold'), `Expected md-bold span to survive trailing space: ${html}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(
|
||||||
|
markdown === '**bold** ',
|
||||||
|
`Expected "**bold** ", got: "${markdown}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('heading prefix stays plain space, not nbsp, after rebuild', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('# Title');
|
||||||
|
const html = await getHTML();
|
||||||
|
// The delimiter span's text must be a literal space (U+0020),
|
||||||
|
// not a non-breaking space, or block classification breaks on
|
||||||
|
// the next keystroke read of textContent.
|
||||||
|
assert(
|
||||||
|
html.includes('<span class="md-delim"># </span>') ||
|
||||||
|
html.includes('class="md-delim"># '),
|
||||||
|
`Unexpected delim content: ${html}`
|
||||||
|
);
|
||||||
|
// The real check: typing more text after this should still
|
||||||
|
// classify as md-h1, not regress to md-paragraph.
|
||||||
|
await typeString(' more');
|
||||||
|
const classes = await getBlockClasses();
|
||||||
|
assert(
|
||||||
|
classes.some(c => c.includes('md-h1')),
|
||||||
|
`Lost md-h1 classification after additional typing: ${JSON.stringify(classes)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getMarkdown round-trips ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\ngetMarkdown round-trips:');
|
||||||
|
|
||||||
|
await test('heading round-trips', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('# Hello World');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '# Hello World', `Expected "# Hello World", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('blockquote round-trips', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('> quoted text');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '> quoted text', `Expected "> quoted text", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('list item round-trips', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('- list item');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '- list item', `Expected "- list item", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('nested inline in heading round-trips', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('# Hello **world**');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '# Hello **world**', `Expected "# Hello **world**", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Enter key behaviour ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\nEnter key behaviour:');
|
||||||
|
|
||||||
|
await test('double Enter exits list', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('- item');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await pressKey('Enter');
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(
|
||||||
|
blocks.some(c => c.includes('md-paragraph')),
|
||||||
|
`Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('double Enter exits blockquote', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('> quote');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await pressKey('Enter');
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(
|
||||||
|
blocks.some(c => c.includes('md-paragraph')),
|
||||||
|
`Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('ordered list increments', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('1. first');
|
||||||
|
await pressKey('Enter');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(
|
||||||
|
markdown.includes('2. '),
|
||||||
|
`Expected "2. " on second line, got: "${markdown}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('heading followed by Enter creates paragraph', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('# Title');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('body');
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(blocks.some(c => c.includes('md-h1')), `No h1: ${blocks}`);
|
||||||
|
assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph: ${blocks}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('heading renders correctly after typing', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('# foo');
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(
|
||||||
|
blocks.some(c => c.includes('md-h1')),
|
||||||
|
`Expected md-h1, got: ${JSON.stringify(blocks)}`
|
||||||
|
);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '# foo', `Expected "# foo", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Enter splits current block into two blocks', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('hello');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('world');
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(blocks.length === 2, `Expected 2 blocks, got ${blocks.length}: ${JSON.stringify(blocks)}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === 'hello\nworld', `Expected "hello\\nworld", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Enter after heading creates new paragraph', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('# Title');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('body');
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(blocks.some(c => c.includes('md-h1')), `No h1 block: ${blocks}`);
|
||||||
|
assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph block: ${blocks}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Enter inside blockquote continues with > prefix', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('> first line');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('second line');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(
|
||||||
|
markdown.includes('> first line'),
|
||||||
|
`Missing "> first line" in markdown: "${markdown}"`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
markdown.includes('> second line'),
|
||||||
|
`Missing "> second line" — continuation prefix not added: "${markdown}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Enter inside list item continues with - prefix', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('- first item');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('second item');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(
|
||||||
|
markdown.includes('- first item'),
|
||||||
|
`Missing "- first item": "${markdown}"`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
markdown.includes('- second item'),
|
||||||
|
`Missing "- second item" — continuation prefix not added: "${markdown}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Backspace key behaviour ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\nBackspace key behaviour:');
|
||||||
|
|
||||||
|
await test('backspace on last empty block does not break editor', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('foo');
|
||||||
|
// Select all and delete
|
||||||
|
await page.keyboard.press('Control+a');
|
||||||
|
await page.keyboard.press('Backspace');
|
||||||
|
await page.keyboard.press('Backspace');
|
||||||
|
// Editor should still be functional
|
||||||
|
await typeString('# bar');
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(
|
||||||
|
blocks.some(c => c.includes('md-h1')),
|
||||||
|
`Editor broken after double backspace, got: ${JSON.stringify(blocks)}`
|
||||||
|
);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === '# bar', `Expected "# bar", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Backspace at start of block merges with previous block', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('foo');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('bar');
|
||||||
|
await pressKey('Home');
|
||||||
|
await pressKey('Backspace');
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(blocks.length === 1, `Expected 1 block after merge, got ${blocks.length}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === 'foobar', `Expected "foobar", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Backspace mid-block does not merge', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('foo');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('bar');
|
||||||
|
await pressKey('Backspace');
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(blocks.length === 2, `Expected 2 blocks, got ${blocks.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mode switching ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\nMode switching:');
|
||||||
|
|
||||||
|
await test('view() switches to view state', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('**bold**');
|
||||||
|
await page.evaluate(() => window.__ribbitEditor.view());
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
const state = await page.evaluate(() => window.__ribbitEditor.getState());
|
||||||
|
assert(state === 'view', `Expected "view", got: "${state}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('wysiwyg() switches back to wysiwyg state', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('hello');
|
||||||
|
await page.evaluate(() => window.__ribbitEditor.view());
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
await page.evaluate(() => window.__ribbitEditor.wysiwyg());
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
const state = await page.evaluate(() => window.__ribbitEditor.getState());
|
||||||
|
assert(state === 'wysiwyg', `Expected "wysiwyg", got: "${state}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('content survives wysiwyg → view → wysiwyg round-trip', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('**bold** and *italic*');
|
||||||
|
const markdownBefore = await getMarkdown();
|
||||||
|
|
||||||
|
await page.evaluate(() => window.__ribbitEditor.view());
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
await page.evaluate(() => window.__ribbitEditor.wysiwyg());
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
|
||||||
|
const markdownAfter = await getMarkdown();
|
||||||
|
assert(
|
||||||
|
markdownAfter === markdownBefore,
|
||||||
|
`Markdown changed after round-trip.\nBefore: "${markdownBefore}"\nAfter: "${markdownAfter}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('getMarkdown() returns source in view state', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('**bold**');
|
||||||
|
const markdownInEditor = await getMarkdown();
|
||||||
|
|
||||||
|
await page.evaluate(() => window.__ribbitEditor.view());
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
|
||||||
|
const markdownInView = await getMarkdown();
|
||||||
|
assert(
|
||||||
|
markdownInView === markdownInEditor,
|
||||||
|
`getMarkdown() changed on view switch.\nEditor: "${markdownInEditor}"\nView: "${markdownInView}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Complex documents ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('\nComplex documents:');
|
||||||
|
|
||||||
|
await test('multi-block document round-trips correctly', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('# Title');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('Some **bold** text.');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('> A quote');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('- A list item');
|
||||||
|
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown.includes('# Title'), `Missing heading: "${markdown}"`);
|
||||||
|
assert(markdown.includes('Some **bold** text.'), `Missing bold paragraph: "${markdown}"`);
|
||||||
|
assert(markdown.includes('> A quote'), `Missing blockquote: "${markdown}"`);
|
||||||
|
assert(markdown.includes('- A list item'), `Missing list item: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('empty lines between blocks preserved', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('first');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await pressKey('Enter');
|
||||||
|
await typeString('second');
|
||||||
|
|
||||||
|
const blocks = await getBlockClasses();
|
||||||
|
assert(blocks.length === 3, `Expected 3 blocks (first, empty, second), got ${blocks.length}`);
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown === 'first\n\nsecond', `Expected "first\\n\\nsecond", got: "${markdown}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
await test('# space becomes md-h1', async () => {
|
||||||
|
await resetEditor();
|
||||||
|
await typeString('#');
|
||||||
|
let classes = await getBlockClasses();
|
||||||
|
assert(!classes.some(c => c.includes('md-h')), `Premature heading after just #: ${classes}`);
|
||||||
|
|
||||||
|
await typeString(' ');
|
||||||
|
const html = await page.evaluate(() => document.getElementById('ribbit').innerHTML);
|
||||||
|
|
||||||
|
classes = await getBlockClasses();
|
||||||
|
assert(classes.some(c => c.includes('md-h1')), `Expected md-h1 after "# ", got: ${classes}`);
|
||||||
|
|
||||||
|
await typeString('Title');
|
||||||
|
const markdown = await getMarkdown();
|
||||||
|
assert(markdown.includes('# Title'), `Expected "# Title" in markdown: ${markdown}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await setup();
|
||||||
|
await runTests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\nSetup failed:', error.message);
|
||||||
|
failed++;
|
||||||
|
} finally {
|
||||||
|
const total = passed + failed;
|
||||||
|
console.log(`\n${passed}/${total} passed — ${failed} failed`);
|
||||||
|
if (errors.length) {
|
||||||
|
console.log('\nFailed tests:');
|
||||||
|
errors.forEach(({ name, message }) => {
|
||||||
|
console.log(` • ${name}`);
|
||||||
|
console.log(` ${message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!HEADLESS) {
|
||||||
|
console.log('\nPress Enter to close...');
|
||||||
|
await new Promise(resolve => process.stdin.once('data', resolve));
|
||||||
|
}
|
||||||
|
await teardown();
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
|
const lib = ribbit();
|
||||||
|
|
||||||
|
const macros = [
|
||||||
|
{
|
||||||
|
name: 'user',
|
||||||
|
toHTML: () => '<a href="/user">TestUser</a>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'npc',
|
||||||
|
toHTML: ({ keywords }: any) => {
|
||||||
|
const name = keywords.join(' ');
|
||||||
|
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'style',
|
||||||
|
toHTML: ({ keywords, content }: any) => '<div class="' + keywords.join(' ') + '">' + (content || '') + '</div>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'toc',
|
||||||
|
toHTML: ({ params }: any) => '<aside class="toc" data-depth="' + (params.depth || '3') + '"></aside>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const editor = new lib.Editor({macros: macros});
|
||||||
|
const converter = editor.converter;
|
||||||
|
|
||||||
|
const H = (md: string) => converter.toHTML(md);
|
||||||
|
const M = (html: string) => converter.toMarkdown(html);
|
||||||
|
|
||||||
|
describe('Macros', () => {
|
||||||
|
describe('self-closing', () => {
|
||||||
|
it('bare name renders', () => expect(H('hello @user world')).toContain('<a href="/user">TestUser</a>'));
|
||||||
|
it('bare name wrapped', () => expect(H('hello @user world')).toContain('data-macro="user"'));
|
||||||
|
it('empty parens', () => expect(H('hello @user() world')).toContain('data-macro="user"'));
|
||||||
|
it('keywords', () => expect(H('@npc(Goblin King)')).toContain('Goblin King'));
|
||||||
|
it('keywords in data attr', () => expect(H('@npc(Goblin King)')).toContain('data-keywords="Goblin King"'));
|
||||||
|
it('params', () => expect(H('@toc(depth="2")')).toContain('data-param-depth="2"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknown macros', () => {
|
||||||
|
it('renders error', () => expect(H('@bogus')).toContain('ribbit-error'));
|
||||||
|
it('shows name', () => expect(H('@bogus')).toContain('@bogus'));
|
||||||
|
it('block error', () => expect(H('@bogus(args\ncontent\n)')).toContain('ribbit-error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('email not matched', () => expect(H('user@example.com')).toBe('<p>user@example.com</p>'));
|
||||||
|
|
||||||
|
describe('block macros', () => {
|
||||||
|
it('content processed', () => expect(H('@style(box\n**bold**\n)')).toContain('<strong>bold</strong>'));
|
||||||
|
it('wrapped with data-macro', () => expect(H('@style(box\ncontent\n)')).toContain('data-macro="style"'));
|
||||||
|
it('keywords in data attr', () => expect(H('@style(box center\ncontent\n)')).toContain('data-keywords="box center"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verbatim', () => {
|
||||||
|
it('skips markdown', () => expect(H('@style(box verbatim\n**bold**\n)')).toContain('**bold**'));
|
||||||
|
it('no strong tag', () => expect(H('@style(box verbatim\n**bold**\n)')).not.toContain('<strong>'));
|
||||||
|
it('escapes html', () => expect(H('@style(box verbatim\n<b>tag</b>\n)')).toContain('<b>'));
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>`;
|
||||||
|
}
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
const { JSDOM } = require('jsdom');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Set up a DOM environment and load the bundle
|
|
||||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
||||||
url: 'http://localhost',
|
|
||||||
pretendToBeVisual: true,
|
|
||||||
});
|
|
||||||
global.window = dom.window;
|
|
||||||
global.document = dom.window.document;
|
|
||||||
global.HTMLElement = dom.window.HTMLElement;
|
|
||||||
global.Node = dom.window.Node;
|
|
||||||
|
|
||||||
// Load the compiled bundle — esbuild IIFE assigns to var ribbit,
|
|
||||||
// but eval in jsdom doesn't attach vars to window, so we patch it.
|
|
||||||
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8');
|
|
||||||
dom.window.eval(bundle.replace('var ribbit =', 'window.ribbit ='));
|
|
||||||
|
|
||||||
const hopdown = new dom.window.ribbit.HopDown();
|
|
||||||
const H = hopdown.toHTML.bind(hopdown);
|
|
||||||
const M = hopdown.toMarkdown.bind(hopdown);
|
|
||||||
function rt(md) { return M(H(md)); }
|
|
||||||
|
|
||||||
// Test harness
|
|
||||||
let passed = 0, failed = 0, errors = [];
|
|
||||||
|
|
||||||
function norm(s) { return (s || '').replace(/\r\n/g, '\n').trim(); }
|
|
||||||
|
|
||||||
function eq(name, actual, expected) {
|
|
||||||
const a = norm(actual), e = norm(expected);
|
|
||||||
if (a === e) {
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
errors.push(name);
|
|
||||||
console.log(` ✗ ${name}`);
|
|
||||||
console.log(` expected: ${e}`);
|
|
||||||
console.log(` actual: ${a}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function has(name, actual, sub) {
|
|
||||||
if (norm(actual).indexOf(norm(sub)) !== -1) {
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
errors.push(name);
|
|
||||||
console.log(` ✗ ${name}`);
|
|
||||||
console.log(` expected to contain: ${sub}`);
|
|
||||||
console.log(` actual: ${actual}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function not(name, actual, sub) {
|
|
||||||
if (norm(actual).indexOf(norm(sub)) === -1) {
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
errors.push(name);
|
|
||||||
console.log(` ✗ ${name}`);
|
|
||||||
console.log(` should NOT contain: ${sub}`);
|
|
||||||
console.log(` actual: ${actual}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function section(n) { /* silent */ }
|
|
||||||
|
|
||||||
// ── 1. Inline formatting ────────────────────────────────
|
|
||||||
section('1. Inline Formatting → HTML');
|
|
||||||
eq('bold', H('**bold**'), '<p><strong>bold</strong></p>');
|
|
||||||
eq('italic', H('*italic*'), '<p><em>italic</em></p>');
|
|
||||||
eq('inline code', H('`code`'), '<p><code>code</code></p>');
|
|
||||||
eq('link', H('[t](http://x)'), '<p><a href="http://x">t</a></p>');
|
|
||||||
eq('bold+italic', H('***bi***'), '<p><em><strong>bi</strong></em></p>');
|
|
||||||
eq('mixed inline', H('a **b** *c* `d`'), '<p>a <strong>b</strong> <em>c</em> <code>d</code></p>');
|
|
||||||
eq('code before bold', H('`a` **b**'), '<p><code>a</code> <strong>b</strong></p>');
|
|
||||||
|
|
||||||
// ── 2. Headings ─────────────────────────────────────────
|
|
||||||
eq('h1', H('# Title'), "<h1 id='Title'>Title</h1>");
|
|
||||||
eq('h2', H('## Sub'), "<h2 id='Sub'>Sub</h2>");
|
|
||||||
eq('h3', H('### Sub3'), "<h3 id='Sub3'>Sub3</h3>");
|
|
||||||
eq('h4', H('#### Sub4'), "<h4 id='Sub4'>Sub4</h4>");
|
|
||||||
eq('h5', H('##### Sub5'), "<h5 id='Sub5'>Sub5</h5>");
|
|
||||||
eq('h6', H('###### Sub6'), "<h6 id='Sub6'>Sub6</h6>");
|
|
||||||
has('heading id multi-word', H('## Hello World'), "id='HelloWorld'");
|
|
||||||
has('heading inline md', H('## **Bold** text'), '<strong>Bold</strong>');
|
|
||||||
|
|
||||||
// ── 3. Horizontal rules ─────────────────────────────────
|
|
||||||
eq('*** rule', H('***'), '<hr>');
|
|
||||||
eq('--- rule', H('---'), '<hr>');
|
|
||||||
eq('___ rule', H('___'), '<hr>');
|
|
||||||
|
|
||||||
// ── 4. Lists ────────────────────────────────────────────
|
|
||||||
eq('ul *', H('* a\n* b'), '<ul><li>a</li><li>b</li></ul>');
|
|
||||||
eq('ul -', H('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
|
|
||||||
eq('ol', H('1. a\n2. b'),'<ol><li>a</li><li>b</li></ol>');
|
|
||||||
has('ul inline', H('* **bold** item'), '<strong>bold</strong>');
|
|
||||||
has('ol inline', H('1. *em* item'), '<em>em</em>');
|
|
||||||
|
|
||||||
// ── 5. Blockquotes ──────────────────────────────────────
|
|
||||||
has('blockquote', H('> text'), '<blockquote>');
|
|
||||||
has('bq content', H('> hello'), 'hello');
|
|
||||||
has('multi-line bq', H('> a\n> b'), 'a');
|
|
||||||
|
|
||||||
// ── 6. Fenced code blocks ───────────────────────────────
|
|
||||||
has('code block', H('```\nx = 1\n```'), '<pre><code>');
|
|
||||||
has('code content', H('```\nx = 1\n```'), 'x = 1');
|
|
||||||
has('lang class', H('```js\nvar x;\n```'), 'language-js');
|
|
||||||
has('html escaped', H('```\n<div>\n```'), '<div>');
|
|
||||||
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'), '&');
|
|
||||||
has('html in code', H('`<div>`'), '<div>');
|
|
||||||
eq('empty html→md', M(''), '');
|
|
||||||
has('para then heading', H('text\n\n## H'), '<h2');
|
|
||||||
has('list then para', H('- a\n\ntext'), '<p>text</p>');
|
|
||||||
has('table no leading pipe', H('a | b\n---|---\n1 | 2'), '<table>');
|
|
||||||
|
|
||||||
// ── 12. Complex document ────────────────────────────────
|
|
||||||
var doc = '# Title\n\nSome **bold** and *italic* text with `code`.\n\n## Section One\n\n- item 1\n- item 2\n\n## Section Two\n\n| Col A | Col B |\n|-------|-------|\n| 1 | 2 |\n\n> A blockquote\n\n```js\nvar x = 1;\n```\n\n[A link](http://example.com)\n\n---';
|
|
||||||
var html = H(doc);
|
|
||||||
has('doc: h1', html, "<h1 id='Title'>Title</h1>");
|
|
||||||
has('doc: bold', html, '<strong>bold</strong>');
|
|
||||||
has('doc: italic', html, '<em>italic</em>');
|
|
||||||
has('doc: code', html, '<code>code</code>');
|
|
||||||
has('doc: h2', html, '<h2');
|
|
||||||
has('doc: ul', html, '<ul>');
|
|
||||||
has('doc: table', html, '<table>');
|
|
||||||
has('doc: blockquote', html, '<blockquote>');
|
|
||||||
has('doc: pre', html, '<pre>');
|
|
||||||
has('doc: link', html, '<a href="http://example.com">');
|
|
||||||
has('doc: hr', html, '<hr>');
|
|
||||||
var md = M(html);
|
|
||||||
has('doc rt: heading', md, '# Title');
|
|
||||||
has('doc rt: bold', md, '**bold**');
|
|
||||||
has('doc rt: italic', md, '*italic*');
|
|
||||||
has('doc rt: code', md, '`code`');
|
|
||||||
has('doc rt: list', md, '- item 1');
|
|
||||||
has('doc rt: table', md, '| Col A | Col B |');
|
|
||||||
has('doc rt: bq', md, '> ');
|
|
||||||
has('doc rt: fenced', md, '```');
|
|
||||||
has('doc rt: link', md, '[A link](http://example.com)');
|
|
||||||
has('doc rt: hr', md, '---');
|
|
||||||
|
|
||||||
// ── 13. Nested Inline ───────────────────────────────────
|
|
||||||
eq('bold wraps italic', H('**a *b* c**'), '<p><strong>a <em>b</em> c</strong></p>');
|
|
||||||
eq('italic wraps bold', H('*a **b** c*'), '<p><em>a <strong>b</strong> c</em></p>');
|
|
||||||
eq('bold wraps code', H('**a `b` c**'), '<p><strong>a <code>b</code> c</strong></p>');
|
|
||||||
eq('italic wraps code', H('*a `b` c*'), '<p><em>a <code>b</code> c</em></p>');
|
|
||||||
eq('bold wraps link', H('**[t](u)**'), '<p><strong><a href="u">t</a></strong></p>');
|
|
||||||
eq('italic wraps link', H('*[t](u)*'), '<p><em><a href="u">t</a></em></p>');
|
|
||||||
eq('link with bold text', H('[**t**](u)'), '<p><a href="u"><strong>t</strong></a></p>');
|
|
||||||
eq('link with italic text', H('[*t*](u)'), '<p><a href="u"><em>t</em></a></p>');
|
|
||||||
eq('link with code text', H('[`t`](u)'), '<p><a href="u"><code>t</code></a></p>');
|
|
||||||
eq('bold>italic>code', H('***`x`***'), '<p><em><strong><code>x</code></strong></em></p>');
|
|
||||||
eq('bold wraps bold-italic', H('**a ***b*** c**'), '<p><strong>a <em><strong>b</strong></em> c</strong></p>');
|
|
||||||
|
|
||||||
// ── 14. Nested Blocks ───────────────────────────────────
|
|
||||||
has('bq > heading', H('> # Title'), '<h1');
|
|
||||||
has('bq > heading content', H('> # Title'), 'Title');
|
|
||||||
has('bq > list', H('> - a\n> - b'), '<ul>');
|
|
||||||
has('bq > list items', H('> - a\n> - b'), '<li>a</li>');
|
|
||||||
has('bq > inline md', H('> **bold**'), '<strong>bold</strong>');
|
|
||||||
has('bq > code', H('> `code`'), '<code>code</code>');
|
|
||||||
has('bq > link', H('> [t](u)'), '<a href="u">');
|
|
||||||
has('bq > bq', H('> > nested'), '<blockquote>');
|
|
||||||
has('bq > fenced code', H('> ```\n> x\n> ```'), '<code>');
|
|
||||||
has('li > bold', H('- **bold**'), '<strong>bold</strong>');
|
|
||||||
has('li > italic', H('- *italic*'), '<em>italic</em>');
|
|
||||||
has('li > code', H('- `code`'), '<code>code</code>');
|
|
||||||
has('li > link', H('- [t](u)'), '<a href="u">');
|
|
||||||
has('heading > link', H('## [t](u)'), '<a href="u">');
|
|
||||||
has('heading > code', H('## `code`'), '<code>code</code>');
|
|
||||||
has('table > bold', H('| **b** |\n|---|\n| x |'), '<strong>b</strong>');
|
|
||||||
has('table > italic', H('| *i* |\n|---|\n| x |'), '<em>i</em>');
|
|
||||||
has('table > code', H('| `c` |\n|---|\n| x |'), '<code>c</code>');
|
|
||||||
has('table > link', H('| [t](u) |\n|---|\n| x |'), '<a href="u">');
|
|
||||||
|
|
||||||
// ── 15. Nested Round-Trips ──────────────────────────────
|
|
||||||
eq('bold>italic rt', rt('**a *b* c**'), '**a *b* c**');
|
|
||||||
eq('italic>bold rt', rt('*a **b** c*'), '*a **b** c*');
|
|
||||||
eq('bold>code rt', rt('**a `b` c**'), '**a `b` c**');
|
|
||||||
eq('bold>link rt', rt('**[t](u)**'), '**[t](u)**');
|
|
||||||
eq('link>bold rt', rt('[**t**](u)'), '[**t**](u)');
|
|
||||||
has('bq>heading rt', rt('> # Title'), '> ');
|
|
||||||
has('bq>heading rt title', rt('> # Title'), '# Title');
|
|
||||||
has('bq>list rt', rt('> - a\n> - b'), '> ');
|
|
||||||
has('li>bold rt', rt('- **bold**'), '**bold**');
|
|
||||||
has('heading>code rt', rt('## `code`'), '`code`');
|
|
||||||
|
|
||||||
// ── 16. Nested Lists ────────────────────────────────────
|
|
||||||
eq('ul > ul', H('- a\n - b\n - c\n- d'), '<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>');
|
|
||||||
eq('ol > ol', H('1. a\n 1. b\n 1. c\n2. d'), '<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>');
|
|
||||||
eq('ul > ol', H('- a\n 1. b\n 2. c\n- d'), '<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>');
|
|
||||||
eq('ol > ul', H('1. a\n - b\n - c\n2. d'), '<ol><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ol>');
|
|
||||||
eq('3-level nesting', H('- a\n - b\n - c\n- d'), '<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>');
|
|
||||||
has('nested li > bold', H('- a\n - **bold**'), '<strong>bold</strong>');
|
|
||||||
has('nested li > link', H('- a\n - [t](u)'), '<a href="u">');
|
|
||||||
eq('ul>ul → md', M('<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>'), '- a\n - b\n - c\n- d');
|
|
||||||
eq('ol>ol → md', M('<ol><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ol>'), '1. a\n 1. b\n 2. c\n2. d');
|
|
||||||
eq('ul>ol → md', M('<ul><li>a<ol><li>b</li><li>c</li></ol></li><li>d</li></ul>'), '- a\n 1. b\n 2. c\n- d');
|
|
||||||
eq('3-level → md', M('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li><li>d</li></ul>'), '- a\n - b\n - c\n- d');
|
|
||||||
eq('ul>ul rt', rt('- a\n - b\n - c\n- d'), '- a\n - b\n - c\n- d');
|
|
||||||
eq('ol>ol rt', rt('1. a\n 1. b\n 1. c\n2. d'), '1. a\n 1. b\n 2. c\n2. d');
|
|
||||||
eq('ul>ol rt', rt('- a\n 1. b\n 2. c\n- d'), '- a\n 1. b\n 2. c\n- d');
|
|
||||||
eq('3-level rt', rt('- a\n - b\n - c\n- d'), '- a\n - b\n - c\n- d');
|
|
||||||
|
|
||||||
// ── 17. Tables with nested markdown ─────────────────────
|
|
||||||
has('td bold', H('| h |\n|---|\n| **b** |'), '<td><strong>b</strong></td>');
|
|
||||||
has('td italic', H('| h |\n|---|\n| *i* |'), '<td><em>i</em></td>');
|
|
||||||
has('td code', H('| h |\n|---|\n| `c` |'), '<td><code>c</code></td>');
|
|
||||||
has('td link', H('| h |\n|---|\n| [t](u) |'), '<td><a href="u">t</a></td>');
|
|
||||||
has('td bold+italic', H('| h |\n|---|\n| ***bi*** |'), '<td><em><strong>bi</strong></em></td>');
|
|
||||||
has('td bold>italic', H('| h |\n|---|\n| **a *b* c** |'), '<strong>a <em>b</em> c</strong>');
|
|
||||||
has('td link>bold', H('| h |\n|---|\n| [**t**](u) |'), '<a href="u"><strong>t</strong></a>');
|
|
||||||
has('td link>code', H('| h |\n|---|\n| [`c`](u) |'), '<a href="u"><code>c</code></a>');
|
|
||||||
has('multi-cell bold+italic', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '<strong>a</strong>');
|
|
||||||
has('multi-cell code+link', H('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '<a href="e">d</a>');
|
|
||||||
eq('td bold → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><strong>b</strong></td></tr></tbody></table>'), '| h |\n| --- |\n| **b** |');
|
|
||||||
eq('td italic → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><em>i</em></td></tr></tbody></table>'), '| h |\n| --- |\n| *i* |');
|
|
||||||
eq('td code → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><code>c</code></td></tr></tbody></table>'), '| h |\n| --- |\n| `c` |');
|
|
||||||
eq('td link → md', M('<table><thead><tr><th>h</th></tr></thead><tbody><tr><td><a href="u">t</a></td></tr></tbody></table>'), '| h |\n| --- |\n| [t](u) |');
|
|
||||||
eq('td bold rt', rt('| h |\n|---|\n| **b** |'), '| h |\n| --- |\n| **b** |');
|
|
||||||
eq('td italic rt', rt('| h |\n|---|\n| *i* |'), '| h |\n| --- |\n| *i* |');
|
|
||||||
eq('td code rt', rt('| h |\n|---|\n| `c` |'), '| h |\n| --- |\n| `c` |');
|
|
||||||
eq('td link rt', rt('| h |\n|---|\n| [t](u) |'), '| h |\n| --- |\n| [t](u) |');
|
|
||||||
eq('td bold+italic rt', rt('| h |\n|---|\n| ***bi*** |'), '| h |\n| --- |\n| ***bi*** |');
|
|
||||||
eq('td link>bold rt', rt('| h |\n|---|\n| [**t**](u) |'), '| h |\n| --- |\n| [**t**](u) |');
|
|
||||||
eq('multi-cell rt', rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |'), '| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |');
|
|
||||||
|
|
||||||
// ── 18. inlineTag() factory ─────────────────────────────
|
|
||||||
const strikethrough = dom.window.ribbit.inlineTag({
|
|
||||||
name: 'strikethrough',
|
|
||||||
delimiter: '~~',
|
|
||||||
htmlTag: 'del',
|
|
||||||
aliases: 'S,STRIKE',
|
|
||||||
precedence: 45,
|
|
||||||
});
|
|
||||||
const customInline = new dom.window.ribbit.HopDown({
|
|
||||||
tags: { ...dom.window.ribbit.defaultTags, 'DEL,S,STRIKE': strikethrough },
|
|
||||||
});
|
|
||||||
eq('factory: md→html', customInline.toHTML('~~struck~~'), '<p><del>struck</del></p>');
|
|
||||||
has('factory: html→md', customInline.toMarkdown('<p><del>struck</del></p>'), '~~struck~~');
|
|
||||||
eq('factory: round-trip', customInline.toMarkdown(customInline.toHTML('~~struck~~')), '~~struck~~');
|
|
||||||
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<del>struck</del>');
|
|
||||||
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<strong>bold</strong>');
|
|
||||||
eq('factory: non-recursive', dom.window.ribbit.inlineTag({
|
|
||||||
name: 'test',
|
|
||||||
delimiter: '%%',
|
|
||||||
htmlTag: 'mark',
|
|
||||||
recursive: false,
|
|
||||||
}).toHTML({ content: '<b>x</b>', raw: '', consumed: 0 }, { inline: s => s, block: s => s, children: n => '', node: n => '' }),
|
|
||||||
'<mark><b>x</b></mark>');
|
|
||||||
|
|
||||||
// ── 19. Custom block tag ────────────────────────────────
|
|
||||||
const spoiler = {
|
|
||||||
name: 'spoiler',
|
|
||||||
match: (context) => {
|
|
||||||
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
|
|
||||||
const content = [];
|
|
||||||
let i = context.index + 1;
|
|
||||||
while (i < context.lines.length && !/^\|{3,}/.test(context.lines[i])) content.push(context.lines[i++]);
|
|
||||||
return { content: content.join('\n'), raw: '', consumed: i + 1 - context.index };
|
|
||||||
},
|
|
||||||
toHTML: (token, convert) => '<details><summary>Spoiler</summary>' + convert.block(token.content) + '</details>',
|
|
||||||
selector: 'DETAILS',
|
|
||||||
toMarkdown: (element, convert) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
|
|
||||||
};
|
|
||||||
const customBlock = new dom.window.ribbit.HopDown({
|
|
||||||
tags: { 'DETAILS': spoiler, ...dom.window.ribbit.defaultTags },
|
|
||||||
});
|
|
||||||
has('custom block: md→html', customBlock.toHTML('|||\nhidden\n|||'), '<details>');
|
|
||||||
has('custom block: content', customBlock.toHTML('|||\nhidden\n|||'), 'hidden');
|
|
||||||
has('custom block: html→md', customBlock.toMarkdown('<details><summary>Spoiler</summary><p>hidden</p></details>'), '|||');
|
|
||||||
has('custom block: nested md', customBlock.toHTML('|||\n**bold** inside\n|||'), '<strong>bold</strong>');
|
|
||||||
|
|
||||||
// ── 20. HopDown({ exclude }) ────────────────────────────
|
|
||||||
const noTables = new dom.window.ribbit.HopDown({ exclude: ['table'] });
|
|
||||||
// With table excluded, pipe lines fall through to paragraph but isBlockStart
|
|
||||||
// still detects table-like patterns, so lines are split across paragraphs.
|
|
||||||
has('exclude: table not rendered', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<p>');
|
|
||||||
not('exclude: no table tag', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<table>');
|
|
||||||
has('exclude: bold still works', noTables.toHTML('**bold**'), '<strong>bold</strong>');
|
|
||||||
|
|
||||||
const noCode = new dom.window.ribbit.HopDown({ exclude: ['code'] });
|
|
||||||
eq('exclude: code not processed', noCode.toHTML('`code`'), '<p>`code`</p>');
|
|
||||||
has('exclude: bold still works', noCode.toHTML('**bold**'), '<strong>bold</strong>');
|
|
||||||
|
|
||||||
// ── 21. Collision detection: delimiter ───────────────────
|
|
||||||
let threw = false;
|
|
||||||
try {
|
|
||||||
const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
|
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
|
|
||||||
} catch (e) {
|
|
||||||
threw = true;
|
|
||||||
}
|
|
||||||
eq('delimiter collision throws', String(threw), 'true');
|
|
||||||
|
|
||||||
threw = false;
|
|
||||||
try {
|
|
||||||
// Same delimiter, higher precedence than existing — should throw
|
|
||||||
const bad = dom.window.ribbit.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 });
|
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'SPAN': bad } });
|
|
||||||
} catch (e) {
|
|
||||||
threw = true;
|
|
||||||
}
|
|
||||||
eq('duplicate delimiter collision throws', String(threw), 'true');
|
|
||||||
|
|
||||||
// ── 22. Collision detection: selector ───────────────────
|
|
||||||
threw = false;
|
|
||||||
try {
|
|
||||||
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
|
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'STRONG': dup } });
|
|
||||||
} catch (e) {
|
|
||||||
threw = true;
|
|
||||||
}
|
|
||||||
eq('selector collision throws', String(threw), 'true');
|
|
||||||
|
|
||||||
// ── 23. Precedence ordering ─────────────────────────────
|
|
||||||
// Longer delimiter with lower precedence should win
|
|
||||||
const tilde = dom.window.ribbit.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 });
|
|
||||||
const doubleTilde = dom.window.ribbit.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 });
|
|
||||||
const precTest = new dom.window.ribbit.HopDown({
|
|
||||||
tags: { ...dom.window.ribbit.defaultTags, 'S': tilde, 'DEL': doubleTilde },
|
|
||||||
});
|
|
||||||
has('precedence: ~~ matches before ~', precTest.toHTML('~~struck~~'), '<del>struck</del>');
|
|
||||||
has('precedence: ~ still works', precTest.toHTML('~light~'), '<s>light</s>');
|
|
||||||
|
|
||||||
// Valid: longer delimiter has lower precedence
|
|
||||||
threw = false;
|
|
||||||
try {
|
|
||||||
const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
|
|
||||||
const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
|
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
|
|
||||||
} catch (e) {
|
|
||||||
threw = true;
|
|
||||||
}
|
|
||||||
eq('valid precedence does not throw', String(threw), 'false');
|
|
||||||
|
|
||||||
// Invalid: longer delimiter has higher precedence
|
|
||||||
threw = false;
|
|
||||||
try {
|
|
||||||
const short = dom.window.ribbit.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 });
|
|
||||||
const long = dom.window.ribbit.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 });
|
|
||||||
new dom.window.ribbit.HopDown({ tags: { ...dom.window.ribbit.defaultTags, 'S': short, 'DEL': long } });
|
|
||||||
} catch (e) {
|
|
||||||
threw = true;
|
|
||||||
}
|
|
||||||
eq('invalid precedence throws', String(threw), 'true');
|
|
||||||
|
|
||||||
// ── 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);
|
|
||||||
@@ -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('& becomes &', () => {
|
||||||
|
const tokens = tokenizer.tokenize('a & b');
|
||||||
|
expect(tokens[0].value).toBe('a & b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('{ becomes {', () => {
|
||||||
|
const tokens = tokenizer.tokenize('{');
|
||||||
|
expect(tokens[0].value).toBe('{');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('{ becomes {', () => {
|
||||||
|
const tokens = tokenizer.tokenize('{');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
|
const lib = ribbit();
|
||||||
|
|
||||||
|
describe('ToolbarManager', () => {
|
||||||
|
beforeEach(() => resetDOM('**bold** text'));
|
||||||
|
|
||||||
|
describe('button registration', () => {
|
||||||
|
it('registers tag buttons', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('bold')).toBeDefined();
|
||||||
|
expect(editor.toolbar.buttons.get('italic')).toBeDefined();
|
||||||
|
expect(editor.toolbar.buttons.get('code')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers editor actions', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('save')).toBeDefined();
|
||||||
|
expect(editor.toolbar.buttons.get('toggle')).toBeDefined();
|
||||||
|
expect(editor.toolbar.buttons.get('markdown')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers macro buttons', () => {
|
||||||
|
const editor = new lib.Editor({
|
||||||
|
macros: [{
|
||||||
|
name: 'user',
|
||||||
|
toHTML: () => 'u',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('macro:user')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips macros with button: false', () => {
|
||||||
|
const editor = new lib.Editor({
|
||||||
|
macros: [{
|
||||||
|
name: 'hidden',
|
||||||
|
toHTML: () => '',
|
||||||
|
button: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips tags without button', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('button properties', () => {
|
||||||
|
it('bold has correct label and shortcut', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const bold = editor.toolbar.buttons.get('bold')!;
|
||||||
|
expect(bold.label).toBe('Bold');
|
||||||
|
expect(bold.shortcut).toBe('Ctrl+B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bold action is wrap', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save action is custom', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('save')!.action).toBe('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('table has template', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const table = editor.toolbar.buttons.get('table')!;
|
||||||
|
expect(table.template).toContain('Header');
|
||||||
|
expect(table.replaceSelection).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('macro button has insert action', () => {
|
||||||
|
const editor = new lib.Editor({
|
||||||
|
macros: [{
|
||||||
|
name: 'toc',
|
||||||
|
toHTML: () => '',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const btn = editor.toolbar.buttons.get('macro:toc')!;
|
||||||
|
expect(btn.action).toBe('insert');
|
||||||
|
expect(btn.template).toBe('@toc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('button.hide() and button.show()', () => {
|
||||||
|
it('hide sets visible false', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const bold = editor.toolbar.buttons.get('bold')!;
|
||||||
|
expect(bold.visible).toBe(true);
|
||||||
|
bold.hide();
|
||||||
|
expect(bold.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show restores visible', () => {
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const bold = editor.toolbar.buttons.get('bold')!;
|
||||||
|
bold.hide();
|
||||||
|
bold.show();
|
||||||
|
expect(bold.visible).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('render()', () => {
|
||||||
|
it('returns an HTMLElement', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
expect(toolbar.tagName).toBe('NAV');
|
||||||
|
expect(toolbar.className).toBe('ribbit-toolbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains buttons', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
expect(toolbar.querySelector('.ribbit-btn-bold')).not.toBeNull();
|
||||||
|
expect(toolbar.querySelector('.ribbit-btn-save')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buttons have aria-label', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||||
|
expect(bold?.getAttribute('aria-label')).toBe('Bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buttons have title with shortcut', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||||
|
expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders spacers', () => {
|
||||||
|
const editor = new lib.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
toolbar: ['bold', '', 'save'],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
expect(toolbar.querySelector('.spacer')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dropdown groups', () => {
|
||||||
|
const editor = new lib.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
toolbar: [{
|
||||||
|
group: 'Test',
|
||||||
|
items: ['bold', 'italic'],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
expect(toolbar.querySelector('.ribbit-dropdown')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('auto-render', () => {
|
||||||
|
it('inserts toolbar before editor by default', () => {
|
||||||
|
resetDOM();
|
||||||
|
const editor = new lib.Editor({});
|
||||||
|
editor.run();
|
||||||
|
const toolbarElement = editor.element.previousElementSibling;
|
||||||
|
expect(toolbarElement?.className).toBe('ribbit-toolbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not insert when autoToolbar is false', () => {
|
||||||
|
resetDOM();
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const toolbarElement = editor.element.previousElementSibling;
|
||||||
|
expect(toolbarElement?.className || '').not.toBe('ribbit-toolbar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom layout', () => {
|
||||||
|
it('respects custom toolbar order', () => {
|
||||||
|
const editor = new lib.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
toolbar: ['save', 'bold'],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
const buttons = toolbar.querySelectorAll('button');
|
||||||
|
expect(buttons[0]?.className).toBe('ribbit-btn-save');
|
||||||
|
expect(buttons[1]?.className).toBe('ribbit-btn-bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-generates layout when not specified', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
expect(toolbar.querySelectorAll('button').length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enable/disable', () => {
|
||||||
|
it('disable adds disabled class', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
editor.toolbar.disable();
|
||||||
|
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||||
|
expect(bold?.classList.contains('disabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enable removes disabled class', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const toolbar = editor.toolbar.render();
|
||||||
|
editor.toolbar.disable();
|
||||||
|
editor.toolbar.enable();
|
||||||
|
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||||
|
expect(bold?.classList.contains('disabled')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateActiveState', () => {
|
||||||
|
it('sets active class on matching buttons', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.toolbar.updateActiveState(['bold']);
|
||||||
|
expect(editor.toolbar.buttons.get('bold')!.element?.classList.contains('active')).toBe(true);
|
||||||
|
expect(editor.toolbar.buttons.get('italic')!.element?.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears active when not in list', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.toolbar.updateActiveState(['bold']);
|
||||||
|
editor.toolbar.updateActiveState([]);
|
||||||
|
expect(editor.toolbar.buttons.get('bold')!.element?.classList.contains('active')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('heading and list buttons', () => {
|
||||||
|
it('registers h1-h6', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
for (let level = 1; level <= 6; level++) {
|
||||||
|
const btn = editor.toolbar.buttons.get(`h${level}`);
|
||||||
|
expect(btn).toBeDefined();
|
||||||
|
expect(btn!.label).toBe(`H${level}`);
|
||||||
|
expect(btn!.shortcut).toBe(`Ctrl+${level}`);
|
||||||
|
expect(btn!.action).toBe('prefix');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers ul and ol', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8');
|
||||||
|
expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyboard shortcuts', () => {
|
||||||
|
it('all formatting buttons have shortcuts', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
const expected = ['bold', 'italic', 'code', 'link', 'save'];
|
||||||
|
for (const id of expected) {
|
||||||
|
expect(editor.toolbar.buttons.get(id)!.shortcut).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('block buttons have shortcuts', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('fencedCode')!.shortcut).toBe('Ctrl+Shift+E');
|
||||||
|
expect(editor.toolbar.buttons.get('blockquote')!.shortcut).toBe('Ctrl+Shift+.');
|
||||||
|
expect(editor.toolbar.buttons.get('table')!.shortcut).toBe('Ctrl+Shift+T');
|
||||||
|
expect(editor.toolbar.buttons.get('hr')!.shortcut).toBe('Ctrl+Shift+-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editor actions have shortcuts', () => {
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V');
|
||||||
|
expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('save button', () => {
|
||||||
|
it('triggers editor.save()', () => {
|
||||||
|
resetDOM();
|
||||||
|
let saved = false;
|
||||||
|
const editor = new lib.Editor({
|
||||||
|
autoToolbar: false,
|
||||||
|
on: {
|
||||||
|
save: () => {
|
||||||
|
saved = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.toolbar.buttons.get('save')!.click();
|
||||||
|
expect(saved).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggle button', () => {
|
||||||
|
it('switches from view to wysiwyg', () => {
|
||||||
|
resetDOM();
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
editor.toolbar.render();
|
||||||
|
expect(editor.getState()).toBe('view');
|
||||||
|
editor.toolbar.buttons.get('toggle')!.click();
|
||||||
|
expect(editor.getState()).toBe('wysiwyg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches from wysiwyg to view', () => {
|
||||||
|
resetDOM();
|
||||||
|
const editor = new lib.Editor({ autoToolbar: false });
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
editor.toolbar.render();
|
||||||
|
editor.toolbar.buttons.get('toggle')!.click();
|
||||||
|
expect(editor.getState()).toBe('view');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ES2017",
|
"target": "ES2018",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
@@ -10,5 +10,6 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"lib": ["ES2019", "DOM"]
|
"lib": ["ES2019", "DOM"]
|
||||||
},
|
},
|
||||||
"include": ["src/ts/**/*.ts"]
|
"include": ["src/ts/**/*.ts"],
|
||||||
|
"exclude": ["test/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user