Compare commits
2 Commits
86d59877f1
...
2b88d2c10b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b88d2c10b | ||
|
|
4237a3f6a2 |
103
README.md
103
README.md
|
|
@ -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.
|
|
||||||
|
|
|
||||||
22
jest.config.js
Normal file
22
jest.config.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/test'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': ['ts-jest', {
|
||||||
|
tsconfig: {
|
||||||
|
strict: true,
|
||||||
|
target: 'ES2017',
|
||||||
|
module: 'CommonJS',
|
||||||
|
moduleResolution: 'node',
|
||||||
|
esModuleInterop: true,
|
||||||
|
lib: ['ES2019', 'DOM'],
|
||||||
|
types: ['jest'],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
6744
package-lock.json
generated
6744
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
|
|
@ -2,11 +2,9 @@
|
||||||
"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:css",
|
||||||
|
|
@ -14,14 +12,17 @@
|
||||||
"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: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"
|
"test": "npm run build && jest --verbose",
|
||||||
|
"test:coverage": "npm run build && jest --coverage"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "evilchili",
|
"author": "evilchili",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jest": "^29.5.14",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"jsdom": "^20.0.3",
|
"happy-dom": "^14.12.3",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.4.9",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,3 +20,38 @@
|
||||||
#ribbit.wysiwyg .md {
|
#ribbit.wysiwyg .md {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ribbit-editing::before,
|
||||||
|
.ribbit-editing::after {
|
||||||
|
opacity: 0.3;
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.wysiwyg strong.ribbit-editing::before,
|
||||||
|
#ribbit.wysiwyg strong.ribbit-editing::after {
|
||||||
|
content: "**";
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.wysiwyg em.ribbit-editing::before,
|
||||||
|
#ribbit.wysiwyg em.ribbit-editing::after {
|
||||||
|
content: "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.wysiwyg code.ribbit-editing::before,
|
||||||
|
#ribbit.wysiwyg code.ribbit-editing::after {
|
||||||
|
content: "\`";
|
||||||
|
}
|
||||||
|
|
||||||
|
#ribbit.wysiwyg h1.ribbit-editing::before { content: "# "; font-size: 0.5em; }
|
||||||
|
#ribbit.wysiwyg h2.ribbit-editing::before { content: "## "; font-size: 0.5em; }
|
||||||
|
#ribbit.wysiwyg h3.ribbit-editing::before { content: "### "; font-size: 0.5em; }
|
||||||
|
#ribbit.wysiwyg h4.ribbit-editing::before { content: "#### "; font-size: 0.5em; }
|
||||||
|
#ribbit.wysiwyg h5.ribbit-editing::before { content: "##### "; font-size: 0.5em; }
|
||||||
|
#ribbit.wysiwyg h6.ribbit-editing::before { content: "###### "; font-size: 0.5em; }
|
||||||
|
|
||||||
|
#ribbit.wysiwyg blockquote.ribbit-editing::before {
|
||||||
|
content: "> ";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export class HopDown {
|
||||||
|
|
||||||
this.blockTags = allTags.filter(tag =>
|
this.blockTags = allTags.filter(tag =>
|
||||||
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
|
defaultBlockNames.has(tag.name) || tag.name === 'macro' ||
|
||||||
(!defaultInlineNames.has(tag.name) && !(tag as any).pattern)
|
(!defaultInlineNames.has(tag.name) && !tag.pattern)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure macro block tag runs after fencedCode but before everything else
|
// Ensure macro block tag runs after fencedCode but before everything else
|
||||||
|
|
@ -83,7 +83,7 @@ export class HopDown {
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|
@ -113,11 +113,11 @@ export class HopDown {
|
||||||
*/
|
*/
|
||||||
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 i = 0; i < withDelimiters.length; i++) {
|
||||||
|
|
@ -159,6 +159,20 @@ export class HopDown {
|
||||||
return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
|
return this.nodeToMd(container).replace(/\n{3,}/g, '\n\n').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the block tags for external iteration (e.g. speculative rendering).
|
||||||
|
*/
|
||||||
|
getBlockTags(): Tag[] {
|
||||||
|
return this.blockTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the inline tags for external iteration (e.g. speculative rendering).
|
||||||
|
*/
|
||||||
|
getInlineTags(): Tag[] {
|
||||||
|
return this.inlineTags;
|
||||||
|
}
|
||||||
|
|
||||||
private processBlocks(md: string): string {
|
private processBlocks(md: string): string {
|
||||||
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
||||||
const output: string[] = [];
|
const output: string[] = [];
|
||||||
|
|
@ -186,6 +200,7 @@ export class HopDown {
|
||||||
output.push(result.html);
|
output.push(result.html);
|
||||||
index = result.end;
|
index = result.end;
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
output.push(tag.toHTML(token, this.makeConverter()));
|
output.push(tag.toHTML(token, this.makeConverter()));
|
||||||
index += token.consumed;
|
index += token.consumed;
|
||||||
}
|
}
|
||||||
|
|
@ -216,13 +231,11 @@ export class HopDown {
|
||||||
|
|
||||||
// Pass 1: extract links and non-recursive tags into placeholders before escaping
|
// Pass 1: extract links and non-recursive tags into placeholders before escaping
|
||||||
for (const tag of sorted) {
|
for (const tag of sorted) {
|
||||||
const recursive = (tag as any).recursive ?? true;
|
const recursive = tag.recursive ?? true;
|
||||||
|
|
||||||
if (tag.name === 'link') {
|
if (tag.name === 'link') {
|
||||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText: string, href: string) => {
|
||||||
// Process link text: restore earlier placeholders, then run inline on any remaining markdown
|
|
||||||
let inner = linkText;
|
let inner = linkText;
|
||||||
// Check if link text contains placeholders (already-processed content)
|
|
||||||
const hasPlaceholders = /\x00P\d+\x00/.test(inner);
|
const hasPlaceholders = /\x00P\d+\x00/.test(inner);
|
||||||
if (hasPlaceholders) {
|
if (hasPlaceholders) {
|
||||||
inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
|
inner = inner.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
|
||||||
|
|
@ -232,9 +245,10 @@ export class HopDown {
|
||||||
placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
|
placeholders.push('<a href="' + escapeHtml(href) + '">' + inner + '</a>');
|
||||||
return '\x00P' + (placeholders.length - 1) + '\x00';
|
return '\x00P' + (placeholders.length - 1) + '\x00';
|
||||||
});
|
});
|
||||||
} else if (!recursive && (tag as any).pattern) {
|
} else if (!recursive && tag.pattern) {
|
||||||
const globalPattern = (tag as any).pattern as RegExp;
|
const globalPattern = tag.pattern as RegExp;
|
||||||
globalPattern.lastIndex = 0;
|
globalPattern.lastIndex = 0;
|
||||||
|
|
||||||
text = text.replace(globalPattern, (_, content: string) => {
|
text = text.replace(globalPattern, (_, content: string) => {
|
||||||
placeholders.push(tag.toHTML(
|
placeholders.push(tag.toHTML(
|
||||||
{ content, raw: '', consumed: 0 },
|
{ content, raw: '', consumed: 0 },
|
||||||
|
|
@ -247,21 +261,20 @@ export class HopDown {
|
||||||
|
|
||||||
text = escapeHtml(text);
|
text = escapeHtml(text);
|
||||||
|
|
||||||
// Pass 2: apply recursive tags in precedence order (longest delimiter first).
|
// Pass 2: apply recursive tags in precedence order.
|
||||||
// Content matched here is already HTML-escaped and has had earlier
|
// Content is already HTML-escaped from pass 1, so we wrap directly
|
||||||
// passes applied, so we wrap directly without re-processing.
|
// without re-processing through convert.inline().
|
||||||
for (const tag of sorted) {
|
for (const tag of sorted) {
|
||||||
const recursive = (tag as any).recursive ?? true;
|
const recursive = tag.recursive ?? true;
|
||||||
if (tag.name === 'link' || !recursive) {
|
if (tag.name === 'link' || !recursive) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const globalPattern = (tag as any).pattern as RegExp | undefined;
|
const globalPattern = tag.pattern as RegExp | undefined;
|
||||||
if (globalPattern) {
|
if (globalPattern) {
|
||||||
globalPattern.lastIndex = 0;
|
globalPattern.lastIndex = 0;
|
||||||
text = text.replace(globalPattern, (_, content: string) => {
|
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 restored = content.replace(/\x00P(\d+)\x00/g, (__, idx: string) => placeholders[parseInt(idx)]);
|
||||||
const htmlTag = (tag as any).name === 'boldItalic'
|
const htmlTag = tag.name === 'boldItalic'
|
||||||
? null
|
? null
|
||||||
: ((tag.selector as string) || '').split(',')[0].toLowerCase();
|
: ((tag.selector as string) || '').split(',')[0].toLowerCase();
|
||||||
if (tag.name === 'boldItalic') {
|
if (tag.name === 'boldItalic') {
|
||||||
|
|
@ -272,7 +285,6 @@ export class HopDown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore placeholders
|
|
||||||
text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
|
text = text.replace(/\x00P(\d+)\x00/g, (_, index: string) => placeholders[parseInt(index)]);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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, RibbitPlugin, RibbitSettings, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
|
||||||
|
import { type MacroDef } from './macros';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
|
* WYSIWYG markdown editor with VIEW, EDIT, and WYSIWYG modes.
|
||||||
|
|
@ -38,18 +39,156 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
#bindEvents(): void {
|
#bindEvents(): void {
|
||||||
|
let debounceTimer: number | undefined;
|
||||||
|
let lastThrottle = 0;
|
||||||
|
|
||||||
this.element.addEventListener('input', () => {
|
this.element.addEventListener('input', () => {
|
||||||
if (this.state !== this.states.VIEW) {
|
if (this.state === this.states.VIEW) {
|
||||||
this.notifyChange();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.invalidateCache();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastThrottle >= 150) {
|
||||||
|
lastThrottle = now;
|
||||||
|
this.refreshPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = window.setTimeout(() => {
|
||||||
|
this.refreshPreview();
|
||||||
|
this.notifyChange();
|
||||||
|
}, 150);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-render the WYSIWYG preview from the current content.
|
||||||
|
* Applies speculative rendering for unclosed inline delimiters
|
||||||
|
* at the cursor position, and uses toHtmlPreview for visible syntax.
|
||||||
|
*/
|
||||||
|
refreshPreview(): void {
|
||||||
|
if (this.state !== this.states.WYSIWYG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorInfo = this.getCursorInfo();
|
||||||
|
const text = this.element.textContent || '';
|
||||||
|
const lines = text.split('\n');
|
||||||
|
|
||||||
|
// Speculatively close unclosed delimiters on the cursor line
|
||||||
|
if (cursorInfo) {
|
||||||
|
const inlineTags = this.converter.getInlineTags();
|
||||||
|
const sorted = [...inlineTags].sort((a, b) =>
|
||||||
|
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
|
||||||
|
);
|
||||||
|
for (const tag of sorted) {
|
||||||
|
if (tag.openPattern && tag.delimiter) {
|
||||||
|
const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset);
|
||||||
|
const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const re = new RegExp(escaped, 'g');
|
||||||
|
const count = (before.match(re) || []).length;
|
||||||
|
if (count % 2 === 1) {
|
||||||
|
lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = this.converter.toHTML(lines.join('\n'));
|
||||||
|
this.updatePreview(html, cursorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track which formatting element contains the cursor and toggle
|
||||||
|
* the .ribbit-editing class so CSS ::before/::after show delimiters.
|
||||||
|
*/
|
||||||
|
private updateEditingContext(): void {
|
||||||
|
const prev = this.element.querySelector('.ribbit-editing');
|
||||||
|
if (prev) {
|
||||||
|
prev.classList.remove('ribbit-editing');
|
||||||
|
}
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let node: Node | null = sel.anchorNode;
|
||||||
|
while (node && node !== this.element) {
|
||||||
|
if (node.nodeType === 1) {
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
if (el.matches('strong, b, em, i, code, h1, h2, h3, h4, h5, h6, blockquote')) {
|
||||||
|
el.classList.add('ribbit-editing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cursor's line index and offset within that line.
|
||||||
|
*/
|
||||||
|
private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const preRange = document.createRange();
|
||||||
|
preRange.selectNodeContents(this.element);
|
||||||
|
preRange.setEnd(range.startContainer, range.startOffset);
|
||||||
|
const absoluteOffset = preRange.toString().length;
|
||||||
|
|
||||||
|
const text = this.element.textContent || '';
|
||||||
|
const beforeCursor = text.slice(0, absoluteOffset);
|
||||||
|
const lineIndex = beforeCursor.split('\n').length - 1;
|
||||||
|
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
|
||||||
|
const offset = absoluteOffset - lineStart;
|
||||||
|
|
||||||
|
return { lineIndex, offset, absoluteOffset };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the editor's HTML and restore the cursor to its
|
||||||
|
* previous text offset position.
|
||||||
|
*/
|
||||||
|
private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void {
|
||||||
|
this.element.innerHTML = html;
|
||||||
|
|
||||||
|
if (!cursorInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
|
||||||
|
let remaining = cursorInfo.absoluteOffset;
|
||||||
|
let node: Text | null;
|
||||||
|
|
||||||
|
while ((node = walker.nextNode() as Text | null)) {
|
||||||
|
if (remaining <= node.length) {
|
||||||
|
const sel = window.getSelection()!;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, remaining);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
remaining -= node.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateEditingContext();
|
||||||
|
}
|
||||||
|
|
||||||
htmlToMarkdown(html?: string): string {
|
htmlToMarkdown(html?: string): string {
|
||||||
return this.converter.toMarkdown(html || this.element.innerHTML);
|
return this.converter.toMarkdown(html || this.element.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMarkdown(): string {
|
getMarkdown(): string {
|
||||||
|
if (this.cachedMarkdown !== null) {
|
||||||
|
return this.cachedMarkdown;
|
||||||
|
}
|
||||||
if (this.getState() === this.states.EDIT) {
|
if (this.getState() === this.states.EDIT) {
|
||||||
let html = this.element.innerHTML;
|
let html = this.element.innerHTML;
|
||||||
html = html.replace(/<(?:div|br)>/ig, '');
|
html = html.replace(/<(?:div|br)>/ig, '');
|
||||||
|
|
@ -57,8 +196,7 @@ export class RibbitEditor extends Ribbit {
|
||||||
this.cachedMarkdown = decodeHtmlEntities(html);
|
this.cachedMarkdown = decodeHtmlEntities(html);
|
||||||
} else if (this.getState() === this.states.WYSIWYG) {
|
} else if (this.getState() === this.states.WYSIWYG) {
|
||||||
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
|
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
|
||||||
}
|
} else {
|
||||||
if (!this.cachedMarkdown) {
|
|
||||||
this.cachedMarkdown = this.element.textContent || '';
|
this.cachedMarkdown = this.element.textContent || '';
|
||||||
}
|
}
|
||||||
return this.cachedMarkdown;
|
return this.cachedMarkdown;
|
||||||
|
|
@ -66,7 +204,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
|
|
||||||
wysiwyg(): void {
|
wysiwyg(): void {
|
||||||
if (this.getState() === this.states.WYSIWYG) return;
|
if (this.getState() === this.states.WYSIWYG) return;
|
||||||
this.changed = false;
|
|
||||||
this.element.contentEditable = 'true';
|
this.element.contentEditable = 'true';
|
||||||
this.element.innerHTML = this.getHTML();
|
this.element.innerHTML = this.getHTML();
|
||||||
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
||||||
|
|
@ -84,7 +221,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.state === this.states.EDIT) return;
|
if (this.state === this.states.EDIT) return;
|
||||||
this.changed = false;
|
|
||||||
this.element.contentEditable = 'true';
|
this.element.contentEditable = 'true';
|
||||||
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
||||||
this.setState(this.states.EDIT);
|
this.setState(this.states.EDIT);
|
||||||
|
|
@ -102,8 +238,6 @@ export class RibbitEditor extends Ribbit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import { type MacroDef } from './macros';
|
|
||||||
|
|
||||||
// Public API — accessed as ribbit.Editor, ribbit.HopDown, etc.
|
// 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 };
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { HopDown } from './hopdown';
|
||||||
import { defaultTheme } from './default-theme';
|
import { defaultTheme } from './default-theme';
|
||||||
import { ThemeManager } from './theme-manager';
|
import { ThemeManager } from './theme-manager';
|
||||||
import { RibbitEmitter, type RibbitEventMap } from './events';
|
import { RibbitEmitter, type RibbitEventMap } from './events';
|
||||||
import { buildMacroTags, type MacroDef } from './macros';
|
import { type MacroDef } from './macros';
|
||||||
import type { RibbitTheme } from './types';
|
import type { RibbitTheme } from './types';
|
||||||
|
|
||||||
export interface RibbitSettings {
|
export interface RibbitSettings {
|
||||||
|
|
@ -174,14 +174,11 @@ export class Ribbit {
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -193,14 +190,14 @@ export class Ribbit {
|
||||||
}
|
}
|
||||||
|
|
||||||
getHTML(): string {
|
getHTML(): string {
|
||||||
if (this.changed || !this.cachedHTML) {
|
if (this.cachedHTML === null) {
|
||||||
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
||||||
}
|
}
|
||||||
return this.cachedHTML;
|
return this.cachedHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMarkdown(): string {
|
getMarkdown(): string {
|
||||||
if (!this.cachedMarkdown) {
|
if (this.cachedMarkdown === null) {
|
||||||
this.cachedMarkdown = this.element.textContent || '';
|
this.cachedMarkdown = this.element.textContent || '';
|
||||||
}
|
}
|
||||||
return this.cachedMarkdown;
|
return this.cachedMarkdown;
|
||||||
|
|
@ -226,12 +223,21 @@ export class Ribbit {
|
||||||
this.element.contentEditable = 'false';
|
this.element.contentEditable = 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate cached markdown and HTML. Called when content changes.
|
||||||
|
* The next call to getMarkdown() or getHTML() will recompute.
|
||||||
|
*/
|
||||||
|
invalidateCache(): void {
|
||||||
|
this.changed = true;
|
||||||
|
this.cachedMarkdown = null;
|
||||||
|
this.cachedHTML = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify that content has changed. Called internally by the editor
|
* Notify that content has changed. Called internally by the editor
|
||||||
* on input events. Fires the 'change' event with current content.
|
* on input events. Fires the 'change' event with current content.
|
||||||
*/
|
*/
|
||||||
notifyChange(): void {
|
notifyChange(): void {
|
||||||
this.changed = true;
|
|
||||||
this.emitter.emit('change', {
|
this.emitter.emit('change', {
|
||||||
markdown: this.getMarkdown(),
|
markdown: this.getMarkdown(),
|
||||||
html: this.getHTML(),
|
html: this.getHTML(),
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,11 @@ import type { Tag, MatchContext, SourceToken, Converter, ListItem, ListResult, I
|
||||||
* inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 })
|
* inlineTag({ name: 'code', delimiter: '`', htmlTag: 'code', recursive: false, precedence: 10 })
|
||||||
* inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' })
|
* inlineTag({ name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE' })
|
||||||
*/
|
*/
|
||||||
export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recursive: boolean; pattern: RegExp; delimiter: string } {
|
export function inlineTag(def: InlineTagDef): Tag {
|
||||||
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
|
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
|
||||||
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
|
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
|
||||||
|
const openPattern = new RegExp(escaped + '(.+)$');
|
||||||
const upperTag = def.htmlTag.toUpperCase();
|
const upperTag = def.htmlTag.toUpperCase();
|
||||||
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
|
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
|
||||||
const recursive = def.recursive !== false;
|
const recursive = def.recursive !== false;
|
||||||
|
|
@ -32,6 +33,7 @@ export function inlineTag(def: InlineTagDef): Tag & { precedence: number; recurs
|
||||||
precedence: def.precedence ?? 50,
|
precedence: def.precedence ?? 50,
|
||||||
recursive,
|
recursive,
|
||||||
pattern: globalPattern,
|
pattern: globalPattern,
|
||||||
|
openPattern,
|
||||||
delimiter: def.delimiter,
|
delimiter: def.delimiter,
|
||||||
match: (context) => {
|
match: (context) => {
|
||||||
const matched = context.text.slice(context.offset).match(matchPattern);
|
const matched = context.text.slice(context.offset).match(matchPattern);
|
||||||
|
|
@ -69,7 +71,7 @@ export function escapeHtml(source: string): string {
|
||||||
/**
|
/**
|
||||||
* Generate a camelCase ID from heading text, for use as an anchor.
|
* Generate a camelCase ID from heading text, for use as an anchor.
|
||||||
*/
|
*/
|
||||||
export function camelId(text: string): string {
|
function camelId(text: string): string {
|
||||||
return text.trim().split(/\s+/).map(word =>
|
return text.trim().split(/\s+/).map(word =>
|
||||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||||
).join('');
|
).join('');
|
||||||
|
|
@ -115,7 +117,7 @@ export function parseListBlock(lines: string[], start: number, indent: number, i
|
||||||
* Convert an HTML list element back to markdown, recursing into
|
* Convert an HTML list element back to markdown, recursing into
|
||||||
* nested sublists with 2-space indentation per depth level.
|
* nested sublists with 2-space indentation per depth level.
|
||||||
*/
|
*/
|
||||||
export function listToMd(node: HTMLElement, depth: number, convert: Converter): string {
|
function listToMd(node: HTMLElement, depth: number, convert: Converter): string {
|
||||||
const isOl = node.nodeName === 'OL';
|
const isOl = node.nodeName === 'OL';
|
||||||
const indent = ' '.repeat(depth);
|
const indent = ' '.repeat(depth);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
@ -141,7 +143,7 @@ export function listToMd(node: HTMLElement, depth: number, convert: Converter):
|
||||||
* Test whether a line begins a block-level element (used to detect
|
* Test whether a line begins a block-level element (used to detect
|
||||||
* paragraph boundaries).
|
* paragraph boundaries).
|
||||||
*/
|
*/
|
||||||
export function isBlockStart(lines: string[], index: number): boolean {
|
function isBlockStart(lines: string[], index: number): boolean {
|
||||||
const line = lines[index];
|
const line = lines[index];
|
||||||
if (/^(`{3,})/.test(line)) return true;
|
if (/^(`{3,})/.test(line)) return true;
|
||||||
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true;
|
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) return true;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RibbitTheme } from './types';
|
import type { RibbitTheme } from './types';
|
||||||
import { HopDown } from './hopdown';
|
|
||||||
|
|
||||||
export class ThemeManager {
|
export class ThemeManager {
|
||||||
private registered: Map<string, RibbitTheme>;
|
private registered: Map<string, RibbitTheme>;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,22 @@ export interface Tag {
|
||||||
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;
|
||||||
|
/**
|
||||||
|
* The regex pattern that matches an unclosed opening delimiter.
|
||||||
|
* Used by the live preview to speculatively close incomplete syntax.
|
||||||
|
* Auto-generated by inlineTag().
|
||||||
|
*/
|
||||||
|
openPattern?: RegExp;
|
||||||
|
/**
|
||||||
|
* The markdown delimiter string. Auto-generated by inlineTag().
|
||||||
|
*/
|
||||||
|
delimiter?: string;
|
||||||
|
/** Lower runs first in inline processing. Default 50. Auto-generated by inlineTag(). */
|
||||||
|
precedence?: number;
|
||||||
|
/** Whether inner content is processed for nested markdown. Auto-generated by inlineTag(). */
|
||||||
|
recursive?: boolean;
|
||||||
|
/** Global regex for matching this tag's delimiter pair. Auto-generated by inlineTag(). */
|
||||||
|
pattern?: RegExp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListItem {
|
export interface ListItem {
|
||||||
|
|
|
||||||
68
test/custom-tags.test.ts
Normal file
68
test/custom-tags.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
|
const r = ribbit();
|
||||||
|
|
||||||
|
describe('Custom inline tags', () => {
|
||||||
|
const strikethrough = r.inlineTag({
|
||||||
|
name: 'strikethrough', delimiter: '~~', htmlTag: 'del', aliases: 'S,STRIKE', precedence: 45,
|
||||||
|
});
|
||||||
|
const h = new r.HopDown({ tags: { ...r.defaultTags, 'DEL,S,STRIKE': strikethrough } });
|
||||||
|
|
||||||
|
it('md→html', () => expect(h.toHTML('~~struck~~')).toBe('<p><del>struck</del></p>'));
|
||||||
|
it('html→md', () => expect(h.toMarkdown('<p><del>struck</del></p>')).toContain('~~struck~~'));
|
||||||
|
it('round-trip', () => expect(h.toMarkdown(h.toHTML('~~struck~~'))).toBe('~~struck~~'));
|
||||||
|
it('mixed with bold', () => expect(h.toHTML('**bold** and ~~struck~~')).toContain('<del>struck</del>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom block tags', () => {
|
||||||
|
const spoiler = {
|
||||||
|
name: 'spoiler',
|
||||||
|
match: (context: any) => {
|
||||||
|
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
|
||||||
|
const content: string[] = [];
|
||||||
|
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: any, convert: any) => '<details>' + convert.block(token.content) + '</details>',
|
||||||
|
selector: 'DETAILS',
|
||||||
|
toMarkdown: (el: any, convert: any) => '\n\n|||\n' + convert.children(el).trim() + '\n|||\n\n',
|
||||||
|
};
|
||||||
|
const h = new r.HopDown({ tags: { 'DETAILS': spoiler, ...r.defaultTags } });
|
||||||
|
|
||||||
|
it('renders', () => expect(h.toHTML('|||\nhidden\n|||')).toContain('<details>'));
|
||||||
|
it('nested md', () => expect(h.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HopDown({ exclude })', () => {
|
||||||
|
it('excludes table', () => {
|
||||||
|
const h = new r.HopDown({ exclude: ['table'] });
|
||||||
|
expect(h.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
|
||||||
|
});
|
||||||
|
it('excludes code', () => {
|
||||||
|
const h = new r.HopDown({ exclude: ['code'] });
|
||||||
|
expect(h.toHTML('`code`')).toBe('<p>`code`</p>');
|
||||||
|
});
|
||||||
|
it('other tags still work', () => {
|
||||||
|
const h = new r.HopDown({ exclude: ['table'] });
|
||||||
|
expect(h.toHTML('**bold**')).toContain('<strong>bold</strong>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Collision detection', () => {
|
||||||
|
it('delimiter collision throws', () => {
|
||||||
|
const bad = r.inlineTag({ name: 'bad', delimiter: '*', htmlTag: 'span', precedence: 10 });
|
||||||
|
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'SPAN': bad } })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selector collision throws', () => {
|
||||||
|
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
|
||||||
|
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'STRONG': dup } })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('valid precedence does not throw', () => {
|
||||||
|
const short = r.inlineTag({ name: 'short', delimiter: '~', htmlTag: 's', precedence: 50 });
|
||||||
|
const long = r.inlineTag({ name: 'long', delimiter: '~~', htmlTag: 'del', precedence: 40 });
|
||||||
|
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'S': short, 'DEL': long } })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
254
test/editor.test.ts
Normal file
254
test/editor.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
|
const r = ribbit();
|
||||||
|
|
||||||
|
describe('RibbitEmitter', () => {
|
||||||
|
beforeEach(() => resetDOM());
|
||||||
|
|
||||||
|
it('fires save event', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
let received: any = null;
|
||||||
|
editor.on('save', (p: any) => { received = p; });
|
||||||
|
editor.save();
|
||||||
|
expect(received).toHaveProperty('markdown');
|
||||||
|
expect(received).toHaveProperty('html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('off removes handler', () => {
|
||||||
|
const editor = new r.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 r.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 r.Viewer({});
|
||||||
|
expect(viewer.getState()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('run sets view state', () => {
|
||||||
|
const viewer = new r.Viewer({});
|
||||||
|
viewer.run();
|
||||||
|
expect(viewer.getState()).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders html', () => {
|
||||||
|
const viewer = new r.Viewer({});
|
||||||
|
viewer.run();
|
||||||
|
expect(viewer.element.innerHTML).toContain('<strong>bold</strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMarkdown returns source', () => {
|
||||||
|
const viewer = new r.Viewer({});
|
||||||
|
expect(viewer.getMarkdown()).toBe('**bold**');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ribbit events', () => {
|
||||||
|
it('ready fires on run', () => {
|
||||||
|
resetDOM('hello');
|
||||||
|
let payload: any = null;
|
||||||
|
const viewer = new r.Viewer({ on: { ready: (p: any) => { payload = p; } } });
|
||||||
|
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 r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.getState()).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to wysiwyg', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
expect(editor.getState()).toBe('wysiwyg');
|
||||||
|
expect(editor.element.contentEditable).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to edit', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
editor.edit();
|
||||||
|
expect(editor.getState()).toBe('edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches back to view', () => {
|
||||||
|
const editor = new r.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 r.Editor({
|
||||||
|
on: { modeChange: ({ current }: any) => { modes.push(current); } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
editor.edit();
|
||||||
|
editor.view();
|
||||||
|
expect(modes).toEqual(['view', 'wysiwyg', 'edit', 'view']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sourceMode disabled blocks edit', () => {
|
||||||
|
resetDOM();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
currentTheme: 'no-source',
|
||||||
|
themes: [{ name: 'no-source', features: { sourceMode: false } }],
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
editor.edit();
|
||||||
|
expect(editor.getState()).toBe('wysiwyg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeManager', () => {
|
||||||
|
beforeEach(() => resetDOM());
|
||||||
|
|
||||||
|
it('lists registered themes', () => {
|
||||||
|
const editor = new r.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 r.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 r.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 r.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 r.Editor({ themes: [{ name: 'dark' }] });
|
||||||
|
editor.run();
|
||||||
|
editor.themes.disable('dark');
|
||||||
|
expect(() => editor.themes.set('dark')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('set unknown throws', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(() => editor.themes.set('nonexistent')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove active throws', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(() => editor.themes.remove(editor.themes.current().name)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires themeChange', () => {
|
||||||
|
let payload: any = null;
|
||||||
|
const editor = new r.Editor({
|
||||||
|
themes: [{ name: 'dark' }],
|
||||||
|
on: { themeChange: (p: any) => { payload = p; } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.themes.set('dark');
|
||||||
|
expect(payload.current.name).toBe('dark');
|
||||||
|
expect(payload.previous.name).toBe('ribbit-default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RibbitPlugin', () => {
|
||||||
|
it('has defaults', () => {
|
||||||
|
resetDOM();
|
||||||
|
const viewer = new r.Viewer({});
|
||||||
|
const plugin = new r.Plugin({ name: 'test', wiki: viewer });
|
||||||
|
expect(plugin.name).toBe('test');
|
||||||
|
expect(plugin.precedence).toBe(50);
|
||||||
|
expect(plugin.toMarkdown('<b>x</b>')).toBe('<b>x</b>');
|
||||||
|
expect(plugin.toHTML('**x**')).toBe('**x**');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultTheme', () => {
|
||||||
|
it('has correct shape', () => {
|
||||||
|
expect(r.defaultTheme.name).toBe('ribbit-default');
|
||||||
|
expect(r.defaultTheme.tags).toBeDefined();
|
||||||
|
expect(r.defaultTheme.features.sourceMode).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Utility functions', () => {
|
||||||
|
it('encodeHtmlEntities', () => {
|
||||||
|
expect(r.encodeHtmlEntities('<')).toBe('<');
|
||||||
|
expect(r.encodeHtmlEntities('>')).toBe('>');
|
||||||
|
expect(r.encodeHtmlEntities('&')).toBe('&');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodeHtmlEntities', () => {
|
||||||
|
expect(r.decodeHtmlEntities('<')).toBe('<');
|
||||||
|
expect(r.decodeHtmlEntities('&')).toBe('&');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('camelCase', () => {
|
||||||
|
expect(r.camelCase('hello').join('')).toBe('Hello');
|
||||||
|
expect(r.camelCase('hello world').join(' ')).toBe('Hello World');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Editor htmlToMarkdown', () => {
|
||||||
|
beforeEach(() => resetDOM());
|
||||||
|
|
||||||
|
it('converts strong', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts em', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
|
||||||
|
});
|
||||||
|
});
|
||||||
151
test/hopdown.test.ts
Normal file
151
test/hopdown.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { ribbit } from './setup';
|
||||||
|
|
||||||
|
const r = ribbit();
|
||||||
|
const hopdown = new r.HopDown();
|
||||||
|
const H = (md: string) => hopdown.toHTML(md);
|
||||||
|
const M = (html: string) => hopdown.toMarkdown(html);
|
||||||
|
const rt = (md: string) => M(H(md));
|
||||||
|
|
||||||
|
describe('Markdown → HTML', () => {
|
||||||
|
describe('inline formatting', () => {
|
||||||
|
it('bold', () => expect(H('**bold**')).toBe('<p><strong>bold</strong></p>'));
|
||||||
|
it('italic', () => expect(H('*italic*')).toBe('<p><em>italic</em></p>'));
|
||||||
|
it('inline code', () => expect(H('`code`')).toBe('<p><code>code</code></p>'));
|
||||||
|
it('link', () => expect(H('[t](http://x)')).toBe('<p><a href="http://x">t</a></p>'));
|
||||||
|
it('bold+italic', () => expect(H('***bi***')).toBe('<p><em><strong>bi</strong></em></p>'));
|
||||||
|
it('mixed', () => expect(H('a **b** *c* `d`')).toBe('<p>a <strong>b</strong> <em>c</em> <code>d</code></p>'));
|
||||||
|
it('code before bold', () => expect(H('`a` **b**')).toBe('<p><code>a</code> <strong>b</strong></p>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headings', () => {
|
||||||
|
it.each([1,2,3,4,5,6])('h%i', (n) => {
|
||||||
|
const prefix = '#'.repeat(n);
|
||||||
|
expect(H(`${prefix} Sub`)).toContain(`<h${n}`);
|
||||||
|
});
|
||||||
|
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) |'));
|
||||||
|
});
|
||||||
83
test/macros.test.ts
Normal file
83
test/macros.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { ribbit } from './setup';
|
||||||
|
|
||||||
|
const r = ribbit();
|
||||||
|
|
||||||
|
const macros = [
|
||||||
|
{
|
||||||
|
name: 'user',
|
||||||
|
toHTML: () => '<a href="/user">TestUser</a>',
|
||||||
|
selector: 'A[href="/user"]',
|
||||||
|
toMarkdown: () => '@user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'npc',
|
||||||
|
toHTML: ({ keywords }: any) => {
|
||||||
|
const name = keywords.join(' ');
|
||||||
|
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
|
||||||
|
},
|
||||||
|
selector: 'A[href^="/NPC/"]',
|
||||||
|
toMarkdown: (el: any) => '@npc(' + el.textContent + ')',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'style',
|
||||||
|
toHTML: ({ keywords, content }: any) => '<div class="' + keywords.join(' ') + '">' + (content || '') + '</div>',
|
||||||
|
selector: 'DIV[class]',
|
||||||
|
toMarkdown: (el: any, convert: any) => '\n\n@style(' + el.className + '\n' + convert.children(el) + '\n)\n\n',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'toc',
|
||||||
|
toHTML: ({ params }: any) => '<aside class="toc" data-depth="' + (params.depth || '3') + '"></aside>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const h = new r.HopDown({ macros });
|
||||||
|
const H = (md: string) => h.toHTML(md);
|
||||||
|
const M = (html: string) => h.toMarkdown(html);
|
||||||
|
|
||||||
|
describe('Macros', () => {
|
||||||
|
describe('self-closing', () => {
|
||||||
|
it('bare name', () => expect(H('hello @user world')).toBe('<p>hello <a href="/user">TestUser</a> world</p>'));
|
||||||
|
it('empty parens', () => expect(H('hello @user() world')).toBe('<p>hello <a href="/user">TestUser</a> world</p>'));
|
||||||
|
it('keywords', () => expect(H('@npc(Goblin King)')).toBe('<p><a href="/NPC/GoblinKing">Goblin King</a></p>'));
|
||||||
|
it('params', () => expect(H('@toc(depth="2")')).toContain('data-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('wraps in div', () => expect(H('@style(box\ncontent\n)')).toContain('<div class="box">'));
|
||||||
|
it('multiple keywords', () => expect(H('@style(box center\ncontent\n)')).toContain('class="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('strips keyword', () => expect(H('@style(box verbatim\ncontent\n)')).not.toContain('verbatim'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nesting', () => {
|
||||||
|
it('inline inside bold', () => expect(H('**@npc(Goblin King)**')).toContain('<strong><a href="/NPC/GoblinKing">'));
|
||||||
|
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('<a href="/user">TestUser</a>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fenced code protection', () => {
|
||||||
|
it('not in code block', () => expect(H('```\n@user\n```')).not.toContain('<a href="/user">'));
|
||||||
|
it('literal in code block', () => expect(H('```\n@user\n```')).toContain('@user'));
|
||||||
|
it('not in inline code', () => expect(H('`@user`')).not.toContain('<a href="/user">'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('round-trips', () => {
|
||||||
|
it('npc', () => expect(M(H('@npc(Goblin King)'))).toBe('@npc(Goblin King)'));
|
||||||
|
it('user', () => expect(M(H('hello @user world'))).toBe('hello @user world'));
|
||||||
|
});
|
||||||
|
});
|
||||||
30
test/setup.ts
Normal file
30
test/setup.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return getWindow().ribbit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetDOM(content = 'test'): void {
|
||||||
|
getWindow().document.body.innerHTML = `<article id="ribbit">${content}</article>`;
|
||||||
|
}
|
||||||
|
|
@ -1,507 +0,0 @@
|
||||||
const { JSDOM } = require('jsdom');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Set up a DOM environment and load the bundle
|
|
||||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
|
||||||
url: 'http://localhost',
|
|
||||||
pretendToBeVisual: true,
|
|
||||||
});
|
|
||||||
global.window = dom.window;
|
|
||||||
global.document = dom.window.document;
|
|
||||||
global.HTMLElement = dom.window.HTMLElement;
|
|
||||||
global.Node = dom.window.Node;
|
|
||||||
|
|
||||||
// Load the compiled bundle — esbuild IIFE assigns to var ribbit,
|
|
||||||
// but eval in jsdom doesn't attach vars to window, so we patch it.
|
|
||||||
const bundle = fs.readFileSync(path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8');
|
|
||||||
dom.window.eval(bundle.replace('var ribbit =', 'window.ribbit ='));
|
|
||||||
|
|
||||||
const hopdown = new dom.window.ribbit.HopDown();
|
|
||||||
const H = hopdown.toHTML.bind(hopdown);
|
|
||||||
const M = hopdown.toMarkdown.bind(hopdown);
|
|
||||||
function rt(md) { return M(H(md)); }
|
|
||||||
|
|
||||||
// Test harness
|
|
||||||
let passed = 0, failed = 0, errors = [];
|
|
||||||
|
|
||||||
function norm(s) { return (s || '').replace(/\r\n/g, '\n').trim(); }
|
|
||||||
|
|
||||||
function eq(name, actual, expected) {
|
|
||||||
const a = norm(actual), e = norm(expected);
|
|
||||||
if (a === e) {
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
errors.push(name);
|
|
||||||
console.log(` ✗ ${name}`);
|
|
||||||
console.log(` expected: ${e}`);
|
|
||||||
console.log(` actual: ${a}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function has(name, actual, sub) {
|
|
||||||
if (norm(actual).indexOf(norm(sub)) !== -1) {
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
errors.push(name);
|
|
||||||
console.log(` ✗ ${name}`);
|
|
||||||
console.log(` expected to contain: ${sub}`);
|
|
||||||
console.log(` actual: ${actual}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function not(name, actual, sub) {
|
|
||||||
if (norm(actual).indexOf(norm(sub)) === -1) {
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
errors.push(name);
|
|
||||||
console.log(` ✗ ${name}`);
|
|
||||||
console.log(` should NOT contain: ${sub}`);
|
|
||||||
console.log(` actual: ${actual}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function section(n) { /* silent */ }
|
|
||||||
|
|
||||||
// ── 1. Inline formatting ────────────────────────────────
|
|
||||||
section('1. Inline Formatting → HTML');
|
|
||||||
eq('bold', H('**bold**'), '<p><strong>bold</strong></p>');
|
|
||||||
eq('italic', H('*italic*'), '<p><em>italic</em></p>');
|
|
||||||
eq('inline code', H('`code`'), '<p><code>code</code></p>');
|
|
||||||
eq('link', H('[t](http://x)'), '<p><a href="http://x">t</a></p>');
|
|
||||||
eq('bold+italic', H('***bi***'), '<p><em><strong>bi</strong></em></p>');
|
|
||||||
eq('mixed inline', H('a **b** *c* `d`'), '<p>a <strong>b</strong> <em>c</em> <code>d</code></p>');
|
|
||||||
eq('code before bold', H('`a` **b**'), '<p><code>a</code> <strong>b</strong></p>');
|
|
||||||
|
|
||||||
// ── 2. Headings ─────────────────────────────────────────
|
|
||||||
eq('h1', H('# Title'), "<h1 id='Title'>Title</h1>");
|
|
||||||
eq('h2', H('## Sub'), "<h2 id='Sub'>Sub</h2>");
|
|
||||||
eq('h3', H('### Sub3'), "<h3 id='Sub3'>Sub3</h3>");
|
|
||||||
eq('h4', H('#### Sub4'), "<h4 id='Sub4'>Sub4</h4>");
|
|
||||||
eq('h5', H('##### Sub5'), "<h5 id='Sub5'>Sub5</h5>");
|
|
||||||
eq('h6', H('###### Sub6'), "<h6 id='Sub6'>Sub6</h6>");
|
|
||||||
has('heading id multi-word', H('## Hello World'), "id='HelloWorld'");
|
|
||||||
has('heading inline md', H('## **Bold** text'), '<strong>Bold</strong>');
|
|
||||||
|
|
||||||
// ── 3. Horizontal rules ─────────────────────────────────
|
|
||||||
eq('*** rule', H('***'), '<hr>');
|
|
||||||
eq('--- rule', H('---'), '<hr>');
|
|
||||||
eq('___ rule', H('___'), '<hr>');
|
|
||||||
|
|
||||||
// ── 4. Lists ────────────────────────────────────────────
|
|
||||||
eq('ul *', H('* a\n* b'), '<ul><li>a</li><li>b</li></ul>');
|
|
||||||
eq('ul -', H('- a\n- b'), '<ul><li>a</li><li>b</li></ul>');
|
|
||||||
eq('ol', H('1. a\n2. b'),'<ol><li>a</li><li>b</li></ol>');
|
|
||||||
has('ul inline', H('* **bold** item'), '<strong>bold</strong>');
|
|
||||||
has('ol inline', H('1. *em* item'), '<em>em</em>');
|
|
||||||
|
|
||||||
// ── 5. Blockquotes ──────────────────────────────────────
|
|
||||||
has('blockquote', H('> text'), '<blockquote>');
|
|
||||||
has('bq content', H('> hello'), 'hello');
|
|
||||||
has('multi-line bq', H('> a\n> b'), 'a');
|
|
||||||
|
|
||||||
// ── 6. Fenced code blocks ───────────────────────────────
|
|
||||||
has('code block', H('```\nx = 1\n```'), '<pre><code>');
|
|
||||||
has('code content', H('```\nx = 1\n```'), 'x = 1');
|
|
||||||
has('lang class', H('```js\nvar x;\n```'), 'language-js');
|
|
||||||
has('html escaped', H('```\n<div>\n```'), '<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');
|
|
||||||
|
|
||||||
// ── 24. Macros ──────────────────────────────────────────
|
|
||||||
const macroConverter = new dom.window.ribbit.HopDown({
|
|
||||||
macros: [
|
|
||||||
{
|
|
||||||
name: 'user',
|
|
||||||
toHTML: () => '<a href="/user">TestUser</a>',
|
|
||||||
selector: 'A[href="/user"]',
|
|
||||||
toMarkdown: () => '@user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'npc',
|
|
||||||
toHTML: ({ keywords }) => {
|
|
||||||
const name = keywords.join(' ');
|
|
||||||
const target = name.replace(/ /g, '');
|
|
||||||
return '<a href="/NPC/' + target + '">' + name + '</a>';
|
|
||||||
},
|
|
||||||
selector: 'A[href^="/NPC/"]',
|
|
||||||
toMarkdown: (el) => '@npc(' + el.textContent + ')',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'toc',
|
|
||||||
toHTML: ({ params }) =>
|
|
||||||
'<aside class="toc" data-depth="' + (params.depth || '3') + '"></aside>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'style',
|
|
||||||
toHTML: ({ keywords, content }) => {
|
|
||||||
const classes = keywords.join(' ');
|
|
||||||
return '<div class="' + classes + '">' + (content || '') + '</div>';
|
|
||||||
},
|
|
||||||
selector: 'DIV[class]',
|
|
||||||
toMarkdown: (el, convert) => {
|
|
||||||
return '\n\n@style(' + el.className + '\n' + convert.children(el) + '\n)\n\n';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const MH = macroConverter.toHTML.bind(macroConverter);
|
|
||||||
const MM = macroConverter.toMarkdown.bind(macroConverter);
|
|
||||||
function mrt(md) { return MM(MH(md)); }
|
|
||||||
|
|
||||||
// Self-closing macros
|
|
||||||
eq('macro: bare name', MH('hello @user world'), '<p>hello <a href="/user">TestUser</a> world</p>');
|
|
||||||
eq('macro: empty parens', MH('hello @user() world'), '<p>hello <a href="/user">TestUser</a> world</p>');
|
|
||||||
eq('macro: with keywords', MH('@npc(Goblin King)'), '<p><a href="/NPC/GoblinKing">Goblin King</a></p>');
|
|
||||||
has('macro: with params', MH('@toc(depth="2")'), 'data-depth="2"');
|
|
||||||
|
|
||||||
// Unknown macro — error
|
|
||||||
has('macro: unknown renders error', MH('@bogus'), 'ribbit-error');
|
|
||||||
has('macro: unknown shows name', MH('@bogus'), '@bogus');
|
|
||||||
|
|
||||||
// Email addresses not matched
|
|
||||||
eq('macro: email not matched', MH('user@example.com'), '<p>user@example.com</p>');
|
|
||||||
|
|
||||||
// Block macros
|
|
||||||
has('macro: block content processed', MH('@style(box\n**bold** inside\n)'), '<strong>bold</strong>');
|
|
||||||
has('macro: block wraps in div', MH('@style(box\ncontent\n)'), '<div class="box">');
|
|
||||||
has('macro: block multiple keywords', MH('@style(box center\ncontent\n)'), 'class="box center"');
|
|
||||||
|
|
||||||
// Verbatim
|
|
||||||
has('macro: verbatim skips markdown', MH('@style(box verbatim\n**bold**\n)'), '**bold**');
|
|
||||||
not('macro: verbatim no strong', MH('@style(box verbatim\n**bold**\n)'), '<strong>');
|
|
||||||
has('macro: verbatim escapes html', MH('@style(box verbatim\n<b>tag</b>\n)'), '<b>');
|
|
||||||
has('macro: verbatim preserves newlines', MH('@style(box verbatim\nline1\nline2\n)'), 'line1<br>');
|
|
||||||
not('macro: verbatim keyword stripped', MH('@style(box verbatim\ncontent\n)'), 'verbatim');
|
|
||||||
|
|
||||||
// Nesting
|
|
||||||
has('macro: inline inside bold', MH('**@npc(Goblin King)**'), '<strong><a href="/NPC/GoblinKing">');
|
|
||||||
has('macro: block contains list', MH('@style(box\n- item 1\n- item 2\n)'), '<ul>');
|
|
||||||
has('macro: block contains heading', MH('@style(box\n## Title\n)'), '<h2');
|
|
||||||
has('macro: inline inside block', MH('@style(box\nhello @user world\n)'), '<a href="/user">TestUser</a>');
|
|
||||||
|
|
||||||
// Inside other elements
|
|
||||||
has('macro: in list item', MH('- @npc(Goblin King)'), '<a href="/NPC/GoblinKing">');
|
|
||||||
has('macro: in heading', MH('## @npc(Goblin King)'), '<a href="/NPC/GoblinKing">');
|
|
||||||
|
|
||||||
// Fenced code protection
|
|
||||||
not('macro: not in code block', MH('```\n@user\n```'), '<a href="/user">');
|
|
||||||
has('macro: literal in code block', MH('```\n@user\n```'), '@user');
|
|
||||||
not('macro: not in inline code', MH('`@user`'), '<a href="/user">');
|
|
||||||
|
|
||||||
// Edge cases
|
|
||||||
has('macro: multiple inline', MH('@npc(Alice) and @npc(Bob)'), 'Alice');
|
|
||||||
has('macro: multiple inline second', MH('@npc(Alice) and @npc(Bob)'), 'Bob');
|
|
||||||
has('macro: unknown block renders error', MH('@bogus(args\ncontent\n)'), 'ribbit-error');
|
|
||||||
|
|
||||||
// Round-trips
|
|
||||||
eq('macro: npc round-trip', mrt('@npc(Goblin King)'), '@npc(Goblin King)');
|
|
||||||
eq('macro: user round-trip', mrt('hello @user world'), 'hello @user world');
|
|
||||||
|
|
||||||
// ── Results ─────────────────────────────────────────────
|
|
||||||
const total = passed + failed;
|
|
||||||
console.log(`\n${passed}/${total} passed (${Math.round(100 * passed / total)}%) — ${failed} failed`);
|
|
||||||
if (errors.length) {
|
|
||||||
console.log('\nFailed:');
|
|
||||||
errors.forEach(e => console.log(` • ${e}`));
|
|
||||||
}
|
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
|
||||||
|
|
@ -10,5 +10,6 @@
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"lib": ["ES2019", "DOM"]
|
"lib": ["ES2019", "DOM"]
|
||||||
},
|
},
|
||||||
"include": ["src/ts/**/*.ts"]
|
"include": ["src/ts/**/*.ts"],
|
||||||
|
"exclude": ["test/**/*"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user