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
+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*');
});