- 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
211 lines
7.8 KiB
TypeScript
211 lines
7.8 KiB
TypeScript
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"')
|
|
})
|
|
})
|