captureAttributes and renderCaptured dropped only on* names, so id, name, style, srcset, background and the like from stored HTML were rendered by ProseMirror into the live admin DOM (DOM clobbering of document.querySelector, fixed-position overlays, remote loads). htmlAttrs now keeps an allowlist: class, title, alt, width, height, colspan, rowspan, start, rel, target and data-*; href and src stay with the link and image nodes. Other attributes are dropped from a block once it is edited; untouched documents and untouched blocks keep their bytes.
262 lines
13 KiB
TypeScript
262 lines
13 KiB
TypeScript
// 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: '<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>',
|
|
section: '<section class="s"><p>Sekcja</p></section>',
|
|
aside: '<aside>Na marginesie</aside>',
|
|
header: '<header><h2>Nagłówek</h2></header>',
|
|
nav: '<nav><a href="/a">a</a></nav>',
|
|
details: '<details><summary>Więcej</summary>Treść</details>',
|
|
style: '<style>.x { color: red; }</style>',
|
|
div: '<div class="box" data-x="1">Pudełko</div>',
|
|
article: '<article><p>Artykuł</p></article>',
|
|
main: '<main><p>Główna</p></main>',
|
|
address: '<address>ul. Leśna 1</address>',
|
|
dl: '<dl><dt>Termin</dt><dd>Opis</dd></dl>',
|
|
iframe: '<iframe title="video"></iframe>',
|
|
video: '<video controls=""></video>',
|
|
audio: '<audio controls=""></audio>',
|
|
picture: '<picture><source srcset="/a.webp"></picture>',
|
|
object: '<object data="/x.bin"></object>',
|
|
embed: '<embed src="/x.bin">',
|
|
form: '<form action="/x"><input name="q"></form>',
|
|
svg: '<svg viewBox="0 0 1 1"><circle r="1"></circle></svg>',
|
|
math: '<math><mi>x</mi></math>',
|
|
script: '<script>window.__schemaPwned = 1</script>',
|
|
noscript: '<noscript>Bez skryptów</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 = `<p>a</p>${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(`<p>Xa</p>${block}`)
|
|
expect((window as unknown as { __schemaPwned?: number }).__schemaPwned).toBeUndefined()
|
|
})
|
|
|
|
it('shows the card source as escaped text, never as live markup', () => {
|
|
const payload = '<div><img src="x" onerror="window.__schemaPwned = 2"><b>bold</b></div>'
|
|
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('<figure><img src="/a.jpg" alt="a"></figure>')).toEqual(['figure'])
|
|
expect(model('<figure><img src="/a.jpg" alt="a"></figure>')).toBe('<figure><img src="/a.jpg" alt="a"></figure>')
|
|
})
|
|
|
|
it('models a figure with an image and a caption', () => {
|
|
const html = '<figure><img src="/a.jpg" alt="a"><figcaption>Podpis <em>zdjęcia</em></figcaption></figure>'
|
|
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', '<figure><p>tekst</p></figure>'],
|
|
['a quote with a caption', '<figure class="quote"><blockquote>Cytat</blockquote><figcaption>Autor</figcaption></figure>'],
|
|
['two images', '<figure><img src="/a.jpg"><img src="/b.jpg"></figure>'],
|
|
['loose text', '<figure>tekst<img src="/a.jpg"></figure>'],
|
|
['a caption first', '<figure><figcaption>Najpierw</figcaption><img src="/a.jpg"></figure>'],
|
|
['a data URL image', '<figure><img src="data:image/png;base64,AAAA"></figure>'],
|
|
['an image without src', '<figure><img alt="x"></figure>'],
|
|
['nothing', '<figure></figure>'],
|
|
])('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('<p><img src="/a.jpg" alt="a"></p>')).toBe('<p><img src="/a.jpg" alt="a"></p>')
|
|
expect(model('<p>a<img src="data:image/png;base64,AAAA">b</p>')).toBe('<p>ab</p>')
|
|
})
|
|
})
|
|
|
|
describe('marks and global attributes', () => {
|
|
it('keeps a span with kept attributes as a mark and drops a bare span', () => {
|
|
expect(shape(model('<p>a <span class="hl" title="t" data-x="1">b</span> c</p>'))).toBe(shape('<p>a <span class="hl" title="t" data-x="1">b</span> c</p>'))
|
|
expect(model('<p>a <span>b</span> c</p>')).toBe('<p>a b c</p>')
|
|
})
|
|
|
|
it('drops a span whose attributes are all view-unsafe', () => {
|
|
expect(model('<p>a <span style="color: red" id="s">b</span> c</p>')).toBe('<p>a b c</p>')
|
|
expect(model('<p>a <span class="hl" style="color: red">b</span> c</p>')).toBe('<p>a <span class="hl">b</span> c</p>')
|
|
})
|
|
|
|
it.each([
|
|
['paragraph', '<p class="lead" data-x="1">a</p>'],
|
|
['heading', '<h2 class="h" title="Intro">a</h2>'],
|
|
['blockquote', '<blockquote class="q"><p>a</p></blockquote>'],
|
|
['bullet list and item', '<ul class="list"><li class="item"><p>a</p></li></ul>'],
|
|
['ordered list with start', '<ol start="3" class="steps"><li><p>a</p></li></ol>'],
|
|
['code block', '<pre class="code"><code>a</code></pre>'],
|
|
['horizontal rule', '<hr class="sep">'],
|
|
['image', '<p><img src="/a.jpg" alt="a" class="photo" data-id="7"></p>'],
|
|
['figcaption', '<figure><img src="/a.jpg" alt="a"><figcaption class="cap">c</figcaption></figure>'],
|
|
['link', '<p><a href="/a" class="more" data-id="7">a</a></p>'],
|
|
['table, row and cells', '<table class="t" width="100%"><tbody><tr class="r"><td class="c" data-x="1" width="50" height="20">a</td></tr></tbody></table>'],
|
|
])('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', '<h2 id="intro" class="h">a</h2>', '<h2 class="h">a</h2>'],
|
|
['name', '<p><img src="/a.jpg" alt="a" name="querySelector"></p>', '<p><img src="/a.jpg" alt="a"></p>'],
|
|
['style', '<p class="x" style="position:fixed;inset:0;z-index:99999">a</p>', '<p class="x">a</p>'],
|
|
['srcset and loading', '<p><img src="/a.jpg" alt="a" srcset="https://evil.test/a.jpg 2x" loading="lazy"></p>', '<p><img src="/a.jpg" alt="a"></p>'],
|
|
['background', '<table background="https://evil.test/bg.png"><tbody><tr><td>a</td></tr></tbody></table>', '<table><tbody><tr><td>a</td></tr></tbody></table>'],
|
|
['form, contenteditable and popover', '<p form="f" contenteditable="true" popover="auto" class="k">a</p>', '<p class="k">a</p>'],
|
|
['itemprop, dir and lang', '<p itemprop="description" dir="rtl" lang="pl">a</p>', '<p>a</p>'],
|
|
['a cell style', '<table><tbody><tr><td style="text-align: right" id="c">a</td></tr></tbody></table>', '<table><tbody><tr><td>a</td></tr></tbody></table>'],
|
|
])('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 =
|
|
'<h2 id="intro">a</h2><p name="querySelector" style="position:fixed" class="k" data-x="1">b <span style="color:red" id="s" class="hl">c</span></p>' +
|
|
'<p><img src="/a.jpg" alt="a" name="img" srcset="https://evil.test/a.jpg 2x" class="photo"></p>' +
|
|
'<table background="https://evil.test/bg.png"><tbody><tr><td style="color:red" id="c">d</td></tr></tbody></table>'
|
|
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('<p class="a" onclick="x()" ONMOUSEOVER="y()">a</p><p><img src="/a.jpg" alt="a" onerror="z()"></p>')
|
|
expect(out).toBe('<p class="a">a</p><p><img src="/a.jpg" alt="a"></p>')
|
|
})
|
|
})
|
|
|
|
describe('links', () => {
|
|
it('adds no target, rel or class and keeps stored ones', () => {
|
|
expect(model('<p><a href="https://example.test/">a</a></p>')).toBe('<p><a href="https://example.test/">a</a></p>')
|
|
const stored = model('<p><a href="https://example.test/" target="_blank" rel="noopener">a</a></p>')
|
|
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(`<p><a href="${href}">tekst</a></p>`)
|
|
expect(out).toBe('<p>tekst</p>')
|
|
},
|
|
)
|
|
})
|
|
|
|
describe('tables', () => {
|
|
it('renders without colgroup or a min-width style, header row in thead, bare cell text', () => {
|
|
const out = model('<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
|
|
expect(out).toBe('<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
|
|
expect(out).not.toContain('colgroup')
|
|
expect(out).not.toContain('min-width')
|
|
})
|
|
|
|
it('keeps a paragraph with attributes inside a cell', () => {
|
|
expect(model('<table><tbody><tr><td><p class="x">1</p></td></tr></tbody></table>')).toBe('<table><tbody><tr><td><p class="x">1</p></td></tr></tbody></table>')
|
|
})
|
|
|
|
it('keeps colspan and rowspan', () => {
|
|
const html = '<table><tbody><tr><td colspan="2">a</td></tr><tr><td rowspan="2">b</td><td>c</td></tr><tr><td>d</td></tr></tbody></table>'
|
|
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 ['<h2>Tytuł</h2>', '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>', '<table><tbody><tr><td>a</td></tr></tbody></table>', 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('<h3>Tytuł</h3>')
|
|
insertAtBlock(editor, 0, 'Nowy ')
|
|
expect(el.value).toBe('<h3>Nowy Tytuł</h3>')
|
|
})
|
|
})
|