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
+144 -6
View File
@@ -27,7 +27,12 @@ export interface RibbitSettings {
}
/**
* Read-only markdown viewer. Renders markdown content into an HTML element.
* Base class providing read-only markdown rendering. RibbitEditor extends
* this with editing capabilities, so consumers who only need to display
* rendered markdown can use Ribbit directly and avoid loading editor code.
*
* const viewer = new Ribbit({ editorId: 'my-element' });
* viewer.run();
*/
export class Ribbit {
api: unknown;
@@ -36,7 +41,6 @@ export class Ribbit {
cachedHTML: string | null;
cachedMarkdown: string | null;
state: string | null;
changed: boolean;
theme: RibbitTheme;
themes: ThemeManager;
converter: HopDown;
@@ -59,7 +63,6 @@ export class Ribbit {
this.cachedHTML = null;
this.cachedMarkdown = null;
this.state = null;
this.changed = false;
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
this.theme = theme;
@@ -138,10 +141,23 @@ export class Ribbit {
}
}
/**
* Subscribe to editor events. Callbacks persist across mode switches.
*
* editor.on('change', ({ markdown, html }) => console.log(markdown));
* editor.on('save', ({ markdown }) => fetch('/api', { body: markdown }));
*/
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
this.emitter.on(event, callback);
}
/**
* Unsubscribe a previously registered event callback.
*
* const handler = (e) => console.log(e);
* editor.on('change', handler);
* editor.off('change', handler);
*/
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
this.emitter.off(event, callback);
}
@@ -155,6 +171,13 @@ export class Ribbit {
});
}
/**
* Initialize the viewer: render toolbar, switch to view mode, and
* fire the ready event. Call once after construction.
*
* const viewer = new Ribbit({ editorId: 'content' });
* viewer.run();
*/
run(): void {
this.element.classList.add('loaded');
if (this.autoToolbar) {
@@ -164,10 +187,21 @@ export class Ribbit {
this.emitReady();
}
/**
* Current mode name ('view', 'edit', or 'wysiwyg').
*
* if (editor.getState() === 'wysiwyg') { ... }
*/
getState(): string | null {
return this.state;
}
/**
* Transition to a new mode. Updates CSS classes on the editor element
* so themes can style each mode differently, and fires modeChange.
*
* editor.setState('edit');
*/
setState(newState: string): void {
const previous = this.state;
if (previous) {
@@ -181,10 +215,20 @@ export class Ribbit {
});
}
markdownToHTML(md: string): string {
return this.converter.toHTML(md);
/**
* One-shot markdown→HTML conversion using the current theme's tags.
*
* const html = viewer.markdownToHTML('**hello**');
*/
markdownToHTML(markdown: string): string {
return this.converter.toHTML(markdown);
}
/**
* Rendered HTML of the current content, cached until invalidated.
*
* document.getElementById('preview').innerHTML = viewer.getHTML();
*/
getHTML(): string {
if (this.cachedHTML === null) {
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
@@ -192,6 +236,12 @@ export class Ribbit {
return this.cachedHTML;
}
/**
* Raw markdown of the current content. In view mode this is the
* original text; in edit/wysiwyg mode it's derived from the DOM.
*
* fetch('/save', { body: editor.getMarkdown() });
*/
getMarkdown(): string {
if (this.cachedMarkdown === null) {
this.cachedMarkdown = this.element.textContent || '';
@@ -199,6 +249,13 @@ export class Ribbit {
return this.cachedMarkdown;
}
/**
* Emit a save event with the current content. Ribbit never persists
* data itself — the consumer handles storage in the callback.
*
* editor.on('save', ({ markdown }) => localStorage.setItem('doc', markdown));
* editor.save();
*/
save(): void {
this.emitter.emit('save', {
markdown: this.getMarkdown(),
@@ -206,6 +263,12 @@ export class Ribbit {
});
}
/**
* Switch to read-only view mode. Renders markdown to HTML and
* disables contentEditable. Disconnects collaboration if active.
*
* editor.view();
*/
view(): void {
if (this.getState() === this.states.VIEW) return;
this.collaboration?.disconnect();
@@ -214,36 +277,78 @@ export class Ribbit {
this.element.contentEditable = 'false';
}
/**
* Force re-conversion on next getHTML()/getMarkdown() call.
* Call after programmatically changing element content.
*
* editor.element.innerHTML = newContent;
* editor.invalidateCache();
*/
invalidateCache(): void {
this.changed = true;
this.cachedMarkdown = null;
this.cachedHTML = null;
}
/**
* Request an advisory editing lock. Returns false if another user
* holds the lock. Requires a collaboration transport.
*
* if (await editor.lockForEditing()) { editor.wysiwyg(); }
*/
async lockForEditing(): Promise<boolean> {
if (!this.collaboration) return false;
return this.collaboration.lock();
}
/**
* Release the advisory editing lock.
*
* editor.unlockEditing();
* editor.view();
*/
unlockEditing(): void {
this.collaboration?.unlock();
}
/**
* Steal the lock from another user. Use when an admin needs to
* override a stale lock.
*
* await editor.forceLockEditing();
*/
async forceLockEditing(): Promise<boolean> {
if (!this.collaboration) return false;
return this.collaboration.forceLock();
}
/**
* Fetch all saved revisions from the revision provider.
*
* const revisions = await editor.listRevisions();
* revisions.forEach(r => console.log(r.id, r.timestamp));
*/
async listRevisions(): Promise<Revision[]> {
if (!this.collaboration) return [];
return this.collaboration.listRevisions();
}
/**
* Fetch a single revision's content by ID.
*
* const rev = await editor.getRevision('abc-123');
* if (rev) { console.log(rev.content); }
*/
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
if (!this.collaboration) return null;
return this.collaboration.getRevision(id);
}
/**
* Replace the editor content with a previous revision and broadcast
* the change to collaborators.
*
* await editor.restoreRevision('abc-123');
*/
async restoreRevision(id: string): Promise<void> {
if (!this.collaboration) return;
const revision = await this.collaboration.getRevision(id);
@@ -260,6 +365,12 @@ export class Ribbit {
});
}
/**
* Snapshot the current content as a named revision. The revision
* provider stores it; ribbit never persists data itself.
*
* const rev = await editor.createRevision({ label: 'v1.0' });
*/
async createRevision(metadata?: RevisionMetadata): Promise<Revision | null> {
if (!this.collaboration) return null;
const revision = await this.collaboration.createRevision(this.getMarkdown(), metadata);
@@ -269,6 +380,14 @@ export class Ribbit {
return revision;
}
/**
* Broadcast the current content to collaborators and fire the
* change event. Called automatically on input; call manually
* after programmatic content changes.
*
* editor.element.innerHTML = '<p>new content</p>';
* editor.notifyChange();
*/
notifyChange(): void {
const markdown = this.getMarkdown();
this.collaboration?.sendUpdate(markdown);
@@ -279,6 +398,12 @@ export class Ribbit {
}
}
/**
* Split a string into words and capitalize each one.
* Used to generate camelCase IDs for heading anchors.
*
* camelCase('hello world') // ['Hello', 'World']
*/
export function camelCase(words: string): string[] {
return words.trim().split(/\s+/g).map(word => {
const lc = word.toLowerCase();
@@ -286,12 +411,25 @@ export function camelCase(words: string): string[] {
});
}
/**
* Decode HTML entities back to characters. Uses a textarea element
* because the browser's HTML parser handles all entity forms.
*
* decodeHtmlEntities('&lt;b&gt;') // '<b>'
*/
export function decodeHtmlEntities(html: string): string {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
}
/**
* Encode characters that would be interpreted as HTML into numeric
* entities. Used when displaying raw markdown in contentEditable
* (edit mode) so the browser doesn't parse it as markup.
*
* encodeHtmlEntities('<b>hi</b>') // '&#60;b&#62;hi&#60;/b&#62;'
*/
export function encodeHtmlEntities(str: string): string {
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
}