// text-keys.mjs's commands over ProseMirror state alone: the link // commands and the keymap's shape. No DOM, no view — EditorState is // enough to run a command and read the marks it left. import test from 'node:test' import assert from 'node:assert/strict' import { EditorState, TextSelection } from 'prosemirror-state' import { schema, docFromParagraphs } from './schema.mjs' import { linkAt, setLink, removeLink, textKeys } from './text-keys.mjs' function state (paragraphs, from, to = from) { const doc = docFromParagraphs(paragraphs) let s = EditorState.create({ schema, doc }) return s.apply(s.tr.setSelection(TextSelection.create(s.doc, from, to))) } function run (s, cmd) { let next = s const ok = cmd(s, tr => { next = s.apply(tr) }) return { ok, state: next } } function marksOf (s) { const out = [] s.doc.descendants(n => { if (n.isText) out.push([n.text, n.marks.map(m => m.type.name === 'href' ? m.attrs.href : m.type.name)]) }) return out } test('linkAt reads the link under the cursor and nothing elsewhere', () => { const s = state([[{ t: 'see ' }, { t: 'this', href: 'https://a' }, { t: ' now' }]], 6) assert.equal(linkAt(s), 'https://a') assert.equal(linkAt(state([[{ t: 'plain' }]], 2)), null) }) test('setLink marks the selection', () => { const { ok, state: s } = run(state([[{ t: 'hello world' }]], 1, 6), setLink('https://x')) assert.ok(ok) assert.deepEqual(marksOf(s), [['hello', ['https://x']], [' world', []]]) }) test('setLink on a collapsed cursor inside a link replaces that link', () => { const { ok, state: s } = run(state([[{ t: 'see ' }, { t: 'this', href: 'https://a' }, { t: ' now' }]], 6), setLink('https://b')) assert.ok(ok) assert.deepEqual(marksOf(s), [['see ', []], ['this', ['https://b']], [' now', []]]) }) test('setLink with nothing selected and no link does nothing', () => { const { ok } = run(state([[{ t: 'plain' }]], 2), setLink('https://x')) assert.equal(ok, false) }) test('removeLink clears the link under the cursor', () => { const { ok, state: s } = run(state([[{ t: 'see ' }, { t: 'this', href: 'https://a' }, { t: ' now' }]], 6), removeLink) assert.ok(ok) assert.deepEqual(marksOf(s), [['see this now', []]]) }) test('the keymap binds every documented shortcut', () => { const keys = textKeys({ undo: () => {}, redo: () => {}, link: () => {} }) for (const k of ['Mod-b', 'Mod-i', 'Mod-e', 'Mod-Shift-h', 'Mod-k', 'Mod-z', 'Mod-Shift-z', 'Mod-y']) { assert.equal(typeof keys[k], 'function', k) } }) test('Mod-b toggles bold through the keymap', () => { const keys = textKeys({ undo: () => {}, redo: () => {}, link: () => {} }) const { state: s } = run(state([[{ t: 'hello' }]], 1, 6), keys['Mod-b']) assert.deepEqual(marksOf(s), [['hello', ['b']]]) }) test('setLink refuses an address outside the allowlist and writes nothing', () => { for (const bad of ['javascript:alert(1)', 'data:text/html,hi', 'JavaScript:void(0)', '/relative', ' https://padded', '']) { const s = state([[{ t: 'click here' }]], 1, 6) const { ok, state: next } = run(s, setLink(bad)) assert.equal(ok, false, bad) assert.deepEqual(marksOf(next), [['click here', []]], bad) } const { ok, state: next } = run(state([[{ t: 'click here' }]], 1, 6), setLink('mailto:a@b.c')) assert.equal(ok, true) assert.deepEqual(marksOf(next), [['click', ['mailto:a@b.c']], [' here', []]]) })