Initial commit of ribbit library
Zero-dependency WYSIWYG markdown editor for the browser, extracted from the ttfrog wiki engine. Initial commit.
This commit is contained in:
parent
5dc50c3c75
commit
5983ce50fd
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -130,3 +130,5 @@ dist
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
|
||||||
43
README.md
43
README.md
|
|
@ -1,3 +1,44 @@
|
||||||
# ribbit
|
# ribbit
|
||||||
|
|
||||||
Dependency-free WYSIWYG markdown/html editor
|
Zero-dependency WYSIWYG markdown editor
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `src/hopdown.js` — Markdown ↔ HTML converter (`HopDown.toHTML()`, `HopDown.toMarkdown()`)
|
||||||
|
- `src/ribbit.js` — Base viewer class (`Ribbit`), plugin base class (`RibbitPlugin`), utilities
|
||||||
|
- `src/ribbit-editor.js` — Editor class (`RibbitEditor`) with VIEW/EDIT/WYSIWYG modes
|
||||||
|
- `src/ribbit.css` — Editor and content styles
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="ribbit/src/ribbit.css">
|
||||||
|
<article id="ribbit">your markdown here</article>
|
||||||
|
|
||||||
|
<script src="ribbit/src/hopdown.js"></script>
|
||||||
|
<script src="ribbit/src/ribbit.js"></script>
|
||||||
|
<script src="ribbit/src/ribbit-editor.js"></script>
|
||||||
|
<script>
|
||||||
|
const editor = new RibbitEditor({ plugins: [] });
|
||||||
|
editor.run();
|
||||||
|
|
||||||
|
// Switch modes
|
||||||
|
editor.wysiwyg(); // WYSIWYG editing
|
||||||
|
editor.edit(); // Source editing
|
||||||
|
editor.view(); // Read-only view
|
||||||
|
|
||||||
|
// Get content
|
||||||
|
editor.getMarkdown();
|
||||||
|
editor.getHTML();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Markdown
|
||||||
|
|
||||||
|
Bold, italic, inline code, links, headings (h1-h6), unordered/ordered/nested lists,
|
||||||
|
blockquotes, fenced code blocks with language, horizontal rules, GFM tables with
|
||||||
|
column alignment, and paragraphs. Arbitrary nesting of all inline formatting.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Open `test/test_ribbit-down.html` in a browser.
|
||||||
|
|
|
||||||
2130
package-lock.json
generated
Normal file
2130
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "ribbit",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Zero-dependency WYSIWYG markdown editor for the browser",
|
||||||
|
"main": "dist/ribbit.js",
|
||||||
|
"types": "dist/ribbit.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"src/"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:check && npm run build:js && npm run build:min",
|
||||||
|
"build:check": "tsc --noEmit",
|
||||||
|
"build:js": "esbuild src/ribbit-editor.ts --bundle --format=iife --sourcemap --outfile=dist/ribbit.js",
|
||||||
|
"build:min": "esbuild src/ribbit-editor.ts --bundle --format=iife --minify --outfile=dist/ribbit.min.js",
|
||||||
|
"test": "npm run build && node test/test_hopdown.js"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "evilchili",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jsdom": "^28.0.1",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
|
"jsdom": "^20.0.3",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
292
src/hopdown.ts
Normal file
292
src/hopdown.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
/*
|
||||||
|
* hopdown.ts — configurable markdown↔HTML converter.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const converter = new HopDown();
|
||||||
|
* const converter = new HopDown({ exclude: ['table'] });
|
||||||
|
* const converter = new HopDown({ tags: { ...defaultTags, 'DEL,S,STRIKE': strikethrough } });
|
||||||
|
*
|
||||||
|
* converter.toHTML('**bold**');
|
||||||
|
* converter.toMarkdown('<strong>bold</strong>');
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Converter, MatchContext, Tag } from './types';
|
||||||
|
import { defaultBlockTags, defaultInlineTags, defaultTags, escapeHtml, parseListBlock } from './tags';
|
||||||
|
|
||||||
|
export type TagMap = Record<string, Tag>;
|
||||||
|
|
||||||
|
export interface HopDownOptions {
|
||||||
|
tags?: TagMap;
|
||||||
|
exclude?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A configurable markdown↔HTML converter.
|
||||||
|
*
|
||||||
|
* By default includes all standard tags. Pass options to customize:
|
||||||
|
* - tags: a mapping of HTML selectors to Tag definitions
|
||||||
|
* - exclude: remove specific tags by name from the defaults
|
||||||
|
*/
|
||||||
|
export class HopDown {
|
||||||
|
private blockTags: Tag[];
|
||||||
|
private inlineTags: Tag[];
|
||||||
|
private tags: Map<string, Tag>;
|
||||||
|
|
||||||
|
constructor(options: HopDownOptions = {}) {
|
||||||
|
let tagMap: TagMap;
|
||||||
|
|
||||||
|
if (options.tags) {
|
||||||
|
tagMap = options.tags;
|
||||||
|
} else if (options.exclude) {
|
||||||
|
const excluded = new Set(options.exclude);
|
||||||
|
tagMap = Object.fromEntries(
|
||||||
|
Object.entries(defaultTags).filter(([, tag]) => !excluded.has(tag.name))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tagMap = defaultTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTags = Object.values(tagMap);
|
||||||
|
const defaultBlockNames = new Set(Object.values(defaultBlockTags).map(t => t.name));
|
||||||
|
const defaultInlineNames = new Set(Object.values(defaultInlineTags).map(t => t.name));
|
||||||
|
|
||||||
|
this.blockTags = allTags.filter(tag =>
|
||||||
|
defaultBlockNames.has(tag.name) ||
|
||||||
|
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern)
|
||||||
|
);
|
||||||
|
this.inlineTags = allTags.filter(tag =>
|
||||||
|
defaultInlineNames.has(tag.name) || (tag as any).pattern
|
||||||
|
);
|
||||||
|
|
||||||
|
this.tags = new Map();
|
||||||
|
for (const [selector, tag] of Object.entries(tagMap)) {
|
||||||
|
for (const sel of selector.split(',').map(s => s.trim()).filter(Boolean)) {
|
||||||
|
if (sel.startsWith('_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = this.tags.get(sel);
|
||||||
|
if (existing && existing !== tag) {
|
||||||
|
throw new Error(
|
||||||
|
`HTML tag "${sel}" is claimed by both "${existing.name}" and "${tag.name}". ` +
|
||||||
|
`Use the exclude option to remove one before adding the other.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.tags.set(sel, tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateInlineTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that no two inline tags have colliding delimiters without
|
||||||
|
* correct precedence ordering. If delimiter A is a prefix of delimiter B,
|
||||||
|
* B must have lower (earlier) precedence so the longer match wins.
|
||||||
|
*/
|
||||||
|
private validateInlineTags(): void {
|
||||||
|
const withDelimiters = this.inlineTags
|
||||||
|
.filter(tag => (tag as any).delimiter)
|
||||||
|
.map(tag => ({
|
||||||
|
name: tag.name,
|
||||||
|
delimiter: (tag as any).delimiter as string,
|
||||||
|
precedence: (tag as any).precedence as number ?? 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (let i = 0; i < withDelimiters.length; i++) {
|
||||||
|
for (let j = i + 1; j < withDelimiters.length; j++) {
|
||||||
|
const a = withDelimiters[i];
|
||||||
|
const b = withDelimiters[j];
|
||||||
|
const aPrefix = b.delimiter.startsWith(a.delimiter);
|
||||||
|
const bPrefix = a.delimiter.startsWith(b.delimiter);
|
||||||
|
if (!aPrefix && !bPrefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const longer = a.delimiter.length > b.delimiter.length ? a : b;
|
||||||
|
const shorter = a.delimiter.length > b.delimiter.length ? b : a;
|
||||||
|
if (longer.precedence >= shorter.precedence) {
|
||||||
|
throw new Error(
|
||||||
|
`Inline tag "${longer.name}" (delimiter "${longer.delimiter}") must have ` +
|
||||||
|
`lower precedence than "${shorter.name}" (delimiter "${shorter.delimiter}") ` +
|
||||||
|
`because its delimiter is a prefix match. ` +
|
||||||
|
`Got ${longer.name}=${longer.precedence}, ${shorter.name}=${shorter.precedence}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a markdown string to HTML.
|
||||||
|
*/
|
||||||
|
toHTML(md: string): string {
|
||||||
|
return this.processBlocks(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an HTML string back to markdown.
|
||||||
|
*/
|
||||||
|
toMarkdown(html: string): string {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = html;
|
||||||
|
return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private processBlocks(md: string): string {
|
||||||
|
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
||||||
|
const output: string[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (index < lines.length) {
|
||||||
|
if (/^\s*$/.test(lines[index])) {
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matched = false;
|
||||||
|
for (const tag of this.blockTags) {
|
||||||
|
const context: MatchContext = {
|
||||||
|
lines,
|
||||||
|
index,
|
||||||
|
text: '',
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
const token = tag.match(context);
|
||||||
|
if (!token) continue;
|
||||||
|
|
||||||
|
if (tag.name === 'list') {
|
||||||
|
const result = parseListBlock(lines, index, 0, (source) => this.processInline(source));
|
||||||
|
output.push(result.html);
|
||||||
|
index = result.end;
|
||||||
|
} else {
|
||||||
|
output.push(tag.toHTML(token, this.makeConverter()));
|
||||||
|
index += token.consumed;
|
||||||
|
}
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private processInline(source: string): string {
|
||||||
|
const sorted = [...this.inlineTags].sort((a, b) =>
|
||||||
|
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
const placeholders: string[] = [];
|
||||||
|
let text = source;
|
||||||
|
|
||||||
|
// Pass 1: extract links and non-recursive tags into placeholders before escaping
|
||||||
|
for (const tag of sorted) {
|
||||||
|
const recursive = (tag as any).recursive ?? true;
|
||||||
|
|
||||||
|
if (tag.name === 'link') {
|
||||||
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
|
||||||
|
// Process link text: restore earlier placeholders, then run inline on any remaining markdown
|
||||||
|
let inner = linkText;
|
||||||
|
// Check if link text contains placeholders (already-processed content)
|
||||||
|
const hasPlaceholders = /\x00P\d+\x00/.test(inner);
|
||||||
|
if (hasPlaceholders) {
|
||||||
|
inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
|
||||||
|
} else {
|
||||||
|
inner = this.processInline(inner);
|
||||||
|
}
|
||||||
|
placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
|
||||||
|
return '\x00P' + (placeholders.length - 1) + '\x00';
|
||||||
|
});
|
||||||
|
} else if (!recursive && (tag as any).pattern) {
|
||||||
|
const globalPattern = (tag as any).pattern as RegExp;
|
||||||
|
globalPattern.lastIndex = 0;
|
||||||
|
text = text.replace(globalPattern, (_, content: string) => {
|
||||||
|
placeholders.push(tag.toHTML(
|
||||||
|
{ content, raw: '', consumed: 0 },
|
||||||
|
this.makeConverter(),
|
||||||
|
));
|
||||||
|
return '\x00P' + (placeholders.length - 1) + '\x00';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text = escapeHtml(text);
|
||||||
|
|
||||||
|
// Pass 2: apply recursive tags in precedence order (longest delimiter first).
|
||||||
|
// Content matched here is already HTML-escaped and has had earlier
|
||||||
|
// passes applied, so we wrap directly without re-processing.
|
||||||
|
for (const tag of sorted) {
|
||||||
|
const recursive = (tag as any).recursive ?? true;
|
||||||
|
if (tag.name === 'link' || !recursive) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const globalPattern = (tag as any).pattern as RegExp | undefined;
|
||||||
|
if (globalPattern) {
|
||||||
|
globalPattern.lastIndex = 0;
|
||||||
|
text = text.replace(globalPattern, (_, content: string) => {
|
||||||
|
// Restore any placeholders in the captured content
|
||||||
|
const restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
|
||||||
|
const htmlTag = (tag as any).name === 'boldItalic'
|
||||||
|
? null
|
||||||
|
: ((tag.selector as string) || '').split(',')[0].toLowerCase();
|
||||||
|
if (tag.name === 'boldItalic') {
|
||||||
|
return '<em><strong>' + restored + '</strong></em>';
|
||||||
|
}
|
||||||
|
return `<${htmlTag}>${restored}</${htmlTag}>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore placeholders
|
||||||
|
text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nodeToMd(node: Node): string {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
return node.textContent || '';
|
||||||
|
}
|
||||||
|
if (node.nodeType !== 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
|
||||||
|
const tag = this.tags.get(element.nodeName);
|
||||||
|
if (tag) {
|
||||||
|
return tag.toMarkdown(element, this.makeConverter());
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.childrenToMd(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private childrenToMd(node: Node): string {
|
||||||
|
return Array.from(node.childNodes).map(child => this.nodeToMd(child)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeConverter(): Converter {
|
||||||
|
return {
|
||||||
|
inline: (source) => this.processInline(source),
|
||||||
|
block: (md) => this.processBlocks(md),
|
||||||
|
children: (node) => this.childrenToMd(node),
|
||||||
|
node: (node) => this.nodeToMd(node),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A default HopDown instance with all standard tags enabled.
|
||||||
|
* Use this for simple cases where no configuration is needed.
|
||||||
|
*/
|
||||||
|
const hopdown = new HopDown();
|
||||||
|
|
||||||
|
export function toHTML(md: string): string {
|
||||||
|
return hopdown.toHTML(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMarkdown(html: string): string {
|
||||||
|
return hopdown.toMarkdown(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default hopdown;
|
||||||
114
src/ribbit-editor.ts
Normal file
114
src/ribbit-editor.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
/*
|
||||||
|
* ribbit-editor.ts — WYSIWYG editing extension for Ribbit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import hopdown from './hopdown';
|
||||||
|
import { HopDown } from './hopdown';
|
||||||
|
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
|
||||||
|
import { Ribbit, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
|
||||||
|
*
|
||||||
|
* Extends Ribbit with contentEditable support and bidirectional
|
||||||
|
* markdown↔HTML conversion on mode switches.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const editor = new RibbitEditor({ editorId: 'my-element' });
|
||||||
|
* editor.run();
|
||||||
|
* editor.wysiwyg(); // switch to WYSIWYG mode
|
||||||
|
* editor.edit(); // switch to source editing mode
|
||||||
|
* editor.view(); // switch to read-only view
|
||||||
|
*/
|
||||||
|
export class RibbitEditor extends Ribbit {
|
||||||
|
|
||||||
|
run(): void {
|
||||||
|
this.states = {
|
||||||
|
VIEW: 'view',
|
||||||
|
EDIT: 'edit',
|
||||||
|
WYSIWYG: 'wysiwyg'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.#bindEvents();
|
||||||
|
this.plugins().forEach(plugin => {
|
||||||
|
plugin.setEditable();
|
||||||
|
});
|
||||||
|
this.element.classList.add('loaded');
|
||||||
|
this.view();
|
||||||
|
}
|
||||||
|
|
||||||
|
#bindEvents(): void {
|
||||||
|
this.element.addEventListener('input', () => {
|
||||||
|
if (this.state !== this.states.VIEW) {
|
||||||
|
this.changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlToMarkdown(html?: string): string {
|
||||||
|
return hopdown.toMarkdown(html || this.element.innerHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkdown(): string {
|
||||||
|
if (this.getState() === this.states.EDIT) {
|
||||||
|
let html = this.element.innerHTML;
|
||||||
|
html = html.replace(/<(?:div|br)>/ig, '');
|
||||||
|
html = html.replace(/<\/div>/ig, '\n');
|
||||||
|
this.cachedMarkdown = decodeHtmlEntities(html);
|
||||||
|
} else if (this.getState() === this.states.WYSIWYG) {
|
||||||
|
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
|
||||||
|
}
|
||||||
|
if (!this.cachedMarkdown) {
|
||||||
|
this.cachedMarkdown = this.element.textContent || '';
|
||||||
|
}
|
||||||
|
return this.cachedMarkdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
wysiwyg(): void {
|
||||||
|
if (this.getState() === this.states.WYSIWYG) return;
|
||||||
|
this.changed = false;
|
||||||
|
this.element.contentEditable = 'true';
|
||||||
|
this.element.innerHTML = this.getHTML();
|
||||||
|
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
||||||
|
const macroEl = el as HTMLElement;
|
||||||
|
if (macroEl.dataset.editable === 'false') {
|
||||||
|
macroEl.contentEditable = 'false';
|
||||||
|
macroEl.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.setState(this.states.WYSIWYG);
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(): void {
|
||||||
|
if (this.state === this.states.EDIT) return;
|
||||||
|
this.changed = false;
|
||||||
|
this.element.contentEditable = 'true';
|
||||||
|
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
||||||
|
this.setState(this.states.EDIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAtCursor(node: Node): void {
|
||||||
|
const sel = window.getSelection()!;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(node);
|
||||||
|
range.setStartAfter(node);
|
||||||
|
this.element.focus();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach public API to window for <script> tag usage.
|
||||||
|
(window as any).HopDown = HopDown;
|
||||||
|
(window as any).hopdown = hopdown;
|
||||||
|
(window as any).inlineTag = inlineTag;
|
||||||
|
(window as any).defaultTags = defaultTags;
|
||||||
|
(window as any).defaultBlockTags = defaultBlockTags;
|
||||||
|
(window as any).defaultInlineTags = defaultInlineTags;
|
||||||
|
(window as any).Ribbit = Ribbit;
|
||||||
|
(window as any).RibbitEditor = RibbitEditor;
|
||||||
|
(window as any).RibbitPlugin = RibbitPlugin;
|
||||||
|
(window as any).camelCase = camelCase;
|
||||||
|
(window as any).decodeHtmlEntities = decodeHtmlEntities;
|
||||||
|
(window as any).encodeHtmlEntities = encodeHtmlEntities;
|
||||||
58
src/ribbit.css
Normal file
58
src/ribbit.css
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* ribbit.css — editor styles for the ribbit WYSIWYG markdown editor.
|
||||||
|
*
|
||||||
|
* Provides base content formatting and editor state styles.
|
||||||
|
* Override with your own theme CSS for custom look and feel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Content formatting ──────────────────────────────── */
|
||||||
|
|
||||||
|
a { text-decoration: none; }
|
||||||
|
|
||||||
|
q, blockquote {
|
||||||
|
margin-left: 30px;
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-style: italic;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
table { width: 100%; }
|
||||||
|
th { border-bottom: 1px solid #000; padding: 3px; }
|
||||||
|
th, td { padding: 2px; }
|
||||||
|
table td table { max-width: 95%; }
|
||||||
|
|
||||||
|
pre {
|
||||||
|
border: 1px dashed black;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 5px;
|
||||||
|
background: #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px dashed black;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
background: #EEE;
|
||||||
|
margin: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Editor states ───────────────────────────────────── */
|
||||||
|
|
||||||
|
#ribbit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.loaded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.edit {
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.wysiwyg .md {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
152
src/ribbit.ts
Normal file
152
src/ribbit.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
/*
|
||||||
|
* ribbit.ts — core editor classes for the ribbit WYSIWYG markdown editor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import hopdown from './hopdown';
|
||||||
|
|
||||||
|
export interface RibbitSettings {
|
||||||
|
api?: unknown;
|
||||||
|
editorId?: string;
|
||||||
|
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for editor plugins. Subclass and override toHTML/toMarkdown
|
||||||
|
* to add custom processing hooks.
|
||||||
|
*/
|
||||||
|
export class RibbitPlugin {
|
||||||
|
name: string;
|
||||||
|
wiki: Ribbit;
|
||||||
|
precedence: number;
|
||||||
|
|
||||||
|
constructor(settings: { name: string; wiki: Ribbit }) {
|
||||||
|
this.name = settings.name;
|
||||||
|
this.wiki = settings.wiki;
|
||||||
|
this.precedence = 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditable(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
toMarkdown(html: string): string {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
toHTML(md: string): string {
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only markdown viewer. Renders markdown content into an HTML element.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const viewer = new Ribbit({ editorId: 'my-element' });
|
||||||
|
* viewer.run();
|
||||||
|
*/
|
||||||
|
export class Ribbit {
|
||||||
|
api: unknown;
|
||||||
|
element: HTMLElement;
|
||||||
|
states: Record<string, string>;
|
||||||
|
cachedHTML: string | null;
|
||||||
|
cachedMarkdown: string | null;
|
||||||
|
state: string | null;
|
||||||
|
changed: boolean;
|
||||||
|
enabledPlugins: Record<string, RibbitPlugin>;
|
||||||
|
|
||||||
|
constructor(settings: RibbitSettings) {
|
||||||
|
this.api = settings.api || null;
|
||||||
|
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
||||||
|
this.states = {
|
||||||
|
VIEW: 'view',
|
||||||
|
};
|
||||||
|
this.cachedHTML = null;
|
||||||
|
this.cachedMarkdown = null;
|
||||||
|
this.state = null;
|
||||||
|
this.changed = false;
|
||||||
|
this.enabledPlugins = {};
|
||||||
|
|
||||||
|
(settings.plugins || []).forEach(plugin => {
|
||||||
|
this.enabledPlugins[plugin.name] = new plugin({
|
||||||
|
name: plugin.name,
|
||||||
|
wiki: this,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
run(): void {
|
||||||
|
this.element.classList.add('loaded');
|
||||||
|
this.view();
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins(): RibbitPlugin[] {
|
||||||
|
return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence);
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): string | null {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(newState: string): void {
|
||||||
|
this.state = newState;
|
||||||
|
Object.values(this.states).forEach(state => {
|
||||||
|
if (state === newState) {
|
||||||
|
this.element.classList.add(state);
|
||||||
|
} else {
|
||||||
|
this.element.classList.remove(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownToHTML(md: string): string {
|
||||||
|
return hopdown.toHTML(md);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHTML(): string {
|
||||||
|
if (this.changed || !this.cachedHTML) {
|
||||||
|
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
||||||
|
}
|
||||||
|
return this.cachedHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkdown(): string {
|
||||||
|
if (!this.cachedMarkdown) {
|
||||||
|
this.cachedMarkdown = this.element.textContent || '';
|
||||||
|
}
|
||||||
|
return this.cachedMarkdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
view(): void {
|
||||||
|
if (this.getState() === this.states.VIEW) return;
|
||||||
|
this.element.innerHTML = this.getHTML();
|
||||||
|
this.setState(this.states.VIEW);
|
||||||
|
this.element.contentEditable = 'false';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string to title case, splitting on whitespace.
|
||||||
|
* Returns an array of capitalized words.
|
||||||
|
*/
|
||||||
|
export function camelCase(words: string): string[] {
|
||||||
|
return words.trim().split(/\s+/g).map(word => {
|
||||||
|
const lc = word.toLowerCase();
|
||||||
|
return lc.charAt(0).toUpperCase() + lc.slice(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode HTML entities in a string using a textarea element.
|
||||||
|
*/
|
||||||
|
export function decodeHtmlEntities(html: string): string {
|
||||||
|
const txt = document.createElement('textarea');
|
||||||
|
txt.innerHTML = html;
|
||||||
|
return txt.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode HTML-significant characters as numeric entities.
|
||||||
|
*/
|
||||||
|
export function encodeHtmlEntities(str: string): string {
|
||||||
|
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
|
||||||
|
}
|
||||||
479
src/tags.ts
Normal file
479
src/tags.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
/*
|
||||||
|
* tags.ts — tag definitions for the hopdown converter.
|
||||||
|
*
|
||||||
|
* Each Tag is a self-contained definition of a markdown element,
|
||||||
|
* with rules for matching, converting to HTML, and converting back.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Tag, MatchContext, SourceToken, Converter, ListItem, ListResult, InlineTagDef } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Tag from a shorthand inline definition.
|
||||||
|
*
|
||||||
|
* Most inline markdown elements follow the same pattern: a delimiter wraps
|
||||||
|
* content, it maps to an HTML element, and the reverse is just unwrapping.
|
||||||
|
* This factory builds a full Tag from that pattern.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* inlineTag({ name: 'bold', delimiter: '**', htmlTag: 'strong', aliases: 'B' })
|
||||||
|
* inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 })
|
||||||
|
* inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' })
|
||||||
|
*/
|
||||||
|
export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recursive: boolean; pattern: RegExp; delimiter: string } {
|
||||||
|
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
|
||||||
|
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
|
||||||
|
const upperTag = def.htmlTag.toUpperCase();
|
||||||
|
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
|
||||||
|
const recursive = def.recursive !== false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: def.name,
|
||||||
|
precedence: def.precedence ?? 50,
|
||||||
|
recursive,
|
||||||
|
pattern: globalPattern,
|
||||||
|
delimiter: def.delimiter,
|
||||||
|
match: (context) => {
|
||||||
|
const matched = context.text.slice(context.offset).match(matchPattern);
|
||||||
|
if (!matched) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: matched[1],
|
||||||
|
raw: matched[0],
|
||||||
|
consumed: matched[0].length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toHTML: (token, convert) => {
|
||||||
|
const inner = recursive ? convert.inline(token.content) : escapeHtml(token.content);
|
||||||
|
return `<${def.htmlTag}>${inner}</${def.htmlTag}>`;
|
||||||
|
},
|
||||||
|
selector,
|
||||||
|
toMarkdown: (element, convert) => {
|
||||||
|
if (!recursive && element.parentNode?.nodeName === 'PRE') {
|
||||||
|
return convert.children(element);
|
||||||
|
}
|
||||||
|
return def.delimiter + convert.children(element) + def.delimiter;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML special characters in a string.
|
||||||
|
*/
|
||||||
|
export function escapeHtml(source: string): string {
|
||||||
|
return source.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a camelCase ID from heading text, for use as an anchor.
|
||||||
|
*/
|
||||||
|
export function camelId(text: string): string {
|
||||||
|
return text.trim().split(/\s+/).map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively parse a markdown list into HTML, handling nested sublists
|
||||||
|
* at arbitrary depth and mixed list types (ul/ol).
|
||||||
|
*/
|
||||||
|
export function parseListBlock(lines: string[], start: number, indent: number, inlineConvert: (s: string) => string): ListResult {
|
||||||
|
const prefix = new RegExp('^' + ' '.repeat(indent) + '([\\*\\-]|\\d+\\.)\\s');
|
||||||
|
const isOl = /^\d+\./.test(lines[start].trim());
|
||||||
|
const tag = isOl ? 'ol' : 'ul';
|
||||||
|
const items: ListItem[] = [];
|
||||||
|
let i = start;
|
||||||
|
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (/^\s*$/.test(line)) break;
|
||||||
|
const lineIndent = line.match(/^(\s*)/)![1].length;
|
||||||
|
if (lineIndent < indent) break;
|
||||||
|
if (lineIndent > indent) {
|
||||||
|
const sub = parseListBlock(lines, i, lineIndent, inlineConvert);
|
||||||
|
items[items.length - 1].sub = sub.html;
|
||||||
|
i = sub.end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!prefix.test(line)) break;
|
||||||
|
items.push({
|
||||||
|
text: line.replace(prefix, ''),
|
||||||
|
sub: '',
|
||||||
|
});
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = '<' + tag + '>' + items.map(item =>
|
||||||
|
'<li>' + inlineConvert(item.text) + item.sub + '</li>'
|
||||||
|
).join('') + '</' + tag + '>';
|
||||||
|
return { html, end: i };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an HTML list element back to markdown, recursing into
|
||||||
|
* nested sublists with 2-space indentation per depth level.
|
||||||
|
*/
|
||||||
|
export function listToMd(node: HTMLElement, depth: number, convert: Converter): string {
|
||||||
|
const isOl = node.nodeName === 'OL';
|
||||||
|
const indent = ' '.repeat(depth);
|
||||||
|
const lines: string[] = [];
|
||||||
|
Array.from(node.children).forEach((listItem, index) => {
|
||||||
|
const marker = isOl ? (index + 1) + '. ' : '- ';
|
||||||
|
let text = '';
|
||||||
|
let sublist = '';
|
||||||
|
Array.from(listItem.childNodes).forEach(child => {
|
||||||
|
if (child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')) {
|
||||||
|
sublist += listToMd(child as HTMLElement, depth + 1, convert);
|
||||||
|
} else {
|
||||||
|
text += convert.node(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lines.push(indent + marker + text.trim());
|
||||||
|
if (sublist) lines.push(sublist);
|
||||||
|
});
|
||||||
|
const result = lines.join('\n');
|
||||||
|
return depth === 0 ? '\n\n' + result + '\n\n' : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a line begins a block-level element (used to detect
|
||||||
|
* paragraph boundaries).
|
||||||
|
*/
|
||||||
|
export function isBlockStart(lines: string[], index: number): boolean {
|
||||||
|
const line = lines[index];
|
||||||
|
if (/^(`{3,})/.test(line)) return true;
|
||||||
|
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true;
|
||||||
|
if (/^#{1,6}\s/.test(line)) return true;
|
||||||
|
if (/^>\s?/.test(line)) return true;
|
||||||
|
if (/^[*\-]\s/.test(line) || /^\d+\.\s/.test(line)) return true;
|
||||||
|
if (line.indexOf('|') !== -1 && index + 1 < lines.length &&
|
||||||
|
/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(lines[index + 1])) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTableRow(line: string): string[] {
|
||||||
|
return line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAligns(line: string): (string | null)[] {
|
||||||
|
return parseTableRow(line).map(cell => {
|
||||||
|
if (/^:-+:$/.test(cell)) return 'center';
|
||||||
|
if (/^-+:$/.test(cell)) return 'right';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default set of block-level tags, matched in order.
|
||||||
|
* Paragraph is the catch-all and must be last.
|
||||||
|
*/
|
||||||
|
export const defaultBlockTags: Record<string, Tag> = {
|
||||||
|
|
||||||
|
'PRE': {
|
||||||
|
/*
|
||||||
|
* ```lang
|
||||||
|
* code here
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
name: 'fencedCode',
|
||||||
|
match: (context) => {
|
||||||
|
const matched = context.lines[context.index].match(/^(`{3,})(.*)/);
|
||||||
|
if (!matched) return null;
|
||||||
|
const fence = matched[1], lang = matched[2].trim();
|
||||||
|
const code: string[] = [];
|
||||||
|
let i = context.index + 1;
|
||||||
|
while (i < context.lines.length && !context.lines[i].startsWith(fence))
|
||||||
|
code.push(context.lines[i++]);
|
||||||
|
return {
|
||||||
|
content: code.join('\n'),
|
||||||
|
raw: '',
|
||||||
|
consumed: i + 1 - context.index,
|
||||||
|
meta: {
|
||||||
|
lang,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toHTML: (token) =>
|
||||||
|
'<pre><code' + (token.meta?.lang ? ` class="language-${escapeHtml(token.meta.lang)}"` : '') +
|
||||||
|
'>' + escapeHtml(token.content) + '</code></pre>',
|
||||||
|
selector: 'PRE',
|
||||||
|
toMarkdown: (element) => {
|
||||||
|
const code = element.querySelector('code');
|
||||||
|
const lang = (code?.getAttribute('class') || '').match(/language-(\S+)/)?.[1] || '';
|
||||||
|
return '\n\n```' + lang + '\n' + (code?.textContent || element.textContent || '') + '\n```\n\n';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'HR': {
|
||||||
|
/*
|
||||||
|
* ***
|
||||||
|
* ---
|
||||||
|
* ___
|
||||||
|
*/
|
||||||
|
name: 'hr',
|
||||||
|
match: (context) => {
|
||||||
|
if (!/^(\*{3,}|-{3,}|_{3,})\s*$/.test(context.lines[context.index])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
raw: '',
|
||||||
|
consumed: 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toHTML: () => '<hr>',
|
||||||
|
selector: 'HR',
|
||||||
|
toMarkdown: () => '\n\n---\n\n',
|
||||||
|
},
|
||||||
|
|
||||||
|
'H1,H2,H3,H4,H5,H6': {
|
||||||
|
/*
|
||||||
|
* # Heading 1
|
||||||
|
* ## Heading 2
|
||||||
|
* ### Heading 3
|
||||||
|
*/
|
||||||
|
name: 'heading',
|
||||||
|
match: (context) => {
|
||||||
|
const matched = context.lines[context.index].match(/^(#{1,6})\s+(.*)/);
|
||||||
|
if (!matched) return null;
|
||||||
|
return {
|
||||||
|
content: matched[2].trim(),
|
||||||
|
raw: '',
|
||||||
|
consumed: 1,
|
||||||
|
meta: {
|
||||||
|
level: String(matched[1].length),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toHTML: (token, convert) =>
|
||||||
|
`<h${token.meta!.level} id='${camelId(token.content)}'>${convert.inline(token.content)}</h${token.meta!.level}>`,
|
||||||
|
selector: 'H1,H2,H3,H4,H5,H6',
|
||||||
|
toMarkdown: (element, convert) =>
|
||||||
|
'\n\n' + '#'.repeat(parseInt(element.nodeName[1])) + ' ' + convert.children(element) + '\n\n',
|
||||||
|
},
|
||||||
|
|
||||||
|
'BLOCKQUOTE': {
|
||||||
|
/*
|
||||||
|
* > quoted text
|
||||||
|
* > more quoted text
|
||||||
|
*/
|
||||||
|
name: 'blockquote',
|
||||||
|
match: (context) => {
|
||||||
|
if (!/^>\s?/.test(context.lines[context.index])) return null;
|
||||||
|
const lines: string[] = [];
|
||||||
|
let i = context.index;
|
||||||
|
while (i < context.lines.length && /^>\s?/.test(context.lines[i]))
|
||||||
|
lines.push(context.lines[i++].replace(/^>\s?/, ''));
|
||||||
|
return {
|
||||||
|
content: lines.join('\n'),
|
||||||
|
raw: '',
|
||||||
|
consumed: i - context.index,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toHTML: (token, convert) => '<blockquote>' + convert.block(token.content) + '</blockquote>',
|
||||||
|
selector: 'BLOCKQUOTE',
|
||||||
|
toMarkdown: (element, convert) =>
|
||||||
|
'\n\n' + convert.children(element).trim().split('\n').map(line => '> ' + line).join('\n') + '\n\n',
|
||||||
|
},
|
||||||
|
|
||||||
|
'UL,OL': {
|
||||||
|
/*
|
||||||
|
* - unordered item
|
||||||
|
* - unordered item
|
||||||
|
* - nested item
|
||||||
|
*
|
||||||
|
* 1. ordered item
|
||||||
|
* 2. ordered item
|
||||||
|
*/
|
||||||
|
name: 'list',
|
||||||
|
match: (context) => {
|
||||||
|
const line = context.lines[context.index];
|
||||||
|
if (!/^[*\-]\s/.test(line) && !/^\d+\.\s/.test(line)) return null;
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
raw: '',
|
||||||
|
consumed: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toHTML: (token) => token.raw,
|
||||||
|
selector: 'UL,OL',
|
||||||
|
toMarkdown: (element, convert) =>
|
||||||
|
listToMd(element, 0, convert),
|
||||||
|
},
|
||||||
|
|
||||||
|
'TABLE': {
|
||||||
|
/*
|
||||||
|
* | head 1 | head 2 |
|
||||||
|
* |--------|--------|
|
||||||
|
* | cell 1 | cell 2 |
|
||||||
|
*/
|
||||||
|
name: 'table',
|
||||||
|
match: (context) => {
|
||||||
|
const { lines, index } = context;
|
||||||
|
if (lines[index].indexOf('|') === -1 || index + 1 >= lines.length) return null;
|
||||||
|
if (!/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(lines[index + 1])) return null;
|
||||||
|
const headers = parseTableRow(lines[index]);
|
||||||
|
const aligns = parseAligns(lines[index + 1]);
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let i = index + 2;
|
||||||
|
while (i < lines.length && lines[i].indexOf('|') !== -1 && !/^\s*$/.test(lines[i]))
|
||||||
|
rows.push(parseTableRow(lines[i++]));
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
raw: '',
|
||||||
|
consumed: i - index,
|
||||||
|
meta: {
|
||||||
|
headers: JSON.stringify(headers),
|
||||||
|
aligns: JSON.stringify(aligns),
|
||||||
|
rows: JSON.stringify(rows),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toHTML: (token, convert) => {
|
||||||
|
const headers: string[] = JSON.parse(token.meta!.headers);
|
||||||
|
const aligns: (string | null)[] = JSON.parse(token.meta!.aligns);
|
||||||
|
const rows: string[][] = JSON.parse(token.meta!.rows);
|
||||||
|
function cell(tag: string, text: string, index: number): string {
|
||||||
|
const align = aligns[index] ? ` style="text-align:${aligns[index]}"` : '';
|
||||||
|
return `<${tag}${align}>${convert.inline(text)}</${tag}>`;
|
||||||
|
}
|
||||||
|
const head = '<thead><tr>' + headers.map((text, i) => cell('th', text, i)).join('') + '</tr></thead>';
|
||||||
|
const body = rows.map(row =>
|
||||||
|
'<tr>' + row.map((text, i) => cell('td', text, i)).join('') + '</tr>'
|
||||||
|
).join('');
|
||||||
|
return '<table>' + head + '<tbody>' + body + '</tbody></table>';
|
||||||
|
},
|
||||||
|
selector: 'TABLE',
|
||||||
|
toMarkdown: (element, convert) => {
|
||||||
|
const rows = Array.from(element.querySelectorAll('tr'));
|
||||||
|
if (!rows.length) return '';
|
||||||
|
const headers = Array.from(rows[0].querySelectorAll('th,td')).map(cell => convert.children(cell).trim());
|
||||||
|
const separator = headers.map(() => '---');
|
||||||
|
const output = [
|
||||||
|
'| ' + headers.join(' | ') + ' |',
|
||||||
|
'| ' + separator.join(' | ') + ' |',
|
||||||
|
];
|
||||||
|
rows.slice(1).forEach(row => {
|
||||||
|
const cells = Array.from(row.querySelectorAll('td,th')).map(cell => convert.children(cell).trim());
|
||||||
|
output.push('| ' + cells.join(' | ') + ' |');
|
||||||
|
});
|
||||||
|
return '\n\n' + output.join('\n') + '\n\n';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
'P': {
|
||||||
|
/*
|
||||||
|
* Any text that doesn't match another block tag
|
||||||
|
* becomes a paragraph.
|
||||||
|
*/
|
||||||
|
name: 'paragraph',
|
||||||
|
match: (context) => {
|
||||||
|
const collected: string[] = [];
|
||||||
|
let i = context.index;
|
||||||
|
while (i < context.lines.length && !/^\s*$/.test(context.lines[i]) && !isBlockStart(context.lines, i))
|
||||||
|
collected.push(context.lines[i++]);
|
||||||
|
return collected.length
|
||||||
|
? {
|
||||||
|
content: collected.join('\n'),
|
||||||
|
raw: '',
|
||||||
|
consumed: i - context.index,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
toHTML: (token, convert) => '<p>' + convert.inline(token.content) + '</p>',
|
||||||
|
selector: 'P',
|
||||||
|
toMarkdown: (element, convert) => '\n\n' + convert.children(element) + '\n\n',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default set of inline tags, matched in precedence order.
|
||||||
|
* Tags created with inlineTag() carry a precedence field; lower values run first.
|
||||||
|
*/
|
||||||
|
export const defaultInlineTags: Record<string, Tag> = {
|
||||||
|
|
||||||
|
'CODE': inlineTag({
|
||||||
|
/*
|
||||||
|
* `inline code`
|
||||||
|
*/
|
||||||
|
name: 'code',
|
||||||
|
delimiter: '`',
|
||||||
|
htmlTag: 'code',
|
||||||
|
precedence: 10,
|
||||||
|
recursive: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
'A': {
|
||||||
|
/*
|
||||||
|
* [link text](http://example.com)
|
||||||
|
*/
|
||||||
|
name: 'link',
|
||||||
|
match: (context) => {
|
||||||
|
const matched = context.text.slice(context.offset).match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
||||||
|
if (!matched) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: matched[1],
|
||||||
|
raw: matched[0],
|
||||||
|
consumed: matched[0].length,
|
||||||
|
meta: {
|
||||||
|
href: matched[2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
toHTML: (token, convert) =>
|
||||||
|
'<a href="' + escapeHtml(token.meta!.href) + '">' + convert.inline(token.content) + '</a>',
|
||||||
|
selector: 'A',
|
||||||
|
toMarkdown: (element, convert) =>
|
||||||
|
'[' + convert.children(element) + '](' + (element.getAttribute('href') || '') + ')',
|
||||||
|
},
|
||||||
|
|
||||||
|
'_boldItalic': {
|
||||||
|
/*
|
||||||
|
* ***bold and italic***
|
||||||
|
*/
|
||||||
|
...inlineTag({
|
||||||
|
name: 'boldItalic',
|
||||||
|
delimiter: '***',
|
||||||
|
htmlTag: 'em',
|
||||||
|
precedence: 30,
|
||||||
|
}),
|
||||||
|
toHTML: (token, convert) =>
|
||||||
|
'<em><strong>' + convert.inline(token.content) + '</strong></em>',
|
||||||
|
selector: ((element: HTMLElement) => false) as string | ((element: HTMLElement) => boolean),
|
||||||
|
toMarkdown: () => '',
|
||||||
|
},
|
||||||
|
|
||||||
|
'STRONG,B': inlineTag({
|
||||||
|
/*
|
||||||
|
* **bold text**
|
||||||
|
*/
|
||||||
|
name: 'bold',
|
||||||
|
delimiter: '**',
|
||||||
|
htmlTag: 'strong',
|
||||||
|
aliases: 'B',
|
||||||
|
precedence: 40,
|
||||||
|
}),
|
||||||
|
|
||||||
|
'EM,I': inlineTag({
|
||||||
|
/*
|
||||||
|
* *italic text*
|
||||||
|
*/
|
||||||
|
name: 'italic',
|
||||||
|
delimiter: '*',
|
||||||
|
htmlTag: 'em',
|
||||||
|
aliases: 'I',
|
||||||
|
precedence: 50,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All default tags: block tags merged with inline tags.
|
||||||
|
*/
|
||||||
|
export const defaultTags: Record<string, Tag> = {
|
||||||
|
...defaultBlockTags,
|
||||||
|
...defaultInlineTags,
|
||||||
|
};
|
||||||
56
src/types.ts
Normal file
56
src/types.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
* types.ts — shared types for the hopdown converter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SourceToken {
|
||||||
|
content: string;
|
||||||
|
raw: string;
|
||||||
|
consumed: number;
|
||||||
|
meta?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Converter {
|
||||||
|
inline: (text: string) => string;
|
||||||
|
block: (md: string) => string;
|
||||||
|
children: (node: Node) => string;
|
||||||
|
node: (node: Node) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchContext {
|
||||||
|
lines: string[];
|
||||||
|
index: number;
|
||||||
|
text: string;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
name: string;
|
||||||
|
match: (context: MatchContext) => SourceToken | null;
|
||||||
|
toHTML: (token: SourceToken, convert: Converter) => string;
|
||||||
|
selector: string | ((element: HTMLElement) => boolean);
|
||||||
|
toMarkdown: (element: HTMLElement, convert: Converter) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListItem {
|
||||||
|
text: string;
|
||||||
|
sub: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResult {
|
||||||
|
html: string;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InlineTagDef {
|
||||||
|
name: string;
|
||||||
|
/** The markdown delimiter, e.g. '**' or '`' or '~~' */
|
||||||
|
delimiter: string;
|
||||||
|
/** The HTML tag to wrap with, e.g. 'strong' or 'code' */
|
||||||
|
htmlTag: string;
|
||||||
|
/** Additional HTML selectors for reverse matching, e.g. 'B' for bold */
|
||||||
|
aliases?: string;
|
||||||
|
/** Lower runs first. Default 50. */
|
||||||
|
precedence?: number;
|
||||||
|
/** Process inner content for nested markdown? Default true. False for code spans. */
|
||||||
|
recursive?: boolean;
|
||||||
|
}
|
||||||
416
test/test_hopdown.js
Normal file
416
test/test_hopdown.js
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Set up a DOM environment and load the bundle
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||||
|
url: 'http://localhost',
|
||||||
|
pretendToBeVisual: true,
|
||||||
|
});
|
||||||
|
global.window = dom.window;
|
||||||
|
global.document = dom.window.document;
|
||||||
|
global.HTMLElement = dom.window.HTMLElement;
|
||||||
|
global.Node = dom.window.Node;
|
||||||
|
|
||||||
|
// Load the compiled bundle (attaches globals to window)
|
||||||
|
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit.js'), 'utf8');
|
||||||
|
dom.window.eval(bundle);
|
||||||
|
|
||||||
|
const hopdown = dom.window.hopdown;
|
||||||
|
const H = hopdown.toHTML.bind(hopdown);
|
||||||
|
const M = hopdown.toMarkdown.bind(hopdown);
|
||||||
|
function rt(md) { return M(H(md)); }
|
||||||
|
|
||||||
|
// Test harness
|
||||||
|
let passed = 0, failed = 0, errors = [];
|
||||||
|
|
||||||
|
function norm(s) { return (s || '').replace(/\r\n/g, '\n').trim(); }
|
||||||
|
|
||||||
|
function eq(name, actual, expected) {
|
||||||
|
const a = norm(actual), e = norm(expected);
|
||||||
|
if (a === e) {
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
errors.push(name);
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` expected: ${e}`);
|
||||||
|
console.log(` actual: ${a}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function has(name, actual, sub) {
|
||||||
|
if (norm(actual).indexOf(norm(sub)) !== -1) {
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
errors.push(name);
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` expected to contain: ${sub}`);
|
||||||
|
console.log(` actual: ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function not(name, actual, sub) {
|
||||||
|
if (norm(actual).indexOf(norm(sub)) === -1) {
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
errors.push(name);
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` should NOT contain: ${sub}`);
|
||||||
|
console.log(` actual: ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(n) { /* silent */ }
|
||||||
|
|
||||||
|
// ── 1. Inline formatting ────────────────────────────────
|
||||||
|
section('1. Inline Formatting → HTML');
|
||||||
|
eq('bold', H('**bold**'), '<p><strong>bold</strong></p>');
|
||||||
|
eq('italic', H('*italic*'), '<p><em>italic</em></p>');
|
||||||
|
eq('inline code', H('`code`'), '<p><code>code</code></p>');
|
||||||
|
eq('link', H('[t](http://x)'), '<p><a href="http://x">t</a></p>');
|
||||||
|
eq('bold+italic', H('***bi***'), '<p><em><strong>bi</strong></em></p>');
|
||||||
|
eq('mixed inline', H('a **b** *c* `d`'), '<p>a <strong>b</strong> <em>c</em> <code>d</code></p>');
|
||||||
|
eq('code before bold', H('`a` **b**'), '<p><code>a</code> <strong>b</strong></p>');
|
||||||
|
|
||||||
|
// ── 2. Headings ─────────────────────────────────────────
|
||||||
|
eq('h1', H('# Title'), "<h1 id='Title'>Title</h1>");
|
||||||
|
eq('h2', H('## Sub'), "<h2 id='Sub'>Sub</h2>");
|
||||||
|
eq('h3', H('### Sub3'), "<h3 id='Sub3'>Sub3</h3>");
|
||||||
|
eq('h4', H('#### Sub4'), "<h4 id='Sub4'>Sub4</h4>");
|
||||||
|
eq('h5', H('##### Sub5'), "<h5 id='Sub5'>Sub5</h5>");
|
||||||
|
eq('h6', H('###### Sub6'), "<h6 id='Sub6'>Sub6</h6>");
|
||||||
|
has('heading id multi-word', H('## Hello World'), "id='HelloWorld'");
|
||||||
|
has('heading inline md', H('## **Bold** text'), '<strong>Bold</strong>');
|
||||||
|
|
||||||
|
// ── 3. Horizontal rules ─────────────────────────────────
|
||||||
|
eq('*** rule', H('***'), '<hr>');
|
||||||
|
eq('--- rule', H('---'), '<hr>');
|
||||||
|
eq('___ rule', H('___'), '<hr>');
|
||||||
|
|
||||||
|
// ── 4. Lists ────────────────────────────────────────────
|
||||||
|
eq('ul *', H('* a\n* b'), '<ul><li>a</li><li>b</li></ul>');
|
||||||
|
eq('ul -', H('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
|
||||||
|
eq('ol', H('1. a\n2. b'),'<ol><li>a</li><li>b</li></ol>');
|
||||||
|
has('ul inline', H('* **bold** item'), '<strong>bold</strong>');
|
||||||
|
has('ol inline', H('1. *em* item'), '<em>em</em>');
|
||||||
|
|
||||||
|
// ── 5. Blockquotes ──────────────────────────────────────
|
||||||
|
has('blockquote', H('> text'), '<blockquote>');
|
||||||
|
has('bq content', H('> hello'), 'hello');
|
||||||
|
has('multi-line bq', H('> a\n> b'), 'a');
|
||||||
|
|
||||||
|
// ── 6. Fenced code blocks ───────────────────────────────
|
||||||
|
has('code block', H('```\nx = 1\n```'), '<pre><code>');
|
||||||
|
has('code content', H('```\nx = 1\n```'), 'x = 1');
|
||||||
|
has('lang class', H('```js\nvar x;\n```'), 'language-js');
|
||||||
|
has('html escaped', H('```\n<div>\n```'), '<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.inlineTag({
|
||||||
|
name: 'strikethrough',
|
||||||
|
delimiter: '~~',
|
||||||
|
htmlTag: 'del',
|
||||||
|
aliases: 'S,STRIKE',
|
||||||
|
precedence: 45,
|
||||||
|
});
|
||||||
|
const customInline = new dom.window.HopDown({
|
||||||
|
tags: { ...dom.window.defaultTags, 'DEL,S,STRIKE': strikethrough },
|
||||||
|
});
|
||||||
|
eq('factory: md→html', customInline.toHTML('~~struck~~'), '<p><del>struck</del></p>');
|
||||||
|
has('factory: html→md', customInline.toMarkdown('<p><del>struck</del></p>'), '~~struck~~');
|
||||||
|
eq('factory: round-trip', customInline.toMarkdown(customInline.toHTML('~~struck~~')), '~~struck~~');
|
||||||
|
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<del>struck</del>');
|
||||||
|
has('factory: mixed with bold', customInline.toHTML('**bold** and ~~struck~~'), '<strong>bold</strong>');
|
||||||
|
eq('factory: non-recursive', dom.window.inlineTag({
|
||||||
|
name: 'test',
|
||||||
|
delimiter: '%%',
|
||||||
|
htmlTag: 'mark',
|
||||||
|
recursive: false,
|
||||||
|
}).toHTML({ content: '<b>x</b>', raw: '', consumed: 0 }, { inline: s => s, block: s => s, children: n => '', node: n => '' }),
|
||||||
|
'<mark><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.HopDown({
|
||||||
|
tags: { 'DETAILS': spoiler, ...dom.window.defaultTags },
|
||||||
|
});
|
||||||
|
has('custom block: md→html', customBlock.toHTML('|||\nhidden\n|||'), '<details>');
|
||||||
|
has('custom block: content', customBlock.toHTML('|||\nhidden\n|||'), 'hidden');
|
||||||
|
has('custom block: html→md', customBlock.toMarkdown('<details><summary>Spoiler</summary><p>hidden</p></details>'), '|||');
|
||||||
|
has('custom block: nested md', customBlock.toHTML('|||\n**bold** inside\n|||'), '<strong>bold</strong>');
|
||||||
|
|
||||||
|
// ── 20. HopDown({ exclude }) ────────────────────────────
|
||||||
|
const noTables = new dom.window.HopDown({ exclude: ['table'] });
|
||||||
|
// With table excluded, pipe lines fall through to paragraph but isBlockStart
|
||||||
|
// still detects table-like patterns, so lines are split across paragraphs.
|
||||||
|
has('exclude: table not rendered', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<p>');
|
||||||
|
not('exclude: no table tag', noTables.toHTML('| a | b |\n|---|---|\n| 1 | 2 |'), '<table>');
|
||||||
|
has('exclude: bold still works', noTables.toHTML('**bold**'), '<strong>bold</strong>');
|
||||||
|
|
||||||
|
const noCode = new dom.window.HopDown({ exclude: ['code'] });
|
||||||
|
eq('exclude: code not processed', noCode.toHTML('`code`'), '<p>`code`</p>');
|
||||||
|
has('exclude: bold still works', noCode.toHTML('**bold**'), '<strong>bold</strong>');
|
||||||
|
|
||||||
|
// ── 21. Collision detection: delimiter ───────────────────
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
const bad = dom.window.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
|
||||||
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'SPAN': bad } });
|
||||||
|
} catch (e) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
eq('delimiter collision throws', String(threw), 'true');
|
||||||
|
|
||||||
|
threw = false;
|
||||||
|
try {
|
||||||
|
// Same delimiter, higher precedence than existing — should throw
|
||||||
|
const bad = dom.window.inlineTag({ name: 'bad', delimiter: '**', htmlTag: 'span', precedence: 60 });
|
||||||
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'SPAN': bad } });
|
||||||
|
} catch (e) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
eq('duplicate delimiter collision throws', String(threw), 'true');
|
||||||
|
|
||||||
|
// ── 22. Collision detection: selector ───────────────────
|
||||||
|
threw = false;
|
||||||
|
try {
|
||||||
|
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
|
||||||
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'STRONG': dup } });
|
||||||
|
} catch (e) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
eq('selector collision throws', String(threw), 'true');
|
||||||
|
|
||||||
|
// ── 23. Precedence ordering ─────────────────────────────
|
||||||
|
// Longer delimiter with lower precedence should win
|
||||||
|
const tilde = dom.window.inlineTag({ name: 'tilde', delimiter: '~', htmlTag: 's', precedence: 45 });
|
||||||
|
const doubleTilde = dom.window.inlineTag({ name: 'doubleTilde', delimiter: '~~', htmlTag: 'del', precedence: 35 });
|
||||||
|
const precTest = new dom.window.HopDown({
|
||||||
|
tags: { ...dom.window.defaultTags, 'S': tilde, 'DEL': doubleTilde },
|
||||||
|
});
|
||||||
|
has('precedence: ~~ matches before ~', precTest.toHTML('~~struck~~'), '<del>struck</del>');
|
||||||
|
has('precedence: ~ still works', precTest.toHTML('~light~'), '<s>light</s>');
|
||||||
|
|
||||||
|
// Valid: longer delimiter has lower precedence
|
||||||
|
threw = false;
|
||||||
|
try {
|
||||||
|
const short = dom.window.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
|
||||||
|
const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
|
||||||
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': short, 'DEL': long } });
|
||||||
|
} catch (e) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
eq('valid precedence does not throw', String(threw), 'false');
|
||||||
|
|
||||||
|
// Invalid: longer delimiter has higher precedence
|
||||||
|
threw = false;
|
||||||
|
try {
|
||||||
|
const short = dom.window.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 30 });
|
||||||
|
const long = dom.window.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 50 });
|
||||||
|
new dom.window.HopDown({ tags: { ...dom.window.defaultTags, 'S': short, 'DEL': long } });
|
||||||
|
} catch (e) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
eq('invalid precedence throws', String(threw), 'true');
|
||||||
|
|
||||||
|
// ── Results ─────────────────────────────────────────────
|
||||||
|
const total = passed + failed;
|
||||||
|
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
|
||||||
|
if (errors.length) {
|
||||||
|
console.log('\nFailed:');
|
||||||
|
errors.forEach(e => console.log(` • ${e}`));
|
||||||
|
}
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2017",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"lib": ["ES2019", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user