Upgrade to 1.0.0-rc835

Concerns: pushword/admin, pushword/admin-block-editor, pushword/core, pushword/flat, @pushword/js-helper, pushword/newsletter, pushword/page-scanner, pushword/static-generator

The database now knows which pages use which media

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.

Unsaved page edits survive a closed tab

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.

Admin dates follow the locale

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 now

The 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:

  • A stylesheet that does not import js-helper's app.css can add @import '@pushword/js-helper/src/clickable.css'; to get just this behavior.
  • Code importing 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.

Page-scan findings have a code, and a page can silence its own

Every page-scan finding now carries a codelink-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:

  • the agent JSON's 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.