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 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(/<\/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('
') expect(html).toContain('
') expect(html).toContain('') expect(html).toContain('start="3"') }) })