Concerns: pushword/core, pushword/newsletter, pushword/page-scanner, @pushword/js-helper
pw:page-scan gained two checks, so a site that was green can go red on the first run after the upgrade without its content having changed.
Image alt. Every rendered <img> with no alt, or an alt that is empty or only whitespace, is reported once per src:
`/media/default/lake.jpg` image without alternative text
Fill the alt in the admin, in media.csv, or as the  caption. An image that really is decorative keeps its empty alt and says so — the scanner skips role="presentation" and aria-hidden="true".
Translation locales. A page whose translations hold its own language, or two of them sharing one language, is now reported:
translation `/home` has the same language as this page (en)
two translations share the language fr: `/bienvenue` and `/accueil`
Detach the extra page from the group: two pages in the same language are variants, not translations.
Either check can be silenced site-wide or per route through the existing errors_to_ignore, which accepts fnmatch patterns:
pushword_page_scanner:
errors_to_ignore:
- '*image without alternative text*'
A contact known by phone and the same person known by address were two rows that could only be reconciled by deleting one. They can now be merged, and the row holding the address is the one that survives — it carries the token the confirm and unsubscribe links already in mailboxes are keyed on.
Nothing to run: no schema change, and a merge only ever happens because somebody asked for it.
In the admin, a save refused for a taken address or number now offers the join under the form. A site overriding @PushwordNewsletter/admin/contact_edit.html.twig has to add the include, or the offer never renders:
{% block main %}
{{ include('@PushwordNewsletter/admin/_contact_merge.html.twig') }}
{{ parent() }}
{% endblock %}
Over the API, ?merge=true on POST /api/newsletter/contact and on PATCH /api/newsletter/contact/{id} asks for it. Two things to know before turning it on in a CRM: a PATCH may answer with another id than the one in the path (patching an address onto a phone-only row leaves the person on the addressed row), and the merge is still refused — 409 — when both rows hold an address, since nothing can pick which consent record to throw away.
One fix under an unchanged call. Creating a contact with a new address and a number another row already held used to reach the unique index and fail as a driver exception; it now answers 409 like every other identifier conflict.
link() obfuscates by default, so a gallery item and a video thumbnail render as a <span data-rot> carrying no href. Glightbox reads the href when it binds, found none, and swallowed the click; convertImageLinkToWebPLink(), which looks for a[data-dwl], had nothing to match either.
The front now decodes data-rot into the data-href the lightbox reads from the node itself, before the lightbox binds. The span stays a span: the media URL never becomes a link a crawler can follow, which is what the obfuscation was for.
Rebuild your assets (composer assets, or yarn build in your own front) — the fix lives in @pushword/js-helper. A site with its own app.js has to add the pass, in this order:
import { resolveLightboxSources, uncloakLinks, convertImageLinkToWebPLink } from '@pushword/js-helper/src/helpers.js'
resolveLightboxSources() // consumes data-rot on .glightbox nodes…
uncloakLinks() // …so this one leaves them alone
convertImageLinkToWebPLink()
lightbox = new Glightbox() // after the first pass, never before
A cloaked lightbox link must declare its type. With no href to sniff, the lightbox cannot tell an image from a video, so data-type is now part of the markup — core's gallery, image(link: …) and the video component carry it. A site passing its own linkAttr has to add it, or the slide opens blank:
{{ image(media, link: true, linkAttr: {class: 'glightbox', 'data-type': 'image'}) }}
Body images changed rendering.  used to render a bare <picture>; it now wraps it in a lightbox link, so a reader can zoom it. An image you already gave a destination — [](/somewhere) — is left alone. To keep a plain, non-zoomable image, render it yourself:
{{ image('image.jpg', alt: 'alt') }}
Cached page fragments are invalidated by the release, so the new markup appears on the first render.