WP Manifestindependent plugin directory
manifest / content / spotlight-posts

Spotlight Posts

Editorial featured-posts plugin built to WordPress VIP standards — object caching, primary-key queries, 106 integration tests, clean WordPress-VIP-Go lint. Query Loop variation, dynamic block, REST API.

by Rajkiran Bagal · github.com/rajkiranbagal/spotlight-posts · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/rajkiranbagal/spotlight-posts/archive/refs/heads/main.zip

Readme

Spotlight Posts

A WordPress plugin that lets editors flag posts as featured — from the editor, the posts list, bulk actions or Quick Edit — and surfaces them three ways: a variation of core's Query Loop, a dedicated dynamic block, and a public REST endpoint.

Not affiliated with WordPress VIP or Automattic. The name and the vip_ prefixes refer to the platform this targets, not to its authorship. It is independent work, built to the standards a VIP engagement depends on: object caching, primary-key queries, disciplined escaping and sanitization, nonce plus capability checks, and a clean WordPress-VIP-Go run.

What that rests on: 106 integration tests against a real WordPress install · CI on PHP 8.1 / 8.2 / 8.3 and WordPress trunk · zero PHPCS findings · every feature exercised end to end in a VIP local environment.

It has not run against VIP production traffic, so read "VIP-ready" as built to the standard and verified locally, not as proven at scale.

  • Requires: WordPress 6.4+, PHP 8.1+
  • Text domain: spotlight-posts
  • Meta key: _spotlight_featured

📘 docs/VIP-GUIDE.md — a full walkthrough of how the VIP platform works end to end: repo anatomy, the request lifecycle, the local environment, this plugin file by file, and how to test it all on localhost.


What it does

Editors can flag a post four ways, because different jobs want different ones:

Where For
Featured checkbox in the post sidebar Editing a single post
Star toggle in the posts list table Changing one post without opening it
Mark as featured bulk action Curating many at once
Quick Edit checkbox Alongside the other inline fields

A Featured / Not featured filter sits above the list table, and the column appears in Screen Options so anyone who does not curate can hide it.

A post can also be featured until a given date and time, set alongside the checkbox in the editor. See scheduled expiry for how that interacts with caching.

Posts → Featured Order arranges the list by dragging, or with keyboard move controls for anyone not using a mouse. That order is what the block and the REST endpoint return. Curation is gated on edit_others_posts rather than post authorship, filterable via spotlight_posts_manage_capability — deciding what the homepage promotes and being able to write posts are different jobs.

Out of the box this applies to posts. Any post type can opt in through the spotlight_posts_post_types filter, and they share one ordered list rather than being grouped by type.

Flagged posts appear in three places:

  • a Featured Posts variation of core's Query Loop — full card layouts with featured images, titles, excerpts and dates, styled by your theme,
  • the Featured Posts block (dynamic, server-rendered, configurable heading and count), and
  • GET /wp-json/spotlight/v1/posts?count=5

All three read the same index and apply the same expiry rule, so they cannot disagree about what is featured.


Setup

composer install     # PHPCS + WordPress VIP coding standards
npm install          # @wordpress/scripts build toolchain
npm run build        # compiles blocks/ -> build/

src/ is PHP and blocks/ is JavaScript — deliberately separated, because PSR-4 wants PHP in src/ while wp-scripts defaults to src/ for JavaScript, and a directory holding both tells you nothing at a glance. --webpack-src-dir=blocks redirects the bundler.

npm run build produces two entry points: build/featured-list for the dedicated block, and build/query-loop for the Query Loop variation. They are separate wp-scripts runs because wp-scripts build only discovers entries from block.json, and a block variation has no block.json of its own.

Building is required before activating the plugin. Both registrations deliberately no-op when their build output is missing, so a fresh clone that has not been built yet degrades to "block absent" rather than a fatal error.

Commands

Command Purpose
composer lint PHPCS against WordPress-VIP-Go
composer lint:fix PHPCBF auto-fix pass
composer test PHPUnit integration suite
npm run build One-off production build
npm run start Watch mode for development

Running the tests

The suite runs against a real WordPress install, not mocks — the things worth testing here are cache behaviour, WP_Query results and REST dispatch, none of which survive being mocked.

bin/install-wp-tests.sh wordpress_test <db-user> <db-pass> <db-host> 6.7
composer test

If you are using the VIP dev environment, its MySQL is already exposed on the host — take the port from docker ps and pass --skip-db-creation style arguments as needed:

bin/install-wp-tests.sh wordpress_test wordpress wordpress 127.0.0.1:50400 6.7 true
WP_TESTS_DIR=/tmp/wordpress-tests-lib composer test

127 tests cover the save guards, the count clamp, REST validation, cache invalidation, index ordering, the draft round-trip, the list-table controls, the ordering screen, scheduled expiry, the Query Loop variation, multi-post-type support and the block's heading levels. They have been mutation-checked: removing the capability check, the meta-key filter or the count clamp each turns the suite red.


Running it in a VIP local environment

Verified against VIP-CLI 4.1.0 with the plugin loaded through a local checkout of Automattic/vip-go-skeleton, so the layout matches a real VIP application.

git clone --depth 1 https://github.com/Automattic/vip-go-skeleton.git ~/vip-skeleton
rsync -a --exclude .git --exclude node_modules --exclude vendor \
  ./ ~/vip-skeleton/plugins/spotlight-posts/

vip dev-env create --slug vip-featured --title "VIP Featured" \
  --multisite false --php 8.2 --wordpress 6.7 \
  --app-code ~/vip-skeleton --mu-plugins demo \
  --elasticsearch n --phpmyadmin n --xdebug n --cron n --mailpit n --photon n

vip dev-env start --slug vip-featured
vip dev-env exec --slug vip-featured -- wp plugin activate spotlight-posts
vip dev-env exec --slug vip-featured -- wp theme activate twentytwentyfive

Two things worth knowing:

  • Use --app-code <path>, not --app-code demo. The demo value mounts a read-only image of the skeleton, so there is nowhere to put your plugin. A local clone is writable and otherwise identical.
  • Pin WordPress to 6.7 or later for the skeleton. The skeleton bundles the twentytwentyfive theme, which requires WP 6.7. On WP 6.4 it cannot activate, no theme renders, and every front-end request returns HTTP 200 with an empty body — which looks like a plugin fault but is not one. The plugin itself supports 6.4+; only the skeleton's theme forces the higher floor.

Because the plugin is copied rather than symlinked (Docker bind mounts do not follow symlinks out of the mount), re-run the rsync after editing, and remember that build/ must exist in the copy for the block to register.


Architecture

Every class implements a small Registrable contract and declares its own hooks. Plugin is the composition root: it builds the object graph once and lets each service attach itself. The main plugin file registers no hooks at all — it holds a plugin header, a PSR-4 autoloader and one call to Plugin::boot().

Dependencies arrive through constructors. Nothing reaches out for a service it was not given, which is what keeps the graph readable:

Cache, PostTypes, Request      depend on nothing
Index, Schedule                depend on Cache + PostTypes
Repository                     depends on Index, Schedule, Cache, PostTypes
Frontend / Admin / Rest / Cli  depend on Repository or Index

That direction is the point. Cache invalidation used to live inside the index and the scheduler, so both called into the query module while it called back into them — two circular dependencies. Nothing was broken, because PHP resolves those at call time, but neither module could be reasoned about or tested in isolation. Extracting Support\Cache gave all three a collaborator they depend on one way only.

What was deliberately not done

Wrapping the existing functions in static classes would have added ceremony and kept both cycles. There are no static-only classes here, no interfaces with a single implementation, and no getters around plain data. Registrable earns its place because a dozen classes implement it; FeaturedPost earns its because an array shape documented in a docblock is enforced by nothing.

Plugin::instance() exists for exactly two callers — the activation hook, which runs before anything holds a reference, and the test suite, which is a composition root of its own. It is not a general-purpose service locator, and application code does not use it.

VIP-relevant design decisions

These are the parts worth reviewing.

Caching: versioned keys, not key enumeration

Support\Cache stores each result set under a key that embeds a cache version integer:

featured_v<version>_n<count>

Invalidation is wp_cache_incr() on that single version key. Every previously cached permutation is orphaned at once and ages out on its own.

It fires from two directions. Every index mutation routes through Index::set(), which flushes as it writes — so featuring a post through the meta box, a bulk action, Quick Edit, the row toggle, WP-CLI or the REST meta API all converge on the same invalidation. Separately, save_post covers edits that change what the list renders without changing who is in it: a retitled post, a new excerpt, a draft going live.

This matters on VIP specifically. The object cache is shared and remote, and it offers no "delete by prefix" primitive — so the alternative is tracking and deleting every n permutation by hand, which is both racy and chatty. One incr() is atomic and costs a single round trip.

The 300-second TTL is only a backstop against a stale entry outliving a cache reset; the version bump is what provides correctness. The TTL is written as a literal rather than a named constant so VIP's LowExpiryCacheTime sniff can statically verify it clears the 300s floor.

The query is bounded on every axis

'posts_per_page'         => $number_of_posts,  // clamped to 1..10
'no_found_rows'          => true,              // skips SQL_CALC_FOUND_ROWS
'update_post_term_cache' => false,             // terms are never rendered
'ignore_sticky_posts'    => true,              // no second query for stickies

The count is clamped before it reaches the cache key, so an attacker cannot spray ?count= values to blow out the cache with distinct entries.

Why WP_Query rather than a convenience wrapper

get_posts() and query_posts() are the two easy answers, and both are wrong here.

query_posts() clobbers the main query global and forces a re-query — it is disqualified on any site, VIP or not. get_posts() is closer, but it silently sets suppress_filters => true, which means caching and query plugins in the stack never see the query — on VIP that quietly bypasses platform-level query behaviour. It also gives no clean way to set no_found_rows.

WP_Query is explicit: every performance flag above is visible at the call site, which is exactly what a reviewer needs to audit.

The read path never searches by meta

A meta_key / meta_value lookup is unindexed — wp_postmeta has no composite index that makes it selective — so it trips WordPress.DB.SlowDBQuery. Rather than suppress that warning on the hot path, the plugin stops asking the database to find featured posts at all and maintains the answer on write.

Layer Role
_spotlight_featured post meta Source of truth, per post
spotlight_featured_post_ids option Ordered index of IDs, maintained on every meta write
wp spotlight rebuild Regenerates the index from meta

The resulting query, captured from the running site:

SELECT wp_posts.* FROM wp_posts
WHERE 1=1 AND wp_posts.ID IN (7,5)
  AND wp_posts.post_type = 'post'
  AND ((wp_posts.post_status = 'publish'))
ORDER BY FIELD(wp_posts.ID,7,5) LIMIT 0, 5

No wp_postmeta join. A primary-key lookup with explicit ordering.

Three consequences worth noting:

  • Ordering is free. The index is an ordered array, so newly featured posts lead and an editor can impose any order without a second storage mechanism.
  • Publication state is not the flag. Posts are indexed in any non-trashed status and filtered to publish at read time, so a post going back to draft keeps its position and reappears where it was when republished.
  • The index is disposable. It is derived from meta, so a lost update self-heals via rebuild rather than becoming corruption — which is what makes the small race window on concurrent option writes an acceptable trade.

The cap of 100 IDs is a platform constraint, not a product one. The option is autoloaded, so it lands in alloptions on every request; VIP runs an alloptions-limit mu-plugin precisely because oversized autoloaded options degrade every page load.

One suppression remains, in Index::rebuild() — the one place that still has to search by meta. It runs on activation and on demand via WP-CLI, never on a front-end request.

Read the full README on GitHub →