Let the translation decide the summary word order The three counts were separately pluralised, which was always right, and then joined by a bare translated "and" with the spacing and the full stop concatenated around it. So the order the counts appear in was a fact about English held in PHP: a translator handed only "and" cannot put the inactive count first, and several languages want to. One joining string with numbered placeholders. The <strong> stays outside the msgid for the reason the comment above it already gives -- a translator has no reason to be handed markup. Rendered output is byte-identical, so the new test reorders the translation and asserts the sentence follows; that is the only thing that can tell the two apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
Recent Commits to wp-pluginsused:master (11)
-
Let the translation decide the summary word order
-
Guard against padding inside a translatable string
Guard against padding inside a translatable string A translator sees a msgid in a list, where a leading or trailing space is invisible and a run of them is indistinguishable from one. They cannot reproduce what they cannot see, so padding survives only in English -- and where it was separating a label from a value concatenated after it, the translated string comes out run together. Three plugins in the family had it. This one does not, and the shared metadata fixture is where it is kept that way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
Notice a plugin that arrived without WordPress being told
Notice a plugin that arrived without WordPress being told Caching the headers behind hooks and an expiry got the scan off the front end, and broke the plugin's one job on the way: a plugin uploaded over FTP, unzipped by hand or dropped in by a provisioning script fires none of those hooks, so the listing went on not listing it until the day ran out. The owner refreshes, sees the old list, and concludes the thing is broken. The e2e suite writes its hostile fixture straight to disk and caught exactly this. The entry names of the plugins directory are read on every request now and the stored headers are only reused while they still match. That is one directory read against get_plugins()' directory read plus a file opened and parsed for every plugin in it -- listing a directory was never the expensive part, and it is the whole of the saving. The expiry drops to an hour and covers what the fingerprint cannot see: a header edited inside a file whose name did not change, when WordPress was not the one editing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
Stop scanning the plugins directory on every front-end request
Stop scanning the plugins directory on every front-end request get_plugins() opens the plugins directory, opens every plugin file in it and reads the first 8KB of each. Core caches the result -- in the `plugins` group, which wp_start_object_cache() registers as non-persistent, so the scan runs again on every request no matter what object cache the site has installed. That is an admin-screen cost, and these shortcodes run on the front end: any visitor arriving past the page cache pays it, as often as they care to ask, on a page whose whole content is a list that changes maybe twice a year. The headers are kept in a site transient for a day. A site transient rather than a plain one because the plugins directory is one directory for the whole network -- a per-site transient would leave every other site reading a copy the deletion never reached. Installing, updating and deleting a plugin discard it because they change what is on disk; activating and deactivating do not, but they are the moment a plugin uploaded over FTP is first noticed, and a plugin the cache has never seen is missing from the listing rather than merely on the wrong side of it. The expiry covers an upload nobody follows with any of the five. The hidden-plugins list and the version switch are applied to the headers on every request rather than baked into them. Filtering is free next to the scan, and caching before that step means a settings save needs no invalidation at all -- which is one fewer way for a hidden plugin to stay published. reset_plugin_state() in the test case now flushes both, so a suite that writes fixture plugins to disk between tests is not reading yesterday's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
Stop keeping a logged-in session where it can be picked up
Stop keeping a logged-in session where it can be picked up artifacts/storage-states/admin.json is a real WordPress auth cookie pair, written by global-setup.js so the browser tests do not each log in through the form. It is scoped to the ephemeral wp-env container and worthless once that is gone, so nothing real leaks -- but it was sitting on disk in every plugin between runs, and two things sweep that directory up. The release rsyncs the working tree rather than a clean export, so the only thing keeping it off wordpress.org is one --exclude in a procedure that lives outside these repositories. This repository's own notes record that exact file class having shipped once already. And CI uploaded the whole of artifacts/ on failure, cookie included, for seven days. Narrowed to artifacts/test-results/, which is what the traces and screenshots are actually in. bin/test-e2e.sh no longer exec's Playwright, so it can remove the session whichever way the suite ends; global-setup.js writes it again on the next run. A session cookie is not a diagnostic, and keeping one is a habit rather than a decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
Gate the block-renderer route, and match hidden names the way they ar…
Gate the block-renderer route, and match hidden names the way they are stored Registering a dynamic block is what creates /wp/v2/block-renderer/<name>, and core gates that route on edit_posts. So merely having this plugin active published the site's whole inventory -- inactive plugins and exact versions included -- to every Contributor, on a site that had never placed a listing anywhere. Core keeps the same inventory behind a higher capability of its own, and an inactive plugin is by definition one nobody has been updating. Gated on the route rather than inside the render callbacks, and that is load-bearing: do_blocks() also runs during a REST render of post content, so a check on REST_REQUEST would blank the block for every reader of a headless site. Dropping ServerSideRender from the editor would not close it either -- the route exists because the block is dynamic. manage_options rather than activate_plugins, because under multisite map_meta_cap() resolves the latter to manage_network_plugins for anyone who is not a super admin, which would have locked every site administrator on every network out of previewing a block on their own site. The multisite suite caught that. manage_options already decides which plugins are hidden, so the person configuring the list is the person who can preview it. Separately: the hidden-plugins list is stored through sanitize_text_field() and was compared against the raw Name header, which get_plugins() applies nothing but trim() to. Any plugin whose name holds a double space, a tab or a percent-octet could be ticked, saved with a success notice, and go on being listed -- with the checkbox coming back unticked, so it read as a save that had not worked. Both sides go through one normaliser now. Every existing fixture survives sanitisation byte for byte, including the "evil" one whose double quote passes straight through, which is why the round-trip looked covered; zzz-gappy is the shape that exercises it. And the per-request cache is dropped on switch_blog, so one request rendering for two sites no longer applies the first site's hidden list to the second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aLvFGxYRW475ZnGjbLopC
-
The metadata fixture keeps its own copy of the canonical sections
The metadata fixture keeps its own copy of the canonical sections Adding Installation to the README meant adding it in two places, and only one of them was done: bin/verify.py went green while every plugin's own PHPUnit suite went red on an array diff whose whole content was "+ 1 => 'Installation'". The list lives in the shared fixture as well as in the checker, and nothing links them -- so the checker passing says nothing about the suite passing, which is exactly the sort of second source of truth this collection keeps finding.
-
An Installation section, because wordpress.org gives it a tab
An Installation section, because wordpress.org gives it a tab Every plugin gets one, including the ones whose honest answer is "activate it and you are done" -- a tab present on some of these and missing on others reads as an omission rather than as a decision. It sits between Description and Usage, which is the order a reader works in. Not boilerplate: "upload to wp-content/plugins/" is advice from before WordPress had a plugin installer and is not here. Step one is install and activate; everything after it is specific to this plugin, and where there is genuinely nothing to do it says so and says what happens instead. Setup steps that were sitting in Usage -- or, worse, in Description -- moved here, which is where somebody looking for them will go.
-
tests: a block's dependencies are core's, and are read off disk
tests: a block's dependencies are core's, and are read off disk The shared metadata fixture demanded an empty dependency array from every registered handle whose name starts with the slug. A block's editor script handle is minted by core from block.json and its dependencies are written by the build, so wp-blocks and wp-block-editor read as a §6 violation when they are what a block is. Block scripts are now held to their own rule: every dependency must be a handle WordPress itself registers, and none may be jQuery, because core ships jquery and "core provides it" alone would reopen the one door §6 exists to close. They are read from the build/*/*.asset.php manifest rather than from wp_scripts(). A block's handles are registered once, at the init the bootstrap fires, into a process-wide global that several plugins rebuild in set_up() - so whether the old loop saw a block at all depended on run order, and in three plugins it saw nothing and passed. fire_init() is for the tests that fire init a second time to watch a front end request: init has already fired once, so the second one re-registers the blocks, which is a _doing_it_wrong() notice. A real second request starts with an empty block registry, so this empties it of the plugin's own blocks first. The guard belongs here and not in the plugin - init fires once per request, and a second registration in production would be a real bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZG7R8RPFwPvWnSpUdKpoD
-
Three blocks, one per shortcode, sharing the shortcodes' renderer
Three blocks, one per shortcode, sharing the shortcodes' renderer Each block's render callback calls WP_PluginsUsed_Template::render() with the same string its shortcode's callback passes. Neither entry point calls the other: no do_shortcode() in a block, no block lookup in a shortcode, so either can be unregistered and the other carries on. All three shortcodes stay registered, documented and undeprecated, and the major version does not move. Three blocks rather than one with a listing-type attribute, even though the three shortcode callbacks differ only in that string. None of the shortcodes takes an attribute, so a type attribute would exist on one entry point and not the other; and a block name is fixed in post_content for the life of the post where an attribute is a value a later default can flip. Which listing a page shows belongs in the name. The hidden-plugins setting is the one thing here that is a disclosure rather than a nicety: somebody ticks a plugin because they do not want visitors knowing it is installed. Both the setting and the filter reach the block, because the block does no collecting of its own -- tests/test-blocks.php pins that through the render callback and through do_blocks(). This adds the first JavaScript build to the plugin. build/ is generated, gitignored and shipped; src/ is committed and not shipped. bin/build compiles and then walks build/ writing the silence-is-golden guards webpack knows nothing about, and bin/test.sh, bin/test-e2e.sh and the PHPUnit CI job all build first, so a checkout that has never been built cannot silently test the previous build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZG7R8RPFwPvWnSpUdKpoD
-
There is no link setting; the screen has two fields and neither is one
There is no link setting; the screen has two fields and neither is one The caption said the screen chooses "what each entry shows and where it links". add_settings_field() is called twice -- show_version and hidden_plugins -- so the screen offers the version toggle and the hide list and nothing about linking. Seventh caption corrected, and the third to promise a control that does not exist.