Concerns: pushword/admin, pushword/admin-block-editor, pushword/core, pushword/flat, @pushword/js-helper, pushword/newsletter, pushword/page-scanner, pushword/static-generator
Media usage was computed nowhere and stored nowhere: pw:ai-index scanned every media against every page and threw the answer away. It is a table now — media_usage, one row per (media, page, source) — filled on every page write.
This adds one table and one column, so run:
php bin/console doctrine:schema:update --force
php bin/console pw:media:usage:rebuild
The rebuild is what fills the table for content that already exists; until it has run, every media reads as referenced by nothing. Run it again after any bulk write that reached the database behind the listener — a restore, or an import run against media that did not exist yet.
In the admin, the media list gained a Referenced by a page filter, and a media's page panel now says why each page uses it: content, main image, or a custom property.
From the CLI, pw:media:clean-unused lists the media no page references, and removes them with --force. Read the list first. It cannot mean "safe to delete": nothing scans Twig templates, so a navbar logo or an OG fallback is listed exactly like a forgotten upload. The command refuses to run at all against an empty usage table, since that state is indistinguishable from a site whose every media is orphaned.
Media inherit the tags of the pages using them, in their own pageTags column with its own admin filter — never merged into the tags somebody put on the media by hand. It is derived, not editable: a page retag rewrites it, and it drops back to empty when the last page using the media stops.
One behaviour changed under an unchanged call. PageRepository::getPagesUsingMedia() reads the table instead of running a LIKE over mainContent, so it is both faster and stricter — myphoto.jpg no longer counts as a use of photo.jpg — and it answers with nothing until the rebuild has run.
The page edit form now keeps what you typed in the browser's localStorage, and offers it back on reopen with a Restore them / Discard banner. It is a local safety net for the crash, the closed tab and the expired session — nothing is sent to the server, and no timer saves for you. That is deliberate: a page save is a publication here, and a timed one would republish half-typed content, rewrite the flat markdown on every pause, and turn each intermediate slug into a redirect page.
Nothing to run, and nothing to configure — unless the site overrides @pwAdmin/page/edit.html.twig. An override that predates this release keeps rendering, silently without the feature. Copy two things from the bundle's template into yours:
{% include '@pwAdmin/components/unsaved_changes_banner.html.twig' %}
and, on the form tag, the attribute the banner is keyed by:
'data-pw-unsaved-key': 'pw:unsaved:page:' ~ entity.instance.id,
Both belong inside the entity.instance.id guard already wrapping the edit-lock banner: a page being created has no id to key a recovery copy on.
The admin screens that hardcoded a French date format (page-scanner results and progress, static-generator progress, flat's git status, the newsletter contact panel) now render dates with format_datetime/format_date from twig/intl-extra, so they follow the request locale. On a French admin 04/08/2026 à 14:30 becomes 04/08/2026 14:30; other locales stop getting French dates. Nothing to do — unless a template you overrode copied one of those lines and you want the same fix.
.clickable boxes are pure CSS nowThe JS that made a .clickable box follow its inner link is gone — @pushword/js-helper no longer ships src/clickable.js (clickable, allClickable, smoothScroll), and app.js no longer binds .clickable. The class now works through CSS in js-helper/src/clickable.css: the box is position: relative and the inner link (or button, or still-cloaked span[data-rot]) stretches a pseudo-element over it, so the whole box is a real link — middle-click, Ctrl+click and keyboard focus behave natively, which the JS never did.
Rebuild your assets (yarn build / npm run build) and the class keeps working wherever your CSS imports @pushword/js-helper/src/app.css. Two cases need a look:
app.css can add @import '@pushword/js-helper/src/clickable.css'; to get just this behavior.clickable.js directly must drop the import; same-page hash links that relied on its smoothScroll can use the CSS scroll-behavior: smooth instead.The commented card_date example in core's component/card.html.twig suggests format_datetime('short', 'short') now; existing copies of that block keep whatever format they chose.
Every page-scan finding now carries a code — link-not-found, image-alt-missing, render-error… — a stable name for what was found, printed next to the message in the admin, in brackets on the CLI, and as a code field in the API and the agent JSON. The full list is in the page-scanner docs.
errors_to_ignore matches that code first, then the message as before, so existing rules keep working — and a rule can now be written against the code instead of a translated sentence:
pushword_page_scanner:
errors_to_ignore:
- 'image-alt-missing' # a whole kind of finding
- 'link-*' # a whole family
- 'localhost.dev/legacy-*: link-not-found' # scoped to routes, as before
A page can also silence its own findings now, with a comment in its content or a pageScanErrorsToIgnore custom property:
Unlike the config, which applies when results are read, a page's own rules apply while scanning: they take effect on the next scan.
Two things changed shape for anything reading the output rather than a human:
issues[].errors are objects ({"code":…,"message":…}) where they were plain strings;GET /api/page-scan findings gained a code field next to message.Results cached by the previous release still render — their findings simply carry an empty code until the next scan.