feat(editor): preservation schema, block reuse, toolbar, uploads and embeds
- preservationExtensions: raw HTML cards for unmodelled blocks (shown as text), figure with figcaption, the YouTube embed placeholder node, span marks, a global attribute map (never on* handlers), tables without colgroup or min-width, inline images without base64 - serializeDocument keeps the original bytes of every top-level block still equal to the loaded one, so editing one paragraph changes only that paragraph; tables serialize with thead and bare cell text - Links accept only http, https, mailto and relative URLs (isPolicyUrl, the rule both sanitizer policies apply) and gain no target, rel or class - Toolbar with headings, marks, lists, links, blockquote, table editing, images, embeds, raw HTML block, undo and redo; en and pl labels - Images from the picker or a drop go out as summer-upload and are inserted on summer-upload-result with alt text and an optional caption - editor-corpus.json: the editor own output, rebuilt and checked by corpus.test.ts (UPDATE_CORPUS=1 rewrites it) for the policy tests - README Blog Editor section
This commit is contained in:
210
tests/blogeditor/corpus.test.ts
Normal file
210
tests/blogeditor/corpus.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||
import { mount, type } from './helpers'
|
||||
|
||||
// The editor's own output (D-05, D-10 amended). Every entry is built through
|
||||
// editor commands and typing only, never by inserting HTML, so it is what
|
||||
// the toolbar and input rules produce. The committed file is read in place
|
||||
// by the Go policy test (classes/editor_policy_test.go) and copied into the
|
||||
// layer, so both sanitizer policies are tested against the real output.
|
||||
// Entry names are stable once committed. UPDATE_CORPUS=1 rewrites the file.
|
||||
const CORPUS = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'editor-corpus.json')
|
||||
|
||||
interface Entry {
|
||||
name: string
|
||||
html: string
|
||||
}
|
||||
|
||||
/** startBlock puts the cursor in an empty paragraph at the end of the document. */
|
||||
function startBlock(editor: Editor): void {
|
||||
const { doc } = editor.state
|
||||
const last = doc.lastChild
|
||||
if (last && last.type.name === 'paragraph' && last.content.size === 0) {
|
||||
editor.commands.setTextSelection(doc.content.size - 1)
|
||||
return
|
||||
}
|
||||
editor.commands.insertContentAt(doc.content.size, { type: 'paragraph' })
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
||||
}
|
||||
|
||||
/** link types text and links it through setLink, then leaves the link. */
|
||||
function link(editor: Editor, text: string, href: string): void {
|
||||
const from = editor.state.selection.from
|
||||
type(editor, text)
|
||||
const to = editor.state.selection.from
|
||||
expect(editor.chain().setTextSelection({ from, to }).setLink({ href }).run(), href).toBe(true)
|
||||
editor.chain().setTextSelection(to).unsetMark('link').run()
|
||||
}
|
||||
|
||||
function mark(editor: Editor, toggle: () => boolean, text: string): void {
|
||||
expect(toggle()).toBe(true)
|
||||
type(editor, text)
|
||||
expect(toggle()).toBe(true)
|
||||
}
|
||||
|
||||
const IMAGE_SRC = '/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg'
|
||||
|
||||
const builders: Record<string, (editor: Editor) => void> = {
|
||||
'embed-youtube': (editor) => {
|
||||
startBlock(editor)
|
||||
const embed = parseEmbedUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')!
|
||||
expect(editor.commands.setEmbed(embed)).toBe(true)
|
||||
},
|
||||
'image-without-caption': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.setImage({ src: IMAGE_SRC, alt: 'Turkey Tail: czy to "grzyb odporności"?' })).toBe(true)
|
||||
},
|
||||
'image-with-caption': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(
|
||||
editor.commands.setFigure({ src: IMAGE_SRC, alt: 'Owocniki wrośniaka', caption: 'Owocniki wrośniaka różnobarwnego.' }),
|
||||
).toBe(true)
|
||||
},
|
||||
'table-header-row': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })).toBe(true)
|
||||
const cells = ['Forma', 'Dawka', 'Uwagi', 'Proszek', '1-2 g', 'z posiłkiem', 'Kapsułki', '2 x 500 mg', 'rano']
|
||||
cells.forEach((text, i) => {
|
||||
if (i > 0) expect(editor.commands.goToNextCell()).toBe(true)
|
||||
type(editor, text)
|
||||
})
|
||||
},
|
||||
links: (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Źródła: ')
|
||||
link(editor, 'strona', 'http://example.test/')
|
||||
type(editor, ', ')
|
||||
link(editor, 'badania', 'https://example.test/badania?rok=2026#wyniki')
|
||||
type(editor, ', ')
|
||||
link(editor, 'redakcja', 'mailto:redakcja@example.test')
|
||||
type(editor, ' i ')
|
||||
link(editor, 'inny wpis', '/blog/beta-glukany')
|
||||
type(editor, '.')
|
||||
},
|
||||
headings: (editor) => {
|
||||
for (const level of [2, 3, 4] as const) {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.setHeading({ level })).toBe(true)
|
||||
type(editor, `Nagłówek ${level}`)
|
||||
}
|
||||
},
|
||||
'bullet-list': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleBulletList()).toBe(true)
|
||||
type(editor, 'Beta-glukany')
|
||||
expect(editor.commands.splitListItem('listItem')).toBe(true)
|
||||
type(editor, 'Antyoksydanty')
|
||||
},
|
||||
'ordered-list': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleOrderedList()).toBe(true)
|
||||
type(editor, 'Pierwszy krok')
|
||||
expect(editor.commands.splitListItem('listItem')).toBe(true)
|
||||
type(editor, 'Drugi krok')
|
||||
},
|
||||
// The blockquote sits between the two ordered lists, so the combined
|
||||
// document keeps them as two lists instead of joining them.
|
||||
blockquote: (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleBlockquote()).toBe(true)
|
||||
type(editor, 'Grzyby funkcjonalne wspierają organizm.')
|
||||
},
|
||||
'ordered-list-start-3': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleOrderedList()).toBe(true)
|
||||
expect(editor.commands.updateAttributes('orderedList', { start: 3 })).toBe(true)
|
||||
type(editor, 'Trzeci krok')
|
||||
expect(editor.commands.splitListItem('listItem')).toBe(true)
|
||||
type(editor, 'Czwarty krok')
|
||||
},
|
||||
'inline-marks': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Tekst ')
|
||||
mark(editor, () => editor.commands.toggleBold(), 'pogrubiony')
|
||||
type(editor, ', ')
|
||||
mark(editor, () => editor.commands.toggleItalic(), 'kursywa')
|
||||
type(editor, ', ')
|
||||
mark(editor, () => editor.commands.toggleStrike(), 'przekreślony')
|
||||
type(editor, ', ')
|
||||
mark(editor, () => editor.commands.toggleUnderline(), 'podkreślony')
|
||||
type(editor, ' i ')
|
||||
mark(editor, () => editor.commands.toggleCode(), 'kod')
|
||||
type(editor, '.')
|
||||
},
|
||||
'code-block': (editor) => {
|
||||
startBlock(editor)
|
||||
expect(editor.commands.toggleCodeBlock()).toBe(true)
|
||||
type(editor, 'dawka = 2 * 500 // mg')
|
||||
},
|
||||
'horizontal-rule': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Przed linią')
|
||||
expect(editor.commands.setHorizontalRule()).toBe(true)
|
||||
type(editor, 'Po linii')
|
||||
},
|
||||
'hard-break': (editor) => {
|
||||
startBlock(editor)
|
||||
type(editor, 'Pierwsza linia')
|
||||
expect(editor.commands.setHardBreak()).toBe(true)
|
||||
type(editor, 'Druga linia')
|
||||
},
|
||||
}
|
||||
|
||||
function build(steps: ((editor: Editor) => void)[]): string {
|
||||
const { el, editor } = mount('')
|
||||
for (const step of steps) step(editor)
|
||||
const html = el.value
|
||||
el.remove()
|
||||
return html
|
||||
}
|
||||
|
||||
function corpus(): Entry[] {
|
||||
const entries: Entry[] = Object.entries(builders).map(([name, step]) => ({ name, html: build([step]) }))
|
||||
entries.push({ name: 'combined', html: build(Object.values(builders)) })
|
||||
return entries
|
||||
}
|
||||
|
||||
describe('editor corpus (D-05)', () => {
|
||||
const entries = corpus()
|
||||
|
||||
afterAll(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('matches the committed editor-corpus.json', () => {
|
||||
const text = `${JSON.stringify(entries, null, 2)}\n`
|
||||
if (process.env.UPDATE_CORPUS === '1') {
|
||||
writeFileSync(CORPUS, text)
|
||||
}
|
||||
expect(readFileSync(CORPUS, 'utf8')).toBe(text)
|
||||
})
|
||||
|
||||
it('holds no iframe, handler, target, rel or style and only policy URLs', () => {
|
||||
expect(entries.length).toBeGreaterThanOrEqual(15)
|
||||
const doc = document.implementation.createHTMLDocument('')
|
||||
for (const { name, html } of entries) {
|
||||
expect(html, name).not.toMatch(/<iframe|target=|rel=| on[a-z]+=|style=/i)
|
||||
expect(html, name).not.toBe('')
|
||||
expect(html, name).not.toMatch(/<p><\/p>$/)
|
||||
const host = doc.createElement('div')
|
||||
host.innerHTML = html
|
||||
for (const el of Array.from(host.querySelectorAll('[href], [src]'))) {
|
||||
const url = el.getAttribute('href') ?? el.getAttribute('src') ?? ''
|
||||
expect(isPolicyUrl(url), `${name}: ${url}`).toBe(true)
|
||||
}
|
||||
for (const a of Array.from(host.querySelectorAll('a'))) {
|
||||
expect(a.getAttributeNames(), name).toEqual(['href'])
|
||||
}
|
||||
}
|
||||
const html = entries.map((e) => e.html).join('\n')
|
||||
expect(html).toContain('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>')
|
||||
expect(html).toContain('<figcaption>')
|
||||
expect(html).toContain('<table>')
|
||||
expect(html).toContain('start="3"')
|
||||
})
|
||||
})
|
||||
62
tests/blogeditor/fixtures/editor-corpus.json
Normal file
62
tests/blogeditor/fixtures/editor-corpus.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"name": "embed-youtube",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure>"
|
||||
},
|
||||
{
|
||||
"name": "image-without-caption",
|
||||
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p>"
|
||||
},
|
||||
{
|
||||
"name": "image-with-caption",
|
||||
"html": "<figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure>"
|
||||
},
|
||||
{
|
||||
"name": "table-header-row",
|
||||
"html": "<table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table>"
|
||||
},
|
||||
{
|
||||
"name": "links",
|
||||
"html": "<p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "headings",
|
||||
"html": "<h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4>"
|
||||
},
|
||||
{
|
||||
"name": "bullet-list",
|
||||
"html": "<ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list",
|
||||
"html": "<ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "blockquote",
|
||||
"html": "<blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote>"
|
||||
},
|
||||
{
|
||||
"name": "ordered-list-start-3",
|
||||
"html": "<ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol>"
|
||||
},
|
||||
{
|
||||
"name": "inline-marks",
|
||||
"html": "<p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p>"
|
||||
},
|
||||
{
|
||||
"name": "code-block",
|
||||
"html": "<pre><code>dawka = 2 * 500 // mg</code></pre>"
|
||||
},
|
||||
{
|
||||
"name": "horizontal-rule",
|
||||
"html": "<p>Przed linią</p><hr><p>Po linii</p>"
|
||||
},
|
||||
{
|
||||
"name": "hard-break",
|
||||
"html": "<p>Pierwsza linia<br>Druga linia</p>"
|
||||
},
|
||||
{
|
||||
"name": "combined",
|
||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
|
||||
}
|
||||
]
|
||||
78
tests/blogeditor/helpers.ts
Normal file
78
tests/blogeditor/helpers.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import '../../assets/src/blogeditor/index'
|
||||
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
|
||||
|
||||
export interface Mounted {
|
||||
el: BlogEditorElement
|
||||
editor: Editor
|
||||
changes: string[]
|
||||
uploads: { id: string; file: File }[]
|
||||
}
|
||||
|
||||
/** mount creates a connected editor element, optionally with a value. */
|
||||
export function mount(value?: string, attributes: Record<string, string> = {}): Mounted {
|
||||
const el = document.createElement(TAG) as BlogEditorElement
|
||||
for (const [name, val] of Object.entries(attributes)) el.setAttribute(name, val)
|
||||
const changes: string[] = []
|
||||
const uploads: { id: string; file: File }[] = []
|
||||
el.addEventListener('summer-change', (e) => changes.push((e as CustomEvent<{ value: string }>).detail.value))
|
||||
el.addEventListener('summer-upload', (e) => uploads.push((e as CustomEvent<{ id: string; file: File }>).detail))
|
||||
if (value !== undefined) el.value = value
|
||||
document.body.append(el)
|
||||
return { el, editor: el.editor!, changes, uploads }
|
||||
}
|
||||
|
||||
/** type inserts text at the selection, as typing does. */
|
||||
export function type(editor: Editor, text: string): void {
|
||||
editor.view.dispatch(editor.state.tr.insertText(text))
|
||||
}
|
||||
|
||||
/** insertAtBlock types text at the start of the top-level textblock index. */
|
||||
export function insertAtBlock(editor: Editor, index: number, text: string): void {
|
||||
let pos = -1
|
||||
editor.state.doc.forEach((node, offset, i) => {
|
||||
if (i === index) pos = node.isTextblock ? offset + 1 : -1
|
||||
})
|
||||
if (pos < 0) throw new Error(`block ${index} is not a textblock`)
|
||||
editor.view.dispatch(editor.state.tr.insertText(text, pos))
|
||||
}
|
||||
|
||||
/** insertAtEveryTextblock types text at the start of every textblock. */
|
||||
export function insertAtEveryTextblock(editor: Editor, text: string): void {
|
||||
const positions: number[] = []
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isTextblock) positions.push(pos + 1)
|
||||
return true
|
||||
})
|
||||
let tr = editor.state.tr
|
||||
for (const pos of positions.reverse()) tr = tr.insertText(text, pos)
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
|
||||
/** button returns the toolbar button for a command. */
|
||||
export function button(el: HTMLElement, command: string): HTMLButtonElement {
|
||||
const found = el.querySelector<HTMLButtonElement>(`button[data-command="${command}"]`)
|
||||
if (!found) throw new Error(`no toolbar button ${command}`)
|
||||
return found
|
||||
}
|
||||
|
||||
/** form returns the inline form of a kind, or null when it is closed. */
|
||||
export function form(el: HTMLElement, kind: string): HTMLElement | null {
|
||||
const found = el.querySelector<HTMLElement>(`[data-form="${kind}"]`)
|
||||
return found && !found.hidden ? found : null
|
||||
}
|
||||
|
||||
export function click(el: Element | null | undefined): void {
|
||||
if (!el) throw new Error('nothing to click')
|
||||
;(el as HTMLElement).click()
|
||||
}
|
||||
|
||||
export function fill(input: Element | null | undefined, value: string): void {
|
||||
if (!input) throw new Error('no input')
|
||||
;(input as HTMLInputElement).value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
export function status(el: HTMLElement): string {
|
||||
return el.querySelector('[role="status"]')?.textContent ?? ''
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import fixture from './fixtures/imported-post.html?raw'
|
||||
import '../../assets/src/blogeditor/index'
|
||||
import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
|
||||
|
||||
function mount(value?: string): { el: BlogEditorElement; changes: string[] } {
|
||||
const el = document.createElement(TAG) as BlogEditorElement
|
||||
const changes: string[] = []
|
||||
el.addEventListener('summer-change', (e) => changes.push((e as CustomEvent<{ value: string }>).detail.value))
|
||||
if (value !== undefined) el.value = value
|
||||
document.body.append(el)
|
||||
return { el, changes }
|
||||
}
|
||||
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||
import { button, click, fill, form, insertAtBlock, insertAtEveryTextblock, mount, status } from './helpers'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
@@ -44,8 +38,7 @@ describe('blog editor element (tracer)', () => {
|
||||
})
|
||||
|
||||
it('emits exactly one summer-change per user edit with the element value', () => {
|
||||
const { el, changes } = mount('<p>Hello</p>')
|
||||
const editor = el.editor!
|
||||
const { el, editor, changes } = mount('<p>Hello</p>')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
expect(changes).toHaveLength(1)
|
||||
expect(changes[0]).toBe(el.value)
|
||||
@@ -75,12 +68,25 @@ describe('blog editor element (tracer)', () => {
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps an edited value after it is detached and attached again', () => {
|
||||
const { el, editor, changes } = mount('<p>a</p>')
|
||||
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
||||
el.remove()
|
||||
expect(el.value).toBe('<p>Xa</p>')
|
||||
document.body.append(el)
|
||||
expect(el.value).toBe('<p>Xa</p>')
|
||||
expect(el.editor!.state.doc.textContent).toBe('Xa')
|
||||
expect(changes).toEqual(['<p>Xa</p>'])
|
||||
})
|
||||
|
||||
it('a new external value replaces the content without an event', () => {
|
||||
const { el, changes } = mount('<p>One</p>')
|
||||
el.value = '<p>Two</p>'
|
||||
expect(el.value).toBe('<p>Two</p>')
|
||||
expect(el.editor!.state.doc.textContent).toBe('Two')
|
||||
expect(changes).toEqual([])
|
||||
// The replaced value is not in the undo history.
|
||||
expect(el.editor!.can().undo()).toBe(false)
|
||||
})
|
||||
|
||||
it('disabled makes the editor and the source read-only; invalid sets aria-invalid', () => {
|
||||
@@ -96,3 +102,400 @@ describe('blog editor element (tracer)', () => {
|
||||
expect(el.editor!.view.dom.hasAttribute('aria-invalid')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preservation schema', () => {
|
||||
const rawBlocks = [
|
||||
'<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person"><p>Autor</p></footer>',
|
||||
'<section class="s"><p>Sekcja</p></section>',
|
||||
'<aside>Na marginesie</aside>',
|
||||
'<header><h2>Nagłówek</h2></header>',
|
||||
'<nav><a href="/a">a</a></nav>',
|
||||
'<details><summary>Więcej</summary>Treść</details>',
|
||||
'<style>.x { color: red; }</style>',
|
||||
'<div class="box" data-x="1">Pudełko</div>',
|
||||
'<figure class="quote"><blockquote>Cytat</blockquote><figcaption>Autor</figcaption></figure>',
|
||||
]
|
||||
|
||||
it('turns unknown block markup into raw HTML cards that keep it through an edit', () => {
|
||||
const html = `<p>Start</p>\n${rawBlocks.join('\n')}\n`
|
||||
const { el, editor } = mount(html)
|
||||
const types: string[] = []
|
||||
editor.state.doc.forEach((node) => types.push(node.type.name))
|
||||
expect(types).toEqual(['paragraph', ...rawBlocks.map(() => 'rawHtml')])
|
||||
|
||||
const cards = Array.from(el.querySelectorAll('.blogeditor__raw-source')).map((n) => n.textContent)
|
||||
expect(cards).toEqual(rawBlocks)
|
||||
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe(`<p>XStart</p>\n${rawBlocks.join('\n')}\n`)
|
||||
})
|
||||
|
||||
it('renders a raw HTML card as text, never as live markup', () => {
|
||||
const payload = '<div><img src="x" onerror="window.__blogeditorPwned = 1"><script>window.__blogeditorPwned = 2</script></div>'
|
||||
const { el, editor } = mount(`<p>a</p>${payload}`)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('rawHtml')
|
||||
expect(el.querySelector('.ProseMirror img')).toBeNull()
|
||||
expect(el.querySelector('.ProseMirror script')).toBeNull()
|
||||
expect(el.querySelector('.blogeditor__raw-source')!.textContent).toBe(payload)
|
||||
expect((window as unknown as { __blogeditorPwned?: number }).__blogeditorPwned).toBeUndefined()
|
||||
|
||||
// Handler attributes on modelled nodes never reach the editor DOM.
|
||||
const inline = mount('<p><img src="/a.png" alt="a" onerror="window.__blogeditorPwned = 3" onload="x()"></p>')
|
||||
const img = inline.el.querySelector('.ProseMirror img:not(.ProseMirror-separator)')!
|
||||
expect(img.getAttribute('src')).toBe('/a.png')
|
||||
expect(img.getAttributeNames().filter((name) => name.startsWith('on'))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps unmodelled attributes on edited blocks and spans as marks', () => {
|
||||
const html =
|
||||
'<p class="lead" data-x="1" itemprop="description">Tekst <span style="color: red;" class="s">czerwony</span></p>' +
|
||||
'<h2 id="h" class="c">Tytuł</h2>' +
|
||||
'<ul class="u"><li data-i="1"><p>Punkt</p></li></ul>' +
|
||||
'<ol start="3" class="o"><li><p>Trzy</p></li></ol>' +
|
||||
'<blockquote class="q"><p>Cytat</p></blockquote>' +
|
||||
'<p><a href="/x" class="lnk" data-t="1">link</a> <img src="/a.png" alt="A" class="photo" data-id="7"></p>' +
|
||||
'<table class="t"><tbody><tr><td class="c" style="text-align: right;">1</td></tr></tbody></table>'
|
||||
const { el, editor } = mount(html)
|
||||
insertAtEveryTextblock(editor, 'X')
|
||||
expect(el.value).toBe(
|
||||
'<p class="lead" data-x="1" itemprop="description">XTekst <span style="color: red;" class="s">czerwony</span></p>' +
|
||||
'<h2 id="h" class="c">XTytuł</h2>' +
|
||||
'<ul class="u"><li data-i="1"><p>XPunkt</p></li></ul>' +
|
||||
'<ol class="o" start="3"><li><p>XTrzy</p></li></ol>' +
|
||||
'<blockquote class="q"><p>XCytat</p></blockquote>' +
|
||||
'<p><a class="lnk" data-t="1" href="/x">Xlink</a> <img class="photo" data-id="7" src="/a.png" alt="A"></p>' +
|
||||
'<table class="t"><tbody><tr><td class="c" style="text-align: right;">X1</td></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves every other top-level block byte-identical after editing one paragraph', () => {
|
||||
const { el, editor, changes } = mount(fixture)
|
||||
insertAtBlock(editor, 1, 'X')
|
||||
const expected = fixture.replace('<p>Turkey Tail', '<p>XTurkey Tail')
|
||||
expect(expected).not.toBe(fixture)
|
||||
expect(el.value).toBe(expected)
|
||||
expect(changes).toEqual([expected])
|
||||
|
||||
// Undoing the edit gives back the exact original bytes.
|
||||
expect(editor.commands.undo()).toBe(true)
|
||||
expect(el.value).toBe(fixture)
|
||||
})
|
||||
|
||||
it('serializes tables without colgroup or min-width, with thead and bare cell text', () => {
|
||||
const html = '<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>'
|
||||
const { el, editor } = mount(html)
|
||||
insertAtEveryTextblock(editor, 'X')
|
||||
expect(el.value).toBe(
|
||||
'<table><thead><tr><th>XA</th><th>XB</th></tr></thead><tbody><tr><td>X1</td><td>X2</td></tr></tbody></table>',
|
||||
)
|
||||
|
||||
const fresh = mount('<p>t</p>')
|
||||
fresh.editor.commands.setTextSelection(1)
|
||||
expect(fresh.editor.commands.insertTable({ rows: 2, cols: 2, withHeaderRow: true })).toBe(true)
|
||||
expect(fresh.el.value).not.toMatch(/colgroup|min-width|style=/)
|
||||
expect(fresh.el.value).toContain('<thead><tr><th></th><th></th></tr></thead><tbody><tr><td></td><td></td></tr></tbody>')
|
||||
expect(fresh.el.value).not.toMatch(/<p><\/p>$/)
|
||||
})
|
||||
|
||||
it('keeps stored link target and rel, adds none and leaves no trailing paragraph', () => {
|
||||
// Text typed at the start of a link joins the link (links are inclusive).
|
||||
// Edited links keep their attributes; their order may change.
|
||||
const { el, editor } = mount('<p><a href="https://x.test/" target="_blank" rel="noopener">x</a> y</p><hr>')
|
||||
insertAtBlock(editor, 0, 'Z')
|
||||
expect(el.value).toBe('<p><a target="_blank" rel="noopener" href="https://x.test/">Zx</a> y</p><hr>')
|
||||
|
||||
const fresh = mount('<p>link</p>')
|
||||
fresh.editor.commands.setTextSelection({ from: 1, to: 5 })
|
||||
expect(fresh.editor.commands.setLink({ href: 'https://example.test/' })).toBe(true)
|
||||
expect(fresh.el.value).toBe('<p><a href="https://example.test/">link</a></p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('embeds', () => {
|
||||
it('parses supported YouTube URLs to the 11-character id', () => {
|
||||
const id = 'dQw4w9WgXcQ'
|
||||
for (const url of [
|
||||
`https://www.youtube.com/watch?v=${id}`,
|
||||
`https://youtube.com/watch?v=${id}&t=42s`,
|
||||
`https://m.youtube.com/watch?feature=share&v=${id}`,
|
||||
`https://youtu.be/${id}`,
|
||||
`https://youtu.be/${id}?si=abc`,
|
||||
`https://www.youtube.com/shorts/${id}`,
|
||||
`https://www.youtube.com/embed/${id}`,
|
||||
` https://www.youtube-nocookie.com/embed/${id} `,
|
||||
]) {
|
||||
expect(parseEmbedUrl(url), url).toEqual({ provider: 'youtube', id })
|
||||
}
|
||||
for (const url of [
|
||||
'https://vimeo.com/123456',
|
||||
'https://www.youtube.com/watch?v=short',
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQQ',
|
||||
'https://youtu.be/dQw4w9WgXc!',
|
||||
'https://evil.test/embed/dQw4w9WgXcQ',
|
||||
'javascript:alert(1)//youtu.be/dQw4w9WgXcQ',
|
||||
'ftp://youtu.be/dQw4w9WgXcQ',
|
||||
'not a url',
|
||||
'',
|
||||
]) {
|
||||
expect(parseEmbedUrl(url), url).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips the exact placeholder and shows a card, never an iframe', () => {
|
||||
const placeholder = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
|
||||
const { el, editor } = mount(`<p>a</p>${placeholder}`)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('embed')
|
||||
expect(el.querySelector('iframe')).toBeNull()
|
||||
expect(el.querySelector('.blogeditor__embed')!.textContent).toContain('YouTube')
|
||||
insertAtBlock(editor, 0, 'X')
|
||||
expect(el.value).toBe(`<p>Xa</p>${placeholder}`)
|
||||
|
||||
const fresh = mount('')
|
||||
expect(fresh.editor.commands.setEmbed({ provider: 'youtube', id: 'dQw4w9WgXcQ' })).toBe(true)
|
||||
expect(fresh.el.value).toBe(placeholder)
|
||||
expect(fresh.editor.commands.setEmbed({ provider: 'youtube', id: 'bad' })).toBe(false)
|
||||
expect(fresh.editor.commands.setEmbed({ provider: 'vimeo', id: 'dQw4w9WgXcQ' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a malformed placeholder as raw HTML', () => {
|
||||
const { editor } = mount('<figure data-embed="youtube" data-id="bad"></figure>')
|
||||
expect(editor.state.doc.child(0).type.name).toBe('rawHtml')
|
||||
})
|
||||
|
||||
it('inserts an embed from the toolbar URL input and refuses unsupported URLs', () => {
|
||||
const { el } = mount('')
|
||||
click(button(el, 'embed'))
|
||||
const embedForm = form(el, 'embed')!
|
||||
fill(embedForm.querySelector('input'), 'https://vimeo.com/1')
|
||||
click(embedForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'embed')).not.toBeNull()
|
||||
expect(embedForm.querySelector('[data-error]')!.textContent).toBe('This video URL is not supported. Paste a YouTube link.')
|
||||
expect(el.value).toBe('')
|
||||
|
||||
fill(embedForm.querySelector('input'), 'https://youtu.be/dQw4w9WgXcQ')
|
||||
click(embedForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'embed')).toBeNull()
|
||||
expect(el.value).toBe('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('image uploads', () => {
|
||||
function pick(el: HTMLElement, files: File[]): void {
|
||||
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
expect(input.accept).toBe('image/jpeg,image/png,image/gif,image/webp')
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||||
input.dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
function answer(el: HTMLElement, detail: Record<string, string>): void {
|
||||
el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
|
||||
}
|
||||
|
||||
const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'grzyb.png', { type: 'image/png' })
|
||||
|
||||
it('sends a picked image as summer-upload and inserts img with alt text', () => {
|
||||
const { el, uploads } = mount('')
|
||||
click(button(el, 'image'))
|
||||
pick(el, [png()])
|
||||
expect(uploads).toHaveLength(1)
|
||||
expect(uploads[0]!.file.name).toBe('grzyb.png')
|
||||
expect(uploads[0]!.id).toMatch(/[0-9a-f-]{36}/)
|
||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||
|
||||
answer(el, { id: 'someone-else', url: '/x.png' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
|
||||
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
|
||||
const imageForm = form(el, 'image')!
|
||||
click(imageForm.querySelector('[data-action="insert"]'))
|
||||
expect(imageForm.querySelector('[data-error]')!.textContent).toBe('Enter the alt text.')
|
||||
expect(el.value).toBe('')
|
||||
|
||||
fill(imageForm.querySelector('input[name="alt"]'), 'Grzyb: czy to "Turkey Tail"?')
|
||||
click(imageForm.querySelector('[data-action="insert"]'))
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(el.value).toBe('<p><img src="/storage/app/uploads/public/journal/grzyb.png" alt="Grzyb: czy to "Turkey Tail"?"></p>')
|
||||
})
|
||||
|
||||
it('inserts figure with figcaption when a caption is given', () => {
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: uploads[0]!.id, url: '/a.png' })
|
||||
const imageForm = form(el, 'image')!
|
||||
fill(imageForm.querySelector('input[name="alt"]'), 'Owocniki')
|
||||
fill(imageForm.querySelector('input[name="caption"]'), 'Owocniki na pniu.')
|
||||
click(imageForm.querySelector('[data-action="insert"]'))
|
||||
expect(el.value).toBe('<figure><img src="/a.png" alt="Owocniki"><figcaption>Owocniki na pniu.</figcaption></figure>')
|
||||
})
|
||||
|
||||
it('shows an upload error and inserts nothing', () => {
|
||||
const { el, uploads, changes } = mount('<p>a</p>')
|
||||
pick(el, [png()])
|
||||
answer(el, { id: uploads[0]!.id, error: 'File too large' })
|
||||
expect(form(el, 'image')).toBeNull()
|
||||
expect(status(el)).toBe('Upload failed: File too large')
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
expect(changes).toEqual([])
|
||||
})
|
||||
|
||||
it('ends a pending upload after 60 seconds', () => {
|
||||
vi.useFakeTimers()
|
||||
const { el, uploads } = mount('')
|
||||
pick(el, [png()])
|
||||
vi.advanceTimersByTime(60_000)
|
||||
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()
|
||||
})
|
||||
|
||||
it('uploads image files dropped on the editor', () => {
|
||||
const { el, editor, uploads } = mount('<p>a</p>')
|
||||
let prevented = false
|
||||
const event = {
|
||||
dataTransfer: { files: [png(), new File(['x'], 'notes.txt', { type: 'text/plain' })] },
|
||||
preventDefault: () => {
|
||||
prevented = true
|
||||
},
|
||||
} as unknown as DragEvent
|
||||
const handled = editor.view.someProp('handleDrop', (f) => f(editor.view, event, editor.state.doc.slice(0, 0), false))
|
||||
expect(handled).toBe(true)
|
||||
expect(prevented).toBe(true)
|
||||
expect(uploads.map((u) => u.file.name)).toEqual(['grzyb.png'])
|
||||
expect(el.value).toBe('<p>a</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('links and the URL rule', () => {
|
||||
it('accepts only http, https, mailto and relative URLs', () => {
|
||||
for (const url of ['http://a.test', 'https://a.test/x?y#z', 'HTTPS://A.TEST', 'mailto:a@b.test', '/rel', 'rel/path', '#frag', '?q=1', '']) {
|
||||
expect(isPolicyUrl(url), url).toBe(true)
|
||||
}
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'java\tscript:alert(1)',
|
||||
'java\nscript:alert(1)',
|
||||
'vbscript:msgbox(1)',
|
||||
'data:text/html,<b>x</b>',
|
||||
'tel:+48123',
|
||||
'ftp://a.test/',
|
||||
]) {
|
||||
expect(isPolicyUrl(url), JSON.stringify(url)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses links outside the rule in commands and in stored HTML', () => {
|
||||
const { el, editor } = mount('<p>text</p><p><a href="javascript:alert(1)">bad</a></p>')
|
||||
expect(el.querySelector('.ProseMirror a')).toBeNull()
|
||||
editor.commands.setTextSelection({ from: 1, to: 5 })
|
||||
expect(editor.commands.setLink({ href: 'javascript:alert(1)' })).toBe(false)
|
||||
expect(editor.commands.setLink({ href: 'tel:+48123' })).toBe(false)
|
||||
expect(editor.commands.setLink({ href: 'java\tscript:alert(1)' })).toBe(false)
|
||||
})
|
||||
|
||||
it('creates, refuses and removes links through the toolbar input', () => {
|
||||
const { el, editor } = mount('<p>link</p>')
|
||||
editor.commands.setTextSelection({ from: 1, to: 5 })
|
||||
click(button(el, 'link'))
|
||||
const linkForm = form(el, 'link')!
|
||||
fill(linkForm.querySelector('input'), 'data:text/html,x')
|
||||
click(linkForm.querySelector('[data-action="apply"]'))
|
||||
expect(linkForm.querySelector('[data-error]')!.textContent).toBe('Links must use http, https, mailto or a relative URL.')
|
||||
expect(el.value).toBe('<p>link</p>')
|
||||
|
||||
fill(linkForm.querySelector('input'), 'mailto:redakcja@example.test')
|
||||
click(linkForm.querySelector('[data-action="apply"]'))
|
||||
expect(el.value).toBe('<p><a href="mailto:redakcja@example.test">link</a></p>')
|
||||
|
||||
editor.commands.setTextSelection(2)
|
||||
click(button(el, 'link'))
|
||||
const again = form(el, 'link')!
|
||||
expect((again.querySelector('input') as HTMLInputElement).value).toBe('mailto:redakcja@example.test')
|
||||
fill(again.querySelector('input'), '')
|
||||
click(again.querySelector('[data-action="apply"]'))
|
||||
expect(el.value).toBe('<p>link</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolbar', () => {
|
||||
it('offers every D-04 feature and the tabs', () => {
|
||||
const { el } = mount('')
|
||||
for (const command of [
|
||||
'paragraph',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'heading4',
|
||||
'bold',
|
||||
'italic',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'link',
|
||||
'blockquote',
|
||||
'insertTable',
|
||||
'addRow',
|
||||
'deleteRow',
|
||||
'addColumn',
|
||||
'deleteColumn',
|
||||
'deleteTable',
|
||||
'image',
|
||||
'embed',
|
||||
'rawHtml',
|
||||
'undo',
|
||||
'redo',
|
||||
]) {
|
||||
expect(button(el, command).getAttribute('aria-label'), command).toBeTruthy()
|
||||
}
|
||||
expect(el.querySelector('[data-tab="wysiwyg"]')!.textContent).toBe('WYSIWYG')
|
||||
expect(el.querySelector('[data-tab="source"]')!.textContent).toBe('Source')
|
||||
})
|
||||
|
||||
it('uses the Polish labels for ui-locale pl', () => {
|
||||
const { el } = mount('', { 'ui-locale': 'pl' })
|
||||
expect(el.querySelector('[data-tab="source"]')!.textContent).toBe('Źródło')
|
||||
expect(button(el, 'bold').getAttribute('aria-label')).toBe('Pogrubienie')
|
||||
})
|
||||
|
||||
it('formats text and edits tables from the toolbar', () => {
|
||||
const { el, editor } = mount('<p>abc</p>')
|
||||
editor.commands.setTextSelection({ from: 1, to: 4 })
|
||||
click(button(el, 'bold'))
|
||||
expect(el.value).toBe('<p><strong>abc</strong></p>')
|
||||
expect(button(el, 'bold').getAttribute('aria-pressed')).toBe('true')
|
||||
click(button(el, 'undo'))
|
||||
expect(el.value).toBe('<p>abc</p>')
|
||||
click(button(el, 'redo'))
|
||||
expect(el.value).toBe('<p><strong>abc</strong></p>')
|
||||
click(button(el, 'heading3'))
|
||||
expect(el.value).toBe('<h3><strong>abc</strong></h3>')
|
||||
|
||||
editor.commands.setTextSelection(4)
|
||||
click(button(el, 'insertTable'))
|
||||
expect(el.value).toContain('<table><thead><tr><th></th><th></th><th></th></tr></thead>')
|
||||
click(button(el, 'addRow'))
|
||||
click(button(el, 'deleteColumn'))
|
||||
expect(el.value.match(/<tr>/g)).toHaveLength(4)
|
||||
expect(el.value).toContain('<tr><th></th><th></th></tr>')
|
||||
click(button(el, 'deleteTable'))
|
||||
expect(el.value).not.toContain('<table')
|
||||
})
|
||||
|
||||
it('inserts a raw HTML block and opens the source tab at it', () => {
|
||||
const { el, editor } = mount('<p>a</p>')
|
||||
editor.commands.setTextSelection(2)
|
||||
click(button(el, 'rawHtml'))
|
||||
const textarea = el.querySelector('textarea')!
|
||||
expect(textarea.hidden).toBe(false)
|
||||
expect(el.value).toBe('<p>a</p><div></div>')
|
||||
expect(textarea.value).toBe('<p>a</p><div></div>')
|
||||
expect(textarea.selectionStart).toBe('<p>a</p><div>'.length)
|
||||
})
|
||||
|
||||
it('edits a raw HTML card in the source tab', () => {
|
||||
const { el } = mount('<p>a</p>\n<footer>f</footer>')
|
||||
click(el.querySelector('.blogeditor__raw button'))
|
||||
const textarea = el.querySelector('textarea')!
|
||||
expect(textarea.hidden).toBe(false)
|
||||
expect(textarea.value.slice(textarea.selectionStart, textarea.selectionEnd)).toBe('<footer>f</footer>')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user