Reimplement as a tokenizer with GFM parity
This commit is contained in:
+277
-59
@@ -1,6 +1,6 @@
|
||||
import { ribbit, resetDOM } from './setup';
|
||||
|
||||
const r = ribbit();
|
||||
const lib = ribbit();
|
||||
|
||||
function mockTransport() {
|
||||
const receiveListeners: Array<(update: Uint8Array) => void> = [];
|
||||
@@ -9,19 +9,39 @@ function mockTransport() {
|
||||
connected: false,
|
||||
sent: [] as Uint8Array[],
|
||||
locked: false,
|
||||
connect() { this.connected = true; },
|
||||
disconnect() { this.connected = false; },
|
||||
send(update: Uint8Array) { this.sent.push(update); },
|
||||
onReceive(cb: (update: Uint8Array) => void) { receiveListeners.push(cb); },
|
||||
connect() {
|
||||
this.connected = true;
|
||||
},
|
||||
disconnect() {
|
||||
this.connected = false;
|
||||
},
|
||||
send(update: Uint8Array) {
|
||||
this.sent.push(update);
|
||||
},
|
||||
onReceive(cb: (update: Uint8Array) => void) {
|
||||
receiveListeners.push(cb);
|
||||
},
|
||||
simulateRemote(content: string) {
|
||||
const encoded = new TextEncoder().encode(content);
|
||||
receiveListeners.forEach(cb => cb(encoded));
|
||||
},
|
||||
lock: async function() { this.locked = true; return true; },
|
||||
unlock() { this.locked = false; },
|
||||
forceLock: async function() { this.locked = true; return true; },
|
||||
onLockChange(cb: (holder: any) => void) { lockListeners.push(cb); },
|
||||
simulateLock(holder: any) { lockListeners.forEach(cb => cb(holder)); },
|
||||
lock: async function() {
|
||||
this.locked = true;
|
||||
return true;
|
||||
},
|
||||
unlock() {
|
||||
this.locked = false;
|
||||
},
|
||||
forceLock: async function() {
|
||||
this.locked = true;
|
||||
return true;
|
||||
},
|
||||
onLockChange(cb: (holder: any) => void) {
|
||||
lockListeners.push(cb);
|
||||
},
|
||||
simulateLock(holder: any) {
|
||||
lockListeners.forEach(cb => cb(holder));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,9 +49,15 @@ function mockPresence() {
|
||||
const listeners: Array<(peers: any[]) => void> = [];
|
||||
return {
|
||||
lastSent: null as any,
|
||||
send(info: any) { this.lastSent = info; },
|
||||
onUpdate(cb: (peers: any[]) => void) { listeners.push(cb); },
|
||||
simulatePeers(peers: any[]) { listeners.forEach(cb => cb(peers)); },
|
||||
send(info: any) {
|
||||
this.lastSent = info;
|
||||
},
|
||||
onUpdate(cb: (peers: any[]) => void) {
|
||||
listeners.push(cb);
|
||||
},
|
||||
simulatePeers(peers: any[]) {
|
||||
listeners.forEach(cb => cb(peers));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,9 +66,14 @@ function mockRevisions() {
|
||||
return {
|
||||
store,
|
||||
list: async () => store,
|
||||
get: async (id: string) => store.find((r: any) => r.id === id),
|
||||
get: async (id: string) => store.find((rev: any) => rev.id === id),
|
||||
create: async (content: string, meta?: any) => {
|
||||
const rev = { id: String(store.length + 1), timestamp: new Date().toISOString(), content, ...meta };
|
||||
const rev = {
|
||||
id: String(store.length + 1),
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
...meta,
|
||||
};
|
||||
store.push(rev);
|
||||
return rev;
|
||||
},
|
||||
@@ -53,15 +84,23 @@ describe('CollaborationManager', () => {
|
||||
beforeEach(() => resetDOM('initial'));
|
||||
|
||||
it('does not create manager without settings', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.collaboration).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates manager with settings', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
expect(editor.collaboration).toBeDefined();
|
||||
@@ -70,8 +109,16 @@ describe('CollaborationManager', () => {
|
||||
describe('connection lifecycle', () => {
|
||||
it('connects on wysiwyg', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
@@ -80,8 +127,16 @@ describe('CollaborationManager', () => {
|
||||
|
||||
it('connects on edit', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
@@ -90,8 +145,16 @@ describe('CollaborationManager', () => {
|
||||
|
||||
it('disconnects on view', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
@@ -103,8 +166,16 @@ describe('CollaborationManager', () => {
|
||||
describe('source mode pausing', () => {
|
||||
it('pauses on entering source mode', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
@@ -113,8 +184,16 @@ describe('CollaborationManager', () => {
|
||||
|
||||
it('counts remote changes while paused', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
@@ -125,9 +204,23 @@ describe('CollaborationManager', () => {
|
||||
|
||||
it('fires remoteActivity event while paused', (done) => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
on: { remoteActivity: ({ count }: any) => { if (count === 1) done(); } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
on: {
|
||||
remoteActivity: ({ count }: any) => {
|
||||
if (count === 1) {
|
||||
done();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
@@ -136,8 +229,16 @@ describe('CollaborationManager', () => {
|
||||
|
||||
it('resumes on switching to wysiwyg', () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
@@ -149,8 +250,16 @@ describe('CollaborationManager', () => {
|
||||
describe('locking', () => {
|
||||
it('lock returns true', async () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
expect(await editor.lockForEditing()).toBe(true);
|
||||
@@ -158,8 +267,16 @@ describe('CollaborationManager', () => {
|
||||
|
||||
it('forceLock returns true', async () => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
expect(await editor.forceLockEditing()).toBe(true);
|
||||
@@ -167,12 +284,31 @@ describe('CollaborationManager', () => {
|
||||
|
||||
it('fires lockChange event', (done) => {
|
||||
const transport = mockTransport();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
on: { lockChange: ({ holder }: any) => { if (holder?.userId === 'alice') done(); } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
on: {
|
||||
lockChange: ({ holder }: any) => {
|
||||
if (holder?.userId === 'alice') {
|
||||
done();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
transport.simulateLock({ userId: 'alice', displayName: 'Alice', status: 'active', lastActive: Date.now() });
|
||||
transport.simulateLock({
|
||||
userId: 'alice',
|
||||
displayName: 'Alice',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,8 +316,18 @@ describe('CollaborationManager', () => {
|
||||
it('sends cursor with status', () => {
|
||||
const transport = mockTransport();
|
||||
const presence = mockPresence();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now(), color: '#f00' } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
presence,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
color: '#f00',
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
@@ -193,8 +339,17 @@ describe('CollaborationManager', () => {
|
||||
it('sends editing status when paused', () => {
|
||||
const transport = mockTransport();
|
||||
const presence = mockPresence();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
presence,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
@@ -205,13 +360,33 @@ describe('CollaborationManager', () => {
|
||||
it('applies idle status to peers', () => {
|
||||
const transport = mockTransport();
|
||||
const presence = mockPresence();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, presence, idleTimeout: 100, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
presence,
|
||||
idleTimeout: 100,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
presence.simulatePeers([
|
||||
{ userId: 'a', displayName: 'A', status: 'active', lastActive: Date.now() - 200 },
|
||||
{ userId: 'b', displayName: 'B', status: 'active', lastActive: Date.now() },
|
||||
{
|
||||
userId: 'a',
|
||||
displayName: 'A',
|
||||
status: 'active',
|
||||
lastActive: Date.now() - 200,
|
||||
},
|
||||
{
|
||||
userId: 'b',
|
||||
displayName: 'B',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
]);
|
||||
const peers = editor.collaboration!.getPeers();
|
||||
expect(peers[0].status).toBe('idle');
|
||||
@@ -224,8 +399,17 @@ describe('CollaborationManager', () => {
|
||||
const transport = mockTransport();
|
||||
const revisions = mockRevisions();
|
||||
await revisions.create('v1', { author: 'test' });
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
revisions,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
const list = await editor.listRevisions();
|
||||
@@ -235,11 +419,23 @@ describe('CollaborationManager', () => {
|
||||
it('creates revision', async () => {
|
||||
const transport = mockTransport();
|
||||
const revisions = mockRevisions();
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
revisions,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
const rev = await editor.createRevision({ author: 'test', summary: 'test rev' });
|
||||
const rev = await editor.createRevision({
|
||||
author: 'test',
|
||||
summary: 'test rev',
|
||||
});
|
||||
expect(rev).toBeDefined();
|
||||
expect(revisions.store).toHaveLength(1);
|
||||
});
|
||||
@@ -248,8 +444,17 @@ describe('CollaborationManager', () => {
|
||||
const transport = mockTransport();
|
||||
const revisions = mockRevisions();
|
||||
await revisions.create('old content', { author: 'test' });
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
revisions,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
@@ -261,9 +466,22 @@ describe('CollaborationManager', () => {
|
||||
const transport = mockTransport();
|
||||
const revisions = mockRevisions();
|
||||
let fired = false;
|
||||
const editor = new r.Editor({
|
||||
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||
on: { revisionCreated: () => { fired = true; } },
|
||||
const editor = new lib.Editor({
|
||||
collaboration: {
|
||||
transport,
|
||||
revisions,
|
||||
user: {
|
||||
userId: 'test',
|
||||
displayName: 'Test',
|
||||
status: 'active',
|
||||
lastActive: Date.now(),
|
||||
},
|
||||
},
|
||||
on: {
|
||||
revisionCreated: () => {
|
||||
fired = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
await editor.createRevision({ author: 'test' });
|
||||
|
||||
+75
-34
@@ -1,68 +1,109 @@
|
||||
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>'));
|
||||
});
|
||||
const lib = ribbit();
|
||||
|
||||
describe('Custom block tags', () => {
|
||||
const spoiler = {
|
||||
name: 'spoiler',
|
||||
match: (context: any) => {
|
||||
if (!/^\|{3,}/.test(context.lines[context.index])) return null;
|
||||
const fencePattern = /^\|{3,}/;
|
||||
if (!fencePattern.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 };
|
||||
let lineIndex = context.index + 1;
|
||||
while (lineIndex < context.lines.length && !fencePattern.test(context.lines[lineIndex])) {
|
||||
content.push(context.lines[lineIndex++]);
|
||||
}
|
||||
return {
|
||||
content: content.join('\n'),
|
||||
raw: '',
|
||||
consumed: lineIndex + 1 - context.index,
|
||||
};
|
||||
},
|
||||
toHTML: (token: any, convert: any) => '<details>' + convert.block(token.content) + '</details>',
|
||||
selector: 'DETAILS',
|
||||
toMarkdown: (el: any, convert: any) => '\n\n|||\n' + convert.children(el).trim() + '\n|||\n\n',
|
||||
toMarkdown: (element: any, convert: any) => '\n\n|||\n' + convert.children(element).trim() + '\n|||\n\n',
|
||||
};
|
||||
const h = new r.HopDown({ tags: { 'DETAILS': spoiler, ...r.defaultTags } });
|
||||
const converter = new lib.HopDown({
|
||||
tags: {
|
||||
'DETAILS': spoiler,
|
||||
...lib.defaultTags,
|
||||
},
|
||||
});
|
||||
|
||||
it('renders', () => expect(h.toHTML('|||\nhidden\n|||')).toContain('<details>'));
|
||||
it('nested md', () => expect(h.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
|
||||
it('renders', () => expect(converter.toHTML('|||\nhidden\n|||')).toContain('<details>'));
|
||||
it('nested md', () => expect(converter.toHTML('|||\n**bold**\n|||')).toContain('<strong>bold</strong>'));
|
||||
});
|
||||
|
||||
describe('HopDown({ exclude })', () => {
|
||||
it('excludes table', () => {
|
||||
const h = new r.HopDown({ exclude: ['table'] });
|
||||
expect(h.toHTML('| a |\n|---|\n| 1 |')).not.toContain('<table>');
|
||||
const converter = new lib.HopDown({ exclude: ['table'] });
|
||||
expect(converter.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>');
|
||||
const converter = new lib.HopDown({ exclude: ['code'] });
|
||||
expect(converter.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>');
|
||||
const converter = new lib.HopDown({ exclude: ['table'] });
|
||||
expect(converter.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();
|
||||
const bad = lib.inlineTag({
|
||||
name: 'bad',
|
||||
delimiter: '*',
|
||||
htmlTag: 'span',
|
||||
precedence: 10,
|
||||
});
|
||||
expect(() => new lib.HopDown({
|
||||
tags: {
|
||||
...lib.defaultTags,
|
||||
'SPAN': bad,
|
||||
},
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('selector collision throws', () => {
|
||||
const dup = { name: 'dup', match: () => null, toHTML: () => '', selector: 'STRONG', toMarkdown: () => '' };
|
||||
expect(() => new r.HopDown({ tags: { ...r.defaultTags, 'STRONG': dup } })).toThrow();
|
||||
const dup = {
|
||||
name: 'dup',
|
||||
match: () => null,
|
||||
toHTML: () => '',
|
||||
selector: 'STRONG',
|
||||
toMarkdown: () => '',
|
||||
};
|
||||
expect(() => new lib.HopDown({
|
||||
tags: {
|
||||
...lib.defaultTags,
|
||||
'STRONG': dup,
|
||||
},
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('valid precedence does not throw', () => {
|
||||
const short = 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();
|
||||
const short = lib.inlineTag({
|
||||
name: 'short',
|
||||
delimiter: '~',
|
||||
htmlTag: 's',
|
||||
precedence: 50,
|
||||
});
|
||||
const long = lib.inlineTag({
|
||||
name: 'long',
|
||||
delimiter: '~~',
|
||||
htmlTag: 'del',
|
||||
precedence: 40,
|
||||
});
|
||||
// Remove default strikethrough to avoid collision with the custom S/DEL tags
|
||||
const { 'DEL,S,STRIKE': _, ...tagsWithoutStrikethrough } = lib.defaultTags;
|
||||
expect(() => new lib.HopDown({
|
||||
tags: {
|
||||
...tagsWithoutStrikethrough,
|
||||
'S': short,
|
||||
'DEL': long,
|
||||
},
|
||||
})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+67
-42
@@ -1,25 +1,29 @@
|
||||
import { ribbit, resetDOM } from './setup';
|
||||
|
||||
const r = ribbit();
|
||||
const lib = ribbit();
|
||||
|
||||
describe('RibbitEmitter', () => {
|
||||
beforeEach(() => resetDOM());
|
||||
|
||||
it('fires save event', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
let received: any = null;
|
||||
editor.on('save', (p: any) => { received = p; });
|
||||
editor.on('save', (payload: any) => {
|
||||
received = payload;
|
||||
});
|
||||
editor.save();
|
||||
expect(received).toHaveProperty('markdown');
|
||||
expect(received).toHaveProperty('html');
|
||||
});
|
||||
|
||||
it('off removes handler', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
let count = 0;
|
||||
const handler = () => { count++; };
|
||||
const handler = () => {
|
||||
count++;
|
||||
};
|
||||
editor.on('save', handler);
|
||||
editor.save();
|
||||
editor.off('save', handler);
|
||||
@@ -28,11 +32,15 @@ describe('RibbitEmitter', () => {
|
||||
});
|
||||
|
||||
it('multiple listeners', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
let count = 0;
|
||||
editor.on('save', () => { count++; });
|
||||
editor.on('save', () => { count++; });
|
||||
editor.on('save', () => {
|
||||
count++;
|
||||
});
|
||||
editor.on('save', () => {
|
||||
count++;
|
||||
});
|
||||
editor.save();
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
@@ -42,24 +50,24 @@ describe('Ribbit viewer', () => {
|
||||
beforeEach(() => resetDOM('**bold**'));
|
||||
|
||||
it('starts with null state', () => {
|
||||
const viewer = new r.Viewer({});
|
||||
const viewer = new lib.Viewer({});
|
||||
expect(viewer.getState()).toBeNull();
|
||||
});
|
||||
|
||||
it('run sets view state', () => {
|
||||
const viewer = new r.Viewer({});
|
||||
const viewer = new lib.Viewer({});
|
||||
viewer.run();
|
||||
expect(viewer.getState()).toBe('view');
|
||||
});
|
||||
|
||||
it('renders html', () => {
|
||||
const viewer = new r.Viewer({});
|
||||
const viewer = new lib.Viewer({});
|
||||
viewer.run();
|
||||
expect(viewer.element.innerHTML).toContain('<strong>bold</strong>');
|
||||
});
|
||||
|
||||
it('getMarkdown returns source', () => {
|
||||
const viewer = new r.Viewer({});
|
||||
const viewer = new lib.Viewer({});
|
||||
expect(viewer.getMarkdown()).toBe('**bold**');
|
||||
});
|
||||
});
|
||||
@@ -68,7 +76,13 @@ 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; } } });
|
||||
const viewer = new lib.Viewer({
|
||||
on: {
|
||||
ready: (eventPayload: any) => {
|
||||
payload = eventPayload;
|
||||
},
|
||||
},
|
||||
});
|
||||
viewer.run();
|
||||
expect(payload).toHaveProperty('markdown');
|
||||
expect(payload).toHaveProperty('mode', 'view');
|
||||
@@ -80,13 +94,13 @@ describe('RibbitEditor modes', () => {
|
||||
beforeEach(() => resetDOM('**bold**'));
|
||||
|
||||
it('starts in view', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.getState()).toBe('view');
|
||||
});
|
||||
|
||||
it('switches to wysiwyg', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
expect(editor.getState()).toBe('wysiwyg');
|
||||
@@ -94,7 +108,7 @@ describe('RibbitEditor modes', () => {
|
||||
});
|
||||
|
||||
it('switches to edit', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
editor.edit();
|
||||
@@ -102,7 +116,7 @@ describe('RibbitEditor modes', () => {
|
||||
});
|
||||
|
||||
it('switches back to view', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
editor.view();
|
||||
@@ -112,8 +126,12 @@ describe('RibbitEditor modes', () => {
|
||||
|
||||
it('fires modeChange events', () => {
|
||||
const modes: string[] = [];
|
||||
const editor = new r.Editor({
|
||||
on: { modeChange: ({ current }: any) => { modes.push(current); } },
|
||||
const editor = new lib.Editor({
|
||||
on: {
|
||||
modeChange: ({ current }: any) => {
|
||||
modes.push(current);
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
@@ -124,9 +142,12 @@ describe('RibbitEditor modes', () => {
|
||||
|
||||
it('sourceMode disabled blocks edit', () => {
|
||||
resetDOM();
|
||||
const editor = new r.Editor({
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'no-source',
|
||||
themes: [{ name: 'no-source', features: { sourceMode: false } }],
|
||||
themes: [{
|
||||
name: 'no-source',
|
||||
features: { sourceMode: false },
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
@@ -139,28 +160,28 @@ describe('ThemeManager', () => {
|
||||
beforeEach(() => resetDOM());
|
||||
|
||||
it('lists registered themes', () => {
|
||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
||||
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||
editor.run();
|
||||
expect(editor.themes.list()).toContain('ribbit-default');
|
||||
expect(editor.themes.list()).toContain('dark');
|
||||
});
|
||||
|
||||
it('set switches theme', () => {
|
||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
||||
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||
editor.run();
|
||||
editor.themes.set('dark');
|
||||
expect(editor.themes.current().name).toBe('dark');
|
||||
});
|
||||
|
||||
it('disable hides from list', () => {
|
||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
||||
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||
editor.run();
|
||||
editor.themes.disable('dark');
|
||||
expect(editor.themes.list()).not.toContain('dark');
|
||||
});
|
||||
|
||||
it('enable restores to list', () => {
|
||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
||||
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||
editor.run();
|
||||
editor.themes.disable('dark');
|
||||
editor.themes.enable('dark');
|
||||
@@ -168,29 +189,33 @@ describe('ThemeManager', () => {
|
||||
});
|
||||
|
||||
it('set disabled throws', () => {
|
||||
const editor = new r.Editor({ themes: [{ name: 'dark' }] });
|
||||
const editor = new lib.Editor({ themes: [{ name: 'dark' }] });
|
||||
editor.run();
|
||||
editor.themes.disable('dark');
|
||||
expect(() => editor.themes.set('dark')).toThrow();
|
||||
});
|
||||
|
||||
it('set unknown throws', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(() => editor.themes.set('nonexistent')).toThrow();
|
||||
});
|
||||
|
||||
it('remove active throws', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(() => editor.themes.remove(editor.themes.current().name)).toThrow();
|
||||
});
|
||||
|
||||
it('fires themeChange', () => {
|
||||
let payload: any = null;
|
||||
const editor = new r.Editor({
|
||||
const editor = new lib.Editor({
|
||||
themes: [{ name: 'dark' }],
|
||||
on: { themeChange: (p: any) => { payload = p; } },
|
||||
on: {
|
||||
themeChange: (eventPayload: any) => {
|
||||
payload = eventPayload;
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.themes.set('dark');
|
||||
@@ -202,27 +227,27 @@ describe('ThemeManager', () => {
|
||||
|
||||
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);
|
||||
expect(lib.defaultTheme.name).toBe('ribbit-default');
|
||||
expect(lib.defaultTheme.tags).toBeDefined();
|
||||
expect(lib.defaultTheme.features.sourceMode).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility functions', () => {
|
||||
it('encodeHtmlEntities', () => {
|
||||
expect(r.encodeHtmlEntities('<')).toBe('<');
|
||||
expect(r.encodeHtmlEntities('>')).toBe('>');
|
||||
expect(r.encodeHtmlEntities('&')).toBe('&');
|
||||
expect(lib.encodeHtmlEntities('<')).toBe('<');
|
||||
expect(lib.encodeHtmlEntities('>')).toBe('>');
|
||||
expect(lib.encodeHtmlEntities('&')).toBe('&');
|
||||
});
|
||||
|
||||
it('decodeHtmlEntities', () => {
|
||||
expect(r.decodeHtmlEntities('<')).toBe('<');
|
||||
expect(r.decodeHtmlEntities('&')).toBe('&');
|
||||
expect(lib.decodeHtmlEntities('<')).toBe('<');
|
||||
expect(lib.decodeHtmlEntities('&')).toBe('&');
|
||||
});
|
||||
|
||||
it('camelCase', () => {
|
||||
expect(r.camelCase('hello').join('')).toBe('Hello');
|
||||
expect(r.camelCase('hello world').join(' ')).toBe('Hello World');
|
||||
expect(lib.camelCase('hello').join('')).toBe('Hello');
|
||||
expect(lib.camelCase('hello world').join(' ')).toBe('Hello World');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,13 +255,13 @@ describe('Editor htmlToMarkdown', () => {
|
||||
beforeEach(() => resetDOM());
|
||||
|
||||
it('converts strong', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
|
||||
});
|
||||
|
||||
it('converts em', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
|
||||
});
|
||||
|
||||
+390
-5
@@ -1,7 +1,7 @@
|
||||
import { ribbit } from './setup';
|
||||
|
||||
const r = ribbit();
|
||||
const hopdown = new r.HopDown();
|
||||
const lib = ribbit();
|
||||
const hopdown = new lib.HopDown();
|
||||
const H = (md: string) => hopdown.toHTML(md);
|
||||
const M = (html: string) => hopdown.toMarkdown(html);
|
||||
const rt = (md: string) => M(H(md));
|
||||
@@ -18,9 +18,9 @@ describe('Markdown → HTML', () => {
|
||||
});
|
||||
|
||||
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.each([1, 2, 3, 4, 5, 6])('h%i', (level) => {
|
||||
const prefix = '#'.repeat(level);
|
||||
expect(H(`${prefix} Sub`)).toContain(`<h${level}`);
|
||||
});
|
||||
it('heading id', () => expect(H('## Hello World')).toContain("id='HelloWorld'"));
|
||||
it('heading inline md', () => expect(H('## **Bold** text')).toContain('<strong>Bold</strong>'));
|
||||
@@ -149,3 +149,388 @@ describe('Tables with nested markdown', () => {
|
||||
it('td bold rt', () => expect(rt('| h |\n|---|\n| **b** |')).toBe('| h |\n| --- |\n| **b** |'));
|
||||
it('multi-cell rt', () => expect(rt('| **a** | *b* |\n|---|---|\n| `c` | [d](e) |')).toBe('| **a** | *b* |\n| --- | --- |\n| `c` | [d](e) |'));
|
||||
});
|
||||
|
||||
describe('Backslash escapes', () => {
|
||||
it('escaped asterisk', () => expect(H('\\*not italic\\*')).toBe('<p>*not italic*</p>'));
|
||||
it('escaped backslash', () => expect(H('a \\\\ b')).toBe('<p>a \\ b</p>'));
|
||||
it('escaped backtick', () => expect(H('\\`not code\\`')).toBe('<p>`not code`</p>'));
|
||||
it('round-trip preserves escape', () => {
|
||||
const html = H('\\*literal\\*');
|
||||
expect(html).toContain('*literal*');
|
||||
expect(html).not.toContain('<em>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Strikethrough', () => {
|
||||
it('md→html', () => expect(H('~~deleted~~')).toBe('<p><del>deleted</del></p>'));
|
||||
it('html→md', () => expect(M('<p><del>gone</del></p>')).toBe('~~gone~~'));
|
||||
it('round-trip', () => expect(rt('~~struck~~')).toBe('~~struck~~'));
|
||||
it('mixed with bold', () => expect(H('**bold** and ~~struck~~')).toContain('<del>struck</del>'));
|
||||
});
|
||||
|
||||
describe('Link titles', () => {
|
||||
it('link with title', () => expect(H('[t](http://x "My Title")')).toBe('<p><a href="http://x" title="My Title">t</a></p>'));
|
||||
it('title round-trip', () => expect(rt('[t](http://x "My Title")')).toBe('[t](http://x "My Title")'));
|
||||
});
|
||||
|
||||
describe('Reference links', () => {
|
||||
it('basic reference', () => expect(H('[text][ref]\n\n[ref]: http://x')).toContain('<a href="http://x">text</a>'));
|
||||
it('shortcut reference', () => expect(H('[ref][]\n\n[ref]: http://x')).toContain('<a href="http://x">ref</a>'));
|
||||
it('reference with title', () => expect(H('[t][r]\n\n[r]: http://x "T"')).toContain('title="T"'));
|
||||
it('case insensitive', () => expect(H('[t][REF]\n\n[ref]: http://x')).toContain('<a href="http://x">'));
|
||||
it('undefined reference passes through', () => expect(H('[t][missing]')).toContain('[t][missing]'));
|
||||
it('definition not rendered', () => expect(H('[ref]: http://x\n\ntext')).toBe('<p>text</p>'));
|
||||
});
|
||||
|
||||
describe('HTML passthrough', () => {
|
||||
it('inline html preserved', () => expect(H('a <span class="x">b</span> c')).toContain('<span class="x">b</span>'));
|
||||
it('self-closing tag', () => expect(H('a <br/> b')).toContain('<br/>'));
|
||||
it('html not double-escaped', () => expect(H('<em>hi</em>')).not.toContain('<'));
|
||||
});
|
||||
|
||||
describe('Autolinks', () => {
|
||||
it('angle bracket autolink', () => expect(H('<https://example.com>')).toContain('<a href="https://example.com">'));
|
||||
it('bare URL', () => expect(H('visit https://example.com today')).toContain('<a href="https://example.com">'));
|
||||
it('URL not matched inside link', () => {
|
||||
const html = H('[text](https://example.com)');
|
||||
// Should have exactly one <a> tag, not nested
|
||||
const anchorPattern = /<a /g;
|
||||
const count = (html.match(anchorPattern) || []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alternate syntax (parse-only, canonical output)', () => {
|
||||
describe('underscore emphasis', () => {
|
||||
it('_italic_ → *italic*', () => {
|
||||
expect(H('_italic_')).toBe('<p><em>italic</em></p>');
|
||||
expect(rt('_italic_')).toBe('*italic*');
|
||||
});
|
||||
it('__bold__ → **bold**', () => {
|
||||
expect(H('__bold__')).toBe('<p><strong>bold</strong></p>');
|
||||
expect(rt('__bold__')).toBe('**bold**');
|
||||
});
|
||||
it('___both___ → ***both***', () => {
|
||||
expect(H('___both___')).toContain('<em><strong>both</strong></em>');
|
||||
expect(rt('___both___')).toBe('***both***');
|
||||
});
|
||||
it('mid-word _ not converted', () => {
|
||||
expect(H('foo_bar_baz')).toBe('<p>foo_bar_baz</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setext headings', () => {
|
||||
it('=== underline → h1', () => {
|
||||
expect(H('Title\n=====')).toContain('<h1');
|
||||
expect(H('Title\n=====')).toContain('Title');
|
||||
});
|
||||
it('--- underline → h2', () => {
|
||||
expect(H('Sub\n---')).toContain('<h2');
|
||||
});
|
||||
it('round-trips to ATX', () => {
|
||||
expect(rt('Title\n=====')).toBe('# Title');
|
||||
expect(rt('Sub\n---')).toBe('## Sub');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ATX closing hashes', () => {
|
||||
it('## Title ## → h2', () => {
|
||||
expect(H('## Title ##')).toContain('<h2');
|
||||
expect(H('## Title ##')).toContain('Title');
|
||||
});
|
||||
it('round-trips without closing', () => {
|
||||
expect(rt('## Title ##')).toBe('## Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tilde fenced code', () => {
|
||||
it('~~~ fence accepted', () => {
|
||||
expect(H('~~~\ncode\n~~~')).toContain('<code>code</code>');
|
||||
});
|
||||
it('round-trips to backtick', () => {
|
||||
expect(rt('~~~\ncode\n~~~')).toContain('```');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plus list marker', () => {
|
||||
it('+ item accepted', () => {
|
||||
expect(H('+ item')).toContain('<li>');
|
||||
});
|
||||
it('round-trips to -', () => {
|
||||
expect(rt('+ item')).toContain('- item');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HopDown delimiter matching API', () => {
|
||||
describe('findCompletePair', () => {
|
||||
it('finds bold pair', () => {
|
||||
const result = hopdown.findCompletePair('hello **world** end');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.htmlTag).toBe('strong');
|
||||
expect(result!.content).toBe('world');
|
||||
expect(result!.delimiter).toBe('**');
|
||||
});
|
||||
|
||||
it('finds italic pair', () => {
|
||||
const result = hopdown.findCompletePair('hello *world* end');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.htmlTag).toBe('em');
|
||||
});
|
||||
|
||||
it('finds strikethrough pair', () => {
|
||||
const result = hopdown.findCompletePair('hello ~~gone~~ end');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.htmlTag).toBe('del');
|
||||
});
|
||||
|
||||
it('returns null when no pair exists', () => {
|
||||
expect(hopdown.findCompletePair('hello world')).toBeNull();
|
||||
});
|
||||
|
||||
it('skips sentinel-wrapped content', () => {
|
||||
expect(hopdown.findCompletePair('hello \x01<strong>world</strong>\x02 end')).toBeNull();
|
||||
});
|
||||
|
||||
it('respects precedence (boldItalic before bold)', () => {
|
||||
const result = hopdown.findCompletePair('***both***');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.htmlTag).toBe('em');
|
||||
expect(result!.tag.name).toBe('boldItalic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUnmatchedOpener', () => {
|
||||
it('finds unclosed bold', () => {
|
||||
const result = hopdown.findUnmatchedOpener('hello **world');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.htmlTag).toBe('strong');
|
||||
expect(result!.content).toBe('world');
|
||||
});
|
||||
|
||||
it('returns null when no opener exists', () => {
|
||||
expect(hopdown.findUnmatchedOpener('hello world end')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for plain text', () => {
|
||||
expect(hopdown.findUnmatchedOpener('hello world')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTagForElement', () => {
|
||||
it('returns tag for strong element', () => {
|
||||
const element = document.createElement('strong');
|
||||
const tag = hopdown.getTagForElement(element);
|
||||
expect(tag).not.toBeNull();
|
||||
expect(tag!.name).toBe('bold');
|
||||
expect(tag!.delimiter).toBe('**');
|
||||
});
|
||||
|
||||
it('returns tag for em element', () => {
|
||||
const element = document.createElement('em');
|
||||
const tag = hopdown.getTagForElement(element);
|
||||
expect(tag).not.toBeNull();
|
||||
expect(tag!.name).toBe('italic');
|
||||
});
|
||||
|
||||
it('returns null for div element', () => {
|
||||
const element = document.createElement('div');
|
||||
expect(hopdown.getTagForElement(element)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEditableSelector', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
const selector = hopdown.getEditableSelector();
|
||||
expect(selector.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes inline tag selectors', () => {
|
||||
const selector = hopdown.getEditableSelector();
|
||||
expect(selector).toContain('strong');
|
||||
expect(selector).toContain('em');
|
||||
expect(selector).toContain('code');
|
||||
});
|
||||
|
||||
it('includes block tag selectors', () => {
|
||||
const selector = hopdown.getEditableSelector();
|
||||
expect(selector).toContain('pre');
|
||||
expect(selector).toContain('blockquote');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hard line breaks', () => {
|
||||
it('trailing two spaces', () => {
|
||||
expect(H('line one \nline two')).toContain('<br>');
|
||||
});
|
||||
it('trailing backslash', () => {
|
||||
expect(H('line one\\\nline two')).toContain('<br>');
|
||||
});
|
||||
it('single space does not break', () => {
|
||||
expect(H('line one \nline two')).not.toContain('<br>');
|
||||
});
|
||||
it('round-trip', () => {
|
||||
const html = H('line one \nline two');
|
||||
const markdown = M(html);
|
||||
expect(markdown).toContain(' \n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Link nesting prevention', () => {
|
||||
it('nested brackets prevent link match', () => {
|
||||
const html = H('[outer [inner](http://b)](http://a)');
|
||||
// The outer [ prevents matching as a single link — the inner
|
||||
// link matches instead, and the outer brackets are literal text
|
||||
expect(html).toContain('<a href="http://b">inner</a>');
|
||||
});
|
||||
it('preserves inner link text', () => {
|
||||
const html = H('[outer [inner](http://b)](http://a)');
|
||||
expect(html).toContain('inner');
|
||||
});
|
||||
it('autolink inside link is stripped', () => {
|
||||
const html = H('[see <https://b.com>](http://a)');
|
||||
const anchorPattern = /<a /g;
|
||||
const linkCount = (html.match(anchorPattern) || []).length;
|
||||
expect(linkCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple-of-3 emphasis rule', () => {
|
||||
it('***foo*** is bold-italic', () => {
|
||||
expect(H('***foo***')).toContain('<em><strong>foo</strong></em>');
|
||||
});
|
||||
it('**foo** is bold', () => {
|
||||
expect(H('**foo**')).toBe('<p><strong>foo</strong></p>');
|
||||
});
|
||||
it('*foo* is italic', () => {
|
||||
expect(H('*foo*')).toBe('<p><em>foo</em></p>');
|
||||
});
|
||||
it('*foo** does not match (1+2=3, rule applies)', () => {
|
||||
const html = H('*foo**');
|
||||
expect(html).not.toContain('<em>');
|
||||
expect(html).not.toContain('<strong>');
|
||||
});
|
||||
it('**foo* does not match (2+1=3, rule applies)', () => {
|
||||
const html = H('**foo*');
|
||||
expect(html).not.toContain('<em>');
|
||||
expect(html).not.toContain('<strong>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML entity resolution', () => {
|
||||
it('& resolves to &', () => {
|
||||
expect(H('a & b')).toBe('<p>a & b</p>');
|
||||
});
|
||||
it('< resolves to <', () => {
|
||||
expect(H('a < b')).toBe('<p>a < b</p>');
|
||||
});
|
||||
it('> resolves to >', () => {
|
||||
expect(H('a > b')).toBe('<p>a > b</p>');
|
||||
});
|
||||
it('{ resolves to {', () => {
|
||||
expect(H('{')).toBe('<p>{</p>');
|
||||
});
|
||||
it('{ resolves to {', () => {
|
||||
expect(H('{')).toBe('<p>{</p>');
|
||||
});
|
||||
it('unknown entity passes through', () => {
|
||||
expect(H('&unknown;')).toContain('&unknown;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nested inline scenarios', () => {
|
||||
describe('markdown → HTML nesting', () => {
|
||||
it('strikethrough wraps bold', () => {
|
||||
expect(H('~~**bold** struck~~')).toBe('<p><del><strong>bold</strong> struck</del></p>');
|
||||
});
|
||||
it('bold wraps strikethrough', () => {
|
||||
expect(H('**~~struck~~ bold**')).toBe('<p><strong><del>struck</del> bold</strong></p>');
|
||||
});
|
||||
it('italic wraps link', () => {
|
||||
expect(H('*[text](http://x)*')).toContain('<em><a href="http://x">text</a></em>');
|
||||
});
|
||||
it('code inside strikethrough', () => {
|
||||
expect(H('~~`code` struck~~')).toContain('<del><code>code</code> struck</del>');
|
||||
});
|
||||
it('adjacent bold and italic', () => {
|
||||
const html = H('**bold***italic*');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
expect(html).toContain('<em>italic</em>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML → markdown → HTML round-trip nesting', () => {
|
||||
it('bold wraps italic', () => {
|
||||
const html = '<p><strong>a <em>b</em> c</strong></p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('italic wraps bold', () => {
|
||||
const html = '<p><em>a <strong>b</strong> c</em></p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('bold wraps code', () => {
|
||||
const html = '<p><strong>a <code>b</code> c</strong></p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('bold wraps link', () => {
|
||||
const html = '<p><strong><a href="http://x">t</a></strong></p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('strikethrough wraps bold', () => {
|
||||
const html = '<p><del><strong>bold</strong> struck</del></p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('italic wraps link', () => {
|
||||
const html = '<p><em><a href="http://x">t</a></em></p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
describe('literal delimiters in text round-trip', () => {
|
||||
it('literal * in bold', () => {
|
||||
const html = '<p><strong>a * b</strong></p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('literal ~ in strikethrough', () => {
|
||||
const html = '<p><del>a ~ b</del></p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('literal ` adjacent to code', () => {
|
||||
const html = '<p>a ` b <code>c</code></p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('literal * in plain text', () => {
|
||||
const html = '<p>hello * world</p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('literal ** in plain text', () => {
|
||||
const html = '<p>hello ** world</p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('literal _ in plain text', () => {
|
||||
const html = '<p>hello _ world</p>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backslash-escaped HTML tags', () => {
|
||||
it('\\<em> does not produce a real em element', () => {
|
||||
const html = H('\\<em>text');
|
||||
expect(html).not.toContain('<em>');
|
||||
expect(html).toContain('<em>');
|
||||
});
|
||||
it('\\<b> does not produce a real b element', () => {
|
||||
const html = H('\\<b>text');
|
||||
expect(html).not.toContain('<b>');
|
||||
});
|
||||
it('round-trip of escaped HTML tag in text', () => {
|
||||
const html = '<p>~~\\<em>---\\<b></em></p>';
|
||||
const markdown = M(html);
|
||||
const rehtml = H(markdown);
|
||||
const markdown2 = M(rehtml);
|
||||
const rehtml2 = H(markdown2);
|
||||
expect(rehtml).toBe(rehtml2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,8 +34,8 @@ function mulberry32(seed) {
|
||||
/* ── Keystroke generation ── */
|
||||
|
||||
const PRINTABLE = 'abcdefghijklmnopqrstuvwxyz 0123456789.,!?';
|
||||
const DELIMITERS = ['*', '**', '***', '`', '~~'];
|
||||
const BLOCK_PREFIXES = ['# ', '## ', '### ', '- ', '1. ', '> ', '---'];
|
||||
const DELIMITERS = ['*', '**', '***', '`', '~~', '_', '__', '___'];
|
||||
const BLOCK_PREFIXES = ['# ', '## ', '### ', '- ', '+ ', '1. ', '> ', '---', '~~~'];
|
||||
const SPECIAL_KEYS = [
|
||||
{ name: 'Enter', keys: Key.ENTER, isSpecial: true },
|
||||
{ name: 'Backspace', keys: Key.BACK_SPACE, isSpecial: true },
|
||||
@@ -70,8 +70,14 @@ function generateSequence(random, length) {
|
||||
} else if (roll < 0.94) {
|
||||
/* repeated delimiter (stress test) */
|
||||
const count = 2 + Math.floor(random() * 4);
|
||||
const character = '*';
|
||||
const delimiters = ['*', '_', '~'];
|
||||
const character = delimiters[Math.floor(random() * delimiters.length)];
|
||||
sequence.push({ name: character.repeat(count), keys: character.repeat(count) });
|
||||
} else if (roll < 0.97) {
|
||||
/* backslash sequences */
|
||||
const escaped = ['\\*', '\\_', '\\`', '\\~', '\\\\', '\\'];
|
||||
const fragment = escaped[Math.floor(random() * escaped.length)];
|
||||
sequence.push({ name: fragment, keys: fragment });
|
||||
} else {
|
||||
/* angle bracket / HTML-like content */
|
||||
const fragments = ['<', '>', '<div>', '</div>', '<b>', '&'];
|
||||
@@ -188,7 +194,8 @@ async function checkInvariants() {
|
||||
var forbiddenRules = {
|
||||
'STRONG': ['STRONG','B'], 'B': ['STRONG','B'],
|
||||
'EM': ['EM','I'], 'I': ['EM','I'],
|
||||
'CODE': ['CODE','STRONG','B','EM','I','A'],
|
||||
'CODE': ['CODE','STRONG','B','EM','I','A','DEL'],
|
||||
'DEL': ['DEL','S','STRIKE'], 'S': ['DEL','S','STRIKE'], 'STRIKE': ['DEL','S','STRIKE'],
|
||||
'A': ['A'],
|
||||
};
|
||||
var allElements = editor.querySelectorAll('*');
|
||||
@@ -212,28 +219,32 @@ async function checkInvariants() {
|
||||
}
|
||||
|
||||
/* Invariant 6: rendered HTML is stable through markdown round-trip.
|
||||
md → toHTML → toMarkdown → toHTML must produce the same HTML.
|
||||
The markdown representation may change (e.g. ***** → ***) but
|
||||
the rendered output must be identical.
|
||||
md → toHTML → toMarkdown → toHTML must eventually stabilize.
|
||||
The first round-trip may change the HTML (e.g. literal <strong>
|
||||
in text becomes a real element via HTML passthrough, then
|
||||
serializes as **). But the second round-trip must be stable.
|
||||
Skip if there are speculative elements (in-progress editing). */
|
||||
var hasSpeculative = editor.querySelector('[data-speculative]');
|
||||
if (!hasSpeculative) {
|
||||
try {
|
||||
var md = window.__ribbitEditor.getMarkdown();
|
||||
var converter = window.__ribbitEditor.converter;
|
||||
// Two round-trips: allow the first to normalize, check
|
||||
// that the second produces identical HTML
|
||||
var html1 = converter.toHTML(md);
|
||||
var md2 = converter.toMarkdown(html1);
|
||||
var html2 = converter.toHTML(md2);
|
||||
/* Compare the rendered HTML, not the markdown */
|
||||
var div1 = document.createElement('div');
|
||||
div1.innerHTML = html1;
|
||||
var div2 = document.createElement('div');
|
||||
div2.innerHTML = html2;
|
||||
var text1 = div1.textContent.replace(/\s+/g, ' ').trim();
|
||||
var text2 = div2.textContent.replace(/\s+/g, ' ').trim();
|
||||
if (text1 !== text2) {
|
||||
return 'Round-trip HTML mismatch:\n html1: "' + text1.slice(0, 80) +
|
||||
'"\n html2: "' + text2.slice(0, 80) + '"';
|
||||
var md3 = converter.toMarkdown(html2);
|
||||
var html3 = converter.toHTML(md3);
|
||||
var normalize = function(html) {
|
||||
return html
|
||||
.replace(/\s*id='[^']*'/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
if (normalize(html2) !== normalize(html3)) {
|
||||
return 'Round-trip HTML not stable after 2 passes:\n pass2: "' + normalize(html2).slice(0, 80) +
|
||||
'"\n pass3: "' + normalize(html3).slice(0, 80) + '"';
|
||||
}
|
||||
} catch (err) {
|
||||
return 'Round-trip check threw: ' + err.message;
|
||||
@@ -241,7 +252,7 @@ async function checkInvariants() {
|
||||
}
|
||||
|
||||
/* Invariant 7: only valid inline elements inside block content */
|
||||
var validInline = ['STRONG','B','EM','I','CODE','A','BR'];
|
||||
var validInline = ['STRONG','B','EM','I','CODE','A','BR','DEL','S','STRIKE'];
|
||||
var blocks = editor.querySelectorAll('p,h1,h2,h3,h4,h5,h6,li,blockquote,td,th');
|
||||
for (var b = 0; b < blocks.length; b++) {
|
||||
var inlineEls = blocks[b].querySelectorAll('*');
|
||||
|
||||
@@ -433,6 +433,55 @@ async function runTests() {
|
||||
assert(html.includes('<h2'), `Missing h2: ${html}`);
|
||||
assert(html.includes('<li') || html.includes('<ul'), `Missing list: ${html}`);
|
||||
});
|
||||
|
||||
console.log(' Strikethrough:');
|
||||
|
||||
await test('~~text~~ transforms to <del>', async () => {
|
||||
await resetEditor();
|
||||
await typeString('~~gone~~');
|
||||
const html = await getHTML();
|
||||
assert(html.includes('<del'), `No <del>: ${html}`);
|
||||
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
|
||||
assert(html.includes('gone'), `Missing content: ${html}`);
|
||||
});
|
||||
|
||||
await test('~~text shows speculative strikethrough', async () => {
|
||||
await resetEditor();
|
||||
await typeString('~~hel');
|
||||
const html = await getHTML();
|
||||
assert(html.includes('data-speculative'), `No speculative: ${html}`);
|
||||
assert(html.includes('<del'), `No <del>: ${html}`);
|
||||
});
|
||||
|
||||
console.log(' Alternate syntax:');
|
||||
|
||||
await test('~~~ transforms to fenced code', async () => {
|
||||
await resetEditor();
|
||||
await typeString('~~~');
|
||||
await driver.sleep(50);
|
||||
const html = await getHTML();
|
||||
assert(html.includes('<pre') || html.includes('<code'), `No code block: ${html}`);
|
||||
});
|
||||
|
||||
await test('+ space transforms to unordered list', async () => {
|
||||
await resetEditor();
|
||||
await typeChar('+');
|
||||
let html = await getHTML();
|
||||
assert(!html.includes('<ul'), `Premature ul: ${html}`);
|
||||
|
||||
await typeChar(' ');
|
||||
html = await getHTML();
|
||||
assert(html.includes('<ul') || html.includes('<li'), `No list after "+ ": ${html}`);
|
||||
});
|
||||
|
||||
console.log(' Backslash escapes:');
|
||||
|
||||
await test('backslash is just a character in WYSIWYG', async () => {
|
||||
await resetEditor();
|
||||
await typeString('hello\\world');
|
||||
const html = await getHTML();
|
||||
assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
|
||||
+9
-6
@@ -1,6 +1,8 @@
|
||||
import { ribbit } from './setup';
|
||||
|
||||
const r = ribbit();
|
||||
const lib = ribbit();
|
||||
|
||||
const spacePattern = / /g;
|
||||
|
||||
const macros = [
|
||||
{
|
||||
@@ -11,7 +13,7 @@ const macros = [
|
||||
name: 'npc',
|
||||
toHTML: ({ keywords }: any) => {
|
||||
const name = keywords.join(' ');
|
||||
return '<a href="/NPC/' + name.replace(/ /g, '') + '">' + name + '</a>';
|
||||
return '<a href="/NPC/' + name.replace(spacePattern, '') + '">' + name + '</a>';
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -24,9 +26,9 @@ const macros = [
|
||||
},
|
||||
];
|
||||
|
||||
const h = new r.HopDown({ macros });
|
||||
const H = (md: string) => h.toHTML(md);
|
||||
const M = (html: string) => h.toMarkdown(html);
|
||||
const converter = new lib.HopDown({ macros });
|
||||
const H = (md: string) => converter.toHTML(md);
|
||||
const M = (html: string) => converter.toMarkdown(html);
|
||||
|
||||
describe('Macros', () => {
|
||||
describe('self-closing', () => {
|
||||
@@ -61,7 +63,8 @@ describe('Macros', () => {
|
||||
it('keyword stripped from data-keywords', () => {
|
||||
const html = H('@style(box verbatim\ncontent\n)');
|
||||
expect(html).toContain('data-keywords="box"');
|
||||
expect(html).not.toMatch(/data-keywords="[^"]*verbatim/);
|
||||
const verbatimKeywordPattern = /data-keywords="[^"]*verbatim/;
|
||||
expect(html).not.toMatch(verbatimKeywordPattern);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -12,8 +12,8 @@ export function getWindow(): any {
|
||||
(global as any).HTMLElement = _window.HTMLElement;
|
||||
(global as any).Node = _window.Node;
|
||||
(global as any).NodeFilter = _window.NodeFilter;
|
||||
(global as any).TextEncoder = _window.TextEncoder || require('util').TextEncoder;
|
||||
(global as any).TextDecoder = _window.TextDecoder || require('util').TextDecoder;
|
||||
(global as any).TextEncoder = _window.TextEncoder || require('util').TextEncoder;
|
||||
(global as any).TextDecoder = _window.TextDecoder || require('util').TextDecoder;
|
||||
|
||||
const { TextEncoder, TextDecoder } = require('util');
|
||||
_window.TextEncoder = TextEncoder;
|
||||
@@ -28,10 +28,10 @@ export function getWindow(): any {
|
||||
}
|
||||
|
||||
export function ribbit(): any {
|
||||
const w = getWindow();
|
||||
const r = w.ribbit;
|
||||
r.window = w;
|
||||
return r;
|
||||
const browserWindow = getWindow();
|
||||
const lib = browserWindow.ribbit;
|
||||
lib.window = browserWindow;
|
||||
return lib;
|
||||
}
|
||||
|
||||
export function resetDOM(content = 'test'): void {
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { ribbit, getWindow } from './setup';
|
||||
import { InlineTokenizer, type InlineToken } from '../src/ts/tokenizer';
|
||||
import { MarkdownSerializer, type SerializerTagDef } from '../src/ts/serializer';
|
||||
|
||||
// Set up DOM globals before any tests run
|
||||
getWindow();
|
||||
|
||||
const boldDef = {
|
||||
delimiter: '**',
|
||||
htmlTag: 'strong',
|
||||
recursive: true,
|
||||
precedence: 40,
|
||||
};
|
||||
const italicDef = {
|
||||
delimiter: '*',
|
||||
htmlTag: 'em',
|
||||
recursive: true,
|
||||
precedence: 50,
|
||||
};
|
||||
const strikeDef = {
|
||||
delimiter: '~~',
|
||||
htmlTag: 'del',
|
||||
recursive: true,
|
||||
precedence: 45,
|
||||
};
|
||||
const codeDef = {
|
||||
delimiter: '`',
|
||||
htmlTag: 'code',
|
||||
recursive: false,
|
||||
precedence: 10,
|
||||
};
|
||||
|
||||
const tokenizer = new InlineTokenizer([boldDef, italicDef, strikeDef, codeDef]);
|
||||
|
||||
function roles(tokens: InlineToken[]): string[] {
|
||||
return tokens.map(token => token.role);
|
||||
}
|
||||
|
||||
function values(tokens: InlineToken[]): string[] {
|
||||
return tokens.map(token => token.value);
|
||||
}
|
||||
|
||||
describe('InlineTokenizer', () => {
|
||||
describe('plain text', () => {
|
||||
it('produces a single text token', () => {
|
||||
const tokens = tokenizer.tokenize('hello world');
|
||||
expect(roles(tokens)).toEqual(['text']);
|
||||
expect(values(tokens)).toEqual(['hello world']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bold', () => {
|
||||
it('tokenizes **bold**', () => {
|
||||
const tokens = tokenizer.tokenize('**bold**');
|
||||
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
|
||||
expect(tokens[0].delimiter).toBe('**');
|
||||
expect(tokens[1].value).toBe('bold');
|
||||
});
|
||||
|
||||
it('tokenizes text **bold** text', () => {
|
||||
const tokens = tokenizer.tokenize('hello **bold** end');
|
||||
expect(roles(tokens)).toEqual(['text', 'open', 'text', 'close', 'text']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('italic', () => {
|
||||
it('tokenizes *italic*', () => {
|
||||
const tokens = tokenizer.tokenize('*italic*');
|
||||
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
|
||||
expect(tokens[0].delimiter).toBe('*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strikethrough', () => {
|
||||
it('tokenizes ~~struck~~', () => {
|
||||
const tokens = tokenizer.tokenize('~~struck~~');
|
||||
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
|
||||
expect(tokens[0].delimiter).toBe('~~');
|
||||
});
|
||||
});
|
||||
|
||||
describe('code spans', () => {
|
||||
it('tokenizes `code`', () => {
|
||||
const tokens = tokenizer.tokenize('`code`');
|
||||
expect(roles(tokens)).toEqual(['code']);
|
||||
expect(tokens[0].content).toBe('code');
|
||||
});
|
||||
|
||||
it('does not parse delimiters inside code', () => {
|
||||
const tokens = tokenizer.tokenize('`**not bold**`');
|
||||
expect(roles(tokens)).toEqual(['code']);
|
||||
expect(tokens[0].content).toBe('**not bold**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backslash escapes', () => {
|
||||
it('\\* becomes literal *', () => {
|
||||
const tokens = tokenizer.tokenize('\\*hello');
|
||||
expect(roles(tokens)).toEqual(['text']);
|
||||
expect(tokens[0].value).toBe('*hello');
|
||||
});
|
||||
|
||||
it('\\\\ becomes literal \\', () => {
|
||||
const tokens = tokenizer.tokenize('\\\\');
|
||||
expect(roles(tokens)).toEqual(['text']);
|
||||
expect(tokens[0].value).toBe('\\');
|
||||
});
|
||||
|
||||
it('\\n at end of line is a hard break', () => {
|
||||
const tokens = tokenizer.tokenize('hello\\\nworld');
|
||||
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hard line breaks', () => {
|
||||
it('two trailing spaces before newline', () => {
|
||||
const tokens = tokenizer.tokenize('hello \nworld');
|
||||
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
|
||||
});
|
||||
|
||||
it('single space does not break', () => {
|
||||
const tokens = tokenizer.tokenize('hello \nworld');
|
||||
const breakTokens = tokens.filter(token => token.role === 'break');
|
||||
expect(breakTokens.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entity resolution', () => {
|
||||
it('& becomes &', () => {
|
||||
const tokens = tokenizer.tokenize('a & b');
|
||||
expect(tokens[0].value).toBe('a & b');
|
||||
});
|
||||
|
||||
it('{ becomes {', () => {
|
||||
const tokens = tokenizer.tokenize('{');
|
||||
expect(tokens[0].value).toBe('{');
|
||||
});
|
||||
|
||||
it('{ becomes {', () => {
|
||||
const tokens = tokenizer.tokenize('{');
|
||||
expect(tokens[0].value).toBe('{');
|
||||
});
|
||||
});
|
||||
|
||||
describe('links', () => {
|
||||
it('tokenizes [text](url)', () => {
|
||||
const tokens = tokenizer.tokenize('[click](http://x)');
|
||||
expect(roles(tokens)).toEqual(['link']);
|
||||
expect(tokens[0].href).toBe('http://x');
|
||||
expect(tokens[0].value).toBe('click');
|
||||
});
|
||||
|
||||
it('tokenizes [text](url "title")', () => {
|
||||
const tokens = tokenizer.tokenize('[click](http://x "My Title")');
|
||||
expect(tokens[0].title).toBe('My Title');
|
||||
});
|
||||
|
||||
it('disallows [ in link text', () => {
|
||||
const tokens = tokenizer.tokenize('[outer [inner](b)](a)');
|
||||
// Should not match as a single link
|
||||
const linkTokens = tokens.filter(token => token.role === 'link');
|
||||
expect(linkTokens.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autolinks', () => {
|
||||
it('tokenizes <url>', () => {
|
||||
const tokens = tokenizer.tokenize('<https://example.com>');
|
||||
expect(roles(tokens)).toEqual(['autolink']);
|
||||
expect(tokens[0].href).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('tokenizes bare URL', () => {
|
||||
const tokens = tokenizer.tokenize('visit https://example.com today');
|
||||
expect(tokens.some(token => token.role === 'autolink')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML passthrough', () => {
|
||||
it('tokenizes HTML tags', () => {
|
||||
const tokens = tokenizer.tokenize('a <span>b</span> c');
|
||||
const htmlTokens = tokens.filter(token => token.role === 'html');
|
||||
expect(htmlTokens.length).toBe(2);
|
||||
expect(htmlTokens[0].value).toBe('<span>');
|
||||
expect(htmlTokens[1].value).toBe('</span>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flanking rules', () => {
|
||||
it('mid-word * is not a delimiter', () => {
|
||||
const tokens = tokenizer.tokenize('2*3*4');
|
||||
expect(roles(tokens)).toEqual(['text']);
|
||||
});
|
||||
|
||||
it('* at word boundary is a delimiter', () => {
|
||||
const tokens = tokenizer.tokenize('*hello*');
|
||||
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested delimiters', () => {
|
||||
it('bold inside italic', () => {
|
||||
const tokens = tokenizer.tokenize('*hello **world***');
|
||||
const openTokens = tokens.filter(token => token.role === 'open');
|
||||
expect(openTokens.length).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownSerializer', () => {
|
||||
const tagMap = new Map<string, SerializerTagDef>([
|
||||
['STRONG', { delimiter: '**' }],
|
||||
['B', { delimiter: '**' }],
|
||||
['EM', { delimiter: '*' }],
|
||||
['I', { delimiter: '*' }],
|
||||
['DEL', { delimiter: '~~' }],
|
||||
['CODE', {
|
||||
serialize: (element) => '`' + (element.textContent || '') + '`',
|
||||
}],
|
||||
['A', {
|
||||
serialize: (element, children) => {
|
||||
const href = element.getAttribute('href') || '';
|
||||
const title = element.getAttribute('title');
|
||||
const titlePart = title ? ` "${title}"` : '';
|
||||
return '[' + children() + '](' + href + titlePart + ')';
|
||||
},
|
||||
}],
|
||||
['BR', {
|
||||
serialize: () => ' \n',
|
||||
}],
|
||||
]);
|
||||
const delimiterChars = new Set(['*', '`', '~']);
|
||||
const serializer = new MarkdownSerializer(tagMap, delimiterChars);
|
||||
|
||||
it('serializes plain text', () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'hello world';
|
||||
expect(serializer.serialize(div)).toBe('hello world');
|
||||
});
|
||||
|
||||
it('serializes bold', () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<strong>bold</strong>';
|
||||
expect(serializer.serialize(div)).toBe('**bold**');
|
||||
});
|
||||
|
||||
it('serializes italic', () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<em>italic</em>';
|
||||
expect(serializer.serialize(div)).toBe('*italic*');
|
||||
});
|
||||
|
||||
it('escapes * in text nodes', () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'hello * world';
|
||||
expect(serializer.serialize(div)).toBe('hello \\* world');
|
||||
});
|
||||
|
||||
it('escapes _ in text nodes', () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'hello_world';
|
||||
expect(serializer.serialize(div)).toBe('hello\\_world');
|
||||
});
|
||||
|
||||
it('escapes \\ in text nodes', () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'back\\slash';
|
||||
expect(serializer.serialize(div)).toBe('back\\\\slash');
|
||||
});
|
||||
|
||||
it('escapes < before letters', () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'a <b> c';
|
||||
expect(serializer.serialize(div)).toBe('a \\<b> c');
|
||||
});
|
||||
|
||||
it('does not escape < before non-letters', () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = '1 < 2';
|
||||
expect(serializer.serialize(div)).toBe('1 < 2');
|
||||
});
|
||||
|
||||
it('does not escape * inside delimiters', () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<strong>bold</strong>';
|
||||
const result = serializer.serialize(div);
|
||||
// The ** are delimiter tokens, not escaped
|
||||
expect(result).toBe('**bold**');
|
||||
expect(result).not.toContain('\\*');
|
||||
});
|
||||
|
||||
it('escapes * in text adjacent to delimiters', () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<strong>bold</strong> * text';
|
||||
const result = serializer.serialize(div);
|
||||
expect(result).toContain('\\*');
|
||||
});
|
||||
|
||||
it('serializes link', () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<a href="http://x">click</a>';
|
||||
expect(serializer.serialize(div)).toBe('[click](http://x)');
|
||||
});
|
||||
|
||||
it('serializes link with title', () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<a href="http://x" title="T">click</a>';
|
||||
expect(serializer.serialize(div)).toBe('[click](http://x "T")');
|
||||
});
|
||||
|
||||
it('serializes code', () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<code>x</code>';
|
||||
expect(serializer.serialize(div)).toBe('`x`');
|
||||
});
|
||||
|
||||
it('serializes hard break', () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = 'hello<br>world';
|
||||
expect(serializer.serialize(div)).toBe('hello \nworld');
|
||||
});
|
||||
});
|
||||
+87
-70
@@ -1,13 +1,13 @@
|
||||
import { ribbit, resetDOM } from './setup';
|
||||
|
||||
const r = ribbit();
|
||||
const lib = ribbit();
|
||||
|
||||
describe('ToolbarManager', () => {
|
||||
beforeEach(() => resetDOM('**bold** text'));
|
||||
|
||||
describe('button registration', () => {
|
||||
it('registers tag buttons', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('bold')).toBeDefined();
|
||||
expect(editor.toolbar.buttons.get('italic')).toBeDefined();
|
||||
@@ -15,7 +15,7 @@ describe('ToolbarManager', () => {
|
||||
});
|
||||
|
||||
it('registers editor actions', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('save')).toBeDefined();
|
||||
expect(editor.toolbar.buttons.get('toggle')).toBeDefined();
|
||||
@@ -23,23 +23,30 @@ describe('ToolbarManager', () => {
|
||||
});
|
||||
|
||||
it('registers macro buttons', () => {
|
||||
const editor = new r.Editor({
|
||||
macros: [{ name: 'user', toHTML: () => 'u' }],
|
||||
const editor = new lib.Editor({
|
||||
macros: [{
|
||||
name: 'user',
|
||||
toHTML: () => 'u',
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('macro:user')).toBeDefined();
|
||||
});
|
||||
|
||||
it('skips macros with button: false', () => {
|
||||
const editor = new r.Editor({
|
||||
macros: [{ name: 'hidden', toHTML: () => '', button: false }],
|
||||
const editor = new lib.Editor({
|
||||
macros: [{
|
||||
name: 'hidden',
|
||||
toHTML: () => '',
|
||||
button: false,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('macro:hidden')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips tags without button', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('paragraph')).toBeUndefined();
|
||||
});
|
||||
@@ -47,7 +54,7 @@ describe('ToolbarManager', () => {
|
||||
|
||||
describe('button properties', () => {
|
||||
it('bold has correct label and shortcut', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
const bold = editor.toolbar.buttons.get('bold')!;
|
||||
expect(bold.label).toBe('Bold');
|
||||
@@ -55,19 +62,19 @@ describe('ToolbarManager', () => {
|
||||
});
|
||||
|
||||
it('bold action is wrap', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('bold')!.action).toBe('wrap');
|
||||
});
|
||||
|
||||
it('save action is custom', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('save')!.action).toBe('custom');
|
||||
});
|
||||
|
||||
it('table has template', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
const table = editor.toolbar.buttons.get('table')!;
|
||||
expect(table.template).toContain('Header');
|
||||
@@ -75,8 +82,11 @@ describe('ToolbarManager', () => {
|
||||
});
|
||||
|
||||
it('macro button has insert action', () => {
|
||||
const editor = new r.Editor({
|
||||
macros: [{ name: 'toc', toHTML: () => '' }],
|
||||
const editor = new lib.Editor({
|
||||
macros: [{
|
||||
name: 'toc',
|
||||
toHTML: () => '',
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
const btn = editor.toolbar.buttons.get('macro:toc')!;
|
||||
@@ -87,7 +97,7 @@ describe('ToolbarManager', () => {
|
||||
|
||||
describe('button.hide() and button.show()', () => {
|
||||
it('hide sets visible false', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
const bold = editor.toolbar.buttons.get('bold')!;
|
||||
expect(bold.visible).toBe(true);
|
||||
@@ -96,7 +106,7 @@ describe('ToolbarManager', () => {
|
||||
});
|
||||
|
||||
it('show restores visible', () => {
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
const bold = editor.toolbar.buttons.get('bold')!;
|
||||
bold.hide();
|
||||
@@ -107,121 +117,124 @@ describe('ToolbarManager', () => {
|
||||
|
||||
describe('render()', () => {
|
||||
it('returns an HTMLElement', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
expect(el.tagName).toBe('NAV');
|
||||
expect(el.className).toBe('ribbit-toolbar');
|
||||
const toolbar = editor.toolbar.render();
|
||||
expect(toolbar.tagName).toBe('NAV');
|
||||
expect(toolbar.className).toBe('ribbit-toolbar');
|
||||
});
|
||||
|
||||
it('contains buttons', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
expect(el.querySelector('.ribbit-btn-bold')).not.toBeNull();
|
||||
expect(el.querySelector('.ribbit-btn-save')).not.toBeNull();
|
||||
const toolbar = editor.toolbar.render();
|
||||
expect(toolbar.querySelector('.ribbit-btn-bold')).not.toBeNull();
|
||||
expect(toolbar.querySelector('.ribbit-btn-save')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('buttons have aria-label', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
const bold = el.querySelector('.ribbit-btn-bold');
|
||||
const toolbar = editor.toolbar.render();
|
||||
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||
expect(bold?.getAttribute('aria-label')).toBe('Bold');
|
||||
});
|
||||
|
||||
it('buttons have title with shortcut', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
const bold = el.querySelector('.ribbit-btn-bold');
|
||||
const toolbar = editor.toolbar.render();
|
||||
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||
expect(bold?.getAttribute('title')).toBe('Bold (Ctrl+B)');
|
||||
});
|
||||
|
||||
it('renders spacers', () => {
|
||||
const editor = new r.Editor({
|
||||
const editor = new lib.Editor({
|
||||
autoToolbar: false,
|
||||
toolbar: ['bold', '', 'save'],
|
||||
});
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
expect(el.querySelector('.spacer')).not.toBeNull();
|
||||
const toolbar = editor.toolbar.render();
|
||||
expect(toolbar.querySelector('.spacer')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders dropdown groups', () => {
|
||||
const editor = new r.Editor({
|
||||
const editor = new lib.Editor({
|
||||
autoToolbar: false,
|
||||
toolbar: [{ group: 'Test', items: ['bold', 'italic'] }],
|
||||
toolbar: [{
|
||||
group: 'Test',
|
||||
items: ['bold', 'italic'],
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
expect(el.querySelector('.ribbit-dropdown')).not.toBeNull();
|
||||
const toolbar = editor.toolbar.render();
|
||||
expect(toolbar.querySelector('.ribbit-dropdown')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-render', () => {
|
||||
it('inserts toolbar before editor by default', () => {
|
||||
resetDOM();
|
||||
const editor = new r.Editor({});
|
||||
const editor = new lib.Editor({});
|
||||
editor.run();
|
||||
const toolbar = editor.element.previousElementSibling;
|
||||
expect(toolbar?.className).toBe('ribbit-toolbar');
|
||||
const toolbarElement = editor.element.previousElementSibling;
|
||||
expect(toolbarElement?.className).toBe('ribbit-toolbar');
|
||||
});
|
||||
|
||||
it('does not insert when autoToolbar is false', () => {
|
||||
resetDOM();
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
const toolbar = editor.element.previousElementSibling;
|
||||
expect(toolbar?.className || '').not.toBe('ribbit-toolbar');
|
||||
const toolbarElement = editor.element.previousElementSibling;
|
||||
expect(toolbarElement?.className || '').not.toBe('ribbit-toolbar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom layout', () => {
|
||||
it('respects custom toolbar order', () => {
|
||||
const editor = new r.Editor({
|
||||
const editor = new lib.Editor({
|
||||
autoToolbar: false,
|
||||
toolbar: ['save', 'bold'],
|
||||
});
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
const buttons = el.querySelectorAll('button');
|
||||
const toolbar = editor.toolbar.render();
|
||||
const buttons = toolbar.querySelectorAll('button');
|
||||
expect(buttons[0]?.className).toBe('ribbit-btn-save');
|
||||
expect(buttons[1]?.className).toBe('ribbit-btn-bold');
|
||||
});
|
||||
|
||||
it('auto-generates layout when not specified', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
expect(el.querySelectorAll('button').length).toBeGreaterThan(3);
|
||||
const toolbar = editor.toolbar.render();
|
||||
expect(toolbar.querySelectorAll('button').length).toBeGreaterThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable/disable', () => {
|
||||
it('disable adds disabled class', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
const toolbar = editor.toolbar.render();
|
||||
editor.toolbar.disable();
|
||||
const bold = el.querySelector('.ribbit-btn-bold');
|
||||
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||
expect(bold?.classList.contains('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('enable removes disabled class', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
const el = editor.toolbar.render();
|
||||
const toolbar = editor.toolbar.render();
|
||||
editor.toolbar.disable();
|
||||
editor.toolbar.enable();
|
||||
const bold = el.querySelector('.ribbit-btn-bold');
|
||||
const bold = toolbar.querySelector('.ribbit-btn-bold');
|
||||
expect(bold?.classList.contains('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateActiveState', () => {
|
||||
it('sets active class on matching buttons', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
editor.toolbar.render();
|
||||
editor.toolbar.updateActiveState(['bold']);
|
||||
@@ -230,7 +243,7 @@ describe('ToolbarManager', () => {
|
||||
});
|
||||
|
||||
it('clears active when not in list', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
editor.toolbar.render();
|
||||
editor.toolbar.updateActiveState(['bold']);
|
||||
@@ -241,19 +254,19 @@ describe('ToolbarManager', () => {
|
||||
|
||||
describe('heading and list buttons', () => {
|
||||
it('registers h1-h6', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const btn = editor.toolbar.buttons.get(`h${i}`);
|
||||
for (let level = 1; level <= 6; level++) {
|
||||
const btn = editor.toolbar.buttons.get(`h${level}`);
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.label).toBe(`H${i}`);
|
||||
expect(btn!.shortcut).toBe(`Ctrl+${i}`);
|
||||
expect(btn!.label).toBe(`H${level}`);
|
||||
expect(btn!.shortcut).toBe(`Ctrl+${level}`);
|
||||
expect(btn!.action).toBe('prefix');
|
||||
}
|
||||
});
|
||||
|
||||
it('registers ul and ol', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('ul')!.shortcut).toBe('Ctrl+Shift+8');
|
||||
expect(editor.toolbar.buttons.get('ol')!.shortcut).toBe('Ctrl+Shift+7');
|
||||
@@ -262,7 +275,7 @@ describe('ToolbarManager', () => {
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
it('all formatting buttons have shortcuts', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
const expected = ['bold', 'italic', 'code', 'link', 'save'];
|
||||
for (const id of expected) {
|
||||
@@ -271,7 +284,7 @@ describe('ToolbarManager', () => {
|
||||
});
|
||||
|
||||
it('block buttons have shortcuts', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('fencedCode')!.shortcut).toBe('Ctrl+Shift+E');
|
||||
expect(editor.toolbar.buttons.get('blockquote')!.shortcut).toBe('Ctrl+Shift+.');
|
||||
@@ -280,7 +293,7 @@ describe('ToolbarManager', () => {
|
||||
});
|
||||
|
||||
it('editor actions have shortcuts', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
expect(editor.toolbar.buttons.get('toggle')!.shortcut).toBe('Ctrl+Shift+V');
|
||||
expect(editor.toolbar.buttons.get('markdown')!.shortcut).toBe('Ctrl+/');
|
||||
@@ -291,9 +304,13 @@ describe('ToolbarManager', () => {
|
||||
it('triggers editor.save()', () => {
|
||||
resetDOM();
|
||||
let saved = false;
|
||||
const editor = new r.Editor({
|
||||
const editor = new lib.Editor({
|
||||
autoToolbar: false,
|
||||
on: { save: () => { saved = true; } },
|
||||
on: {
|
||||
save: () => {
|
||||
saved = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
editor.run();
|
||||
editor.toolbar.render();
|
||||
@@ -305,7 +322,7 @@ describe('ToolbarManager', () => {
|
||||
describe('toggle button', () => {
|
||||
it('switches from view to wysiwyg', () => {
|
||||
resetDOM();
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
editor.toolbar.render();
|
||||
expect(editor.getState()).toBe('view');
|
||||
@@ -315,7 +332,7 @@ describe('ToolbarManager', () => {
|
||||
|
||||
it('switches from wysiwyg to view', () => {
|
||||
resetDOM();
|
||||
const editor = new r.Editor({ autoToolbar: false });
|
||||
const editor = new lib.Editor({ autoToolbar: false });
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
editor.toolbar.render();
|
||||
|
||||
+88
-16
@@ -1,65 +1,127 @@
|
||||
import { ribbit, resetDOM } from './setup';
|
||||
|
||||
const r = ribbit();
|
||||
const lib = ribbit();
|
||||
|
||||
describe('VimHandler', () => {
|
||||
beforeEach(() => resetDOM('hello world'));
|
||||
|
||||
it('starts in insert mode', () => {
|
||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
||||
});
|
||||
|
||||
it('Esc enters normal mode', () => {
|
||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(editor.element.classList.contains('vim-normal')).toBe(true);
|
||||
expect(editor.element.classList.contains('vim-insert')).toBe(false);
|
||||
});
|
||||
|
||||
it('i returns to insert mode', () => {
|
||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
// Enter normal mode
|
||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
// Back to insert
|
||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'i' }));
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
|
||||
expect(editor.element.classList.contains('vim-insert')).toBe(true);
|
||||
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
||||
});
|
||||
|
||||
it('disables toolbar in normal mode', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false, currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||
const editor = new lib.Editor({
|
||||
autoToolbar: false,
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.toolbar.render();
|
||||
editor.edit();
|
||||
editor.toolbar.enable();
|
||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
const bold = editor.toolbar.buttons.get('bold');
|
||||
expect(bold?.element?.classList.contains('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('re-enables toolbar in insert mode', () => {
|
||||
const editor = new r.Editor({ autoToolbar: false, currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||
const editor = new lib.Editor({
|
||||
autoToolbar: false,
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.toolbar.render();
|
||||
editor.edit();
|
||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'i' }));
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
|
||||
const bold = editor.toolbar.buttons.get('bold');
|
||||
expect(bold?.element?.classList.contains('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('detaches when leaving edit mode', () => {
|
||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.edit();
|
||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(editor.element.classList.contains('vim-normal')).toBe(true);
|
||||
editor.wysiwyg();
|
||||
// vim classes should be gone after mode switch
|
||||
@@ -68,11 +130,21 @@ describe('VimHandler', () => {
|
||||
});
|
||||
|
||||
it('only activates in edit mode', () => {
|
||||
const editor = new r.Editor({ currentTheme: 'vim', themes: [{ name: 'vim', features: { sourceMode: true, vim: true }, tags: r.defaultTags }] });
|
||||
const editor = new lib.Editor({
|
||||
currentTheme: 'vim',
|
||||
themes: [{
|
||||
name: 'vim',
|
||||
features: {
|
||||
sourceMode: true,
|
||||
vim: true,
|
||||
},
|
||||
tags: lib.defaultTags,
|
||||
}],
|
||||
});
|
||||
editor.run();
|
||||
editor.wysiwyg();
|
||||
// Esc in wysiwyg should not add vim classes
|
||||
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(editor.element.classList.contains('vim-normal')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user