import { describe, expect, it } from 'vitest'; import { createRequire } from 'node:module'; import { WORDS_EMPTY_TEXT_ATTR, WORDS_NODE_ATTR, WORDS_PATH_ATTR, actionFromPaste, actionFromBeforeInput, applyHistoryCommand, applyWordsCommand, batchHistoryCommands, beginWordsComposition, createCollapsedSelection, createEmptyWordsDocument, createLink, createList, createListItem, createParagraph, createText, createWordsHistory, createWordsState, decodeWordsPath, documentPlainText, domTargetToWordsPoint, domTargetToWordsSelection, endWordsComposition, encodeWordsDomSelectionTarget, encodeWordsPath, normalizeDocument, parseWordsHtml, parseWordsJson, parseWordsMarkdown, parseWordsPlainText, redoWords, renderWordsDomHtml, renderWordsDocument, resolveDomPointFromTarget, restoreSelectionApiFromDomTarget, serializeWordsJson, serializeWordsHtml, serializeWordsMarkdown, selectionApiToWordsSelection, updateWordsComposition, undoWords, type WordsDocument, type WordsEditorState } from './index'; const require = createRequire(import.meta.url); const { JSDOM } = require('jsdom') as { readonly JSDOM: new (html: string) => { readonly window: Window }; }; function stateWithText(text: string): WordsEditorState { return createWordsState( { version: 1, children: [createParagraph([createText(text)])] }, createCollapsedSelection([0, 0], text.length) ); } describe('Words engine', () => { it('normalizes invalid and empty documents into one paragraph', () => { const result = normalizeDocument({ version: 99, children: [ { type: 'unknown' }, { type: 'heading', level: 9, children: [] }, { type: 'paragraph', children: [ { type: 'text', text: 'A', marks: ['bold', 'bogus', 'bold'] }, { type: 'text', text: 'B', marks: ['bold'] } ] } ] }); expect(result.document.version).toBe(1); expect(result.issues.map((issue) => issue.reason)).toContain('invalid-block'); expect(result.issues.map((issue) => issue.reason)).toContain('invalid-heading-level'); expect(result.issues.map((issue) => issue.reason)).toContain('invalid-mark'); expect(result.document.children).toHaveLength(2); expect(result.document.children[0]).toMatchObject({ type: 'heading', level: 1, children: [{ type: 'text', text: '' }] }); expect(result.document.children[1]).toMatchObject({ type: 'paragraph', children: [{ type: 'text', text: 'AB', marks: ['bold'] }] }); expect(normalizeDocument(null).document).toEqual(createEmptyWordsDocument()); }); it('rejects unsafe links but preserves their text', () => { const result = normalizeDocument({ version: 1, children: [ { type: 'paragraph', children: [ { type: 'link', href: 'javascript:alert(1)', children: [{ type: 'text', text: 'click me' }] } ] } ] }); expect(result.issues.map((issue) => issue.reason)).toContain('unsafe-url'); expect(result.document.children[0]).toMatchObject({ type: 'paragraph', children: [{ type: 'text', text: 'click me' }] }); }); it('flattens nested links during normalization and blocks inserting links inside links', () => { const result = normalizeDocument({ version: 1, children: [ createParagraph([ createLink('/outer', [createText('Outer '), createLink('/inner', [createText('Inner')])]) ]) ] }); expect(result.issues.map((issue) => issue.reason)).toContain('nested-link'); expect(result.document.children[0]).toMatchObject({ type: 'paragraph', children: [ { type: 'link', href: '/outer', children: [{ type: 'text', text: 'Outer Inner' }] } ] }); const state = createWordsState( result.document, createCollapsedSelection([0, 0, 0], 'Outer'.length) ); const nested = applyWordsCommand(state, { type: 'insertLink', href: '/nested' }); expect(nested.changed).toBe(false); expect(nested.state.document).toEqual(state.document); }); it('serializes and parses canonical JSON', () => { const doc: WordsDocument = { version: 1, children: [createParagraph([createText('Hello', ['italic', 'bold'])])] }; const json = serializeWordsJson(doc); expect(parseWordsJson(json)).toEqual({ version: 1, children: [createParagraph([createText('Hello', ['bold', 'italic'])])] }); }); it('applies text, mark and block commands', () => { let state = stateWithText('Hello'); let result = applyWordsCommand(state, { type: 'insertText', text: ' world' }); expect(result.changed).toBe(true); expect(documentPlainText(result.state.document)).toBe('Hello world'); expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 11)); state = result.state; result = applyWordsCommand(state, { type: 'toggleMark', mark: 'bold' }); expect(result.state.activeMarks).toEqual(['bold']); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [{ type: 'text', text: 'Hello world' }] }); result = applyWordsCommand(result.state, { type: 'insertText', text: '!' }); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [ { type: 'text', text: 'Hello world' }, { type: 'text', text: '!', marks: ['bold'] } ] }); result = applyWordsCommand(result.state, { type: 'setBlock', block: 'heading', level: 2 }); expect(result.state.document.children[0]).toMatchObject({ type: 'heading', level: 2 }); }); it('applies mark commands only to selected text ranges', () => { const state: WordsEditorState = { ...stateWithText('Alpha beta'), selection: { anchor: { path: [0, 0], offset: 6 }, focus: { path: [0, 0], offset: 10 } } }; const result = applyWordsCommand(state, { type: 'toggleMark', mark: 'italic' }); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [ { type: 'text', text: 'Alpha ' }, { type: 'text', text: 'beta', marks: ['italic'] } ] }); }); it('toggles lists and links', () => { let state = stateWithText('Alpha'); let result = applyWordsCommand(state, { type: 'toggleList', kind: 'check' }); expect(result.state.document.children[0]).toMatchObject({ type: 'list', kind: 'check', children: [{ type: 'list-item', children: [{ type: 'text', text: 'Alpha' }] }] }); result = applyWordsCommand(result.state, { type: 'toggleList', kind: 'check' }); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [{ type: 'text', text: 'Alpha' }] }); state = { ...result.state, selection: { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 5 } } }; result = applyWordsCommand(state, { type: 'insertLink', href: 'https://active.dev' }); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [ { type: 'link', href: 'https://active.dev', children: [{ type: 'text', text: 'Alpha' }] } ] }); result = applyWordsCommand( { ...result.state, selection: createCollapsedSelection([0, 0], 0) }, { type: 'unlink' } ); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [{ type: 'text', text: 'Alpha' }] }); }); it('deletes by word or line instead of collapsing advanced beforeinput to one char', () => { let state = stateWithText('Alpha beta'); let result = applyWordsCommand(state, { type: 'deleteWordBackward' }); expect(documentPlainText(result.state.document)).toBe('Alpha '); expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 6)); state = stateWithText('Alpha beta'); result = applyWordsCommand( { ...state, selection: createCollapsedSelection([0, 0], 6) }, { type: 'deleteWordForward' } ); expect(documentPlainText(result.state.document)).toBe('Alpha '); result = applyWordsCommand(state, { type: 'deleteLineBackward' }); expect(documentPlainText(result.state.document)).toBe(''); expect(actionFromBeforeInput({ inputType: 'deleteWordBackward' })).toEqual({ type: 'command', command: { type: 'deleteWordBackward' }, batch: 'typing' }); expect(actionFromBeforeInput({ inputType: 'deleteHardLineForward' })).toEqual({ type: 'command', command: { type: 'deleteLineForward' }, batch: 'typing' }); }); it('tracks undo and redo history around changed commands only', () => { const initial = stateWithText('A'); let history = createWordsHistory(initial); history = applyHistoryCommand(history, { type: 'insertText', text: 'B' }); history = applyHistoryCommand(history, { type: 'insertText', text: '' }); history = applyHistoryCommand(history, { type: 'insertText', text: 'C' }); expect(history.past).toHaveLength(2); expect(documentPlainText(history.present.document)).toBe('ABC'); history = undoWords(history); expect(documentPlainText(history.present.document)).toBe('AB'); history = undoWords(history); expect(documentPlainText(history.present.document)).toBe('A'); history = redoWords(history); expect(documentPlainText(history.present.document)).toBe('AB'); }); it('edits text inside list items with deep paths', () => { let state = createWordsState( { version: 1, children: [createList('unordered', [createListItem([createText('Item')])])] }, createCollapsedSelection([0, 0, 0], 4) ); let result = applyWordsCommand(state, { type: 'insertText', text: '!' }); expect(result.changed).toBe(true); expect(result.state.document.children[0]).toMatchObject({ type: 'list', children: [{ children: [{ type: 'text', text: 'Item!' }] }] }); expect(result.state.selection).toEqual(createCollapsedSelection([0, 0, 0], 5)); state = { ...result.state, selection: { anchor: { path: [0, 0, 0], offset: 0 }, focus: { path: [0, 0, 0], offset: 4 } } }; result = applyWordsCommand(state, { type: 'toggleMark', mark: 'bold' }); expect(result.state.document.children[0]).toMatchObject({ type: 'list', children: [ { children: [ { type: 'text', text: 'Item', marks: ['bold'] }, { type: 'text', text: '!' } ] } ] }); }); it('keeps selection stable after normalization merges adjacent text', () => { const state: WordsEditorState = { document: { version: 1, children: [createParagraph([createText('AC', ['bold'])])] }, selection: createCollapsedSelection([0, 0], 1), activeMarks: ['bold'] }; const result = applyWordsCommand(state, { type: 'insertText', text: 'B' }); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [{ type: 'text', text: 'ABC', marks: ['bold'] }] }); expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 2)); }); it('replaces multi-block ranges with inserted text', () => { const state = createWordsState( { version: 1, children: [createParagraph([createText('Alpha')]), createParagraph([createText('Beta')])] }, { anchor: { path: [0, 0], offset: 2 }, focus: { path: [1, 0], offset: 2 } } ); const result = applyWordsCommand(state, { type: 'insertText', text: 'X' }); expect(documentPlainText(result.state.document)).toBe('AlXta'); expect(result.state.document.children).toHaveLength(1); expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 3)); }); it('splits paragraphs and list items for Enter and merges them for Backspace', () => { let state = stateWithText('Alpha'); let result = applyWordsCommand( { ...state, selection: createCollapsedSelection([0, 0], 2) }, { type: 'insertParagraph' } ); expect(result.state.document.children).toHaveLength(2); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [{ type: 'text', text: 'Al' }] }); expect(result.state.document.children[1]).toMatchObject({ type: 'paragraph', children: [{ type: 'text', text: 'pha' }] }); result = applyWordsCommand(result.state, { type: 'deleteBackward' }); expect(documentPlainText(result.state.document)).toBe('Alpha'); expect(result.state.document.children).toHaveLength(1); state = createWordsState( { version: 1, children: [createList('unordered', [createListItem([createText('Item')])])] }, createCollapsedSelection([0, 0, 0], 2) ); result = applyWordsCommand(state, { type: 'insertParagraph' }); expect(result.state.document.children[0]).toMatchObject({ type: 'list', children: [ { type: 'list-item', children: [{ type: 'text', text: 'It' }] }, { type: 'list-item', children: [{ type: 'text', text: 'em' }] } ] }); }); it('converts list items to blocks and lets users leave lists', () => { let state = createWordsState( { version: 1, children: [ createList('unordered', [ createListItem([createText('One')]), createListItem([createText('Two')]) ]) ] }, createCollapsedSelection([0, 1, 0], 1) ); let result = applyWordsCommand(state, { type: 'setBlock', block: 'paragraph' }); expect(result.state.document.children).toMatchObject([ { type: 'paragraph', children: [{ type: 'text', text: 'One' }] }, { type: 'paragraph', children: [{ type: 'text', text: 'Two' }] } ]); expect(result.state.selection).toEqual(createCollapsedSelection([1, 0], 1)); state = createWordsState( { version: 1, children: [ createList('unordered', [ createListItem([createText('Item')]), createListItem([createText('')]) ]) ] }, createCollapsedSelection([0, 1, 0], 0) ); result = applyWordsCommand(state, { type: 'insertParagraph' }); expect(result.state.document.children).toMatchObject([ { type: 'list', children: [{ children: [{ type: 'text', text: 'Item' }] }] }, { type: 'paragraph', children: [{ type: 'text', text: '' }] } ]); expect(result.state.selection).toEqual(createCollapsedSelection([1, 0], 0)); state = createWordsState( { version: 1, children: [ createList('check', [ createListItem([createText('First')], true), createListItem([createText('Second')]) ]) ] }, createCollapsedSelection([0, 0, 0], 0) ); result = applyWordsCommand(state, { type: 'deleteBackward' }); expect(result.state.document.children).toMatchObject([ { type: 'paragraph', children: [{ type: 'text', text: 'First' }] }, { type: 'list', kind: 'check', children: [{ children: [{ type: 'text', text: 'Second' }] }] } ]); expect(result.state.selection).toEqual(createCollapsedSelection([0, 0], 0)); }); it('unlinks from an inner text selection and allows relative URLs', () => { let state = createWordsState( { version: 1, children: [createParagraph([createText('Docs')])] }, { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 4 } } ); let result = applyWordsCommand(state, { type: 'insertLink', href: '/docs' }); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [{ type: 'link', href: '/docs', children: [{ type: 'text', text: 'Docs' }] }] }); state = { ...result.state, selection: createCollapsedSelection([0, 0, 0], 2) }; result = applyWordsCommand(state, { type: 'unlink' }); expect(result.state.document.children[0]).toMatchObject({ type: 'paragraph', children: [{ type: 'text', text: 'Docs' }] }); }); it('does not normalize while serializing and batches history snapshots', () => { const doc: WordsDocument = { version: 1, children: [createParagraph([createText('A'), createText('B')])] }; expect(JSON.parse(serializeWordsJson(doc)).children[0].children).toHaveLength(2); const initial = stateWithText(''); let history = createWordsHistory(initial, { limit: 2 }); history = batchHistoryCommands(history, [ { type: 'insertText', text: 'A' }, { type: 'insertText', text: 'B' }, { type: 'insertText', text: 'C' } ]); expect(history.past).toHaveLength(1); expect(documentPlainText(history.present.document)).toBe('ABC'); expect(documentPlainText(undoWords(history).present.document)).toBe(''); history = applyHistoryCommand(history, { type: 'insertText', text: 'D' }); history = applyHistoryCommand(history, { type: 'insertText', text: 'E' }); history = applyHistoryCommand(history, { type: 'insertText', text: 'F' }); expect(history.past).toHaveLength(2); }); it('keeps DOM render metadata separate from clean export formats', () => { const doc: WordsDocument = { version: 1, children: [ createParagraph([ createText('A', ['bold']), createLink('/docs', [createText('Docs', ['italic'])]) ]), createList('check', [ createListItem([createText('Done')], true), createListItem([createText('')]) ]) ] }; const domHtml = renderWordsDomHtml(doc); expect(domHtml).toContain('data-words-path'); expect(domHtml).toContain('\u200b'); const cleanHtml = serializeWordsHtml(doc); expect(cleanHtml).toBe( '
ADocs
Alpha
Beta
'); const exportedJson = applyWordsCommand(imported.state, { type: 'exportContent', format: 'json' }); expect(exportedJson.exportedContent).toBe(serializeWordsJson(imported.state.document)); const exportedMarkdown = applyWordsCommand(imported.state, { type: 'exportContent', format: 'markdown' }); expect(exportedMarkdown.exportedContent).toBe('Alpha\n\nBeta'); const importedMarkdown = applyWordsCommand(initial, { type: 'importContent', format: 'markdown', content: '## Notes\n\n- one\n- two' }); expect(importedMarkdown.changed).toBe(true); expect(documentPlainText(importedMarkdown.state.document)).toBe('Notes\none\ntwo'); const importedHtml = applyWordsCommand(initial, { type: 'importContent', format: 'html', content: 'Alpha Docs
Alpha