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
This commit is contained in:
Jakub Zych
2026-11-04 10:35:19 +01:00
parent 391435b4bf
commit 4b09bd9312
16 changed files with 1482 additions and 170 deletions

View File

@@ -0,0 +1,209 @@
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>')
})
})

View File

@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { EMBED_PROVIDERS, isEmbedProvider, parseEmbedUrl } from '../../assets/src/blogeditor/embed'
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
import { format, labelsFor } from '../../assets/src/blogeditor/labels'
import { button, click, fill, form, mount, status } from './helpers'
import { button, chooseImage, click, fill, form, mount, status } from './helpers'
afterEach(() => {
vi.useRealTimers()
@@ -179,33 +179,36 @@ describe('upload flow', () => {
}
it('ignores an answer with an id it did not send', () => {
const { el, uploads, changes } = mount('<p>a</p>')
const { el, uploads } = mount('<p>a</p>')
pick(el, [png()])
expect(uploads).toHaveLength(0)
chooseImage(el, { alt: 'Grzyb' })
answer(el, { id: 'not-ours', url: '/x.png' })
answer(el, { id: 'not-ours', error: 'nope' })
expect(form(el, 'image')).toBeNull()
expect(status(el)).toBe('Uploading grzyb.png…')
expect(el.value).toBe('<p>a</p>')
answer(el, { id: uploads[0]!.id, url: '/ours.png' })
expect(form(el, 'image')).not.toBeNull()
expect(changes).toEqual([])
expect(el.value).toContain('<img src="/ours.png" alt="Grzyb">')
})
it('times out after 60 seconds and ignores the late answer', () => {
vi.useFakeTimers()
const { el, uploads } = mount('')
pick(el, [png()])
chooseImage(el, { alt: 'Grzyb' })
vi.advanceTimersByTime(59_999)
expect(status(el)).toBe('Uploading grzyb.png…')
vi.advanceTimersByTime(1)
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()
expect(el.value).toBe('')
})
it('answers each of several uploads by its own id', () => {
const { el, uploads } = mount('')
pick(el, [png(), new File([new Uint8Array([255, 216, 255])], 'b.jpg', { type: 'image/jpeg' })])
chooseImage(el, { alt: 'A' })
chooseImage(el, { alt: 'B' })
expect(uploads).toHaveLength(2)
expect(uploads[0]!.id).not.toBe(uploads[1]!.id)
answer(el, { id: uploads[1]!.id, error: 'Too big' })
@@ -215,6 +218,7 @@ describe('upload flow', () => {
it('refuses an uploaded URL outside the policy', () => {
const { el, uploads } = mount('')
pick(el, [png()])
chooseImage(el, { alt: 'Grzyb' })
answer(el, { id: uploads[0]!.id, url: 'javascript:alert(1)' })
expect(form(el, 'image')).toBeNull()
expect(status(el)).toMatch(/^Upload failed/)

View File

@@ -76,3 +76,21 @@ export function fill(input: Element | null | undefined, value: string): void {
export function status(el: HTMLElement): string {
return el.querySelector('[role="status"]')?.textContent ?? ''
}
/**
* chooseImage fills the open J4 image form (alt, caption, decorative, file
* name) and presses Insert, which uploads the file.
*/
export function chooseImage(el: HTMLElement, choice: { alt?: string; caption?: string; decorative?: boolean; fileName?: string } = {}): void {
const imageForm = form(el, 'image')
if (!imageForm) throw new Error('the image form is not open')
if (choice.fileName !== undefined) fill(imageForm.querySelector('input[name="fileName"]'), choice.fileName)
if (choice.decorative) {
const box = imageForm.querySelector<HTMLInputElement>('input[name="decorative"]')!
box.checked = true
box.dispatchEvent(new Event('change', { bubbles: true }))
}
if (choice.alt !== undefined) fill(imageForm.querySelector('input[name="alt"]'), choice.alt)
if (choice.caption !== undefined) fill(imageForm.querySelector('input[name="caption"]'), choice.caption)
click(imageForm.querySelector('[data-action="insert"]'))
}

View File

@@ -4,7 +4,7 @@ import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
import { uploadRequestId } from '../../assets/src/blogeditor/element'
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'
import { button, chooseImage, click, fill, form, insertAtBlock, insertAtEveryTextblock, mount, status } from './helpers'
afterEach(() => {
vi.useRealTimers()
@@ -320,11 +320,12 @@ describe('image uploads', () => {
const { el, uploads } = mount('')
click(button(el, 'image'))
pick(el, [png()])
chooseImage(el, { alt: 'Grzyb' })
expect(uploads).toHaveLength(1)
expect(uploads[0]!.id).toMatch(uuid)
expect(status(el)).toBe('Uploading grzyb.png…')
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
expect(form(el, 'image')).not.toBeNull()
expect(el.value).toContain('grzyb.png')
vi.stubGlobal('crypto', undefined)
const plain = [uploadRequestId(), uploadRequestId()]
@@ -336,44 +337,45 @@ describe('image uploads', () => {
expect(globalThis.crypto).toBe(real)
})
it('sends a picked image as summer-upload and inserts img with alt text', () => {
it('opens the image form for a picked image and uploads it on Insert with the alt text (J4)', () => {
const { el, uploads } = mount('')
click(button(el, 'image'))
pick(el, [png()])
expect(uploads).toHaveLength(0)
const imageForm = form(el, 'image')!
expect((imageForm.querySelector('input[name="fileName"]') as HTMLInputElement).value).toBe('grzyb')
expect(imageForm.querySelector('.blogeditor__ext')!.textContent).toBe('.png')
click(imageForm.querySelector('[data-action="insert"]'))
expect(imageForm.querySelector('[data-error]')!.textContent).toBe('Enter the alt text, or mark the image as decorative.')
expect(uploads).toHaveLength(0)
chooseImage(el, { alt: 'Grzyb: czy to "Turkey Tail"?' })
expect(form(el, 'image')).toBeNull()
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()
answer(el, { id: 'someone-else', url: '/x.png' })
expect(el.value).toBe('')
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
expect(status(el)).toBe('')
expect(el.value).toBe('<p><img src="/storage/app/uploads/public/journal/grzyb.png" alt="Grzyb: czy to &quot;Turkey Tail&quot;?"></p>')
})
it('inserts figure with figcaption when a caption is given', () => {
const { el, uploads } = mount('')
pick(el, [png()])
chooseImage(el, { alt: 'Owocniki', caption: 'Owocniki na pniu.' })
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()])
chooseImage(el, { alt: 'Grzyb' })
answer(el, { id: uploads[0]!.id, error: 'File too large' })
expect(form(el, 'image')).toBeNull()
expect(status(el)).toBe('Upload failed: File too large')
@@ -385,13 +387,14 @@ describe('image uploads', () => {
vi.useFakeTimers()
const { el, uploads } = mount('')
pick(el, [png()])
chooseImage(el, { alt: 'Grzyb' })
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()
expect(el.value).toBe('')
})
it('uploads image files dropped on the editor', () => {
it('opens the image form for image files dropped on the editor', () => {
const { el, editor, uploads } = mount('<p>a</p>')
let prevented = false
const event = {
@@ -403,6 +406,8 @@ describe('image uploads', () => {
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(form(el, 'image')).not.toBeNull()
chooseImage(el, { alt: 'Grzyb' })
expect(uploads.map((u) => u.file.name)).toEqual(['grzyb.png'])
expect(el.value).toBe('<p>a</p>')
})