// 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: '

Sekcja

', aside: '', header: '

Nagłówek

', nav: '', details: '
WięcejTreść
', 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: '', } 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('
a
')).toEqual(['figure']) expect(model('
a
')).toBe('
a
') }) it('models a figure with an image and a caption', () => { const html = '
a
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', '
x
'], ['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('

a

')).toBe('

a

') expect(model('

ab

')).toBe('

ab

') }) }) describe('marks and global attributes', () => { it('keeps a span with kept 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('drops a span whose attributes are all view-unsafe', () => { expect(model('

a b c

')).toBe('

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', '
  1. a

'], ['code block', '
a
'], ['horizontal rule', '
'], ['image', '

a

'], ['figcaption', '
a
c
'], ['link', '

a

'], ['table, row and cells', '
a
'], ])('keeps unmodelled attributes on a %s', (_name, html) => { expect(shape(model(html))).toBe(shape(html)) }) // WR-05: ProseMirror renders captured attributes into the live admin DOM, // so only view-safe ones are kept once a block is serialized from the model. it.each([ ['id', '

a

', '

a

'], ['name', '

a

', '

a

'], ['style', '

a

', '

a

'], ['srcset and loading', '

a

', '

a

'], ['background', '
a
', '
a
'], ['form, contenteditable and popover', '

a

', '

a

'], ['itemprop, dir and lang', '

a

', '

a

'], ['a cell style', '
a
', '
a
'], ])('drops the view-unsafe attribute %s from a block serialized from the model', (_name, html, want) => { expect(shape(model(html))).toBe(shape(want)) }) it('never renders a view-unsafe stored attribute into the live editor DOM', () => { const html = '

a

b c

' + '

a

' + '
d
' const { el } = mount(html) const view = el.querySelector('.ProseMirror')! for (const attribute of ['id', 'name', 'srcset', 'background']) { expect(Array.from(view.querySelectorAll(`[${attribute}]`)).map((node) => node.outerHTML), attribute).toEqual([]) } // The table node view sets its own min-width style; no stored style // reaches the view. const styles = Array.from(view.querySelectorAll('[style]')).map((node) => node.getAttribute('style')) expect(styles.filter((style) => /fixed|red/.test(style ?? ''))).toEqual([]) expect(view.querySelector('p.k[data-x="1"]')).not.toBeNull() expect(view.querySelector('span.hl')).not.toBeNull() expect(view.querySelector('img.photo')).not.toBeNull() // Untouched, the element still returns the stored bytes (D-03). expect(el.value).toBe(html) }) it('never keeps an event handler attribute', () => { const out = model('

a

a

') expect(out).toBe('

a

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('
AB
12
') expect(out).toBe('
AB
12
') expect(out).not.toContain('colgroup') expect(out).not.toContain('min-width') }) it('keeps a paragraph with attributes inside a cell', () => { expect(model('

1

')).toBe('

1

') }) it('keeps colspan and rowspan', () => { const html = '
a
bc
d
' 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ł

', '
', '
a
', 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ł

') }) })