Files
sm-journal-plugin/tests/blogeditor/corpus.test.ts
Jakub Zych 086e485215 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
2026-10-10 11:53:07 +02:00

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"')
})
})