// 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 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', '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))
})
// 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', '
', '
'],
['style', 'a
', 'a
'],
['srcset and loading', '
', '
'],
['background', '', ''],
['form, contenteditable and popover', 'a
', 'a
'],
['itemprop, dir and lang', 'a
', 'a
'],
['a cell style', '', ''],
])('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
' +
'
' +
''
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
')
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ł ')
})
})