Files
sm-journal-plugin/tests/blogeditor/additions.test.ts
Jakub Zych 4b09bd9312 feat: editor heading levels, outline, link rel, generic-anchor warning and image naming
- the blogeditor and mlblogeditor types serve headingLevels and
  genericAnchors through RequestOptions, per schema request
- J1: H2 to H4 with a pasted or loaded H1 parsed as H2 while the H1
  setting is on, H1 to H4 while it is off; H5 and H6 still parse
- J2: the outline bar and panel (skipped, empty, link-only, H1 in Source,
  generic link texts), 300 ms after a change, collapsed, read-only
- J3: nofollow/sponsored/ugc checkboxes for absolute http(s) links only,
  and a non-blocking warning for a generic link text
- J4: one form per picked file with the slugged file name, alt required
  unless decorative (alt=""); Insert uploads the renamed file and the
  image is inserted on the upload result
- assets/dist rebuilt
2026-11-04 10:35:19 +01:00

210 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readOptions } from '../../assets/src/blogeditor/element'
import { mediaFileName, slugFileStem } from '../../assets/src/blogeditor/imageform'
import { labelsFor, plural } from '../../assets/src/blogeditor/labels'
import { isExternalUrl } from '../../assets/src/blogeditor/linkform'
import { normalizeAnchor, OUTLINE_DEBOUNCE_MS } from '../../assets/src/blogeditor/outline'
import { button, chooseImage, click, fill, form, insertAtBlock, mount, status } from './helpers'
afterEach(() => {
vi.useRealTimers()
document.body.replaceChildren()
})
const ON = JSON.stringify({ headingLevels: [2, 3, 4], genericAnchors: ['click here', 'more', 'kliknij tutaj'] })
const OFF = JSON.stringify({ headingLevels: [1, 2, 3, 4], genericAnchors: ['click here'] })
function outlineToggle(el: HTMLElement): HTMLButtonElement {
return el.querySelector<HTMLButtonElement>('.blogeditor__outline-toggle')!
}
function openOutline(el: HTMLElement): HTMLElement {
click(outlineToggle(el))
return el.querySelector<HTMLElement>('.blogeditor__outline-panel')!
}
describe('options', () => {
it('reads headingLevels and genericAnchors with defaults', () => {
expect(readOptions(null)).toEqual({ headingLevels: [2, 3, 4], genericAnchors: expect.arrayContaining(['click here', 'czytaj więcej']) })
expect(readOptions('not json').headingLevels).toEqual([2, 3, 4])
expect(readOptions(JSON.stringify({ headingLevels: [4, 1, 9, 'x', 2.5, 2], genericAnchors: [' Read More! ', 3] }))).toEqual({
headingLevels: [1, 2, 4],
genericAnchors: ['read more'],
})
})
})
describe('J1 heading levels', () => {
it('offers H2 to H4 and parses an H1 as an H2 while the H1 setting is on', () => {
const { el, editor } = mount('<h1 class="lead">Title</h1><p>x</p><h5>Small</h5>', { options: ON })
expect(el.querySelector('button[data-command="heading1"]')).toBeNull()
expect(button(el, 'heading2')).toBeTruthy()
const first = editor.state.doc.firstChild!
expect(first.type.name).toBe('heading')
expect(first.attrs.level).toBe(2)
// Untouched, the value is the exact string (D-03).
expect(el.value).toBe('<h1 class="lead">Title</h1><p>x</p><h5>Small</h5>')
insertAtBlock(editor, 1, 'y')
expect(el.value).toBe('<h1 class="lead">Title</h1><p>yx</p><h5>Small</h5>')
insertAtBlock(editor, 0, 'A ')
expect(el.value).toBe('<h2 class="lead">A Title</h2><p>yx</p><h5>Small</h5>')
expect(editor.can().toggleHeading({ level: 1 as 2 })).toBe(false)
})
it('offers H1 to H4 and keeps an H1 while the H1 setting is off', () => {
const { el, editor } = mount('<h1>Title</h1>', { options: OFF })
const commands = Array.from(el.querySelectorAll<HTMLButtonElement>('.blogeditor__group button')).map((b) => b.dataset.command)
expect(commands.slice(0, 5)).toEqual(['paragraph', 'heading1', 'heading2', 'heading3', 'heading4'])
expect(button(el, 'heading1').getAttribute('aria-label')).toBe('Heading 1')
insertAtBlock(editor, 0, 'A ')
expect(el.value).toBe('<h1>A Title</h1>')
})
})
describe('J2 outline', () => {
it('starts collapsed, never takes focus and counts headings and problems', () => {
const { el } = mount('<h3>Deep</h3><h2></h2><h2><a href="/x">Only link</a></h2><p><a href="/y">click here</a></p>', { options: ON })
const toggle = outlineToggle(el)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).not.toBe(toggle)
expect(el.querySelector<HTMLElement>('.blogeditor__outline-panel')!.hidden).toBe(true)
expect(toggle.textContent).toBe('Outline · 3 headings4 problems')
const panel = openOutline(el)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
const items = Array.from(panel.querySelectorAll('li'))
expect(items.map((li) => li.querySelector('.blogeditor__outline-level')!.textContent)).toEqual(['H3', 'H2', 'H2'])
expect(items[0]!.textContent).toContain('Skipped level: H1 → H3')
expect(items[1]!.textContent).toContain('Empty heading')
expect(items[2]!.textContent).toContain('Heading is only a link')
expect(items[0]!.style.getPropertyValue('--blogeditor-outline-depth')).toBe('1')
expect(panel.querySelector('.blogeditor__outline-links')!.textContent).toBe('LinksLink text “click here” does not say where it leads')
})
it('shows outlineEmpty without headings and uses Polish plurals', () => {
const { el } = mount('<p>Tekst</p>', { options: ON, 'ui-locale': 'pl' })
expect(outlineToggle(el).textContent).toBe('Konspekt · 0 nagłówków')
expect(openOutline(el).textContent).toBe('Brak nagłówków. Nagłówki dzielą tekst na części dla czytelników i wyszukiwarek.')
const pl = labelsFor('pl')
expect(plural(pl, pl.outlineCount, 1)).toBe('1 nagłówek')
expect(plural(pl, pl.outlineCount, 3)).toBe('3 nagłówki')
expect(plural(pl, pl.outlineCount, 5)).toBe('5 nagłówków')
expect(plural(pl, pl.outlineProblems, 22)).toBe('22 problemy')
})
it('recomputes 300 ms after a change and flags an H1 in the Source tab while the setting is on', () => {
vi.useFakeTimers()
const { el, editor, changes } = mount('<h2>One</h2>', { options: ON })
openOutline(el)
insertAtBlock(editor, 0, 'x')
expect(outlineToggle(el).textContent).toBe('Outline · 1 heading')
el.showSource(true)
fill(el.querySelector('textarea'), '<h1>Top</h1><h2>Two</h2>')
vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS - 1)
expect(outlineToggle(el).textContent).toBe('Outline · 1 heading')
vi.advanceTimersByTime(1)
expect(outlineToggle(el).textContent).toBe('Outline · 2 headings1 problem')
expect(el.querySelector('.blogeditor__outline-panel li')!.textContent).toContain('H1 in the text: saved as H2')
// The outline never edits: only the two user edits changed the value.
expect(changes).toHaveLength(2)
})
it('does not flag an H1 while the setting is off', () => {
const { el } = mount('<h1>Top</h1><h2>Two</h2>', { options: OFF })
el.showSource(true)
expect(outlineToggle(el).textContent).toBe('Outline · 2 headings')
})
})
describe('J3 link form', () => {
it('offers rel only for absolute http(s) URLs and writes the checked tokens', () => {
const { el, editor } = mount('<p>Partner</p>', { options: ON })
editor.commands.setTextSelection({ from: 1, to: 8 })
click(button(el, 'link'))
const linkForm = form(el, 'link')!
const box = (token: string) => linkForm.querySelector<HTMLInputElement>(`input[name="rel-${token}"]`)!
expect(linkForm.querySelector('legend')!.textContent).toBe('Link attributes')
expect(box('nofollow').disabled).toBe(true)
expect(linkForm.querySelector<HTMLElement>('.blogeditor__hint')!.hidden).toBe(false)
fill(linkForm.querySelector('input[name="url"]'), 'https://partner.test/')
expect(box('nofollow').disabled).toBe(false)
expect(linkForm.querySelector<HTMLElement>('.blogeditor__hint')!.hidden).toBe(true)
box('nofollow').checked = true
box('sponsored').checked = true
click(linkForm.querySelector('[data-action="apply"]'))
expect(el.value).toBe('<p><a rel="nofollow sponsored" href="https://partner.test/">Partner</a></p>')
editor.commands.setTextSelection(3)
click(button(el, 'link'))
const again = form(el, 'link')!
expect(again.querySelector<HTMLInputElement>('input[name="rel-sponsored"]')!.checked).toBe(true)
fill(again.querySelector('input[name="url"]'), '/blog/partner')
click(again.querySelector('[data-action="apply"]'))
expect(el.value).toBe('<p><a href="/blog/partner">Partner</a></p>')
})
it('warns about a generic link text without blocking Apply', () => {
const { el, editor } = mount('<p>Click here!</p>', { options: ON })
editor.commands.setTextSelection({ from: 1, to: 12 })
click(button(el, 'link'))
const linkForm = form(el, 'link')!
const warning = linkForm.querySelector<HTMLElement>('[data-warning]')!
expect(warning.hidden).toBe(false)
expect(warning.getAttribute('aria-live')).toBe('polite')
expect(warning.textContent).toBe('“Click here!” does not say where the link leads. Use words that describe the target page.')
fill(linkForm.querySelector('input[name="url"]'), '/blog/x')
click(linkForm.querySelector('[data-action="apply"]'))
expect(el.value).toBe('<p><a href="/blog/x">Click here!</a></p>')
})
it('treats only absolute http(s) URLs as external', () => {
for (const url of ['https://a.test', 'HTTP://a.test/x']) expect(isExternalUrl(url), url).toBe(true)
for (const url of ['/a', 'a.test', 'mailto:a@b.test', '//a.test', 'https://', '#x']) expect(isExternalUrl(url), url).toBe(false)
expect(normalizeAnchor(' Read MORE… ')).toBe('read more')
})
})
describe('J4 image naming', () => {
const pick = (el: HTMLElement, files: File[]) => {
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
Object.defineProperty(input, 'files', { value: files, configurable: true })
input.dispatchEvent(new Event('change'))
}
const answer = (el: HTMLElement, detail: Record<string, string>) => el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
it('slugs the file name before the upload (UI-SPEC J4 backstop)', () => {
expect(mediaFileName('Lwia Grzywa – w lesie!.JPG')).toBe('lwia-grzywa-w-lesie.jpg')
expect(mediaFileName('.png')).toBe('image.png')
expect(slugFileStem('')).toBe('image')
expect(slugFileStem('ŻÓŁĆ gęślą jaźń')).toBe('zolc-gesla-jazn')
expect(slugFileStem('a'.repeat(81))).toHaveLength(80)
expect(slugFileStem('a'.repeat(79) + ' b')).toBe('a'.repeat(79))
const { el, uploads } = mount('', { options: ON })
pick(el, [new File([new Uint8Array([255, 216, 255])], 'Lwia Grzywa – w lesie!.JPG', { type: 'image/jpeg' })])
const imageForm = form(el, 'image')!
const name = imageForm.querySelector<HTMLInputElement>('input[name="fileName"]')!
expect(name.value).toBe('lwia-grzywa-w-lesie')
expect(imageForm.querySelector('.blogeditor__ext')!.textContent).toBe('.jpg')
fill(name, 'Lew w Lesie')
name.dispatchEvent(new Event('blur'))
expect(name.value).toBe('lew-w-lesie')
chooseImage(el, { alt: 'Lew' })
expect(uploads[0]!.file.name).toBe('lew-w-lesie.jpg')
expect(uploads[0]!.file.type).toBe('image/jpeg')
expect(status(el)).toBe('Uploading lew-w-lesie.jpg…')
})
it('inserts alt="" for a decorative image', () => {
const { el, uploads } = mount('', { options: ON })
pick(el, [new File([new Uint8Array([137, 80, 78, 71])], 'kreska.png', { type: 'image/png' })])
const imageForm = form(el, 'image')!
const alt = imageForm.querySelector<HTMLInputElement>('input[name="alt"]')!
fill(alt, 'temporary')
chooseImage(el, { decorative: true })
expect(alt.value).toBe('')
expect(alt.disabled).toBe(true)
answer(el, { id: uploads[0]!.id, url: '/kreska.png' })
expect(el.value).toBe('<p><img src="/kreska.png" alt=""></p>')
})
})