diff --git a/tests/blogeditor/embed.test.ts b/tests/blogeditor/embed.test.ts
new file mode 100644
index 0000000..0d6421d
--- /dev/null
+++ b/tests/blogeditor/embed.test.ts
@@ -0,0 +1,276 @@
+// Embeds, the URL rule, uploads and labels of the Blog Editor (Phase 16.2
+// D-04, D-05, D-13, D-18).
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { EMBED_PROVIDERS, isEmbedProvider, parseEmbedUrl } from '../../assets/src/blogeditor/embed'
+import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
+import { format, labelsFor } from '../../assets/src/blogeditor/labels'
+import { button, click, fill, form, mount, status } from './helpers'
+
+afterEach(() => {
+ vi.useRealTimers()
+ document.body.replaceChildren()
+})
+
+const ID = 'dQw4w9WgXcQ'
+const PLACEHOLDER = ` `
+
+describe('parseEmbedUrl', () => {
+ it.each([
+ [`https://www.youtube.com/watch?v=${ID}`],
+ [`https://youtube.com/watch?v=${ID}`],
+ [`https://m.youtube.com/watch?v=${ID}`],
+ [`https://music.youtube.com/watch?v=${ID}`],
+ [`http://www.youtube.com/watch?v=${ID}`],
+ [`https://WWW.YOUTUBE.COM/watch?v=${ID}`],
+ [`https://www.youtube.com/watch?v=${ID}&t=42s&list=PL123`],
+ [`https://www.youtube.com/watch?feature=share&v=${ID}`],
+ [`https://youtu.be/${ID}`],
+ [`https://youtu.be/${ID}?t=10`],
+ [`https://www.youtube.com/shorts/${ID}`],
+ [`https://www.youtube.com/shorts/${ID}/`],
+ [`https://www.youtube.com/embed/${ID}`],
+ [`https://www.youtube-nocookie.com/embed/${ID}?rel=0`],
+ [` https://youtu.be/${ID} `],
+ ])('accepts %s', (url) => {
+ expect(parseEmbedUrl(url)).toEqual({ provider: 'youtube', id: ID })
+ })
+
+ it.each([
+ ['a 10-character id', 'https://youtu.be/dQw4w9WgXc'],
+ ['a 12-character id', 'https://youtu.be/dQw4w9WgXcQQ'],
+ ['an id with a dot', 'https://www.youtube.com/watch?v=dQw4w9WgX.Q'],
+ ['no id', 'https://www.youtube.com/watch'],
+ ['a channel page', 'https://www.youtube.com/@channel'],
+ ['an extra path segment', `https://www.youtube.com/embed/${ID}/extra`],
+ ['another provider', `https://vimeo.com/${ID}`],
+ ['a lookalike host', `https://www.youtube.com.evil.test/watch?v=${ID}`],
+ ['a suffix host', `https://notyoutube.com/watch?v=${ID}`],
+ ['a subdomain of youtu.be', `https://x.youtu.be/${ID}`],
+ ['javascript:', `javascript:alert('${ID}')`],
+ ['ftp:', `ftp://youtu.be/${ID}`],
+ ['data:', `data:text/html,${ID}`],
+ ['a relative URL', `/watch?v=${ID}`],
+ ['text', 'not a url'],
+ ['empty', ''],
+ ])('refuses %s', (_name, url) => {
+ expect(parseEmbedUrl(url)).toBeNull()
+ })
+
+ it('has YouTube as the only provider, with an 11-character id pattern', () => {
+ expect(Object.keys(EMBED_PROVIDERS)).toEqual(['youtube'])
+ expect(EMBED_PROVIDERS.youtube.idPattern.test(ID)).toBe(true)
+ expect(EMBED_PROVIDERS.youtube.idPattern.test(`${ID}\n`)).toBe(false)
+ expect(isEmbedProvider('youtube')).toBe(true)
+ expect(isEmbedProvider('vimeo')).toBe(false)
+ expect(isEmbedProvider('toString')).toBe(false)
+ expect(isEmbedProvider('__proto__')).toBe(false)
+ })
+})
+
+describe('embed node', () => {
+ it('round-trips the exact placeholder between other blocks', () => {
+ const html = `
Film:
${PLACEHOLDER}Koniec
`
+ const { el, editor } = mount(html)
+ const node = editor.state.doc.child(1)
+ expect(node.type.name).toBe('embed')
+ expect(node.attrs).toMatchObject({ provider: 'youtube', id: ID })
+ expect(el.value).toBe(html)
+ editor.view.dispatch(editor.state.tr.insertText('X', 1))
+ expect(el.value).toBe(`XFilm:
${PLACEHOLDER}Koniec
`)
+ })
+
+ it('shows a card and never an iframe', () => {
+ const { el } = mount(PLACEHOLDER)
+ expect(el.querySelector('iframe')).toBeNull()
+ expect(el.querySelector('.blogeditor__embed')!.textContent).toBe(`${labelsFor("en").embedCard}: YouTube ${ID}`)
+ })
+
+ it.each([
+ ['another provider', ` `],
+ ['a short id', ' '],
+ ['an extra attribute', ` `],
+ ['content', `x `],
+ ['no id', ' '],
+ ])('keeps a placeholder with %s as raw HTML, byte for byte', (_name, html) => {
+ const { el, editor } = mount(html)
+ expect(editor.state.doc.child(0).type.name).toBe('rawHtml')
+ expect(el.value).toBe(html)
+ })
+
+ it('serializes the attributes in placeholder order whatever the stored order', () => {
+ const { el, editor } = mount(`a
`)
+ expect(editor.state.doc.child(1).type.name).toBe('embed')
+ editor.view.dispatch(editor.state.tr.insertText('X', 1))
+ // The untouched embed block keeps its stored bytes.
+ expect(el.value).toBe(`Xa
`)
+ })
+
+ it('setEmbed refuses an unknown provider or a bad id', () => {
+ const { el, editor } = mount('')
+ expect(editor.commands.setEmbed({ provider: 'vimeo', id: ID })).toBe(false)
+ expect(editor.commands.setEmbed({ provider: 'youtube', id: 'short' })).toBe(false)
+ expect(el.value).toBe('')
+ expect(editor.commands.setEmbed({ provider: 'youtube', id: ID })).toBe(true)
+ expect(el.value).toBe(PLACEHOLDER)
+ })
+
+ it('inserts from the toolbar, keeps the form open on an error and closes on cancel', () => {
+ const { el, changes } = mount('a
')
+ click(button(el, 'embed'))
+ const embedForm = form(el, 'embed')!
+ fill(embedForm.querySelector('input'), 'javascript:alert(1)')
+ click(embedForm.querySelector('[data-action="insert"]'))
+ expect(form(el, 'embed')).not.toBeNull()
+ expect(el.value).toBe('a
')
+ expect(changes).toEqual([])
+ click(embedForm.querySelector('[data-action="cancel"]'))
+ expect(form(el, 'embed')).toBeNull()
+
+ click(button(el, 'embed'))
+ fill(form(el, 'embed')!.querySelector('input'), `https://www.youtube.com/shorts/${ID}`)
+ click(form(el, 'embed')!.querySelector('[data-action="insert"]'))
+ expect(el.value).toContain(PLACEHOLDER)
+ expect(changes).toHaveLength(1)
+ })
+})
+
+describe('isPolicyUrl', () => {
+ it.each(['http://a.test', 'https://a.test/x?y#z', 'HTTPS://A.TEST', 'mailto:a@b.test', '/rel', 'rel/path', '../up', '#fragment', '?q=1', '', 'https://a.test/javascript:x'])(
+ 'accepts %j',
+ (url) => {
+ expect(isPolicyUrl(url)).toBe(true)
+ },
+ )
+
+ it.each([
+ 'javascript:alert(1)',
+ 'JavaScript:alert(1)',
+ ' javascript:alert(1)',
+ 'java\tscript:alert(1)',
+ 'java\nscript:alert(1)',
+ 'java\rscript:alert(1)',
+ 'javascript\u0000:alert(1)',
+ 'vbscript:msgbox(1)',
+ 'data:text/html,x',
+ 'tel:123',
+ 'ftp://a.test',
+ 'file:///etc/passwd',
+ ])('refuses %j', (url) => {
+ expect(isPolicyUrl(url)).toBe(false)
+ })
+
+ it('refuses null and undefined', () => {
+ expect(isPolicyUrl(null)).toBe(false)
+ expect(isPolicyUrl(undefined)).toBe(false)
+ })
+})
+
+describe('upload flow', () => {
+ const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'grzyb.png', { type: 'image/png' })
+
+ function pick(el: HTMLElement, files: File[]): void {
+ const input = el.querySelector('input[type="file"]')!
+ Object.defineProperty(input, 'files', { value: files, configurable: true })
+ input.dispatchEvent(new Event('change'))
+ }
+
+ function answer(el: HTMLElement, detail: Record): void {
+ el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
+ }
+
+ it('ignores an answer with an id it did not send', () => {
+ const { el, uploads, changes } = mount('a
')
+ pick(el, [png()])
+ answer(el, { id: 'not-ours', url: '/x.png' })
+ answer(el, { id: 'not-ours', error: 'nope' })
+ expect(form(el, 'image')).toBeNull()
+ expect(status(el)).toBe('Uploading grzyb.png…')
+ answer(el, { id: uploads[0]!.id, url: '/ours.png' })
+ expect(form(el, 'image')).not.toBeNull()
+ expect(changes).toEqual([])
+ })
+
+ it('times out after 60 seconds and ignores the late answer', () => {
+ vi.useFakeTimers()
+ const { el, uploads } = mount('')
+ pick(el, [png()])
+ vi.advanceTimersByTime(59_999)
+ expect(status(el)).toBe('Uploading grzyb.png…')
+ vi.advanceTimersByTime(1)
+ expect(status(el)).toBe('The upload of grzyb.png took too long.')
+ answer(el, { id: uploads[0]!.id, url: '/late.png' })
+ expect(form(el, 'image')).toBeNull()
+ expect(el.value).toBe('')
+ })
+
+ it('answers each of several uploads by its own id', () => {
+ const { el, uploads } = mount('')
+ pick(el, [png(), new File([new Uint8Array([255, 216, 255])], 'b.jpg', { type: 'image/jpeg' })])
+ expect(uploads).toHaveLength(2)
+ expect(uploads[0]!.id).not.toBe(uploads[1]!.id)
+ answer(el, { id: uploads[1]!.id, error: 'Too big' })
+ expect(status(el)).toBe('Upload failed: Too big')
+ })
+
+ it('refuses an uploaded URL outside the policy', () => {
+ const { el, uploads } = mount('')
+ pick(el, [png()])
+ answer(el, { id: uploads[0]!.id, url: 'javascript:alert(1)' })
+ expect(form(el, 'image')).toBeNull()
+ expect(status(el)).toMatch(/^Upload failed/)
+ expect(el.value).toBe('')
+ })
+})
+
+describe('labels', () => {
+ it('has Polish labels, an English fallback and every key in both', () => {
+ const en = labelsFor('en')
+ const pl = labelsFor('pl')
+ expect(Object.keys(pl).sort()).toEqual(Object.keys(en).sort())
+ for (const key of Object.keys(en) as (keyof typeof en)[]) {
+ expect(en[key], key).not.toBe('')
+ expect(pl[key], key).not.toBe('')
+ }
+ expect(pl.tabSource).not.toBe(en.tabSource)
+ expect(labelsFor('pl-PL')).toBe(pl)
+ expect(labelsFor('PL')).toBe(pl)
+ expect(labelsFor('de')).toBe(en)
+ expect(labelsFor('')).toBe(en)
+ expect(labelsFor(null)).toBe(en)
+ expect(labelsFor(undefined)).toBe(en)
+ })
+
+ it('follows the ui-locale attribute of the element', () => {
+ const { el } = mount('a
', { 'ui-locale': 'pl' })
+ expect(el.querySelector('button[data-tab="source"]')!.textContent).toBe(labelsFor('pl').tabSource)
+ const { el: other } = mount('a
', { 'ui-locale': 'fr' })
+ expect(other.querySelector('button[data-tab="source"]')!.textContent).toBe(labelsFor('en').tabSource)
+ })
+
+ it('format fills known placeholders and leaves unknown ones', () => {
+ expect(format('Uploading {name}…', { name: 'a.png' })).toBe('Uploading a.png…')
+ expect(format('{a} and {b}', { a: '1' })).toBe('1 and {b}')
+ })
+})
+
+describe('disabled and invalid', () => {
+ it('disables the formatting buttons and the source while disabled, and marks the editor invalid', () => {
+ const { el } = mount('a
', { disabled: '', invalid: '' })
+ expect(el.editor!.isEditable).toBe(false)
+ expect(button(el, 'bold').disabled).toBe(true)
+ expect(el.querySelector('textarea')!.readOnly).toBe(true)
+ expect(el.editor!.view.dom.getAttribute('aria-invalid')).toBe('true')
+ // The preview toggle stays usable: it never edits.
+ expect(button(el, 'preview').disabled).toBe(false)
+ el.removeAttribute('disabled')
+ expect(button(el, 'bold').disabled).toBe(false)
+ })
+
+ it('emits nothing for clicks on a disabled editor', () => {
+ const { el, changes } = mount('a
', { disabled: '' })
+ click(button(el, 'bold'))
+ click(button(el, 'embed'))
+ expect(changes).toEqual([])
+ expect(el.value).toBe('a
')
+ })
+})
diff --git a/tests/blogeditor/schema.test.ts b/tests/blogeditor/schema.test.ts
new file mode 100644
index 0000000..3f9a462
--- /dev/null
+++ b/tests/blogeditor/schema.test.ts
@@ -0,0 +1,220 @@
+// The Blog Editor's preservation schema (Phase 16.2 D-03, D-04): what the
+// schema models, what becomes a raw HTML card, which attributes survive an
+// edit, and how the stored form of links, tables and figures looks when a
+// block is serialized from the model.
+import { afterEach, describe, expect, it } from 'vitest'
+import { RAW_HTML_TAGS } from '../../assets/src/blogeditor/extensions'
+import { serializeDocument } from '../../assets/src/blogeditor/serializer'
+import { insertAtBlock, mount } from './helpers'
+
+afterEach(() => {
+ document.body.replaceChildren()
+})
+
+/** types lists the top-level node types of the editor's document. */
+function types(html: string): string[] {
+ const { editor } = mount(html)
+ const out: string[] = []
+ editor.state.doc.forEach((node) => out.push(node.type.name))
+ return out
+}
+
+/**
+ * shape describes markup for comparison: element names, attributes sorted by
+ * name (a style without its final semicolon) and text. An edited block may
+ * list attributes in another order (the model renders modelled ones after
+ * the captured ones) and the DOM normalizes a style declaration; names and
+ * values must stay the same.
+ */
+function shape(html: string): string {
+ const template = document.createElement('template')
+ template.innerHTML = html
+ const walk = (node: Node): string => {
+ if (node.nodeType === 3) return JSON.stringify(node.nodeValue)
+ if (node.nodeType !== 1) return ''
+ const el = node as Element
+ const attrs = Array.from(el.attributes)
+ .map((a) => `${a.name}=${JSON.stringify(a.name === 'style' ? a.value.trim().replace(/;$/, '') : a.value)}`)
+ .sort()
+ return `<${el.nodeName.toLowerCase()} ${attrs.join(' ')}>${Array.from(el.childNodes).map(walk).join('')}>`
+ }
+ return Array.from(template.content.childNodes).map(walk).join('')
+}
+
+/** model serializes the whole document from the model, without block reuse. */
+function model(html: string): string {
+ const { editor } = mount(html)
+ return serializeDocument(editor, null)
+}
+
+const rawSamples: Record<(typeof RAW_HTML_TAGS)[number], string> = {
+ footer: '',
+ section: '',
+ aside: '',
+ header: '',
+ nav: 'a ',
+ details: 'Więcej Treść ',
+ style: '',
+ div: 'Pudełko
',
+ article: 'Artykuł
',
+ main: 'Główna
',
+ address: 'ul. Leśna 1 ',
+ dl: 'Termin Opis ',
+ iframe: '',
+ video: ' ',
+ audio: ' ',
+ picture: ' ',
+ object: ' ',
+ embed: '',
+ form: '',
+ svg: ' ',
+ math: 'x ',
+ script: '',
+ noscript: 'Bez skryptów ',
+}
+
+describe('raw HTML blocks', () => {
+ it('has a sample for every raw HTML tag rule', () => {
+ expect(Object.keys(rawSamples).sort()).toEqual([...RAW_HTML_TAGS].sort())
+ })
+
+ it.each(Object.entries(rawSamples))('keeps <%s> as one raw HTML card through an edit elsewhere', (_tag, block) => {
+ const html = `a
${block}`
+ const { el, editor } = mount(html)
+ expect(editor.state.doc.childCount).toBe(2)
+ expect(editor.state.doc.child(1).type.name).toBe('rawHtml')
+ insertAtBlock(editor, 0, 'X')
+ expect(el.value).toBe(`Xa
${block}`)
+ expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined()
+ })
+
+ it('shows the card source as escaped text, never as live markup', () => {
+ const payload = 'bold '
+ const { el } = mount(payload)
+ const source = el.querySelector('.blogeditor__raw-source')!
+ expect(source.textContent).toBe(payload)
+ expect(source.innerHTML).toContain('<img')
+ expect(el.querySelector('.ProseMirror img, .ProseMirror b')).toBeNull()
+ expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined()
+ })
+
+ it('emits a raw block verbatim when the document is serialized from the model', () => {
+ expect(model(rawSamples.footer)).toBe(rawSamples.footer)
+ })
+})
+
+describe('figures', () => {
+ it('models a figure holding only an image', () => {
+ expect(types(' ')).toEqual(['figure'])
+ expect(model(' ')).toBe(' ')
+ })
+
+ it('models a figure with an image and a caption', () => {
+ const html = 'Podpis zdjęcia '
+ const { editor } = mount(html)
+ const figure = editor.state.doc.child(0)
+ expect(figure.type.name).toBe('figure')
+ expect(figure.child(0).type.name).toBe('figureImage')
+ expect(figure.child(1).type.name).toBe('figcaption')
+ expect(serializeDocument(editor, null)).toBe(html)
+ })
+
+ it.each([
+ ['other content', 'tekst
'],
+ ['a quote with a caption', 'Cytat Autor '],
+ ['two images', ' '],
+ ['loose text', 'tekst '],
+ ['a caption first', 'Najpierw '],
+ ['a data URL image', ' '],
+ ['an image without src', ' '],
+ ['nothing', ' '],
+ ])('keeps a figure with %s as raw HTML', (_name, html) => {
+ expect(types(html)).toEqual(['rawHtml'])
+ })
+
+ it('makes an uncaptioned inline image a paragraph image without base64', () => {
+ expect(model('
')).toBe('
')
+ expect(model('a b
')).toBe('ab
')
+ })
+})
+
+describe('marks and global attributes', () => {
+ it('keeps a span with attributes as a mark and drops a bare span', () => {
+ expect(shape(model('a b c
'))).toBe(shape('a b c
'))
+ expect(model('a b c
')).toBe('a b c
')
+ })
+
+ it.each([
+ ['paragraph', 'a
'],
+ ['heading', 'a '],
+ ['blockquote', 'a
'],
+ ['bullet list and item', ''],
+ ['ordered list with start', 'a
'],
+ ['code block', 'a '],
+ ['horizontal rule', ' '],
+ ['image', '
'],
+ ['figcaption', 'c '],
+ ['link', 'a
'],
+ ['table, row and cells', ''],
+ ])('keeps unmodelled attributes on a %s', (_name, html) => {
+ expect(shape(model(html))).toBe(shape(html))
+ })
+
+ it('never keeps an event handler attribute', () => {
+ const out = model('a
')
+ expect(out).toBe('a
')
+ })
+})
+
+describe('links', () => {
+ it('adds no target, rel or class and keeps stored ones', () => {
+ expect(model('a
')).toBe('a
')
+ const stored = model('a
')
+ expect(stored).toContain('target="_blank"')
+ expect(stored).toContain('rel="noopener"')
+ })
+
+ it.each(['javascript:alert(1)', 'JAVASCRIPT:alert(1)', 'java\tscript:alert(1)', 'vbscript:x', 'data:text/html,x', 'tel:123'])(
+ 'drops the link but keeps the text for %s',
+ (href) => {
+ const out = model(`tekst
`)
+ expect(out).toBe('tekst
')
+ },
+ )
+})
+
+describe('tables', () => {
+ it('renders without colgroup or a min-width style, header row in thead, bare cell text', () => {
+ const out = model('')
+ expect(out).toBe('')
+ expect(out).not.toContain('colgroup')
+ expect(out).not.toContain('min-width')
+ })
+
+ it('keeps a paragraph with attributes inside a cell', () => {
+ expect(model('')).toBe('')
+ })
+
+ it('keeps colspan and rowspan', () => {
+ const html = ''
+ const out = model(html)
+ expect(out).toContain('colspan="2"')
+ expect(out).toContain('rowspan="2"')
+ })
+})
+
+describe('document shape', () => {
+ it('adds no trailing paragraph after a final block', () => {
+ for (const html of ['Tytuł ', ' ', '', rawSamples.footer]) {
+ const { el, editor } = mount(html)
+ expect(editor.state.doc.lastChild!.type.name).not.toBe('paragraph')
+ expect(el.value).toBe(html)
+ }
+ })
+
+ it('serializes an edited heading level and list without wrappers it did not have', () => {
+ const { el, editor } = mount('Tytuł ')
+ insertAtBlock(editor, 0, 'Nowy ')
+ expect(el.value).toBe('Nowy Tytuł ')
+ })
+})
diff --git a/tests/blogeditor/serializer.test.ts b/tests/blogeditor/serializer.test.ts
new file mode 100644
index 0000000..125fc1f
--- /dev/null
+++ b/tests/blogeditor/serializer.test.ts
@@ -0,0 +1,155 @@
+// Top-level block reuse (Phase 16.2 D-03): after an edit, every top-level
+// block the edit did not touch is emitted as its original source bytes,
+// with the white space and comments between blocks, whatever the edit was
+// (one changed block, a deletion, an insertion).
+import { afterEach, describe, expect, it } from 'vitest'
+import { getSchema } from '@tiptap/core'
+import { preservationExtensions } from '../../assets/src/blogeditor/extensions'
+import { captureBlocks } from '../../assets/src/blogeditor/serializer'
+import { insertAtBlock, mount } from './helpers'
+
+afterEach(() => {
+ document.body.replaceChildren()
+})
+
+// Source formatting the schema serializer would not reproduce: upper-case
+// tags, single quotes, unquoted and reordered attributes, entities,
+// self-closing void tags and odd white space.
+const blocks = [
+ "Pierwszy akapit & więcej
",
+ 'Wstęp ',
+ 'Drugi link
',
+ '',
+ 'Trzeci wiersz
',
+ '',
+]
+const separators = ['\n\n', '\n\n', '\r\n', '\n \n', '\n']
+const source = blocks.map((block, i) => block + (separators[i] ?? '')).join('') + '\n\n'
+
+/** topLevel returns the editor document's top-level node types. */
+function topLevel(el: ReturnType['editor']): string[] {
+ const out: string[] = []
+ el.state.doc.forEach((node) => out.push(node.type.name))
+ return out
+}
+
+describe('block reuse', () => {
+ it('returns the source unchanged before any edit', () => {
+ const { el, editor } = mount(source)
+ expect(topLevel(editor)).toEqual(['paragraph', 'heading', 'paragraph', 'bulletList', 'paragraph', 'rawHtml'])
+ expect(el.value).toBe(source)
+ })
+
+ it('keeps every other block, separator and comment byte for byte after one edit', () => {
+ const { el, editor } = mount(source)
+ insertAtBlock(editor, 2, 'X')
+ const value = el.value
+ expect(value).not.toBe(source)
+ for (const [i, block] of blocks.entries()) {
+ if (i === 2) continue
+ expect(value).toContain(block)
+ }
+ for (const separator of separators) expect(value).toContain(separator)
+ expect(value).toContain('XDrugi')
+ expect(value.endsWith('\n\n')).toBe(true)
+ // Only the edited block differs from the source.
+ expect(value.replace(/
XDrugi[^]*?<\/p>/, blocks[2]!)).toBe(source)
+ })
+
+ it('keeps the remaining blocks after a deletion', () => {
+ const { el, editor } = mount(source)
+ let from = 0
+ let to = 0
+ editor.state.doc.forEach((node, offset, index) => {
+ if (index === 1) {
+ from = offset
+ to = offset + node.nodeSize
+ }
+ })
+ editor.view.dispatch(editor.state.tr.delete(from, to))
+ const value = el.value
+ expect(value).not.toContain('Wstęp')
+ for (const [i, block] of blocks.entries()) {
+ if (i !== 1) expect(value).toContain(block)
+ }
+ expect(value.startsWith(blocks[0]!)).toBe(true)
+ })
+
+ it('keeps every block around an insertion and serializes only the new one', () => {
+ const { el, editor } = mount(source)
+ let at = 0
+ editor.state.doc.forEach((_node, offset, index) => {
+ if (index === 3) at = offset
+ })
+ const paragraph = editor.schema.nodes.paragraph!.create(null, editor.schema.text('Nowy'))
+ editor.view.dispatch(editor.state.tr.insert(at, paragraph))
+ const value = el.value
+ for (const block of blocks) expect(value).toContain(block)
+ const inserted = value.indexOf('
Nowy
')
+ expect(inserted).toBeGreaterThan(value.indexOf(blocks[2]!))
+ expect(inserted).toBeLessThan(value.indexOf(blocks[3]!))
+ })
+
+ it('keeps reused blocks after several edits and an undo returns the exact source', () => {
+ const { el, editor } = mount(source)
+ insertAtBlock(editor, 0, 'A')
+ insertAtBlock(editor, 4, 'B')
+ const value = el.value
+ for (const i of [1, 2, 3, 5]) expect(value).toContain(blocks[i]!)
+ editor.commands.undo()
+ editor.commands.undo()
+ expect(el.value).toBe(source)
+ })
+
+ it('serializes every block when the document is replaced entirely', () => {
+ const { el, editor } = mount(source)
+ editor.commands.setContent('Inny
')
+ expect(el.value).toBe('Inny
')
+ })
+})
+
+describe('captureBlocks', () => {
+ const schema = getSchema(preservationExtensions())
+
+ it('records each block with the text before it and the trailing text', () => {
+ const captured = captureBlocks('\na
\n\nb ', schema)!
+ expect(captured.blocks.map((b) => [b.before, b.html])).toEqual([
+ ['\n', 'a
'],
+ ['\n\n', 'b '],
+ ])
+ expect(captured.trailing).toBe(' ')
+ expect(captured.blocks.map((b) => b.nodes.map((n) => n.type.name))).toEqual([['paragraph'], ['heading']])
+ })
+
+ it('splits raw text elements and nested elements by source position', () => {
+ const html = 'c
'
+ const captured = captureBlocks(html, schema)!
+ expect(captured.blocks.map((b) => b.html)).toEqual(['', '', 'c
'])
+ })
+
+ it.each([
+ ['an unclosed comment', 'a