show.fm Podcast Player
show.fm WordPress plugin: blocks, embeds and Publish to WordPress for show.fm shows. GPLv2 or later.
by show.fm · github.com/showdotfm/showfm-wordpress · website
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/showdotfm/showfm-wordpress/archive/refs/heads/main.zipshow.fm for WordPress
The show.fm WordPress plugin: a podcast player, episode lists and Publish to WordPress for show.fm shows. GPLv2 or later, copyright show.fm Ltd.
This file is for developers. The WordPress.org readme is readme.txt.
Status
Version 1.0.0 is the first release for WordPress.org. It has the four blocks, the shortcode and oEmbed, Settings > show.fm with the Connection, Publishing, Display and Migrate tabs, the admin notices, connecting a site to show.fm (in the browser and with WP-CLI), the background sync engine and the embed migrator. The editor UI is described in docs-editor.md. How the release is built, checked and submitted is in docs-release.md.
Embed migration
The engine, the Settings > show.fm > Migrate tab, its REST routes and the
wp showfm migrate-embeds command are documented in docs-migrator.md,
including fixtures, undo behaviour and the current server contract gaps.
Requirements
- WordPress 6.6 or later, PHP 7.4 or later.
- For development: Node 22.12 or later on the 22 line, 24, or 26 and later (what Vitest 5 needs;
.nvmrcand CI use the latest 22), Docker (forwp-env), and Composer (or Docker to run it).
Layout
| Path | What it is |
|---|---|
showfm.php |
Plugin header, constants and bootstrap. |
uninstall.php |
Removes the plugin's options, transients, cron events and _showfm_* post meta. Never deletes posts. |
includes/ |
One small class per job, in the ShowFM namespace, autoloaded from class-*.php. |
tests/phpunit/ |
PHPUnit tests, run inside wp-env. |
tests/e2e/ |
Playwright tests, run against wp-env. |
bin/build-zip.sh |
Builds the distribution zip in dist/, checked against bin/release-files.txt. |
.wordpress-org/ |
WordPress.org banners, icons and screenshots, for the SVN assets/ folder. Never in the zip. |
Classes
Api_Clientcallshttps://api.show.fmwithwp_safe_remote_getandwp_safe_remote_postonly: 5-second timeout, no redirects,User-Agent: showfm-wordpress/{version}; +{home_url}andIf-None-Match. It returns anApi_Resulttyped as success, not modified, unavailable (403 or 404), unauthorised (401), rate limited (429 with Retry-After), transient failure (network error or 5xx) or failed (anything else). A 401 marks the connection "reconnect needed" and stops every keyed call until the admin reconnects. The key is sent only as a Bearer header and never logged, echoed or returned.Cachekeeps public API responses in transients. Fresh for 15 minutes, served stale for up to 7 days, refreshed only by WP-Cron single events with 0 to 120 seconds of jitter. Reads never make HTTP calls. A 403 or 404 stores an "unavailable" marker and callers render nothing. Network errors, 429 and 5xx keep the last good copy and back off, doubling up to an hour.flush()bumps a version in the key prefix and never callswp_cache_flush().Connectionstores the site key, ping secret, site id and expiry encrypted with libsodium secretbox, keyed fromwp_salt( 'auth' ), in options with autoload off. If the salts change, the state becomes "reconnect needed" without errors.masked_key()shows the last four characters only. Set the salts inwp-config.php: without them WordPress keeps a generated salt in the same database, and a database dump alone would then be enough.Uninstallerdoes the uninstall cleanup, on every site of a multisite network.Connectruns the connect protocol (below). Its outcomes are typed (Connect::ERROR_*) and kept per user for the settings screen, withConnect::message()for the text.Challenge_EndpointandPing_Endpointare the two REST routes show.fm calls.Healthsends the daily health report and finishes a verify that failed at connect time.Adminregisters Settings > show.fm (options-general.php?page=showfm), its React app (src/admin.jsx, built tobuild/admin.jswith@wordpress/componentsfrom core) and the connect action.admin.php?page=showfm, which the show.fm app links to, redirects there with the connect return kept. The app's data is preloaded into the page.Admin_Endpointisshowfm/v1/admin/connection(GET),admin/connection/dismiss-result,admin/disconnectandadmin/notices/dismiss(all POST), formanage_optionswith thewp_restnonce.Admin_Statusbuilds the Connection tab's data from local state only: no request, no key (the last four characters only), no ping secret. Reading it changes nothing: the connect outcome stays for 15 minutes (Connect::RESULT_TTL) until the admin dismisses it or starts again, so a reload or a second tab still shows it. The live state wins. Every state has its own random id (wp_generate_uuid4()), stored asiinshowfm_connection: a connect or reconnect writes a new id with the encrypted credentials in one write, a disconnect replaces both with a new id alone in one write, and a failed connect that leaves no connection starts a new disconnected state with a conditional UPDATE (or an INSERT on a new install) that never overwrites credentials another request has just saved. An outcome records the state id it belongs to and shows only while that id is still the stored one, compared for equality only, so it never comes back after any later connect or disconnect, from another tab, another admin or WP-CLI. "Connected" also shows only while the stored key works, and Disconnect clears the admin's outcome. Each flow reads the connection once (Connection::pinned()): the browser flow keeps that snapshot ({credentials, id}, never the ciphertext) in its transient from the start to the exchange, every outcome it records is bound to it, and the settings view, its notice and the Dashboard notice answer from one read. Every change to the connection state runs throughConnection::mutate(): one per-site lock (the sync's lease on its own row,showfm_connection_lock, with a 30-second TTL and a MySQL named lock where available), a fresh read inside it, and the decision made on that read. That covers saving (with the verify marker and scheduled jobs), disconnect with its whole teardown, the "needs reconnecting" flag, the plan pause, verify results, account details and connect results. Inside a change, a write guard on WordPress'sadd_option,update_optionanddelete_optionactions renews the lease and checks by compare-and-swap that the change still owns it before every option write, including each cron and transient write;guarded()does the same before a step that writes another way. A change that outlived its lease stops before its next write (Connection_Lost). A change that waits more than three seconds changes nothing and reports busy. Account details are stored with the state id they were fetched for, only when the site id and both answers come from that one pinned state and it is still stored, and they show only while it is the current state: after a reconnect, even with the same site id, the account and shows stay hidden until a fetch for the new state succeeds. The REST disconnect needs thestateIdthe screen showed (400 without it, 409 if the connection changed, 503 if busy). Reading the state never writes. Keyed API results carry the state id of the key they sent. The counter of earlier development builds (showfm_connection_generation) is deleted onadmin_initand on uninstall.- Disconnect (the REST route and
wp showfm disconnect) first asks show.fm to revoke the site key withConnect::revoke(): onePOST /v1/me/sites/{id}/disconnectwith{}, the pinned key and a 3-second timeout, made before the connection lock is taken and never retried. Then it removes the local connection throughConnection::mutate()whatever the answer. The outcome isrevoked(200),refused(a 401, or a key show.fm had already refused or that has expired: nothing is sent then) ornot_revoked(no answer, a server error, a rate limit, or a key that can't be read after the salts changed). The answer'sdisconnectedcarries the outcome, its message and the first show's Connected sites page ({app}/p/{slug}/settings/sites), which the notice links to when the key must be revoked there. A 401 here never flags the stored state, since it is removed next. If the revoke succeeded but the local clear finds the lock busy or lost (503,data.revoke), the message says the key was revoked and that Disconnect again finishes: the retry's revoke gets a 401, which counts as nothing left to revoke, and the clear goes through. The teardown itself is resumable:Connect::disconnect()writes a marker (showfm_disconnect_teardown, with the revoke outcome) before the swap and removes it after the last step. If the lease is lost after the swap, the site is disconnected and the message says the clean-up finishes on the next admin page or Disconnect.Connect::finish_teardown()runs onadmin_init, at the start of every sync, first in the REST disconnect (without needing the old state id) and inwp showfm disconnect(which does not stop at "not connected" while it is pending). It runs the idempotent teardown only while no credentials are stored, so a connection saved since keeps its jobs. Afterwards focus moves to the Connect card's heading and one polite message is spoken. Publishingholds the Publishing tab's settings (showfm_publishing, autoload off): auto-post (on), post type (post), category, author (0 means the first user who can publish the type), theme template, transcript (on) and featured image (on). It checks them: the post type must be public, in the REST API and support the editor; the author must exist, belong to the site and be able to publish that type; the category must exist (and is dropped for a type without categories); the template must be one the theme offers for the type. The sync reads them only when it creates a post. A post keeps its type, author, category and template, and the transcript and featured image choices are stored on it (_showfm_post_options), so changing a setting never rewrites an existing post. The template is set as_wp_page_templatemeta after checking it, never aspage_template, which would fail the insert after the row exists.Publishing_Endpointisshowfm/v1/admin/publishing(GET and POST), formanage_optionswith thewp_restnonce. The GET answers with the settings, the choices for each field, the connected shows, a fixable sync problem (row_post_typeorrow_author) and the recent activity. The POST type-checks and sanitises the fields, thenPublishing::validate()refuses a bad one with a 400 naming it (data.field). When the sync was held on the post type or author, saving records a retry request (Sync::RETRY_OPTION, a counter) and queues a pull (Sync::retry_now()). The request is durable: a pull already running on the old settings persists no back-off once it sees it, and the next pull drops any back-off.Sync_Activitykeeps the last 20 things the sync did (showfm_sync_activity, autoload off): posted, scheduled, updated (title, description or date), updated after an edit here (date and status only), moved to draft, moved to the bin, no longer synced, paused by the plan, and not posted because auto-posting is off (recorded once per episode until another event for it, tracked inshowfm_sync_skipped, a map of up to 1,000 episode ids kept apart from the 20 events, so an entry pushed out of them is not recorded again). It stores plugin event codes, the episode title, the show and post ids, never remote error text. Re-applying a row that changes nothing records nothing.Noticesshows at most one admin notice on the Dashboard and Plugins screens (the settings screen shows it on every tab except Connection): refused key, plan pause, sync configuration problem, expiry within 7 days, within 30 days. Reconnect is a form that POSTs toadmin-post.phpwith a nonce; the other actions are links. Dismissals are per user and per instance key (user metashowfm_dismissed_notices), so the next stage shows again.Accountkeeps the account holder's name and the connected shows fromGET /v1/meandGET /v1/me/podcasts, fetched after connecting and after each daily health report.Embed_Settingsregisters the four Display settings (show_in_rest), saved through/wp/v2/settings.
The plugin infers what show.fm does not expose: Connection records when show.fm first refused
the key (showfm_connection_refused_at) and when a verify or health report got
403 plan_upgrade_required (showfm_plan_paused_at); Ping_Endpoint::note_change() records a
change that arrived through the 15-minute check with no ping after a 10-minute grace
(showfm_ping_missed_at), which the next accepted ping clears. A return from show.fm carries
showfm_return={token}, a random token kept with the flow. With the flow's own token and no
code or state, it means the admin cancelled; any other value changes nothing.
Cliiswp showfm connect,statusanddisconnect(which revokes the key first, as above), andwp showfm cache flush [--network], which bumps the cache key version, deletes the stored entries it can find and says how many.Privacyadds the suggested privacy policy text.Editor_Apiis the block editor's read-only REST proxy (showfm/v1/editor/*), andEditorprints the editor's settings and the post panel'sshowfm_syncfield. See docs-editor.md.
Connecting a site
Plan sections 5.2.2, 5.2.6 and 5.3.5 (show.fm issue #731). Every admin action needs
manage_options and a nonce. No key, code or verifier is logged, echoed or put in a URL.
- Connect (
admin-post.php?action=showfm_connect). The plugin makes astate(32 random bytes, base64url) and a PKCEcode_verifier, keeps both for 10 minutes in a per-user transient, and redirects to{app}/connect/wordpress(the environment's app) withsite_url(home_url()),rest_root(rest_url()),state,code_challenge(S256),return(the settings page) andpartner(ifSHOWFM_PARTNERis set). No outbound HTTP. - Challenge. show.fm fetches
GET /wp-json/showfm/v1/challenge?state=…. The route is public, answers{"code_challenge": "…"}for that exact state only (the transient is named by the state's SHA-256 and the stored hash is compared withhash_equals), 404 for anything else and never cacheable. While no connection has been started in the last 10 minutes (showfm_challenge_open_until), every request gets a 404 with no lookup or write. While one has, the right state is always answered, and wrong states share one global budget of 60 a minute: one options row per minute, counted with one atomic conditional UPDATE, with or without an object cache. Only rows of older minutes are removed. The caller's address plays no part and rotating addresses add no rows. - Return. On the settings page load the plugin checks
stateagainst the user's flow (single use), keeps the code server-side and redirects to the clean URL at once. - Exchange. On the clean load it POSTs
{code, code_verifier}tohttps://api.show.fm/v1/sites/exchange, storessite_id,api_key,ping_secretandexpires_atwithConnection(encrypted), then POSTs/v1/me/sites/{id}/verifywith the plugin, WordPress and PHP versions and the site name. A reconnect keeps the old credentials until the new ones are stored. - WP-CLI (
wp showfm connect). The key comes from--key=-(standard input), then--key=<key>, then theSHOWFM_KEYenvironment variable, then a hidden prompt when standard input is a terminal. PreferSHOWFM_KEYor--key=-: a key typed as--key=<key>stays in shell history and shows inps, and the command warns about it. The plugin makes a state and challenge and POSTs/v1/me/siteswithsite_url,rest_root,stateandcode_challenge; show.fm fetches the challenge back inside that request, then returns the site id and ping secret. Then it verifies.wp showfm statusshows the state, the masked key, the expiry, the last sync and the last ping.wp showfm disconnect [--yes]asks show.fm to revoke the key (best effort), then removes the local credentials and ping nonce claims and unschedules the plugin's events. When the key could not be revoked, it warns with the Connected sites link. - Ping (
POST /wp-json/showfm/v1/ping). The permission callback checksX-Showfm-Signature: v1={hex HMAC-SHA256(ping_secret, "v1.{site_id}.{timestamp}.{nonce}")}withX-Showfm-Site,X-Showfm-Timestamp(within 300 seconds) andX-Showfm-Nonce(each accepted once in 10 minutes). A nonce is claimed with oneINSERT IGNOREinto the options table, so two copies of a ping arriving together cannot both pass; expired claims are removed on the next claim. The body is never read. The handler queues oneshowfm_pullevent, callsspawn_cron()and answers 202. - Health. The daily
showfm_healthevent POSTs/v1/me/sites/{id}/healthwith the versions,last_sync_atandsync_error_count. A 401 from any keyed call marks the connection "reconnect needed" and stops keyed calls. A 429 holds every keyed call until its Retry-After has passed (showfm_rate_limited_until).
A WordPress install in a subdirectory sends a site_url with a path, and show.fm accepts it
(podcaster-plus-app PR #741, merged and live).
Publishing engine
wp showfm sync [--dry-run] [--from-start] runs the same consumer as showfm_pull
and the jittered 15-minute showfm_poll. wp showfm sync status reads local state only.
A run handles at most ten pages of twenty rows, then queues a continuation. Dry runs
preview one page only and leave posts, reports and the cursor unchanged. The server
records contact and the requested after cursor, so a dry run never requests the next
page at a cursor it has not applied. dry_run_limit means more rows remain beyond the
preview. Authentication and rate-limit protection remain active during a dry run.
A synced post's Player and Transcript blocks carry the snapshot the editor saves for a
published episode: the title from the feed, and the listen page and audio from the cached
public episode (the listen page otherwise from the show's and episode's slugs), checked by
Attributes::snapshot(). Scheduled episodes save none. Building it reads the cache, which
schedules the episode's refresh, and the artwork step stores the public answer it fetches
with Cache::store(). When a cache entry changes, Cache::purge_posts() calls
clean_post_cache() for the published posts that render it, so page caches drop the cold
render. SQL prefilters on a show.fm block or shortcode and the ID or slug, reading candidates
in pages in ID order. parse_blocks() and the [showfm] shortcode regex with
shortcode_parse_atts() confirm each one, until 200 are confirmed or 2,000 candidates are
read. A confirmed synced pattern (wp_block) also purges the posts that reference it, within
the same limits. The Publishing tab's "Include the transcript" defaults to whether the key has
transcripts:read (from /v1/me), until the setting is saved. While that is unknown (a
connection made before 1.0.1), opening the tab schedules one showfm_account_refresh at
most hourly (Account::ask_if_unknown()), and the help text says it starts on.
Connection::forget_sync_health() clears the last sync time, the sync error count and the
sync log on disconnect, and in Connection::save() whenever credentials move to another
site id. A key refresh or a reconnect of the same site keeps them. A pull remembers the site
it started with, and Sync::owns_health() skips its health writes once the stored site
differs.
A per-blog options lease uses a unique row and conditional updates to prevent
concurrent claims. It renews before work and expires after five minutes if a worker
crashes. A MySQL session lock adds immediate crash recovery when supported. Unsupported
GET_LOCK or session ownership falls back to the lease; hosts with multiplexed database
connections can disable named locks through showfm_sync_use_named_lock. A contending
pull queues another attempt after 15 seconds. A stale worker cannot release a new lease.
The consumer validates the page envelope and requires cursor.next to match the
rows' highest usable sequence, then handles rows individually. Unknown or
malformed rows and validation refusals are skipped with a plugin-owned error code and
sequence number. Configuration errors (unavailable post types or publishing authors)
and transient write failures stop at the failing row, save only the successful prefix
and back off. After five failed attempts at one sequence, the row is skipped with its
reason retained in showfm_connection_sync_status. Its active retry and latest 50
exhausted rows remain available to CLI status and the later connection screen.
After handling the page it stores the cursor and pending reports
together in the non-autoloaded showfm_sync option. The next GET acknowledges
last_pulled_seq, including an extra read after the final page. Indexed identity
receipts and a bounded primary-key lookup recover interrupted inserts; existing posts
migrate through their episode meta. _showfm_content_hash, lifecycle fields and
pending artwork together determine whether a row needs work. _showfm_synced_revision
hashes the stored title, content and excerpt. A WordPress edit permanently sets
_showfm_edited; content is then protected. User trash or permanent deletion records
a durable local detachment and is never undone by an upsert or replay. Restoring a
post from the bin keeps that detachment; automatic reattachment is not supported.
The player is the WP-2a showfm/player block. Description/show notes are sanitised,
saved block HTML, rather than a live binding that would bypass edit protection.
The per-blog showfm_publishing option is the Publishing tab's (see Publishing above):
auto_post, post_type, category, author, template, transcript and
featured_image. With auto-posting off, an episode without a post is skipped (and shows as
"Not posted" in the recent activity); existing posts keep updating. A new post is a player,
then the showfm/transcript block when the transcript setting is on, then show notes,
falling back to the plain description. Public artwork is
sideloaded once from the exact show.fm media hosts, m.cdn.media or
media.podcasterplus.com (plus the selected environment's, see below). Downloads require HTTPS, no
credentials, explicit ports or redirects, and safe HTTP validation. Limits are 10 MB,
8000 pixels per side and 16 million pixels, checked before image processing; only
JPEG, PNG, WebP and GIF are accepted. Indexed attachment receipts deduplicate source
URLs. Permanent failures are recorded per post and skipped; network errors, 429,
5xx and temporarily missing public metadata retry up to three times independently
of the feed cursor. Scheduled artwork is
not exposed by the merged feed, so it waits for the public episode on publication.
Scheduled/published rows become future/published posts; removed or unpublished rows
become drafts; deleted rows go to the bin, including when automatic trash is disabled.
Detached rows retain the post and _showfm_sync_notice says “No longer synced from
show.fm”; paused rows retain the post. A restored-access upsert resumes the existing
post and still respects the permanent edit flag. Tombstones without a local post do
not create empty posts or send a report, since the API requires a positive post ID.
The server has a per-episode report endpoint, not a bulk endpoint. Reports are drained
in batches of twenty requests. Network and server failures retry idempotently with
separate backoff, without holding up feed reads or pings. HTTP 400/403/404 responses,
missing posts and invalid local permalinks drop that report with a local reason code.
Detached and paused source rows do not send reports. A successful user trash or
permanent deletion queues one trashed report, the terminal state accepted by the
server, using its saved post ID and URL. These separate per-episode outbox entries
survive removal of the post and cannot be overwritten by a concurrent feed save. A valid report URL must use HTTPS,
the home host and a path beneath the home path. A 401 stops keyed requests and asks for
reconnection; 429 honours Retry-After up to a one-day cap. Feed failures and report
retries back off to an hour. wp showfm sync status shows pending report/artwork counts,
the active apply retry, exhausted rows and the latest 50 local diagnostic entries (own codes, sequence numbers and timestamps,
never remote error text or post content).
A new connection ID starts its own cursor and outbox without claiming the old
connection's posts. Uninstall removes plugin receipts, never posts or media files.
Contract reviewed against podcaster-plus-app 04f2718fc344f4fdecd9a90a6cfb9b8d5685db33:
connected-sites.md, the keyed site routes and the generated OpenAPI schemas.
Another show.fm environment
Every show.fm host comes from ShowFM\Environment: the API (the only host the site key is
ever sent to), the app, the listen domain, the media hosts and the embed hosts. Production
is built in. A site's own code, such as a must-use plugin, can select another show.fm
environment for testing:
add_filter(
'showfm_environment',
function () {
return array(
'api' => 'https://api.example.test',
'app' => 'https://my.example.test',
'listen' => 'example.test',
'media' => array( 'm.example.test' ),
'embed' => array( 'embed.example.test' ),
);
}
);
Every part is required. api and app are https origins with no port, credentials or
path. listen is the domain whose subdomains serve listen pages. media and embed list
up to ten hosts. Hosts are plain names: no wildcards or IP addresses. If anything is
missing or invalid, the whole value is ignored, production is used and WordPress logs a
"doing it wrong" notice. show.fm's production listen, media and embed hosts stay recognised
alongside the environment's. The editor gets the listen domains and media hosts from
window.showfmEditor, and on another environment the player gets its media hosts from
window.showfmMediaHosts, set before the player script and the click loader.
Saved credentials record the API that issued the key. If the environment's API differs,
the site shows "Reconnect" and the key is never sent: not from production to a test API,
and not from a test API to production. Credentials saved before 1.0.2 are pinned at the
first request after the upgrade (Connection::pin_issuer(), first on init): to the API
a valid SHOWFM_API_URL constant names, otherwise production. That is never worked out
again, so removing or adding the constant later changes nothing, and a pinned test API the
environment doesn't select is another environment's. Disconnect can't revoke a key another
environment issued, and says it may still work. While either old constant is defined,
administrators see a dismissible notice pointing to the filter. The embed hosts of every
environment a site has used are remembered (showfm_embed_hosts_seen), so oEmbed markup
cached from one is still replaced after the site moves to another.
1.0.2 removed the SHOWFM_API_URL and SHOWFM_APP_URL constants: the filter names a
whole environment, so they had nothing left to select.
A host that resells show.fm can set its partner code, which is passed to show.fm for attribution:
define( 'SHOWFM_PARTNER', 'your-partner-code' );
Development
NODE_ENV=development npm install
composer install # or: docker run --rm -v "$PWD":/app -w /app composer:2 composer install
npm run env:start # WordPress on http://localhost:8888 (user admin, password password)
| Command | What it does |
|---|---|
composer lint |
PHPCS with WordPress Coding Standards 3 and PHPCompatibilityWP (PHP 7.4+). |
composer analyse |
PHPStan level 6 with the WordPress extension. |
npm run test:php |
PHPUnit inside wp-env (tests-cli). |
npm run test:php:multisite |
The same suite as a multisite network. |
npm run test:e2e |
Playwright tests against the wp-env development site. |
npm run test:js |
Vitest (jsdom) unit tests: the editor in src/test/, the settings screen in tests/js/. |
npm run lint:js |
ESLint through @wordpress/scripts. |
npm run i18n:pot |
Builds, then regenerates languages/showfm.pot with WP-CLI in wp-env. |
npm run i18n:check |
Fails if a translatable string is missing from the .pot, or if the .pot is stale. |
npm run format |
Prettier through @wordpress/scripts. |
npm run release |
Builds dist/showfm/ and dist/showfm-{version}.zip (npm run zip does the same). |
npm run zip:check |
Checks the zip's files against the allow-list in bin/release-files.txt. |
npm run wporg:assets |
Renders the WordPress.org banners and icons into .wordpress-org/. |
npm run wporg:screenshots |
Captures the five WordPress.org screenshots from wp-env into .wordpress-org/. |
The PHPUnit suite runs the WP-CLI commands against a stand-in for WP_CLI
(tests/stubs/wp-cli.php), which PHPStan also reads for the signatures.
Run PHPUnit before Playwright, or on a fresh environment: the core test installer resets
the tables of the wp-env tests site (port 8889), so the browser tests use the development
site (port 8888).
npm run build builds the block editor script, the settings app and the notice script from
src/ into build/. bin/build-zip.sh runs it when src/ exists.
The Playwright settings tests set up each connection state with a test-only plugin,
tests/e2e/plugin/showfm-e2e-states.php, which wp-env maps into the site and the tests
activate. It stores a local connection without contacting show.fm, and never ships.
The editor tests use a second test-only plugin, tests/e2e/plugins/showfm-e2e-fixtures,
which answers the server's show.fm requests from fixtures and sets up a connection and
synced posts. It never ships either.
Rules
- No HTTP on the render path, on activation or on
init. Tests hookpre_http_requestto prove it, and the PHPUnit bootstrap blocks every request in the suite. - No Composer runtime dependencies. Composer and npm are development tooling only.
- Everything is prefixed
showfm_or namespacedShowFM. - The zip is built from
.distignore.bin/build-zip.shfails if tests, dependencies, CI files, AI tool directories or Markdown get into it, or if its files differ frombin/release-files.txt. Releasing to WordPress.org is in docs-release.md.
CI
.github/workflows/ci.yml runs PHPCS, PHPStan, PHPUnit (single site and multisite) on
wp-env, the .pot checks, ESLint, the Vitest unit tests, builds the zip and checks it
against the allow-list, runs Plugin Check (Plugin Repo category) against the built zip, and
runs the Playwright tests.
.github/workflows/security-review.yml runs the Claude security review when a pull request
has the security-review label. See the comments in that file and
scripts/security-review/README.md.
Licence
GPLv2 or later. See LICENSE.