Reimplement as a tokenizer with GFM parity

This commit is contained in:
gsb
2026-04-30 07:09:19 +00:00
parent 005db2f431
commit d41716c8b2
28 changed files with 4798 additions and 1398 deletions
+277 -59
View File
@@ -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
View File
@@ -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
View File
@@ -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('&#60;');
expect(r.encodeHtmlEntities('>')).toBe('&#62;');
expect(r.encodeHtmlEntities('&')).toBe('&#38;');
expect(lib.encodeHtmlEntities('<')).toBe('&#60;');
expect(lib.encodeHtmlEntities('>')).toBe('&#62;');
expect(lib.encodeHtmlEntities('&')).toBe('&#38;');
});
it('decodeHtmlEntities', () => {
expect(r.decodeHtmlEntities('&#60;')).toBe('<');
expect(r.decodeHtmlEntities('&amp;')).toBe('&');
expect(lib.decodeHtmlEntities('&#60;')).toBe('<');
expect(lib.decodeHtmlEntities('&amp;')).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
View File
@@ -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('&lt;'));
});
describe('Autolinks', () => {
it('angle bracket autolink', () => expect(H('<https://example.com>')).toContain('<a href="https://example.com">'));
it('bare URL', () => expect(H('visit https://example.com today')).toContain('<a href="https://example.com">'));
it('URL not matched inside link', () => {
const html = H('[text](https://example.com)');
// Should have exactly one <a> tag, not nested
const anchorPattern = /<a /g;
const count = (html.match(anchorPattern) || []).length;
expect(count).toBe(1);
});
});
describe('Alternate syntax (parse-only, canonical output)', () => {
describe('underscore emphasis', () => {
it('_italic_ → *italic*', () => {
expect(H('_italic_')).toBe('<p><em>italic</em></p>');
expect(rt('_italic_')).toBe('*italic*');
});
it('__bold__ → **bold**', () => {
expect(H('__bold__')).toBe('<p><strong>bold</strong></p>');
expect(rt('__bold__')).toBe('**bold**');
});
it('___both___ → ***both***', () => {
expect(H('___both___')).toContain('<em><strong>both</strong></em>');
expect(rt('___both___')).toBe('***both***');
});
it('mid-word _ not converted', () => {
expect(H('foo_bar_baz')).toBe('<p>foo_bar_baz</p>');
});
});
describe('setext headings', () => {
it('=== underline → h1', () => {
expect(H('Title\n=====')).toContain('<h1');
expect(H('Title\n=====')).toContain('Title');
});
it('--- underline → h2', () => {
expect(H('Sub\n---')).toContain('<h2');
});
it('round-trips to ATX', () => {
expect(rt('Title\n=====')).toBe('# Title');
expect(rt('Sub\n---')).toBe('## Sub');
});
});
describe('ATX closing hashes', () => {
it('## Title ## → h2', () => {
expect(H('## Title ##')).toContain('<h2');
expect(H('## Title ##')).toContain('Title');
});
it('round-trips without closing', () => {
expect(rt('## Title ##')).toBe('## Title');
});
});
describe('tilde fenced code', () => {
it('~~~ fence accepted', () => {
expect(H('~~~\ncode\n~~~')).toContain('<code>code</code>');
});
it('round-trips to backtick', () => {
expect(rt('~~~\ncode\n~~~')).toContain('```');
});
});
describe('plus list marker', () => {
it('+ item accepted', () => {
expect(H('+ item')).toContain('<li>');
});
it('round-trips to -', () => {
expect(rt('+ item')).toContain('- item');
});
});
});
describe('HopDown delimiter matching API', () => {
describe('findCompletePair', () => {
it('finds bold pair', () => {
const result = hopdown.findCompletePair('hello **world** end');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('strong');
expect(result!.content).toBe('world');
expect(result!.delimiter).toBe('**');
});
it('finds italic pair', () => {
const result = hopdown.findCompletePair('hello *world* end');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('em');
});
it('finds strikethrough pair', () => {
const result = hopdown.findCompletePair('hello ~~gone~~ end');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('del');
});
it('returns null when no pair exists', () => {
expect(hopdown.findCompletePair('hello world')).toBeNull();
});
it('skips sentinel-wrapped content', () => {
expect(hopdown.findCompletePair('hello \x01<strong>world</strong>\x02 end')).toBeNull();
});
it('respects precedence (boldItalic before bold)', () => {
const result = hopdown.findCompletePair('***both***');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('em');
expect(result!.tag.name).toBe('boldItalic');
});
});
describe('findUnmatchedOpener', () => {
it('finds unclosed bold', () => {
const result = hopdown.findUnmatchedOpener('hello **world');
expect(result).not.toBeNull();
expect(result!.htmlTag).toBe('strong');
expect(result!.content).toBe('world');
});
it('returns null when no opener exists', () => {
expect(hopdown.findUnmatchedOpener('hello world end')).toBeNull();
});
it('returns null for plain text', () => {
expect(hopdown.findUnmatchedOpener('hello world')).toBeNull();
});
});
describe('getTagForElement', () => {
it('returns tag for strong element', () => {
const element = document.createElement('strong');
const tag = hopdown.getTagForElement(element);
expect(tag).not.toBeNull();
expect(tag!.name).toBe('bold');
expect(tag!.delimiter).toBe('**');
});
it('returns tag for em element', () => {
const element = document.createElement('em');
const tag = hopdown.getTagForElement(element);
expect(tag).not.toBeNull();
expect(tag!.name).toBe('italic');
});
it('returns null for div element', () => {
const element = document.createElement('div');
expect(hopdown.getTagForElement(element)).toBeNull();
});
});
describe('getEditableSelector', () => {
it('returns a non-empty string', () => {
const selector = hopdown.getEditableSelector();
expect(selector.length).toBeGreaterThan(0);
});
it('includes inline tag selectors', () => {
const selector = hopdown.getEditableSelector();
expect(selector).toContain('strong');
expect(selector).toContain('em');
expect(selector).toContain('code');
});
it('includes block tag selectors', () => {
const selector = hopdown.getEditableSelector();
expect(selector).toContain('pre');
expect(selector).toContain('blockquote');
});
});
});
describe('Hard line breaks', () => {
it('trailing two spaces', () => {
expect(H('line one \nline two')).toContain('<br>');
});
it('trailing backslash', () => {
expect(H('line one\\\nline two')).toContain('<br>');
});
it('single space does not break', () => {
expect(H('line one \nline two')).not.toContain('<br>');
});
it('round-trip', () => {
const html = H('line one \nline two');
const markdown = M(html);
expect(markdown).toContain(' \n');
});
});
describe('Link nesting prevention', () => {
it('nested brackets prevent link match', () => {
const html = H('[outer [inner](http://b)](http://a)');
// The outer [ prevents matching as a single link — the inner
// link matches instead, and the outer brackets are literal text
expect(html).toContain('<a href="http://b">inner</a>');
});
it('preserves inner link text', () => {
const html = H('[outer [inner](http://b)](http://a)');
expect(html).toContain('inner');
});
it('autolink inside link is stripped', () => {
const html = H('[see <https://b.com>](http://a)');
const anchorPattern = /<a /g;
const linkCount = (html.match(anchorPattern) || []).length;
expect(linkCount).toBe(1);
});
});
describe('Multiple-of-3 emphasis rule', () => {
it('***foo*** is bold-italic', () => {
expect(H('***foo***')).toContain('<em><strong>foo</strong></em>');
});
it('**foo** is bold', () => {
expect(H('**foo**')).toBe('<p><strong>foo</strong></p>');
});
it('*foo* is italic', () => {
expect(H('*foo*')).toBe('<p><em>foo</em></p>');
});
it('*foo** does not match (1+2=3, rule applies)', () => {
const html = H('*foo**');
expect(html).not.toContain('<em>');
expect(html).not.toContain('<strong>');
});
it('**foo* does not match (2+1=3, rule applies)', () => {
const html = H('**foo*');
expect(html).not.toContain('<em>');
expect(html).not.toContain('<strong>');
});
});
describe('HTML entity resolution', () => {
it('&amp; resolves to &', () => {
expect(H('a &amp; b')).toBe('<p>a &amp; b</p>');
});
it('&lt; resolves to <', () => {
expect(H('a &lt; b')).toBe('<p>a &lt; b</p>');
});
it('&gt; resolves to >', () => {
expect(H('a &gt; b')).toBe('<p>a &gt; b</p>');
});
it('&#123; resolves to {', () => {
expect(H('&#123;')).toBe('<p>{</p>');
});
it('&#x7B; resolves to {', () => {
expect(H('&#x7B;')).toBe('<p>{</p>');
});
it('unknown entity passes through', () => {
expect(H('&unknown;')).toContain('&amp;unknown;');
});
});
describe('Nested inline scenarios', () => {
describe('markdown → HTML nesting', () => {
it('strikethrough wraps bold', () => {
expect(H('~~**bold** struck~~')).toBe('<p><del><strong>bold</strong> struck</del></p>');
});
it('bold wraps strikethrough', () => {
expect(H('**~~struck~~ bold**')).toBe('<p><strong><del>struck</del> bold</strong></p>');
});
it('italic wraps link', () => {
expect(H('*[text](http://x)*')).toContain('<em><a href="http://x">text</a></em>');
});
it('code inside strikethrough', () => {
expect(H('~~`code` struck~~')).toContain('<del><code>code</code> struck</del>');
});
it('adjacent bold and italic', () => {
const html = H('**bold***italic*');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<em>italic</em>');
});
});
describe('HTML → markdown → HTML round-trip nesting', () => {
it('bold wraps italic', () => {
const html = '<p><strong>a <em>b</em> c</strong></p>';
expect(H(M(html))).toBe(html);
});
it('italic wraps bold', () => {
const html = '<p><em>a <strong>b</strong> c</em></p>';
expect(H(M(html))).toBe(html);
});
it('bold wraps code', () => {
const html = '<p><strong>a <code>b</code> c</strong></p>';
expect(H(M(html))).toBe(html);
});
it('bold wraps link', () => {
const html = '<p><strong><a href="http://x">t</a></strong></p>';
expect(H(M(html))).toBe(html);
});
it('strikethrough wraps bold', () => {
const html = '<p><del><strong>bold</strong> struck</del></p>';
expect(H(M(html))).toBe(html);
});
it('italic wraps link', () => {
const html = '<p><em><a href="http://x">t</a></em></p>';
expect(H(M(html))).toBe(html);
});
});
describe('literal delimiters in text round-trip', () => {
it('literal * in bold', () => {
const html = '<p><strong>a * b</strong></p>';
expect(H(M(html))).toBe(html);
});
it('literal ~ in strikethrough', () => {
const html = '<p><del>a ~ b</del></p>';
expect(H(M(html))).toBe(html);
});
it('literal ` adjacent to code', () => {
const html = '<p>a ` b <code>c</code></p>';
expect(H(M(html))).toBe(html);
});
it('literal * in plain text', () => {
const html = '<p>hello * world</p>';
expect(H(M(html))).toBe(html);
});
it('literal ** in plain text', () => {
const html = '<p>hello ** world</p>';
expect(H(M(html))).toBe(html);
});
it('literal _ in plain text', () => {
const html = '<p>hello _ world</p>';
expect(H(M(html))).toBe(html);
});
});
});
describe('Backslash-escaped HTML tags', () => {
it('\\<em> does not produce a real em element', () => {
const html = H('\\<em>text');
expect(html).not.toContain('<em>');
expect(html).toContain('&lt;em&gt;');
});
it('\\<b> does not produce a real b element', () => {
const html = H('\\<b>text');
expect(html).not.toContain('<b>');
});
it('round-trip of escaped HTML tag in text', () => {
const html = '<p>~~\\<em>---\\<b></em></p>';
const markdown = M(html);
const rehtml = H(markdown);
const markdown2 = M(rehtml);
const rehtml2 = H(markdown2);
expect(rehtml).toBe(rehtml2);
});
});
+29 -18
View File
@@ -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>', '&amp;'];
@@ -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('*');
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+322
View File
@@ -0,0 +1,322 @@
import { ribbit, getWindow } from './setup';
import { InlineTokenizer, type InlineToken } from '../src/ts/tokenizer';
import { MarkdownSerializer, type SerializerTagDef } from '../src/ts/serializer';
// Set up DOM globals before any tests run
getWindow();
const boldDef = {
delimiter: '**',
htmlTag: 'strong',
recursive: true,
precedence: 40,
};
const italicDef = {
delimiter: '*',
htmlTag: 'em',
recursive: true,
precedence: 50,
};
const strikeDef = {
delimiter: '~~',
htmlTag: 'del',
recursive: true,
precedence: 45,
};
const codeDef = {
delimiter: '`',
htmlTag: 'code',
recursive: false,
precedence: 10,
};
const tokenizer = new InlineTokenizer([boldDef, italicDef, strikeDef, codeDef]);
function roles(tokens: InlineToken[]): string[] {
return tokens.map(token => token.role);
}
function values(tokens: InlineToken[]): string[] {
return tokens.map(token => token.value);
}
describe('InlineTokenizer', () => {
describe('plain text', () => {
it('produces a single text token', () => {
const tokens = tokenizer.tokenize('hello world');
expect(roles(tokens)).toEqual(['text']);
expect(values(tokens)).toEqual(['hello world']);
});
});
describe('bold', () => {
it('tokenizes **bold**', () => {
const tokens = tokenizer.tokenize('**bold**');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
expect(tokens[0].delimiter).toBe('**');
expect(tokens[1].value).toBe('bold');
});
it('tokenizes text **bold** text', () => {
const tokens = tokenizer.tokenize('hello **bold** end');
expect(roles(tokens)).toEqual(['text', 'open', 'text', 'close', 'text']);
});
});
describe('italic', () => {
it('tokenizes *italic*', () => {
const tokens = tokenizer.tokenize('*italic*');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
expect(tokens[0].delimiter).toBe('*');
});
});
describe('strikethrough', () => {
it('tokenizes ~~struck~~', () => {
const tokens = tokenizer.tokenize('~~struck~~');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
expect(tokens[0].delimiter).toBe('~~');
});
});
describe('code spans', () => {
it('tokenizes `code`', () => {
const tokens = tokenizer.tokenize('`code`');
expect(roles(tokens)).toEqual(['code']);
expect(tokens[0].content).toBe('code');
});
it('does not parse delimiters inside code', () => {
const tokens = tokenizer.tokenize('`**not bold**`');
expect(roles(tokens)).toEqual(['code']);
expect(tokens[0].content).toBe('**not bold**');
});
});
describe('backslash escapes', () => {
it('\\* becomes literal *', () => {
const tokens = tokenizer.tokenize('\\*hello');
expect(roles(tokens)).toEqual(['text']);
expect(tokens[0].value).toBe('*hello');
});
it('\\\\ becomes literal \\', () => {
const tokens = tokenizer.tokenize('\\\\');
expect(roles(tokens)).toEqual(['text']);
expect(tokens[0].value).toBe('\\');
});
it('\\n at end of line is a hard break', () => {
const tokens = tokenizer.tokenize('hello\\\nworld');
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
});
});
describe('hard line breaks', () => {
it('two trailing spaces before newline', () => {
const tokens = tokenizer.tokenize('hello \nworld');
expect(roles(tokens)).toEqual(['text', 'break', 'text']);
});
it('single space does not break', () => {
const tokens = tokenizer.tokenize('hello \nworld');
const breakTokens = tokens.filter(token => token.role === 'break');
expect(breakTokens.length).toBe(0);
});
});
describe('entity resolution', () => {
it('&amp; becomes &', () => {
const tokens = tokenizer.tokenize('a &amp; b');
expect(tokens[0].value).toBe('a & b');
});
it('&#123; becomes {', () => {
const tokens = tokenizer.tokenize('&#123;');
expect(tokens[0].value).toBe('{');
});
it('&#x7B; becomes {', () => {
const tokens = tokenizer.tokenize('&#x7B;');
expect(tokens[0].value).toBe('{');
});
});
describe('links', () => {
it('tokenizes [text](url)', () => {
const tokens = tokenizer.tokenize('[click](http://x)');
expect(roles(tokens)).toEqual(['link']);
expect(tokens[0].href).toBe('http://x');
expect(tokens[0].value).toBe('click');
});
it('tokenizes [text](url "title")', () => {
const tokens = tokenizer.tokenize('[click](http://x "My Title")');
expect(tokens[0].title).toBe('My Title');
});
it('disallows [ in link text', () => {
const tokens = tokenizer.tokenize('[outer [inner](b)](a)');
// Should not match as a single link
const linkTokens = tokens.filter(token => token.role === 'link');
expect(linkTokens.length).toBeLessThanOrEqual(1);
});
});
describe('autolinks', () => {
it('tokenizes <url>', () => {
const tokens = tokenizer.tokenize('<https://example.com>');
expect(roles(tokens)).toEqual(['autolink']);
expect(tokens[0].href).toBe('https://example.com');
});
it('tokenizes bare URL', () => {
const tokens = tokenizer.tokenize('visit https://example.com today');
expect(tokens.some(token => token.role === 'autolink')).toBe(true);
});
});
describe('HTML passthrough', () => {
it('tokenizes HTML tags', () => {
const tokens = tokenizer.tokenize('a <span>b</span> c');
const htmlTokens = tokens.filter(token => token.role === 'html');
expect(htmlTokens.length).toBe(2);
expect(htmlTokens[0].value).toBe('<span>');
expect(htmlTokens[1].value).toBe('</span>');
});
});
describe('flanking rules', () => {
it('mid-word * is not a delimiter', () => {
const tokens = tokenizer.tokenize('2*3*4');
expect(roles(tokens)).toEqual(['text']);
});
it('* at word boundary is a delimiter', () => {
const tokens = tokenizer.tokenize('*hello*');
expect(roles(tokens)).toEqual(['open', 'text', 'close']);
});
});
describe('nested delimiters', () => {
it('bold inside italic', () => {
const tokens = tokenizer.tokenize('*hello **world***');
const openTokens = tokens.filter(token => token.role === 'open');
expect(openTokens.length).toBe(2);
});
});
});
describe('MarkdownSerializer', () => {
const tagMap = new Map<string, SerializerTagDef>([
['STRONG', { delimiter: '**' }],
['B', { delimiter: '**' }],
['EM', { delimiter: '*' }],
['I', { delimiter: '*' }],
['DEL', { delimiter: '~~' }],
['CODE', {
serialize: (element) => '`' + (element.textContent || '') + '`',
}],
['A', {
serialize: (element, children) => {
const href = element.getAttribute('href') || '';
const title = element.getAttribute('title');
const titlePart = title ? ` "${title}"` : '';
return '[' + children() + '](' + href + titlePart + ')';
},
}],
['BR', {
serialize: () => ' \n',
}],
]);
const delimiterChars = new Set(['*', '`', '~']);
const serializer = new MarkdownSerializer(tagMap, delimiterChars);
it('serializes plain text', () => {
const div = document.createElement('div');
div.textContent = 'hello world';
expect(serializer.serialize(div)).toBe('hello world');
});
it('serializes bold', () => {
const div = document.createElement('div');
div.innerHTML = '<strong>bold</strong>';
expect(serializer.serialize(div)).toBe('**bold**');
});
it('serializes italic', () => {
const div = document.createElement('div');
div.innerHTML = '<em>italic</em>';
expect(serializer.serialize(div)).toBe('*italic*');
});
it('escapes * in text nodes', () => {
const div = document.createElement('div');
div.textContent = 'hello * world';
expect(serializer.serialize(div)).toBe('hello \\* world');
});
it('escapes _ in text nodes', () => {
const div = document.createElement('div');
div.textContent = 'hello_world';
expect(serializer.serialize(div)).toBe('hello\\_world');
});
it('escapes \\ in text nodes', () => {
const div = document.createElement('div');
div.textContent = 'back\\slash';
expect(serializer.serialize(div)).toBe('back\\\\slash');
});
it('escapes < before letters', () => {
const div = document.createElement('div');
div.textContent = 'a <b> c';
expect(serializer.serialize(div)).toBe('a \\<b> c');
});
it('does not escape < before non-letters', () => {
const div = document.createElement('div');
div.textContent = '1 < 2';
expect(serializer.serialize(div)).toBe('1 < 2');
});
it('does not escape * inside delimiters', () => {
const div = document.createElement('div');
div.innerHTML = '<strong>bold</strong>';
const result = serializer.serialize(div);
// The ** are delimiter tokens, not escaped
expect(result).toBe('**bold**');
expect(result).not.toContain('\\*');
});
it('escapes * in text adjacent to delimiters', () => {
const div = document.createElement('div');
div.innerHTML = '<strong>bold</strong> * text';
const result = serializer.serialize(div);
expect(result).toContain('\\*');
});
it('serializes link', () => {
const div = document.createElement('div');
div.innerHTML = '<a href="http://x">click</a>';
expect(serializer.serialize(div)).toBe('[click](http://x)');
});
it('serializes link with title', () => {
const div = document.createElement('div');
div.innerHTML = '<a href="http://x" title="T">click</a>';
expect(serializer.serialize(div)).toBe('[click](http://x "T")');
});
it('serializes code', () => {
const div = document.createElement('div');
div.innerHTML = '<code>x</code>';
expect(serializer.serialize(div)).toBe('`x`');
});
it('serializes hard break', () => {
const div = document.createElement('div');
div.innerHTML = 'hello<br>world';
expect(serializer.serialize(div)).toBe('hello \nworld');
});
});
+87 -70
View File
@@ -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
View File
@@ -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);
});
});