Skip to content

Add theme tokens for interactive Leaflet maps - #2083

Merged
mauteri merged 2 commits into
GatherPress:developfrom
drewmt:feature/osm-map-theme-tokens
Aug 11, 2026
Merged

mauteri merged 2 commits into
GatherPress:developfrom
drewmt:feature/osm-map-theme-tokens

Conversation

@drewmt

@drewmt drewmt commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1717

Proposed changes:

  • Bundle component-scoped theme variables with the interactive Leaflet stylesheet.
  • Let themes override the attribution link color through --gatherpress--gatherpress-venue-map--attribution-link-color.
  • Let themes apply a CSS filter to Leaflet's existing raster marker through --gatherpress--gatherpress-venue-map--marker-filter, without duplicating marker assets.
  • Keep the server-rendered static marker out of scope; it is tracked separately in Allow static OpenStreetMap marker styling through a server-side extension point #2082.

Other information:

  • No automated unit test is applicable: this is a stylesheet-only extension point. The production asset build and complete CSS and JavaScript lint suites pass.

Testing instructions:

  1. Under Settings > GatherPress > Venues > Maps, select OpenStreetMap and set a Venue Map block to Interactive.
  2. View an event or venue with coordinates and confirm that the Leaflet map, default marker, and attribution render normally.
  3. Add the following CSS in a theme or a test stylesheet:
.gatherpress-venue-map {
    --gatherpress--gatherpress-venue-map--attribution-link-color: #8b1e3f;
    --gatherpress--gatherpress-venue-map--marker-filter: grayscale(1);
}
  1. Reload the page and confirm that the attribution link adopts the configured color and the existing marker image receives the filter.
  2. Switch the block to Static and confirm that its server-rendered PNG remains unchanged.

Credit (for release notes)

My WordPress.org username: linewebdigital

Changelog entry

A manual changelog entry is included in .github/changelog/1717-venue-map-theme-tokens.

  • Significance: Minor
  • Type: Changed
  • Message: Interactive OpenStreetMap venue maps now let themes customize Leaflet marker filters and attribution link colors without replacing map assets.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props linewebdigital, mauteri, carstenbach.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Test Coverage Report

✅ All coverage checks passed!

✅ PHP Coverage
=== PR Coverage Check ===

Configuration loaded:
  Minimum coverage: 100%
  Exclude patterns: 46 patterns
  Low coverage exceptions: 0 files

Checking coverage for files changed from origin/develop...

✅ No PHP files changed in this PR.
✅ JavaScript Coverage
=== PR JavaScript Coverage Check ===

Configuration loaded:
  Minimum coverage: 100%
  Exclude patterns: 46 patterns

Checking coverage for JavaScript files changed from origin/develop...

Found 1 changed JavaScript file(s):
  - src/leaflet-style.js

✅ All changed JavaScript files are excluded from coverage checks.
Changed files match exclusion patterns from sonar-project.properties

This comment is automatically updated on each push.

@mauteri mauteri added this to the 0.36.0 milestone Aug 3, 2026
The public tokens read --gatherpress--gatherpress-venue-map--* here and
--gatherpress--gatherpress-tooltip--* in the tooltip, repeating the plugin
name because the component segment already carries it.

That was never deliberate. The tooltip commit that introduced these
(34594aa) documented them as --gatherpress--tooltip--* in constants.js and
implemented them as --gatherpress--gatherpress-tooltip--* in the SCSS, so the
documentation and the code have disagreed since the first commit. This PR
inherited the doubled form by copying what the working code said, which was
the right instinct.

Use the single form everywhere: --gatherpress--{component}--{property},
matching WordPress's own --wp--preset--color--primary shape and matching what
constants.js has always claimed.

The tooltip rename is technically breaking for a theme overriding that token,
but the only place it was ever documented named the form this commit moves
to, so anything following the docs starts working rather than stopping.

Also documents both components' tokens under Theme customizations. They are a
public API and until now lived only in SCSS docblocks. Includes the fallback
chains and a warning that --gatherpress--venue-map--marker-filter takes a CSS
filter list rather than a color, which is the sharp edge a theme author would
otherwise hit first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Preview changes with Playground

Customize PR Playground

To customize the Playground preview for this specific PR, create a file at:

.github/playground/PR-2083-blueprint-override.json

The override is merged into the generated blueprint and can be used to:

  • Enable Playground features
  • Install additional plugins
  • Change site options
  • Run steps before or after the GatherPress' default steps

Example:

{
	"$schema": "https://gatherpress.org/playground-preview/pr-override-schema.json",
	"landingPage": "/wp-admin/edit.php?post_type=gatherpress_venue",
	"siteOptions": {
		"show_on_front": "page"
	},
	"features": {
		"networking": true
	},
	"prependSteps": [
		{
			"step": "installPlugin",
			"pluginData": {
				"resource": "wordpress.org/plugins",
				"slug": "simple-location"
			},
			"options": {
				"activate": true
			}
		}
	],
	"appendSteps": [
		{
			"step": "runPHP",
			"code": ""
		}
	]
}

Go to Playground PR preview customization for more details.

You can preview the recent changes for PR#2083 with the following PHP versions:

PHP Version 8.4

PHP Version 8.2

PHP Version 8.1

Download .zip with build changes

Made with 💙 from GatherPress & a little bit of WordPress Playground. Changes will not persist between sessions.

@mauteri
mauteri merged commit 3d039bc into GatherPress:develop Aug 11, 2026
14 checks passed
mauteri added a commit that referenced this pull request Oct 16, 2026
* Roll up changelog for 0.35.0 (#2133)

* Release for 0.33.3.

* Fix trailing space.

* Make readme.md explicitly.

* Fix formatting in readme.md.

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Roll up changelog for 0.34.0 (#1919) (#1922)

* Release for 0.33.3.

* Fix trailing space.

* Make readme.md explicitly.

* Fix formatting in readme.md.

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Optimised images with calibre/image-actions

* Roll up changelog for 0.34.0

---------




(cherry picked from commit e4a2738737dd3f8e44b08c700ebfa4d3e0258295)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Add the gatherpress_loaded signal (#1982)

Fixes a site-wide fatal: when GatherPress fails its requirements check
while GatherPress Alpha is active, the whole site white-screens instead
of showing the notice explaining what to fix.

GATHERPRESS_VERSION is defined at gatherpress.php:37, two lines before
the requirements gate at line 39. When the gate fails the file returns,
so the autoloader at lines 47-48 never registers -- but the constant is
already set. Alpha gates on that constant, concludes GatherPress is
loaded, and calls GatherPress_Alpha\Setup::get_instance(). Alpha's
classes autoload through GatherPress's autoloader, which does not exist,
so the class is not found and the site fatals.

The constants mean "GatherPress's main file began executing". Alpha was
reading them as "GatherPress finished bootstrapping".

Moving the define below the gate would be the obvious fix and is wrong:
duplicate-check.php detects a second active copy precisely by testing
whether GATHERPRESS_VERSION is already defined, so moving it later stops
duplicates being detected.

Instead this adds the gatherpress_loaded action, which exists on develop
but is absent from both main and the 0.34.0 tag. It fires after the gate
passes, so a companion plugin booting from it simply never boots when
GatherPress bails. Wrapping it in plugins_loaded means a listener in any
plugin is registered in time regardless of load order.

Written as a focused commit rather than a cherry-pick of a3b6ddbe: that
commit also deletes 12 lines from class-setup.php that do not exist on
this branch. The resulting code matches develop; the docblock does not,
because develop's version references docs/developer/plugin-lifecycle.md
and the RSVP provider registry, neither of which exists in the 0.34.x
line. The next develop->main release merge resolves that in develop's
favor, which is the correct end state.

The companion Alpha change ships separately and is required for the fix
to take effect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Exclude .wp-env.test.json from the distribution package (#1920) (#1980)

.wp-env.test.json, the CI test-environment config added during the
0.34.0 cycle, shipped to WordPress.org because .distignore never gained
an entry for it. Its sibling .wp-env.json has been excluded since line
56; the test variant was simply missed when it was introduced.

The GitHub release zip was never affected: wp-scripts plugin-zip builds
from the `files` allowlist in package.json, which does not include it.
Only the wp.org rsync, which is driven by .distignore, picked it up.

The file is inert local dev config with no secrets, so 0.34.0 shipped
as-is rather than being recut, matching the fix-forward handling of
patches/ and jsconfig.json in #1905.

Bound for 0.34.1 by cherry-pick.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit f967ceac260ce2b6e414a2057a3d6fcf874ffc26)

* Add Admin\Notice + Admin\Notifications, warn on upcoming 0.35.0 requirements (#1963) (#1981)

* Notify sites below PHP 8.1 / WP 7.0 about 0.35.0 (#1963)

GatherPress 0.35.0 raises the minimum requirements to PHP 8.1 and
WordPress 7.0. Ship a warning first so nobody discovers the new floors
by silently stopping receiving updates.

Two separate notices, each gated on its own condition, so a site failing
both floors sees both messages and a compliant site sees nothing.

Rather than two ad-hoc admin_notices callbacks, this adds a small
Notifications registry. Lifecycle notices all have the same shape --
show while some condition about the site holds, disappear on their own
once it stops -- so future requirement bumps, migration prompts and
deprecation warnings are one registration each.

This is deliberately separate from requirements-check.php, which
enforces the floors in effect *now* and blocks the plugin from loading.
These notices are advisory: the site works fine today, but something is
coming the administrator should act on first.

Three details worth noting:

- The condition and the message are both callables, evaluated at render
  time. That matters for the message: building it eagerly would call the
  translation functions before init, which WordPress 6.7 flags as
  loading a textdomain too early.
- The version comparisons take the version as an argument rather than
  reading PHP_VERSION directly, so both sides of each gate are testable
  without the suite having to run on an old PHP.
- Notices are dismissible, which in WordPress means dismissed for the
  current page view only. For a requirement warning that is the right
  behavior: it can be waved away while reading a screen but returns on
  the next load, and keeps returning until the site is actually updated.

The capability gate is update_plugins rather than manage_options --
updating PHP or WordPress is not something an editor can act on, nor a
site administrator on multisite.

The class is PHP 7.4-compatible, since 0.34.x still supports 7.4 and
that is precisely the audience for the notice. Verified with
PHPCompatibilityWP at testVersion 7.4- rather than by inspection; the
repo ruleset targets 8.1- so this needs an explicit override to check.

Bound for 0.34.1 by cherry-pick. The two requirement notices should be
unregistered from develop before 0.35.0 ships -- a 0.35.0 notice
announcing 0.35.0's own floors is nonsense -- but the registry itself
stays.

Verified: 1624 tests / 6844 assertions single-site, 323 / 856 multisite,
PHPCS and PHPStan clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cover the requirement notice registration (#1963)

The PR coverage gate requires 100% on changed files and reported 48.53%
for Notifications. Two distinct gaps, both worth naming:

The render tests swap in fixture notices, so the real condition and
message closures were registered but never executed. test_requirement_
notice_callbacks now invokes all four directly.

That only reached 79.41%. The remainder was the argument lines of the
$this->register(...) calls themselves. register_requirement_notices()
runs from the constructor during plugin bootstrap, which happens before
PHPUnit starts collecting coverage, so those lines never get recorded
even though they certainly execute. test_register_requirement_notices
now clears the registry and re-invokes the method under test.

Verified locally against the CI script rather than by inference:
class-notifications.php 100% (68/68), class-setup.php 100% (195/195).

Note for future debugging: the checker merges coverage.xml and
coverage-multisite.xml, and judges whole files rather than changed
lines. Running only the single-site suite makes untouched multisite
paths look uncovered -- class-setup.php read as 91.24% that way, and is
actually 100%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rework notices into Admin\Notice + Admin\Notifications (#1963)

Replaces the flat Core\Notifications from the previous commits with a
namespaced pair under Core\Admin, and gives notices a real base class
instead of a bag of callbacks.

Notice describes a notice as data: slug, message, type (error, warning,
info, success), dismissible, persistent, capability, condition. Two
kinds of dismissal are now distinct. A dismissible notice is dismissed
for the current page view only, which is all WordPress does natively. A
*persistent* notice records its slug in the new
gatherpress_admin_notifications option and stays gone.

Notifications is the registry: it holds Notice objects, renders the ones
that apply, and handles dismissal requests.

requirements-check.php now builds its two notices through Notice rather
than hand-rolling wp_admin_notice calls, which is what motivated the
split in the first place.

The constraint that shapes Notice: requirements-check.php runs at
gatherpress.php:39, before the autoloader is registered and before we
know anything about the site's PHP. So Notice is required explicitly
rather than autoloaded, and is written in deliberately old syntax -- no
return types, no scalar parameter types, no typed properties, no null
coalescing. Getting that wrong would replace the "please upgrade PHP"
notice with a parse error, turning the file that explains the problem
into a worse one, and taking down the whole site rather than just the
plugin.

That constraint is now enforced rather than commented: npm run
lint:php:early runs PHPCompatibilityWP at testVersion 7.2 over the three
pre-autoloader files. 7.2 is WordPress 6.7's own PHP floor, so it is the
oldest PHP that can run a WordPress capable of running this plugin. The
guard immediately earned its keep by flagging a `: void` return type in
duplicate-check.php -- PHP 7.1+, so not actually reachable, but that
file loads even earlier and nothing was checking it.

Two fixes found while testing:

- handle_dismissal now uses wp_verify_nonce rather than
  check_admin_referer. The latter calls wp_die() on a bad nonce, so a
  stale dismiss link would have replaced the admin screen with an error
  page instead of being quietly ignored.
- Notice::render falls back to markup when wp_admin_notice() is missing.
  That function arrived in WordPress 6.4, and this class is reachable
  from the pre-requirements path where the running WordPress may predate
  it. The fallback is codeCoverageIgnore'd since the test bootstrap
  always has the function.

The two requirement notices stay non-persistent: a warning that 0.35.0
raises the floors should keep coming back until the site is actually
updated, rather than being silenceable forever.

Verified: 1644 tests / 6892 assertions single-site, 323 / 856 multisite,
PHPCS, PHPStan and the 7.2 early guard all clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rename the dismissal option to gatherpress_admin_notices (#1963)

Matches the Core\Admin\Notice namespace the option belongs to.

Single-line change: everything reads Notice::OPTION_NAME rather than the
literal, including the tests, so the constant was the only occurrence.

No migration needed -- the option has never shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Revert the dismissal option to gatherpress_admin_notifications (#1963)

Restores the name from before 8464546b. Net effect of that commit and
this one is zero; both squash away on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restructure notices into Admin\Notices with a class per notice (#1963)

Each notice is now its own class extending a shared base, rather than
one configurable Notice built from an args array.

  includes/core/classes/admin/notices/
    class-base.php                      abstract Base
    class-requires-php.php              blocking
    class-missing-build.php             blocking
    class-upcoming-requirement.php      abstract, shared by the two below
    class-upcoming-php-requirement.php  advisory
    class-upcoming-wp-requirement.php   advisory
    class-setup.php                     the registry

The registry moved here from Core\Admin\Notifications and is named Setup,
matching the per-subsystem Setup singletons everywhere else in the
codebase. Core\Setup imports it aliased as Notices_Setup, per the
cross-subsystem alias rule in AGENTS.md.

Each notice owns its condition through applies(), which is what makes
requirements-check.php read as a loop over blocking notices rather than
a series of hand-written checks. The condition that halts loading and
the condition that shows the notice are now literally the same method,
so they cannot drift.

Upcoming_Requirement exists so 0.35.0 is named once rather than in both
advisory notices, and so the version comparison is written once. It also
gives them a shared type, capability and non-persistence.

The early-PHP guard now enumerates the three pre-autoloader files rather
than globbing the directory, because Setup and the advisory notices live
alongside them but load after the gate and are ordinary modern code.
Adding a new blocking notice means adding it to that list, which is the
right moment to be thinking about the constraint.

The dismissal option is gatherpress_admin_notices.

One collision worth noting for anyone writing tests here: naming the
test class Test_Base while aliasing the PMC parent as Test_Base is a
fatal redeclare, not a lint warning. The parent is aliased
Unit_Test_Base in that file.

Verified: 37 notices tests green, PHPCS, PHPStan and the 7.2 early guard
all clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add return types to the notice classes (#1963)

The admin notice classes were written without return types under the
belief the early-loaded ones had to parse on PHP as old as 7.2. That
premise was wrong: GatherPress has never supported below 7.4, and
WordPress blocks activation of a plugin below its Requires PHP, so these
files never parse under 7.4. The typeless style was inconsistent with
the rest of the codebase for no benefit.

Add native return types (and the one method's scalar parameter types)
across Base and the five notice subclasses, matching house standards.
All of them -- return types, scalar/nullable params, void, typed
properties, arrow functions -- are PHP 7.4-safe; only 8.0+ features
(union types, promotion, readonly, match, nullsafe, enums) remain off
limits for the pre-gate files.

The lint:php:early guard now runs at testVersion 7.4 rather than 7.2,
matching the actual floor, and Base's header documents the real
constraint (parse on 7.4) instead of the mistaken 7.2/WordPress-6.7
reasoning. The anonymous test doubles gain matching return types so
their overrides stay signature-compatible with the now-typed abstract
methods.

Verified: 37 notices tests, PHPCS, PHPStan, the 7.4 early guard, and
100% coverage on all eight files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 7166ccdf5f8644d08ef32ed626e7243085128d6a)

* Bump version to 0.34.1

Ran `wp gatherpress develop generate_version --version=0.34.1` (the
0.34.x-line release tooling) plus `npm i --package-lock-only`:

  - gatherpress.php Version header -> 0.34.1
  - package.json version + lockfile -> 0.34.1
  - README.md version badge, readme.txt Stable tag -> 0.34.1
  - includes/data/credits.php regenerated from the 0.34.1 credits entry
    (a straight copy of 0.34.0's roster)

The .github/scripts release tooling (#1827) is a 0.35.0-cycle addition
that does not exist on this line, so the older gatherpress-develop
WP-CLI flow was used, matching how 0.34.0 was cut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make the upcoming-requirement notices persistently dismissible (#1963)

The PHP-8.1/WP-7.0 warnings were dismissible-but-not-persistent: closed
for the page view, back on the next load, until the site actually met
the floor. The intent is the opposite -- tell the owner once, then let
them make it go away.

Some sites genuinely can't move off an old PHP or WordPress: a managed
host that hasn't upgraded, a dependency that pins them. Nagging someone
who is already stuck helps no one. So Upcoming_Requirement::is_persistent
now returns true; dismissing writes the slug to the
gatherpress_admin_notifications option and it stays gone.

Test updates: the PHP-requirement shape test now asserts persistence,
and Setup's "ignores non-persistent notices" test uses a plain non-
persistent Base double now that the requirement notices are persistent.

Verified: 37 notices tests, PHPCS, and 100% coverage on the changed file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Roll up changelog for 0.34.1 (#1998)

(cherry picked from commit 5885a84dbfc8123dbadeb3750a658ec472737493)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Drop the upcoming-requirement notices main still carried

0.35.0 raises the PHP and WordPress floors for real, so develop deleted the
notices that warned about them coming. The merge from main reintroduced the
files because they only exist on that side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Roll up changelog for 0.35.0

---------

Co-authored-by: Mike Auteri <mauteri@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(email): make event-update subject editable per send (#2042)

* fix(email): make event-update subject editable per send

- Add subject arg to the REST endpoint and thread it through to wp_mail
- Add a Subject TextControl to the email modal, pre-populated with the default
- Add gatherpress_email_subject filter for customizing the default template
- Empty subject falls back to the existing 📅 {title} default

Fixes #827

* fix(email): address review feedback for editable subject

- Bump @since tags for subject plumbing from 0.35.0 to 0.36.0
  to match the current 0.36.0 release cycle.
- Add bottom margin to Subject TextControl in the
  email-communication modal so it sits apart from the
  Optional message label below it.

Refs #2042

* fix(email): use 1rem for subject field spacing

Switch subject TextControl marginBottom from 16px to 1rem to match
the rem-based spacing convention used elsewhere in the modal.

Refs #2042

* Credit faisalahammad (PR #2042) (#2137)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Hook docs updated! (#2138)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Convey the disabled state of the RSVP response filter (#2113)

When the response filter collapses to a single selectable option the
trigger is disabled, but only visually and via the tab order. It keeps
role="button" and aria-expanded while gaining no aria-disabled, so an
assistive technology announces an operable collapsed button that cannot
be operated and gives no reason why.

Set aria-disabled alongside the existing class and tabindex, matching
what the sibling menu-item branch in the same callback already does, and
remove it again when the filter regains a second option.

* Add theme tokens for interactive Leaflet maps (#2083)

* feat: add Leaflet map theme tokens

* Drop the duplicated namespace from the theme token names

The public tokens read --gatherpress--gatherpress-venue-map--* here and
--gatherpress--gatherpress-tooltip--* in the tooltip, repeating the plugin
name because the component segment already carries it.

That was never deliberate. The tooltip commit that introduced these
(34594aab) documented them as --gatherpress--tooltip--* in constants.js and
implemented them as --gatherpress--gatherpress-tooltip--* in the SCSS, so the
documentation and the code have disagreed since the first commit. This PR
inherited the doubled form by copying what the working code said, which was
the right instinct.

Use the single form everywhere: --gatherpress--{component}--{property},
matching WordPress's own --wp--preset--color--primary shape and matching what
constants.js has always claimed.

The tooltip rename is technically breaking for a theme overriding that token,
but the only place it was ever documented named the form this commit moves
to, so anything following the docs starts working rather than stopping.

Also documents both components' tokens under Theme customizations. They are a
public API and until now lived only in SCSS docblocks. Includes the fallback
chains and a warning that --gatherpress--venue-map--marker-filter takes a CSS
filter list rather than a color, which is the sharp edge a theme author would
otherwise hit first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Mike Auteri <mauteri@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Update @wordpress/blocks to drop the unpatched showdown dependency (#2139)

Two Dependabot advisories sit against showdown 1.9.1, both XSS:

  GHSA-cr32-g25g-vxjj  metadata title handling
  GHSA-22g5-r2x5-97cx  stored XSS via table header ID injection

Neither has a patched release. Every version through the current 2.1.0 is
affected, so bumping showdown fixes nothing and the only resolutions are
removing it or dismissing the alerts.

This is dependency hygiene rather than a security fix. Nothing exploitable
reaches us: @wordpress/blocks is externalized at build time, so browsers load
WordPress core's copy and ours is never bundled; we import only
registerBlockType, not the Markdown paste handling showdown backs; and
node_modules is excluded from both distribution allowlists. The vulnerable
code was never shipped and is not invoked by the build or the test suite.
Removing it keeps the alert list meaningful instead of accumulating noise
that buries a real finding later.

showdown arrives only through @wordpress/blocks, which dropped it in 15.25.0.
Our range is already ^15.14.0, so the lockfile was pinned to an older
resolution and package.json needs no change.

15.25.0 replaced showdown with marked 18.0.9, which ships ESM only. Jest
could not parse it and start-blank.test.js stopped running, so marked joins
the existing transformIgnorePatterns entries.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Cache the iCalendar responses and answer conditional requests (#2067)

* Cache the iCalendar responses and answer conditional requests

Subscribed clients poll on their own schedule, as often as every five
minutes in Apple Calendar and every fifteen in Outlook, and every hit
rebuilt the whole payload event by event to return bytes that had usually
not changed.

Responses now carry Cache-Control, an ETag hashed from the body, and
Last-Modified, and a request whose If-None-Match or If-Modified-Since
still matches gets a 304 with no body. The rendered body is kept in the
object cache for the same window the response advertises, so the two
cannot disagree about freshness.

Invalidation is by version stamp rather than key deletion: every cache key
carries the timestamp of the last calendar-relevant change, so one option
write makes every stale entry unreachable without having to know which
feeds an event appeared in. Post saves and deletions, GatherPress meta
writes and term changes on event and venue post types all stamp it. RSVP
status changes deliberately do not, since they travel on the same
set_object_terms hook against a comment taxonomy and do not alter a
VEVENT.

Part of #1682. The remaining per-event and per-feed cache keys in that
issue are an optimization on top of this, not a correctness requirement,
since the version stamp already bounds staleness.

* Cover the calendar caching surface

The cache key, entity tag, conditional-request check, and the no-cache
header branch all shipped untested; so did the Cache constructor, which
only runs during bootstrap unless the stored instance is cleared.

* Target the calendar caching since tags at 0.36.0

The feature is landing in 0.36.0 rather than 0.35.0, so the @since tags
on the new Cache class, the send_ics_headers parameters and the
gatherpress_calendar_max_age filter should name that release.

* Re-trigger CI

The workflows were never scheduled for 1f6d95cc: it was pushed during a
window on 2026-08-06 when this repository scheduled no Actions runs at
all, and nothing has re-triggered the branch since. This empty commit
exists only to give GitHub a fresh head to queue against, and will
disappear when the PR is squash-merged.

* Store calendar payloads in transients and rename touch()

Addresses review on #2067.

Transients rather than wp_cache_* directly: with a persistent backend a
transient is an object cache entry anyway, and without one it persists in
the options table instead of evaporating at the end of the request, so the
win no longer depends on the host.

The scope key and the version stamp are now hashed together rather than
concatenated, keeping the transient name inside the 172-character limit on
option names however long a caller's key grows.

touch() becomes mark_changed(), with touch_for_post/_meta/_terms following
as mark_changed_for_post/_meta/_terms, and the docblock now says what the
stamp actually does: it moves lookups to a fresh key and strands the old
entries until they expire, rather than invalidating them.

Props motylanogha

* Hook docs updated! (#2144)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Add a project-tuned CodeRabbit config and skip coderabbitai in Props Bot (#2145)

* Add CodeRabbit YAML.

* Tune CodeRabbit config to the project's conventions

Path instructions gain the project specifics a generic reviewer gets
wrong: the PHP 8.1 floor, the @since-must-be-a-real-version rule, the
no-@wordpress/i18n constraint in Interactivity API module scripts, the
privileged-workflow security model with the @vN (not SHA) action-ref
convention, the CI-generated docs/developer/hooks directory, and the
full-branch-coverage bar for tests.

Path filters additionally exclude generated assets (.wordpress-org,
.gatherpress-org) and one-line changelog entry files.

Built-in tool runs that CI already performs (PHPStan, PHPCS, PHPMD,
ESLint) are disabled to avoid duplicate comments, as are languagetool
and markdownlint since typos enforces spelling in CI.

All keys validated against CodeRabbit's schema.v2.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Skip coderabbitai in the Props Bot user resolution

Props Bot fails hard on any PR CodeRabbit has commented on: its
skipUser() list doesn't include the app login, so the GraphQL user
lookup dies with "Could not resolve to a User with the login of
'coderabbitai'". Extend the existing what-the-diff dist patch to also
skip coderabbitai / coderabbitai[bot], with a guard grep per app.

Because the workflow runs on pull_request_target, PRs execute the
copy on develop; the fix takes effect on the first PR event after
this merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address CodeRabbit feedback on its own config

Path instructions fire per changed file, so coverage guidance living
only under test/** never applied to the PRs that most need it (code
changes with no test changes). The coverage-flagging sentence now also
lives in the includes/** and src/** entries, pointed at their suites.

The props-bot guard greps now verify all four injected skip-list
values, including the [bot] variants, with -F so the brackets read as
literals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* CodeRabbit follow-ups: distignore entry and comment-triggered reviews (#2146)

* Keep .coderabbit.yaml out of the wp.org deploy

The GitHub zip is safe via package.json's files allowlist, but
.distignore lists dotfiles individually, so without an entry the
config would ship to wp.org SVN (the #1920 rule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make CodeRabbit reviews comment-triggered only

Disable automatic reviews so the hourly rate limit is spent
intentionally: comment '@coderabbitai review' on a PR to review it
(or 'full review' to redo from scratch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Improve RSVP response handling, access checks, and caching (#2148)

* Improve RSVP response handling, access checks, and caching

- Route the RSVP response endpoints through a shared read-permission
  check keyed on the caller's access to the event, rather than trusting
  the post ID and post type alone.
- Gate the RSVP update route the same way: a magic-link token authorizes
  only the event it was issued for, and any other caller must be logged
  in and able to read the event.
- Return only per-status counts, not attendee records, from the public
  RSVP form submission response.
- Restrict the RSVP response cache to the public variant so it no longer
  reuses a payload across callers whose serialization differs by
  capability.
- Make the anonymous-response serialization consistent so every
  identity-derived field follows the same path as the rest of the record.

Adds unit coverage for the permission checks, the counts-only form
response, the capability-aware cache, and the anonymous serialization.

Co-Authored-By: Matthew Neil Cowan <matthewneilcowan@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Centralize the RSVP identity-masking decision

Each surface that renders or returns RSVP data repeated its own version of
"is this an anonymous RSVP the viewer may not see through", and they had
drifted: the block output and REST payload keyed on edit_posts while the
avatar keyed on moderate_comments, so a contributor saw a real name beside
a masked avatar. Enumerating surfaces also meant new ones were missed --
the core avatar block builds its alt text from the comment author, which
published the responder's real name in the rendered attendee list, and the
single-comment REST route resolves its target with get_comment(), which
the existing comment-query exclusion never covered.

Rsvp::should_mask_identity() now answers that question once, behind the
gatherpress_rsvp_mask_identity filter, and the block render, avatar,
serializer, and comment route all defer to it. The avatar block's alt text
is rewritten for a masked responder, and the comment route returns the
same response it gives for an unknown comment.

The serializer fixture built a plain comment carrying neither the RSVP
comment type nor the anonymity meta, so it never resembled a real RSVP;
it now builds both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Mask anonymous responders where comments are read

Masking the RSVP payload left the underlying comment untouched, and most
readers take the responder's name straight off that object rather than
through a display function: the core avatar block builds its alt text from
comment_author, and the comments REST route returns it as author_name. Both
published the real name of a responder who had asked to stay anonymous.

Comments are now masked as they are read, on the get_comment filter, so the
name, email, and URL are withheld from every reader at once instead of at
each call site. The comment keeps its real user_id, which RSVP lookups
depend on and which run for the responder themselves, so the comments REST
response withholds that field on its own.

Whether to mask stays with Rsvp::should_mask_identity(), so this reads the
same condition as the block output, the serializer, and the avatar.

A storage test asserted a stored author name through the read path that now
masks it; it reads the value back as a viewer allowed to see it, which is
what it meant to assert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Drop the per-block anonymization in favor of the masked comment

Rewriting blocks was how anonymity used to be kept: the avatar and
comment-author-name blocks were rendered, their markup edited, and the
result swapped back in as core/html and core/paragraph. That only covered
the blocks it named, which is how the avatar's alt text kept publishing the
responder's real name -- core builds it from the comment author, and
nothing was rewriting it.

Now that comments are masked as they are read, both blocks resolve the
masked author on their own: the avatar renders "Anonymous Avatar", the
author name renders "Anonymous", and core skips the author link because the
URL is empty. The rewriting is redundant, so it goes, along with the tests
that covered it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Inline the masking condition at the get_comment filter

The condition had been lifted into a shared predicate, which added a layer
the fix does not need: core's get_comment filter is already the one place
every reader passes through, so the test belongs in that callback. The
REST author field no longer repeats it either -- the comment it receives
has been through the filter, so a masked author is what marks it withheld.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Import WP_REST_Response and cover the comment masking

PHPStan resolved the WP_REST_Response docblock type against the plugin
namespace because the class was never imported, and the two masking methods
had no tests of their own.

The added cases cover each way a comment reaches the filter: a value that
is not a comment, a comment that is not an RSVP, an RSVP that is not
anonymous, and a viewer who can edit posts -- none of which mask -- plus
the responder who does, asserting the stored comment keeps its real values
because the mask is applied to a clone. The REST cases cover a masked
author, a response with no author field, an unmasked RSVP, and a regular
comment whose author is literally "Anonymous".

The fixture reads with WP_Comment::get_instance() rather than get_comment()
so it returns the stored comment instead of one the filter already masked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Pivot anonymity on the RSVP capability

Anonymity was deciding on three different answers: the avatar asked for
Rsvp::CAPABILITY while the serializer asked for edit_posts, and the new
comment mask followed the serializer. edit_posts reaches contributors and
authors, who on a community site are ordinary members rather than staff,
so an anonymous responder was exposed to them -- including to a
contributor who cannot edit the event at all.

All four sites now ask for Rsvp::CAPABILITY, through the constant rather
than a literal, so the answer cannot drift again. The REST author field
re-reads the responder's own state instead of comparing against the masked
output, which stops the two filters depending on what one of them writes.

Reported by Matthew Neil Cowan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Render event blocks for viewers allowed to read the event

Every block guarded on the event being published, with no capability check
and only a preview escape, so an unpublished event rendered as empty space
for everyone -- an organizer opening their own draft or private event saw
no date, no add-to-calendar, no RSVP button or form, and no roster.

Event::is_viewable() now answers that once, and the eight guards across the
seven block classes defer to it: published renders for everyone, anything
else renders for viewers who can read that specific event, and a preview
always renders. The capability is Event::READ_CAPABILITY rather than a
literal, matching how the REST routes ask the same question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Move the roster read check onto Event and name its capabilities

Whether a viewer may read an event's responses is a question about the
event, so it now lives beside the event's other visibility rule as
Event::can_read_rsvps(), and the REST permission callback asks it rather
than answering it. That also closes a gap: the check trusted the route's
validation, which only asks whether a post type keeps event dates, so it
never confirmed the post type takes RSVPs at all.

The capabilities are constants now -- Event::READ_CAPABILITY and
Event::EDIT_CAPABILITY -- across the routes and the tests that assert
against them. Rsvp::responses() was still pivoting its cache on a literal
edit_posts while the masking had moved to Rsvp::CAPABILITY; the two have
to name the same capability or the cached variant no longer matches the
one the serializer would produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Link the display name in RSVP responses to the responder

The comment-author-name block links only when the comment carries an author
URL, and responses saved through the store keep that column empty -- the
form path fills it in, the store path never has -- so the block's link
toggle did nothing for most responses.

The URL is now resolved from the responder's account when the comment has
none, the same way the avatar already resolves from it. It follows the
name: withheld from viewers an anonymous responder is masked from, and
shown to whoever already sees who they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Read the comment ID the author URL filter is given

The filter took a comment ID it never used and read the same value off the
comment object instead, which the mess detector flagged. Two assertions in
the test also ran past the line length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Resolve the responder profile URL on the comment itself

The link was being resolved through get_comment_author_url(), which the
comment-author-name block calls but the REST comments route does not -- it
reads the column straight off the comment, so the route kept reporting an
empty author URL while the block linked.

Resolving it where the identity is already prepared covers both, because
that is the object each of them reads, and the separate filter goes away
with it. Only a URL the store never wrote is filled, so a response
identified by its own URL keeps the one it was saved with.

The filter no longer only masks, so it is now prepare_rsvp_comment().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep email addresses out of public RSVP records

A response saved against an email address published that address twice in
the public roster. Reading a response never carried the name it was saved
with, so the display name fell through to the email provider, which returns
the address; the identifier field carried it a second time.

Hydration now carries the stored name, so a response reports the name the
guest gave, and the identifier withholds an email address from anyone who
does not manage RSVPs. Other identity types keep reporting theirs, since a
user ID is already public in the same record.

Preview access is scoped to the post being previewed. is_preview() is a
property of the request, so previewing any post was standing in for read
access to every event, and block context carries a post ID of its own.

Reported by CodeRabbit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Describe the RSVP disclosures in the changelog

The entry was deliberately vague while the report was embargoed. It is
replaced by one entry per disclosure, typed security, so the release states
what was exposed rather than describing the work as access checks and
caching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Matthew Neil Cowan <matthewneilcowan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Hook docs updated! (#2149)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Roll up changelog for 0.35.1 (#2151)

Brings the released section back to develop and removes the entry files it
consumed, so the next release does not list them a second time.

Rebuilt on develop rather than on main: the branch the release workflow cut
carried main's version bump, which would have moved develop to 0.35.1 and
reverted work main does not have. The 0.34.1 rollup landed the same three
things this one does.

Co-authored-by: Mike Auteri <mauteri@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Mark the calendar cache hashes as non-cryptographic (#2153)

The calendar cache keys and the ICS entity tag are built with md5(), which
the scanner reads as a weak hashing algorithm. None of them protect
anything: two discriminate a cache entry and the third validates a cached
response. Marked the way the geocoding and static-map cache keys already
are, rather than moving to a slower digest that would invalidate every
existing entry to satisfy a scanner.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Remove extract-zip from the dependency tree (#2154)

The advisory covers <= 2.0.1 and 2.0.1 is the latest published release, so
there is no patched version to upgrade to. Drop the package instead.

Bump @wordpress/env to 11.13.0, which no longer depends on it, and regenerate
the Composer-audit patch against that version. Override @puppeteer/browsers to
^3.2.0, which replaced extract-zip with modern-tar, clearing the two remaining
paths under @wordpress/scripts and lighthouse.

@puppeteer/browsers@3.x requires Node >= 22.12, so pr-coverage.yml now reads
.nvmrc like every other workflow instead of pinning Node 20.

* Bump "Tested up to" to WordPress 7.1 (#2157)

* Add the 0.35.1 credits file to develop (#2159)

* Reject open RSVP to unpublished or RSVP-disabled events (#2155)

* Reject open RSVP to unpublished or RSVP-disabled events

* Add test coverage

* Use Event::is_viewable instead of checking post status

---------

Co-authored-by: Mike Auteri <mauteri@gmail.com>

* Credit vanyukov (PR #2155) (#2161)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Stop an email address standing in for a responder's name (#2152)

* Resolve the display name for RSVP responses consistently

Providers were required to answer "what should I call this responder", and
the contract told those without a name to fall back to the identity value
itself. That answer is stored and displayed as a name, so an email response
carried the address into the column every reader takes the name from, and
readers disagreed about what a response was called.

The base provider now answers with nothing by default, so a provider only
implements this when it can actually name someone, the way the user provider
reports an account's display name. Callers decide what to show in its place:
the store writes nothing, and a record names whoever manages RSVPs the
address it was saved with and everyone else "Attendee", the label the RSVP
admin table already uses for that column.

Responses carrying a name from before this keep reading as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Apply review feedback on display-name resolution

Scope the stored-address check to what the old store actually wrote, which
put the same value in both columns. Matching on the name merely looking like
an address caught every account registered with one, and core publishes those
display names.

Branch the address and URL treatments independently. They are separate
conditions, so a response matching both was only getting the first, leaving
the URL unresolved where the responses path still filled it.

* Describe the display-name fix as security in the changelog

* Require the withheld display name to be an address

The equality with the email column marks the legacy write, but on its own it
also matches a row whose two columns agree on something that is not an
address. That cannot come from this store, which only fills the address column
for an email identity, but imports and migrations write here too.

* Trim the comment on the stored-address check

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Credit matthewneilcowan (PR #2152) (#2162)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Scope radio option focus to the field being edited (#2124)

* Scope radio option focus to the field being edited

Adding or removing a radio option looked the new option up with
document.querySelectorAll across the whole page. That fails in the iframed
editor, where the options live in the canvas document rather than the admin
one, and it picks the wrong element when a post holds more than one radio
field. Start the lookup from the element that received the keystroke
instead, and take the range and selection from its ownerDocument.

* Guard the caret placement against a discarded view

The selection is taken inside a 50ms timer, so the canvas can go away before
it runs — a Code Editor switch or a device-preview toggle discards the
browsing context and leaves the document without a defaultView. The previous
code used the global getSelection(), which could not throw, so scoping it to
the element's own document introduced the failure mode.

---------

Co-authored-by: Mike Auteri <mauteri@gmail.com>

* Create the venue detail cursor range from the canvas document (#2163)

The keydown handler already takes its selection from the element's own
document, but the range that measures the cursor position came from the admin
document while operating on canvas nodes. Modern browsers tolerate a
cross-document range since DOM4 dropped WrongDocumentError, so this worked by
leniency rather than by design — the same boundary the WordPress 7.1 iframed
editor guidance says to respect, and the same class of defect as the radio
field fix in #2124.

* Roll up changelog for 0.35.2 (#2164)

Co-authored-by: Mike Auteri <mauteri@gmail.com>

* Allow noteworthy promotions to be queued between releases (#2136)

* Allow noteworthy promotions to be queued between releases

The credits-sync automation only ever appends to unreleased.json's
contributors array, so moving someone into the noteworthy group has to be
remembered by hand at the next version bump, in a file that does not exist
yet at the moment the decision is made.

fold_unreleased_credits() now also reads an optional noteworthy array from
the same file. A queued name already credited as a contributor for the
release moves groups rather than appearing twice, names already in
noteworthy or leads are no-ops, and the staging file resets both arrays.

Who belongs in noteworthy stays a lead's judgment call; this only gives
that decision somewhere to live until the bump.

Props motylanogha

* Queue motylanogha for the noteworthy group

Per mauteri's answer in Slack on 2026-08-10, when asked directly about the
Contributors and Developers section on the wp.org plugin page: "yes, you
will be listed in next version for sure".

That section is generated from leads + noteworthy, so this is the change
that implements it. Group assignment is the leads' call, so this sits in
its own commit and can be dropped without losing the one before it.

* Promote from the waiting list by walking the queue (#2173)

check_waiting_list() indexed the waiting list with a counter bounded by
the number of free spots. The two are different quantities, so the loop
failed in both directions:

- Fewer waiting than free spots read past the end of the array and
  passed null to Intent::attend(), a fatal TypeError. An event with an
  attendance limit hits this as soon as one person is waiting and a
  seat frees up.
- More waiting than free spots stopped early, because the bound shrank
  as the index grew, so three open spots promoted only two people.

Walk the queue and stop when the room is full. Covers both directions.

* Fix/#2140 reduced motion (#2170)

* Honor prefers-reduced-motion in modal and loading animations

Drops the modal's scale-in transform and the loading pulse's
looping animation when the OS-level reduced-motion preference is
set, since both are the kind of motion that can trigger symptoms
for people with vestibular disorders. The modal still cross-fades
in and the loading control still reads as busy via a static dim,
so neither state loses its meaning — only the motion is removed.

Co-Authored-By: Claude <noreply@anthropic.com>

* Add changelog entry for the reduced-motion guards

The implementation commit predates the changelog requirement check on this branch, so the entry lands separately; it is classified as a patch-level addition because the plugin previously had no prefers-reduced-motion handling and honoring the preference is best practice rather than a conformance fix.

Co-Authored-By: Claude <noreply@anthropic.com>

* Correct the changelog entry type to changed

The entry was classified as added, which misreads the change: honoring prefers-reduced-motion adjusts how the existing modal and loading animations behave rather than introducing a feature. Changed also matches the classification already given in the PR description, which the file contradicted.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Guard array-to-string casts that can warn or throw in production (#2169)

* Fix Array to string conversion warning on event archive redirect

get_query_var( 'post_type' ) can return an array on multi-post-type
archive queries; casting it directly to a string triggered a PHP
"Array to string conversion" warning in production. Guard with
is_string() instead, matching the existing pattern already used in
Admin_List::handle_column_sorting().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Guard remaining unsafe casts on values that can arrive as arrays

Audited the codebase for the same class of bug fixed in the previous
commit: a (string) cast on a value that can legitimately be an array,
which emits a PHP "Array to string conversion" warning instead of
failing loudly or safely. Found and fixed four more:

- Venue map REST endpoint: aspect_ratio/map_type validate_callbacks
  cast the raw request value before checking its type, so a malformed
  `aspect_ratio[]=` request warned before being rejected.
- Settings page sanitizer: an array-valued POST field for a text/select
  field warned in the default sanitize arm; an array-valued
  autocomplete field would have thrown a TypeError against
  sanitize_autocomplete()'s string-typed parameter.
- Venue map prewarm job: a hand-edited or malformed post_content block
  attribute for aspectRatio warned when cast during the prewarm scan.

Each now guards with is_string()/is_scalar() before casting, matching
the pattern already established in Geocoding's Photon property
extraction and Admin_List::handle_column_sorting(). Added regression
tests for each new branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Date the new tests at 0.36.0

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Mike Auteri <mauteri@gmail.com>

* Hook docs updated! (#2174)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Remove RSVP CTA from event emails when RSVP is disabled (#2075)

* Omit RSVP Now CTA from event emails when registration is closed

Past-event (and RSVP-disabled) member update emails still rendered a
useless RSVP Now link. Gate the button on has_event_past() and
rsvp->is_enabled(), and cover future/past/disabled cases in Rest_Api tests.

* Fix email CTA coding standards

* Add changelog entry

* Remove only test email filters

* Restore RSVP mode during test teardown

* Simplify RSVP email availability check

* Date the new tests at 0.36.0

---------

Co-authored-by: Mike Auteri <mauteri@gmail.com>

* Credit raicem (PR #2075) (#2176)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Raise PHPStan to level 6 (#2175)

Level 6 requires every array in a docblock to say what it contains. Documents
the real shape of 284 parameters, returns, properties and constants across 65
files, using array shapes and @phpstan-type aliases where the structure is
stable rather than falling back to a blanket mixed.

Three runtime lines changed, each one a defect the new types exposed:

- Assets::get_block_variation_names() passed scandir() straight into
  array_diff() without handling its false return on an unreadable directory.
- Settings::is_option_inherited() and Settings\Network::render_page() both
  guarded $config['inherited'] with ??, but get_config() merges defaults, so
  the key is always set and the fallback was dead.

* Hook docs updated! (#2177)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Keep the email subject default per recipient, and sanitize the text params (#2179)

The send modal pre-filled the Subject field with the sender's rendering of
the default template and posted it on every send, so the server-side
default composed inside each recipient's locale was unreachable. The field
now starts empty with that default shown as a placeholder.

The email endpoint also declared sanitize_text_field as a validate_callback
for message and subject. A sanitizer in that slot never sanitizes: the REST
server only treats an exact false return as invalid, so the raw value was
used. Both move to sanitize_callback, with sanitize_textarea_field for the
message so newlines survive to the nl2br() in the email template.

Follow-up to #2042.

* fix(form-field): render a real <select> for the Select field type (#2118)

* fix(form-field): add select field support

Fixes #2105

* fix(js): use ellipsis character in select option placeholder

- fix: replace three-dot placeholder 'Option label...' with ellipsis 'Option label…' in src/blocks/form-field/types/select.js

Errors fixed:
- @wordpress/i18n-ellipsis: use ellipsis character in place of three dots

PHP 8.1 compatible. All CI checks passing.

Refs #2118

* fix(form-field): correct @since version and close coverage gap

* Fix select and radio option value resolution and required select placeholder

* fix(form-field): address CodeRabbit review feedback for select field

Persist "0" as a valid select/radio option value through form submission,
exclude the select field type from text-input settings, and keep the
default field value in step when a label edit regenerates an option value.

Adds regression coverage for "0" persistence and scopes the select option
focus query to the current block.

* fix(ci): pin WordPress core to 7.0.3 in wp-env test config

* fix(form-field): normalize selected rendering and tighten select assertions

* Revert WordPress core pin from wp-env test config

* chore: retrigger CI after transient xdebug image build failure

* fix(form-field): preserve no-default when editing blank select option

Guard select label edits against promoting an empty default.
Also cast radioOptions branch to array before array_map.

Fixes #2105

* Collapse the select field's options when the block is not selected

The editor rendered the closed control and a separate list of the option
labels underneath it, so the options appeared twice and the second copy was
unstyled inline text.

A select shows one option at a time, so the field now behaves the way the
control does: selected, it expands into the list a browser shows when the
select is open, with the default choice marked; deselected, it collapses back
to the closed control. The options stay editable without the canvas showing
something the visitor never sees. The collapsed preview mirrors the template,
opening on the placeholder when the field is required and has no value and on
the first option otherwise.

The label was a <legend>, which belongs to a fieldset grouping several
controls. A select is one control and the template already labels it with
<label for>, so the editor now matches.

Option focus was scoped to the whole admin document, which is the defect
fixed for radio options in #2124: the options render inside the editor canvas
iframe WordPress 7.1 makes unconditional, so the lookup found nothing and the
caret never moved. Focus now starts from the element that received the
keystroke and takes its range from that element's own document.

Adds the help-text markup the other field templates carry, so the select is
not left out once #2112 introduces the attribute.

* Insert a new option below the one Enter was pressed in

Enter appended to the end of the group instead of continuing the list where
the author was working, so pressing it in the first of two options put the
new one third. Radio and select both did this; both now splice at the
current index plus one and move the caret there.

The existing radio test encoded the old behavior — it pressed Enter in the
first option and expected focus at the last — while its own comment said the
option should be added "there". It now asserts the surrounding options keep
their positions, which is what fails if this regresses.

* Keep the select's appearance steady across selection

Selecting the block changed the field's size and border: a <select> is a form
control, so it took the browser's control font while the expanded list took
the document font, and the list drew its own border while the control drew
the browser's.

Both states now share one set of styles. The box takes the same inline styles
the closed control takes, with the rows carrying the padding, and the border
and font are declared for both so an unset font size cannot make them differ.
An explicit font size on the field is inline and still wins.

Drops the separate option styles from the expanded list. They come from a
different attribute than the closed control uses, which is what let the two
disagree, and the rendered <option> elements never received them.

* Share one implementation of option editing across radio and select

The rules for editing an option lived in four places: the canvas component
and the inspector panel, for each of radio and select. They had already
drifted — the fix for a blank option becoming the default had to be applied
twice, and radio never synced its default at all, so renaming the option a
radio field defaulted to left the default pointing at a value no option
carried any more and the selection was silently lost.

getOptionUpdates() is now the single place those rules live, and all four
call sites use it, which fixes radio by construction rather than by copying
the guard a third and fourth time.

* Drop the Option Styles controls from the Select field

A rendered <option> is drawn by the browser, and select.php emits the options
unstyled, so the font size, line height and option text color offered here
changed nothing a visitor would see. They were also the attributes that let
the editor's expanded list disagree with the closed control, since they are a
different set from the ones the control itself uses.

Radio keeps them; radio.php applies them to its option labels. The attributes
stay in block.json for that reason, and any value already stored on a select
is simply no longer offered for editing.

* Drop the free-text default from the Select field

Select picks its default from the option list, the same way radio does, so
the text control offered a second way to set it that could name a value no
option carries — which selects nothing at all.

Radio already returned null here for that reason; select now shares the case.
Every other field type keeps the control.

---------

Co-authored-by: Mike Auteri <mauteri@gmail.com>

* Form-field accessibility: fieldset semantics, autocomplete, help text (#2112)

* Form-field accessibility: fieldset semantics, autocomplete, help text

Three related gaps in the form-field block, filed at mauteri's request
from the #2104 discussion so they can ride along with the Form_Field
public-API work:

Radio groups rendered a legend without a fieldset — a legend outside a
fieldset has no semantics, so the radio options had no programmatic
group name (WCAG 1.3.1). The template now renders a real fieldset with
the legend as its first child (the label wrapper moves inside the
legend, preserving the label/required layout), the editor preview
mirrors the structure, and a small style reset strips the browser's
default fieldset chrome so the change is semantic, not visual.

autocomplete rendered the generic "on" unless the editor's type-change
handler had stored a token (WCAG 1.3.5). Form_Field now resolves the
token server-side with the same map the editor uses — "on" and absent
both mean "infer from field type" since no UI offers "on" as a
deliberate choice — fixing existing email/tel/url fields and direct
API consumers alike; an explicitly stored token still wins.

helpText was read by process_attributes() but was not a registered
attribute, had no editor control, and no template rendered it. It is
now a block.json attribute with a sidebar control, rendered as a
help paragraph associated via aria-describedby — on the input for
text-like fields, on the fieldset for radio groups so it announces
once with the group.

Fixes #2110

Co-Authored-By: Claude <noreply@anthropic.com>

* Add url field-type coverage to autocomplete inference test

Co-Authored-By: Claude <noreply@anthropic.com>

* Add changelog entry for #2112

Co-Authored-By: Claude <noreply@anthropic.com>

* Address review: @since, match expression, and canvas help text

Follow-ups from wppoland's review of #2112, all three verified against the repo before taking them.

0.35.0 shipped on 10 August, so the new @since tags on resolve_autocomplete() and the two new tests move to 0.36.0 — the branch was cut while 0.35.0-beta.2 was current, which is how they went stale. AGENTS.md: commits not yet in any tag map to the next release.

resolve_autocomplete() had five returns, over the three php:S1142 allows, and existed only to produce a value — the shape AGENTS.md says to write as a match. Converted, keeping the default arm so an unrecognized field type can't raise UnhandledMatchError; both subjects are strings, so match's === comparison is equivalent to the switch it replaces.

Help text rendered on the front end but nowhere in the editor canvas, while the control's own description promised it appears below the field — so an author saw nothing and had to view the post to find out it worked. The four editor type components now render the same help paragraph, making the canvas match the front end.

Co-Authored-By: Claude <noreply@anthropic.com>

* Wire radio help text to aria-describedby; fix autocomplete inference

The radio field's help text rendered as a sibling paragraph after the
fieldset, so nothing tied it to the fieldset for assistive tech even
though the frontend template associates them. Give the help text an
id via useInstanceId and point the fieldset's aria-describedby at it,
moving the paragraph inside the fieldset to match the frontend markup.

Separately, resolve_autocomplete() used "on" as the sentinel for
"infer from field type," which meant a deliberately stored "on" was
indistinguishable from the inferred default and got silently
overridden. Switch the sentinel to an empty string — block.json's
default, which the serializer never persists over, so content saved
under the old "on" default still infers correctly. Any stored token,
including "on", is now an explicit author choice and wins. Update
edit.js's getDefaultAutocomplete() and its help text to match, and
add a tel-type inference test alongside the corrected stored-"on"
test.

Co-Authored-By: Claude <noreply@anthropic.com>

* Correct the autocomplete test docblock to match the new semantics

The previous commit inverted the sentinel — empty now means "infer"
and a stored "on" is an author choice — but this test's docblock
still described the old rule, contradicting the assertion a few
lines below it. Left as-is, a future reader would have taken the
docblock as the contract and "fixed" resolve_autocomplete() back.

Co-Authored-By: Claude <noreply@anthropic.com>

* Give resolve_autocomplete's parameter a value type

Level 6 landed on develop after this method was written, and it requires an
iterable's value type. Matches process_attributes(), which takes the same
array.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mike Auteri <mauteri@gmail.com>

* Wait on the RSVP flow instead of a fixed sleep in the modal-switch tests (#2181)

flushRsvpFlow() waited a flat 30ms, in which the flow has to complete a
nonce fetch, an RSVP POST, the promise chain through sendRsvpApiRequest
and a 10ms timeout before closeModal fires. That is roughly 3ms of slack
on an idle machine and none on a loaded one, so both tests fail
intermittently for reasons unrelated to the code under test.

Replaces it with the waitFor already used across this suite, waiting on
closeModal having been called. closeModal is the last step in both
branches and openModal is decided synchronously before it is scheduled,
so the existing assertions, including the negative one, stay sound.

Fixes #2180.

* Raise PHPStan to level 8 (#2178)

* Resolve missing asset metadata to defaults

get_asset_data() returned whatever the .asset.php file held, or an empty
array when it was absent, while every caller reads both 'dependencies' and
'version' unguarded on the way into wp_register_script() and friends. A
missing or partial metadata file therefore produced undefined-key warnings
rather than a usable registration. Fill both keys instead, and tighten the
surrounding narrowing PHPStan level 8 surfaces in the same files.

* Raise PHPStan to level 8

Levels 7 and 8 check that values which can be null or false are narrowed
before use. Most findings were latent def…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adjust pre-built OSM colors of markers and contribution links

2 participants