One monorepo, two independently installable WordPress plugins.
| Plugin directory | Slug | Namespace | Text domain | |
|---|---|---|---|---|
| Free | the repository root | wconvert |
WConvert\ |
wconvert |
| Pro | pro/ |
wconvert-pro |
WConvert\Pro\ |
wconvert-pro |
Pro is installed alongside free, not instead of it. It does not unlock free's premium features — it supplies them, and a free install has never contained them (ADR 0014, ADR 0015).
Read CONTEXT.md before using any domain term.
Planned follow-ups and deferred work: Product todo list.
React to live campaign opening, closing, and first lead capture with the JavaScript events API. Campaign details include a copy button for the existing campaign/variant ID.
See the step-by-step import/export guide, including where to find Campaign actions and how to resolve upload problems.
In the campaign editor, open Campaign actions → Export design to download the
current design, including unsaved changes, as a .wconvert.zip. Supported local
PNG/JPEG/WebP images travel with it. Unavailable images need explicit omission.
Use Campaign actions → Import design, choose the file, and review its desktop and mobile preview. File content is the default; Keep my current content fits current words and pictures into matching slots. Review links before applying. Apply to draft is undoable and does not save or publish the campaign. Imported pictures become normal Media Library items and remain there after Undo.
Transport is available in Free; designs using paid formats/journeys still need Pro. ZIP support and private temporary storage are required on the server. Maximum ZIP size is 25 MiB or the host's lower limit. No campaign settings, connections, leads or history are exported. Fonts, remote-only pictures, SVG uploads and linked documents are not embedded. See the contract and limits.
Settings → Spam protection offers optional Cloudflare Turnstile Managed, Google reCAPTCHA v2 checkbox, or hCaptcha with merchant-owned keys in Free. Baseline hidden-field checks and request limits work without a provider. Every queued resource email also uses a ten-minute recipient/resource guard; this can suppress recent bulk re-pushes while preserving separate Leads. Pro adds exact email/domain blocklists and email exceptions.
No external verification service is enabled by default. Saving keys is not a connection test: use Test saved setup on the configured hostname before relying on it. Verification errors preserve the form for retry. Secrets never appear in API read responses. No table or column is added.
See the setup guide, implementation and deferred work, and architecture decision.
Free includes editable linear journeys: offer screens, questions, a final Submit, and acknowledgement. Next and Back keep answers on the current page. An optional other-channel signup can follow a completed email or SMS signup: each accepted signup is saved and queued immediately, while the journey keeps one combined Lead and one Conversion. Skipping optional SMS preserves the email signup.
Start with one of the four journey-* Templates/Playbooks. JSON uses tree.v: 2
with explicit screen wrappers and field/consent ownership. See the
contract and
verification record.
Atomic capture requires transactional InnoDB storage; Action Scheduler handles
queued delivery and recovery. Pro display/integration entitlements are unchanged.
The dependency runs one way and only one way:
resources/loader/src/ free's loader modules ─┐
pro/resources/loader/src/ Pro's loader modules ─┴─> pro/…/main.ts
= free's + Pro's
Free's entry composes free's modules. Pro's entry composes free's plus its own. There is no mode flag and no tree-shaking, ever: premium code is absent from the free build because it was never in free's source (ADR 0028).
That boundary is drawn on day one, deliberately, rather than deferred behind a flag. It is the cost of the decision, and it is the whole cost.
With both plugins active a page carries exactly one loader, and it is
Pro's. Pro dequeues free's on wp_enqueue_scripts, in PHP, before one byte of
HTML exists — so an aggregating optimizer never sees two scripts to reorder
(ADR 0014). That is the whole
argument against the add-on shape the premium-SDK research recommended: a
second script that must register before the first evaluates is precisely the
failure Autoptimize's force-in-head produces, silently, with nothing in any
log.
Three details are load-bearing rather than incidental:
- Pro's priority is derived from free's constant, not written as a number.
WordPress loads plugins in the order its own option lists them, so a swap
that worked because
wconvert-prowas read second would work by accident. - Free's handle is deregistered, not just dequeued. WordPress prints the
registered dependencies of anything queued, so one third-party script
depending on
wconvert-loaderwould otherwise put free's loader back on a page that already has Pro's. - A broken Pro degrades to free, never to nothing. The dequeue is
conditional on Pro's own bundle existing; an incomplete upload costs the
merchant
exit_intent, not every popup on the site.
Turning Pro on or off changes that asset URL on every already-cached page, so
both lifecycle hooks purge the page cache
(PageCache). Nothing rests on the purge
landing: deactivating a plugin does not delete its files, so a stale page
serves the previous tier's behaviour for a few minutes rather than a 404.
Free purges the same caches whenever the published set changes — see
Clearing the page cache.
wp eval-file bin/verify-loader-replacement.php # needs both plugins activeThe unit test proves the swap against a script queue that records what it was
handed. But WordPress's queue is a dependency graph, not a list, and the
hazard lives in the graph: a dequeued handle stays registered, and WordPress
prints the registered dependencies of anything queued. That script enqueues a
third-party script declaring wconvert-loader as a dependency, prints the
scripts for real, and asserts free's loader is nowhere in the markup. It also
asserts the cost — that the dependent is dropped along with it — so the
trade-off is on record rather than met in a support ticket.
There is no licence check anywhere on this path, and nothing is missing.
Being in Pro's ZIP is the entitlement
(ADR 0015), so not one
premium feature needs an if —
tests/unit/Contract/NoLicenceOnTheFrontEndTest.php
fails if a second file answers "is Pro loaded", if anything outside
pro/src/License/ reads a licence value (the updater and the License page,
ADR 0143), or if the front-end
path grows a tier branch or touches the licence at all.
bin/verify-source-contract.sh # no build, runs on every pull request
npm run check:loader # two loader builds, runs on every pull request
npm run check:admin # free's admin + inspector builds, same jobUse Node 22 (nvm use, matching .nvmrc) for release and loader-size checks,
as CI does. Node versions can bundle different gzip implementations: the same
JavaScript can measure differently even with identical compression settings.
No file in free's tree may import a pro/ path or the WConvert\Pro
namespace, in TypeScript and PHP
(ADR 0029). The
check fails closed: a tree it cannot inspect fails, because "couldn't
look" reading as "clean" is how a leak ships.
One scanner per language does the looking, and neither is a grep.
bin/pro-ts-scan.php parses module specifiers, so
repro/harness is not mistaken for a pro/ path.
bin/pro-php-scan.php tokenizes PHP, so free may
document the boundary without tripping the guard that enforces it — while a
Pro name in a string literal still fails, because a dynamic class name resolves
it. Both halves apply in both languages: PHP is checked for pro/ paths too,
since require_once WCONVERT_DIR . 'pro/…' is the shape a WordPress developer
reaches for first.
npm run check:loader and npm run check:admin are the builds a pull
request pays for, and they earn it: every assertion is about build output. Free's and Pro's
loader, gzip -9 on Node 22, hard-fail at 17,456 / 30,720 bytes for Free / Pro (ADR 0139, amended; ADR 0146; ADR 0152; ADR 0155; ADR 0158; ADR 0159); and free's loader is scanned for
every rule identifier the manifest calls premium. Free's admin bundle is
never scanned for identifiers, because its Pro hints name Pro's rules on
purpose. check:admin scans free's admin and inspector instead for Pro's admin
code — each module's admin_markers — and for the word "upgrade"
(ADR 0147).
The artifact contract, Plugin Check and the release guard run at release, not here — each half lands with the thing it inspects, and their subject is a build rather than a source tree. See Releasing.
resources/rules/manifest.json is the single
source of truth for every rule type, on all three axes, in both tiers. Free
ships the premium entries too, because free's PHP is what will strip an
unentitled rule at enqueue, and it can only strip what its own manifest calls
premium (ADR 0005).
That strip lands with degradation and suspension; today PHP reads the manifest
for one thing, the publish-time partition into triggers and conditions.
Free's loader must never import it. The lookup would be dynamic, so
nothing tree-shakes, and every tier: pro identifier would land in free's
bundle — failing the scan above on a build that leaked nothing. Each loader
module declares its own kind and consent_category instead, and the
parity tests assert the declaration matches the manifest:
tests/js/manifest-parity.test.ts # free's modules against the manifest
pro/tests/js/manifest-parity.test.ts # Pro's own, from Pro's side
tests/unit/Rules/RuleManifestParityTest.php # the four-field invariant, every axisThey are also what decides where a new rule may land: a tier: pro entry
with no Pro module fails on the pull request that adds it, so a premium entry
and its implementation arrive in the same commit.
resources/templates/manifest.json is the
closed list of everything a Template may name: the leaf nodes, the layouts, the
token set, the Slot Roles and the field kinds. A Template is a JSON node
tree plus tokens with no HTML and no CSS in it
(ADR 0010), so
validating against this manifest is the whole of the sanitisation story —
wp_kses does not apply to templates at all.
One dependency-free renderer in
resources/renderer/src/ owns the entire vocabulary
and every line of the stylesheet, and both bundles import it: the loader
draws the live Optin, the admin draws gallery cards and previews. There are no
static thumbnails to produce or to let go stale, and nothing React-shaped may
enter that tree — two consumers, two bundles, one byte budget.
resources/templates/library/*.json is what this ZIP ships; every facet the
picker filters by is derived from the tree rather than authored, because a
Template carries no words and so cannot honestly be tagged by industry or
season (ADR 0043).
The manifest's sibling facets section says which of them are offered as chips.
GET /wconvert/v1/templates is an index — id, name, Display Type, tier,
availability and facets, with no tree — and GET /templates/trees?ids=…
answers for the cards on screen. A card mounts its live render only while it is
near the viewport and takes it down again, which is what makes a library ten
times this size affordable without a virtualization library.
A design declares its tier. Free ships free designs only. A premium design
reaches free solely as a locked image card from the hosted index — a server
thumbnail, its plan's name and a link to wconvert.io, with no tree
(ADR 0156). Shipping
the design and refusing the save is trialware
(#7), so
bin/verify-artifact-contract.sh check (e) refuses a tier: pro entry
inside the free artifact — and refuses a Pro artifact that carries no
premium design at all, which is the same rule read from the other end.
Pro's designs live under pro/resources/templates/library/ and are
registered through the same TemplateSource seam, from
ProServiceProvider::register(). A hosted design is locked only when the
installed plan is below its tier, so "a paying customer is never shown an
advertisement for what they bought"
(ADR 0026) holds
per design.
tests/js/renderer-manifest-parity.test.ts # the renderer against the manifest
tests/unit/Template/TemplateVocabularyTest.php # what validation drops on the way in
tests/unit/Template/TemplateSnapshotTest.php # an Optin's copy outlives its entry
tests/unit/Template/TemplateFacetsTest.php # every shipped design's facets, from its tree
tests/unit/Rest/TemplateRoutesTest.php # the index carries no tree, and locked cards link
tests/unit/Frontend/PayloadBudgetTest.php # five distinct Campaigns, ≤2.5KiB gzipped
tests/unit/Template/LibraryLintTest.php # bin/verify-templates.php, over the shipped library
tests/unit/Pro/Template/ProLibraryTest.php # a Pro install is shown no upsell for a design it hasLike the rule manifest, the loader must never import it: an unrecognised
node is skipped by the renderer's own switch, so the lookup buys nothing and
the import would put the whole vocabulary in the byte budget. The parity test
asserts the renderer implements exactly what the manifest declares, in both
directions.
A stored tree carries a v — the vocabulary version that wrote it
(TemplateTree::VERSION). Nothing reads it
yet, and that is the point: an Optin's snapshot is as old as the Optin and the
code reading it is as new as the release, which is safe while the vocabulary
only widens and unrecoverable the first time something is renamed.
composer verify:templates # or: php bin/verify-templates.php [tree ...]bin/verify-templates.php decodes each library file raw, runs it through
the vocabulary, and diffs — reporting every dropped node, param, token and Slot
Role, plus files that failed to decode at all. It runs without WordPress,
because an authoring check nobody can run from the directory they are authoring
in is an authoring check nobody runs;
tests/unit/Template/LibraryLintTest.php is what makes it a gate.
It exists because authoring has six silent failures and three of them cost a day
each: a JSON syntax error skips the file entirely with no rejection recorded
and nothing logged; a misspelled tree key becomes a Pro upsell card for the
design the file contains, because no tree is the whole discriminator for "a
design this install did not get"; and a dropped Slot Role reaches a real
Optin as an empty <p>, while the gallery card looks right because an entry
keeps its own placeholder text.
A [[Goal]] is an enum plus data — src/Goal/Goal.php — and the fifth closed
set this project has refused to make a registry
(ADR 0019). Each
of the five declares the metric that counts it, its tier, and what the site
must have; GoalRegistry resolves those two facts into one
[[Availability]] state, and the precedence — unavailable beats locked —
lives in one function so no surface recombines it in an order of its own
(ADR 0026).
The registry filters nothing. The three states name why a member is absent;
how it renders is the surface's, and the two surfaces disagree on purpose —
the goal screen hides what a settings list explains, and neither ever renders
unavailable as an upsell.
A [[Playbook]] is data, not code: resources/playbooks/*.php, each
returning an array. PHP rather than JSON because a Playbook is nothing but words
and wp i18n make-pot cannot see a JSON string
(ADR 0013); remote entries
are JSON and enter through the same PlaybookLibrary::fromEntries(), so the
fetch is designed and not built.
Validation happens at registration, never at runtime, and that is the only
place the guarantee lives — there is no runtime check behind it. An entry is
refused for filling a [[Slot Role]] its Template does not declare, for naming
anything site-local (a post or term id, a Destination id — including one
sitting inside destination_hint.types — or its own link href), for pairing a
Goal with a Template metered by the other converting act, for declaring a
[[Display Type]] its Template does not serve, for naming no [[Trigger]], and for
claiming an id another entry already has. A Template offering two converting
acts, or none, is refused the same way.
Refusals are recorded rather than thrown — one bad entry must not take the
gallery down — and not silent: each one goes to _doing_it_wrong(), which
is WordPress's own channel for "a plugin called this wrong". A rejection is an
authoring error, so it surfaces where an author is working rather than as an
admin notice the merchant cannot act on.
tests/unit/Playbook/PlaybookRegistrationTest.php # one test per rejection
tests/unit/Playbook/PrefillSnapshotTest.php # the snapshot boundary, both halves
tests/unit/Playbook/BundledPlaybooksTest.php # a shipped entry gets no exemption
tests/unit/Goal/GoalParityTest.php # Availability is spelled twice; a Goal is not
tests/js/availability.test.ts # one rule, two surfaces, opposite renderings
tests/js/playbook-copy.test.ts # plain text, and no innerHTML in any treePrefill is the snapshot boundary that already exists, not a second one: the
Optin takes a copy of the Template's design and the Playbook's words are written
into it, and the two never speak again. playbook_id is provenance exactly as
template_id is — neither reaches the browser, and nothing joins on either.
Reading wconvert_leads is the one part of WConvert whose correctness is a
property of SQL rather than of PHP, and the unit suite deliberately cannot
prove it: tests/unit/Support/FakeConnection.php models the table and ignores
the query text, because a fake that re-implemented GROUP BY would make itself
the authority on what a database does.
So the queries are proven where queries can be proven:
wp eval-file bin/verify-lead-log.php --use-include # against a real WordPress and databaseIt writes a small fixture, checks that two rows sharing an identifier really do collapse into one group of two, that the grouping toggle does not move the headline, that erasure deletes rather than blanks, and that the retention prune removes what has outlived the period and nothing else — then deletes what it wrote. It refuses to run on a log that already has Leads in it, because two of those checks delete over the whole table; point it at the throwaway WordPress above.
The log adds no index to a table that takes a write per capture, and
ADR 0033 records
why: the prune is a range over the ULID primary key, the per-Optin listing
walks that key backwards under a LIMIT, and grouping is two aggregates that
each use an index already there.
A [[Destination]] is outbound and fallible — configured, optional, one of
several, and able to fail without the capture failing
(ADR 0007). The
Lead log is not one: it is the Lead store, written first and always. Free
ships the WSMS push, MailPoet push when MailPoet is installed, and lead-magnet
delivery email over wp_mail(). The Pro destinations module adds Mailchimp,
Brevo and Mailtrap email-contact adapters.
For a remote provider, connect an account on the Destinations page, create a named Destination for one audience/list, and choose whether existing contacts keep their details or receive the fields included in a new submission. A campaign sends basic supported contact details automatically. Its optional Send extra answers control maps captured questions, interest or message to provider text fields for each signup step. Preview uses sample values without sending; Send test contact sends the sample to the provider. Publishing the campaign makes edited mappings live.
For marketing signups, Mailchimp requests confirmation for a new member and Brevo adds a new Contact to the selected list. An enquiry is saved as a Mailchimp transactional Contact or a Brevo Contact without list membership; its email address alone does not grant marketing consent. An explicit test send uses the merchant's own address and can enter the selected marketing list.
Mailtrap has no name field of its own, so a Mailtrap Destination also chooses
which text field the name goes to, preselected to first_name when the account
has one. Mailtrap applies a write seconds after answering, so each push looks
the address up and writes once: a new Contact is created with its details and
list together, and in keep mode an existing one only joins the list. An enquiry
is a Mailtrap Contact on no list. WConvert never sends a subscription status
or removes a list, and Mailtrap's API has no double opt-in, so a new marketing
Contact lands subscribed.
Everything is queued, including the WSMS push. Action Scheduler is a core
dependency bundled in the free plugin — three free features want a scheduler
with no ESP in sight — and it is loaded from wconvert.php rather than through
Composer's autoloader, because it version-negotiates at load and the newest
copy on the site must win.
A job carries a Lead id and a Destination id, and nothing else. Job
arguments live in actionscheduler_actions for that plugin's whole retention
period, which outlives any retention policy WConvert sets for itself — so
personal data in one would survive both the prune and an honoured erasure
request, in a table nothing here would ever look in again.
Long-lived delivery state is per-Destination health, not a per-Lead record
(ADR 0008).
There is no wconvert_lead_deliveries table, because push() is idempotent:
once re-pushing a Lead that already landed is harmless, per-Lead precision buys
efficiency rather than correctness. "Harmless" is a claim about remote state,
and the delivery email is the one type where it does not extend to the
recipient — wp_mail() has no upsert, so a bulk re-push there re-sends the
file and re-counts the delivery. That seam is accepted and recorded rather than
guarded against. What is stored is last_success_at,
last_error, last_error_at and consecutive_failures, in their own
non-autoloaded option — deliberately separate from Destination configuration,
so the lost-increment race update_option allows can only ever eat advisory
health and never an admin's edit.
The Destinations page also shows recent Action Scheduler attempts for each route. WConvert records whether the provider accepted the request, a retry was scheduled, or the attempt needs attention. Scheduler completion alone does not prove provider acceptance, and attempts without a WConvert outcome marker are shown as unknown. This history lasts only as long as the site's Action Scheduler cleanup policy retains it.
The failure split is the whole design, and it inverts under a naive implementation:
| Outcome | Health | The ring |
|---|---|---|
| success | last_success_at, counts cleared |
— |
| skipped — nothing to send | — | — |
| retryable — the vendor is down | consecutive_failures++, retried with backoff |
on the last attempt |
| terminal — this Lead, specifically | untouched | recorded |
never enqueued — the type is not ready |
skipped_captures++ |
— |
A hundred malformed addresses are a hundred Lead-specific rejections, not a hundred consecutive outages. They are covered instead by a bounded ring of the last ~200 terminal failures, holding a Lead id and an error string.
The last row is the third thing that goes wrong and is neither of the other
two. A Destination whose type is not ready — a deactivated WP SMS, a lapsed
licence — is skipped and recorded, never enqueued: an Action Scheduler job
whose handler is unregistered retries against nothing forever. Nothing was
attempted, so it is not an outage; and one ring entry per capture would fill
200 slots in an afternoon. So it is a counter, and it is what turns a silent
fortnight of dropped pushes into a number beside a re-push button.
The one Destination type that works on a [[Standalone]] install. Everything
else reaches something that may not be there; this reaches wp_mail(), which
every WordPress has — so the Deliver a lead magnet [[Goal]] is one a
merchant can complete out of the box rather than one that captures Leads and
sends nothing.
It carries a link, never an attachment. wp_mail()'s $attachments takes
absolute server paths and WConvert has no media-library integration at all, so
there is nothing here that turns a merchant's choice into a path on disk — and
pushing a large PDF through wp_mail() on shared hosting is a deliverability
and a memory hazard besides. Three settings: the file URL, the subject and the
message. One {link} token, substituted where the message names it and
appended where it does not, so a body that never mentions it still arrives
with the download in it.
Its failure split is the table above, argued case by case rather than guessed.
A Lead with no email is skipped. A Destination with no file configured is
retryable, not terminal — it is about the Destination rather than about
this Lead, so health says "N failures in a row: no lead magnet file is
configured" on the screen built to say exactly that, and someone who published
before finishing configuration gets a window to finish in. A transport that
refuses the message is an outage. Only an address WordPress will not send to is
terminal, and that is near-vacuous — capture already refuses anything
filter_var rejects.
A successful delivery writes one lead_magnet_delivered counter, scoped both
by Goal and by Destination type: a merchant can bind the WSMS push and the
delivery email to one lead-magnet Optin, and counting the WSMS success as a
delivery would report two deliveries per conversion. conversions − lead_magnet_delivered, clamped at zero, is then the conversions with no
delivery yet figure on the Goal's card. Exactly-once is a property of the job
chain — it re-queues only on a retryable failure and stops at success — rather
than of any stored per-Lead flag.
Recovery is bulk re-push: replay every Lead for Optins bound to this
Destination since last_success_at, staggered against the type's declared
jobs-per-minute — which is what replaces a rate limiter in v1, since only a
replay can approach a vendor's limit. The window is a range over the ULID
primary key, so it costs no new index.
wp eval-file bin/verify-destinations.php --use-include # the plugin boots, AS loads, a capture queues ids onlyA green suite is not the same as a plugin that runs: that script asserts
as_enqueue_async_action() is genuinely defined by the time a capture fires,
captures through the visitor's route so the accepted submission is handed off
the way a real one is, and reads the job's four arguments (Lead, Destination,
attempt, submission) back out of Action Scheduler's own table to check that no
captured value is in them. It runs the lead-magnet email through wp_mail(),
proves a test send goes only to the address given and writes no Lead or
counter, and asserts whichever MailPoet shape it is booted into. It refuses
to run on an install that has Leads. CI runs it on every pull request that
touches PHP.
With WSMS deactivated the install is [[Standalone]] and fully working: capture,
the lead log, CSV export and the lead-magnet delivery email all run, and the
WSMS Destination reads as unavailable rather than locked — a missing plugin
is not something we can sell. The delivery email never reads unavailable,
because wp_mail() is WordPress's and there is nothing for it to be missing.
wconvert_stats holds one row per (optin_id, stat_date, kind) with a
count, and there is no event table
(ADR 0019). A
modest site writes ~3.65M raw rows a year against ~29k as daily counters. The
price is written down rather than hidden: you can never recompute, and
there is no hour-of-day breakdown, ever.
The write is INSERT … ON DUPLICATE KEY UPDATE count = count + 1, atomic at
the database. That is a claim about MySQL rather than about PHP, so it is
proven against MySQL:
wp eval-file bin/verify-stats.php --use-include # against a real WordPress and MySQLIt fires two increments concurrently on two connections — the second blocking
on the row lock the first holds — and asserts the count is two; then does the
same two increments as a read-modify-write, which loses one, so the check
is known to be able to fail. It also sets the site to a non-UTC timezone and
asserts the row is stamped with the merchant's day, and dispatches real beacon
requests through rest_do_request() to check the published-set validation, the
bot and prefetch filters and the rate limit. It refuses to run on a site that
already has counts, or on a log that already has Leads, because counters that
cannot be recomputed are not something to be careful around.
A Conversion is interpreted at read, never frozen at write
(ADR 0020). A counter
row carries no goal at all, so changing an Optin's Goal restates its entire
history rather than splitting it at the moment of the edit. That will look
like a bug to someone; it is the decision, and it is the price of making a Goal
freely correctable.
The screen is per-goal cards and no leaderboard. There is no site-wide
conversion rate, because "click-throughs to the offer" and "conversions on
Optins that capture an email" are different acts and ranking them means
nothing. An Optin's numbers arrive inside its Goal's card, which is what
makes a cross-Goal ranking unexpressible rather than merely absent. Conversion
rate is conversions ÷ impressions, and there is no "left without converting" —
it is already impressions − conversions − dismissals.
Soft-deleted Optins keep their counts and lose their row. A merchant tidying up in March must not watch February's goal total fall, which is also why an Optin is never hard-deleted.
The two tables meet in PHP rather than in a JOIN
(ADR 0034): a Goal is tens of
rows of fact, and joining it would denormalise it onto thousands of counters
while costing Connection a third widening. wconvert_stats still ships no
secondary index — the read is a scan, bounded to one year by
StatRange::MAX_DAYS, against an index that would be paid on every beacon.
No reported number comes from wconvert_leads, which is what
ADR 0018 depends on:
erasure deletes those rows, so a metric read from them would let one erasure
request rewrite a merchant's history. It is checked from both ends —
tests/unit/Stats/NoCountComesFromTheLeadLogTest.php walks the reporting
classes' computed dependency closure, and bin/verify-stats.php reads the real
query log, then writes a Lead and erases it and asserts no number moved.
"Today" means the merchant's today. The screen asks for a number of days
and never a date — a date built in the browser is the day of whoever is at the
keyboard — and the far end is StatDay::today(), read on the server against
the site's own timezone.
This one needs MySQL rather than the Playground SQLite above, on the
concurrency half: the check needs two connections holding real row locks, and
Playground gives one. The other reason this used to give — that ON DUPLICATE KEY UPDATE is MySQL's spelling — has expired. The SQLite integration now
translates it, and three increments on one key really do land as one row with
count = 3. So the counters can be exercised under Playground; what cannot be
is the claim the statement exists to make, which is that two writers in the
same instant produce two.
CI installs a fresh WordPress 6.8 on MySQL 8.4 and runs the stats, lead-log and literal-search verifiers under PHP 8.2. Together they check MySQL's counter atomicity, the lead log's grouping/erasure/pruning SQL, and captured-value search escaping. Run the same checks only against a throwaway MySQL-backed WordPress with the free plugin active:
WP_PATH=/absolute/path/to/throwaway-wordpress
wp eval-file "$PWD/bin/verify-stats.php" --use-include --path="$WP_PATH"
wp eval-file "$PWD/bin/verify-lead-log.php" --use-include --path="$WP_PATH"
wp eval-file "$PWD/bin/verify-lead-search.php" --use-include --path="$WP_PATH"--use-include preserves the scripts' strict_types declaration under WP-CLI
2.12. Both write and delete fixtures: the stats verifier refuses existing
counters or Leads, and the lead-log verifier refuses existing Leads. Never
point them at a live or valued site.
With Pro active as well, CI runs bin/verify-claims.php. It checks that every
short-lived claim shares one sweep event rather than adding its own to the
cron option, that a claim is taken once, and that a sweep removes only claims
at or below a mark at least an hour old, however early it is run. It refuses a
site that holds live claims.
The beacon is stateless — no visitor id, no device id, no hashed
fingerprint (ADR 0017) — so there is
no consent gate on it at all and no identifier to reintroduce. The endpoint is
public by necessity and hardened lightly and deliberately: the optin_id is
validated against the published set, the IP is hashed into a short-lived
transient as a rate limit and stored nowhere, and prefetch, prerender and bot
requests are dropped. What abuse costs is a wrong number on one merchant's
dashboard, not data loss and not a breach.
An [[Optin]] can carry starts_at, ends_at, both or neither. Both live in the
config blob beside frequency and priority — no column, because no
screen filters or sorts on a schedule and parent_id is a column only because
the Optins list has to filter on it
(ADR 0001).
The one thing to know before touching any of it: a scheduled Optin is IN the published set on both sides of its window. That is ADR 0050, and excluding one is the implementation that looks obviously right and silently never shows the campaign — the set is rebuilt on write and never on a timer, so nothing runs at the moment a window opens. The Optin ships and the browser decides.
The merchant authors a local wall time and the payload carries one
absolute instant in milliseconds. src/Optin/Schedule.php is the one
converter, it is pure and takes the zone as an argument for the reason
StatDay does, and PublishedProjection resolves through wp_timezone() on
every rebuild — so correcting the site's timezone corrects every schedule with
it, which update_option_timezone_string and update_option_gmt_offset are
hooked to make actually happen. resources/loader/src/schedule.ts compares the
result to Date.now() and names no timezone anywhere;
tests/js/builder-schedule.test.ts asserts that structurally, on the source.
Its two doors are the thing to read before changing either. fromArray() is
the author's and refuses — an unreadable boundary and an impossible pair,
which are the same harm reached two ways. windowIn() is the reader's, is
total, and fails shut: an impossible stored pair ships verbatim, so the
half-open comparison finds no instant inside it and the Optin never shows,
where dropping it would read as never scheduled and show a finished sale
forever.
wp eval-file bin/verify-schedule.php --use-include # against a real WordPress; runs on Playground's SQLiteIt sets a real timezone_string to a half-hour offset zone and then to another,
asserting the published option's instants move; asserts a not-yet-started Optin
and a finished one are both in it; proves a bare gmt_offset site resolves as
well as a named zone; dispatches a backwards schedule through
rest_do_request() for its 400; and rebuilds over a hand-edited backwards blob
to prove the reader's door is total. It stops with exit 1 if a fixture
campaign is refused publication, rather than reporting schedule failures
against a campaign that never went live. It refuses to run on a site that
already has Optins, because it rebuilds the published set. CI runs it on
every pull request that touches PHP.
Every page inlines the published set, so publishing, pausing or editing a
campaign changes what a cached page should say. Free purges the page caches it
knows on wconvert_published_set_changed
(ADR 0150):
WP Super Cache, W3 Total Cache, WP Rocket, WP Fastest Cache, SiteGround
Optimizer, Cache Enabler, Autoptimize, LiteSpeed, Nginx Helper, Breeze and
WP-Optimize. Nothing rests on it: a window's ends are enforced in the browser,
so a stale page still stops showing a campaign whose dates passed. Pausing one
early is what needs the purge.
Cloudflare, or your own cache. After every purge attempt, whether or not a
known plugin was found, WConvert fires wconvert_purge_page_cache. Hook it to
clear anything else:
add_action('wconvert_purge_page_cache', static function (): void {
wp_remote_post('https://api.cloudflare.com/client/v4/zones/' . MY_CF_ZONE . '/purge_cache', [
'headers' => ['Authorization' => 'Bearer ' . MY_CF_TOKEN, 'Content-Type' => 'application/json'],
'body' => wp_json_encode(['purge_everything' => true]),
'blocking' => false,
]);
});The official Cloudflare plugin purges on its own hooks; this is for a site that caches HTML at Cloudflare without it.
The Targeting axis holds two visitor predicates — logged_in and role — as
fields rather than as members of the include and exclude lists. Those lists
union page SETS, so a visitor rule in one would not narrow an Optin: it would
widen it to the whole site for anyone who matched, silently. TargetingType
enumerates the page rules and nothing else, which is what makes that
unbuildable rather than merely discouraged
(ADR 0005).
role is one predicate over several systems. WordPress roles are one answer
to what is this visitor; a membership level, a plan or an enrolment is the
same question asked of another plugin, so they share one rule type, one control
and one evaluator. A membership or LMS plugin adds a source and changes nothing
here:
add_action('wconvert_loaded', function (): void {
WConvert\Bootstrap::container()
->resolve(WConvert\Targeting\RoleRegistry::class)
->add(new MyMembershipRoles()); // implements RoleSource
});RoleSource is two methods: offered(), what a merchant may choose, asked once
while the rules panel renders; and held(), what THIS visitor holds, asked on
every uncached page view of a page whose published set names a role — so it is a
hot path, and the slugs share one namespace with WordPress's roles.
wp eval-file bin/verify-role-sources.php # registers a source the documented way, then asks both surfacesThat script exists because a unit suite structurally cannot prove the one claim
the seam makes: RoleRegistryTest builds a registry by hand and asserts the
arithmetic, and a fake registry passes however wrong the container's lifetime
and the hook's timing are.
inline is the one [[Display Type]] that is not an overlay. The other three
mount themselves into the top layer; this one renders where it was embedded, so
it needs somewhere on the page to go — and both authoring surfaces emit the
same element and nothing else.
src/Frontend/InlineAnchor.php the element, and the one place it is written
src/Frontend/InlineOptinBlock.php wconvert/inline-optin → InlineAnchor::html()
src/Frontend/InlineOptinShortcode.php [wconvert_optin id=…] → InlineAnchor::html()
resources/blocks/inline-optin/ block.json + the editor bundle's source
public/blocks/inline-optin.js built by `npm run build:block`
<div data-wconvert-optin="01J…"></div>That attribute is INLINE_ANCHOR_ATTRIBUTE in
resources/loader/src/present.ts, which resolves it with one
document.querySelector per payload entry.
tests/unit/Frontend/InlineAnchorTest.php reads the constant out of the
TypeScript and holds the PHP to it, because the two spellings drift silently:
the anchor stays on the page, the loader queries a name that is not on it, and
every inline Optin on the site renders nothing.
A third surface is a one-liner. A page builder module or a theme helper
calls InlineAnchor::html($id) and is done. Nothing else about rendering
belongs on either surface — the block does not preview the template
server-side, and the shortcode enqueues nothing the loader does not already
enqueue.
- An anchor whose Optin is not in the payload renders nothing and records nothing. The loader walks the payload looking for anchors, never the reverse, so a block left behind by an unpublished, deleted, [[Suspended]] or retyped Optin is never looked at. That is the ordinary end of an Optin's life, not an error, and the page it sits on is one WConvert was asked to leave alone (ADR 0004).
- Two anchors for one Optin on one page report one [[Impression]]. One Optin appearing to one visitor, once.
- Neither surface validates against the published set. They stay exactly as clever as each other, which is what makes them substitutable. Where an unresolvable id is reported is the editor, which is the surface that can ask.
tests/js/loader-inline-anchor.test.ts holds the first two through the real
presenter; tests/js/block-shortcode-parity.test.ts holds the names the editor
bundle and PHP share.
It offers the site's published inline Optins by name — a merchant picks something they recognise rather than pasting a ULID — and it renders a labelled placeholder in the editor canvas, never the Optin. ADR 0040 made the builder's preview an input, a surface the merchant works on; the post editor is not the builder, nothing on that screen can change what the Optin says, and a render there would be the static thumbnail ADR 0010 says does not exist anywhere in this flow.
The picker's list is handed over on enqueue_block_editor_assets rather than
fetched. A route would need a capability, and the honest one is not
manage_options — whoever can open the post editor can place this block — so
answering it properly means a third exception to WConvert\Rest\Routes' one
capability, plus a loading state, an error state and a controller, for a list
the editor page could simply have arrived with. What it costs instead is
staleness measured in one page load. WConvert\Frontend\InspectorEnqueue
declines a route for a related reason.
The block is also the only screen in WConvert that shows an Optin's id. The Optins list reads no LONGTEXT and so carries no display type (ADR 0001), the builder is opened by React state rather than by a URL, and the eligibility inspector prints rules rather than ids — so the picker printing the shortcode for whichever Optin was chosen is what makes the shortcode usable at all.
WConvert registers a WordPress exporter, an eraser and suggested privacy-policy text (ADR 0018).
Data & privacy also shows a read-only, site-specific data map. It explains the stored Lead fields, saved retention period, configured destination types, browser-local campaign state, short-lived anonymous-count rate limit and copies outside WConvert. The policy suggestion reads the same facts, while WordPress still leaves publication and legal review to the site owner (ADR 0094). Its copy follows WordPress's collect/purpose, recipients, retention and rights structure; merchant-only customization guidance is omitted when WordPress copies the suggested text into a policy.
Privacy guidance is on by default. It adds a short, purpose-specific Privacy Policy notice to new Campaign setups and checks that notice in the editor's readiness review. A merchant can turn that authoring help off under Data & privacy for a simpler editor and simpler future drafts. Existing Campaigns do not change, and export, erasure, retention, Consent Records, the Data Map and WordPress privacy-policy tools remain active (ADR 0096).
The eraser issues a DELETE, never an anonymising update. Anonymising is
an update, while erasure must remove the whole accepted record. The only allowed
Lead additions belong to the same short-lived capture journey; general profile
editing remains outside WConvert (ADR 0103).
The WordPress adapter remains email-addressed. A verified phone-only request
uses capture history's exact-phone search and CSV, then deletes every Lead whose
phone column directly carries that value. It never follows an email/phone link
to another row, and the action cannot be narrowed by Campaign, date or an
individual Lead (ADR 0093).
tests/unit/Lead/NoLeadIsEverUpdatedTest.php # no update() in either tree names the lead log
tests/unit/Privacy/LeadEraserTest.php # it deletes, retains nothing, and writes nothing
tests/unit/Privacy/LeadErasureTest.php # exact email/phone scope and diagnostic cleanup
tests/unit/Privacy/LeadExporterTest.php # the Consent Record travels; an absent one is not a refusal
tests/unit/Privacy/DataMapTest.php # only provable configured facts; no credentialsRetention ships as keep forever with pruning off, and the WP-Cron job exists from day one with nothing to do.
composer install && composer install -d pro && npm install
npm run build # build:free then build:pro — every bundle, both plugins
npm run build:free # free's bundles only; what the shipped package.json rebuilds
npm run check:loader # the loader byte budget + the premium-identifier scan
npm run check:admin # free's admin + inspector carry no Pro admin code, no "upgrade"
composer verify:artifact dist/stage/plain/wconvert # the artifact contract, on a staged tree
composer test # PHPUnit
composer phpstan # PHPStan, level 7
composer verify:source # the source contract
npm test # Vitest — free's tree and Pro's
npm run typecheck # tsc --noEmit
npm run lint # ESLint, --max-warnings=0
npm run check:pot # every translatable call is in its POT (needs wp-cli + npm run build)
npm run build:pot # free's POT → dist/languages/wconvert.pot (wp.org makes its own)
npm run build:pot:pro # Pro's POT → pro/resources/languages/wconvert-pro.pot (ships in Pro's ZIP)
npm run preflight # what CI would run for this diff, before pushingnpm run preflight reads the path filters out of .github/workflows/ci.yml
and runs the checks the branch's diff would trigger there, fastest first,
stopping at the first failure and printing a timing summary. --all ignores
the diff, --visual adds the Playwright suites (popup and fullscreen fail
locally from a path with a space in it), and --dry-run lists the steps
without running them. The MySQL database job stays CI-only.
Two plugins mean two catalogues, and a string's text domain says which one it
can ever be found in (ADR 0165). Code under pro/ says 'wconvert-pro', and
everything else says 'wconvert'. ESLint enforces this per directory.
- Free ships no
.pot. wp.org builds its language pack from free's ZIP, so its catalogue holds exactly the strings free's shipped PHP and built JS carry. - Pro is not on wp.org, so
bin/build.sh promakesresources/languages/wconvert-pro.potfrom the built bundles. Any.pothere is updated to it and compiled to.mo,.l10n.phpand per-script.jsonin the stage. Commit.pofiles only. The.potnames content-hashed bundle files, so it is rebuilt every time and is gitignored.
The extractors read the built JavaScript, never the TypeScript, by callee
name. The admin bundle is therefore minified with GETTEXT_SAFE_TERSER
(vite.admin-config.mjs), and npm run check:pot proves every literal call
reached its POT. It runs in CI as its own job. Locally it needs wp-cli on the
PATH and npm run build first. A free helper that only Pro's bundle ships fails
the check: no catalogue can hold it, so it belongs under pro/.
Three things do not come from a gettext call where they are drawn, and each has a route of its own (ADR 0166):
- The visitor's page carries no
wp.i18n. A word the renderer or a loader writes itself is added toPageLabels(free) orProPageLabels(Pro) and read withpageLabel(position, 'English'). Append and never reorder: the position is the contract, andPageLabelsTestholds the two sides together. - Design and screen names live in JSON. After adding or renaming a bundled
design, run
npm run templates:labels. It rewrites the twolabels.phpfiles, andTemplateNamesTestfails until you do. - Pro's inspector explanations read their words from
ProInspectorLabels, by group and key.
To see a locale for real, turn both POTs into pseudo-translated .po files,
compile them with wp i18n make-json and make-php, mount them as
wp-content/languages (Pro's into pro/resources/languages), and force the
locale with a one-line mu-plugin: add_filter('locale', fn () => 'de_DE').
WPLANG is refused for a locale whose core pack is not installed.
Free and Pro release on independent tags —
ADR 0030. Publish a
GitHub Release whose tag is free-vX.Y.Z or pro-vX.Y.Z; the prefix decides
which of the two workflows runs, and the other reports as skipped. A tag push
on its own does nothing.
composer install # the root vendor/: the collection checks below load the plugin's classes
npm run build # public/ and pro/public/ are gitignored — nothing is stale
bin/build.sh free # → dist/wconvert-v1.0.0.zip
bin/build.sh pro # → dist/wconvert-pro-v1.0.0.zip (below)
bin/build.sh allbin/build.sh first rebuilds and checks reviewed collection revisions, then stages a copy, runs composer install --no-dev inside it,
applies the tree's own .distignore, and then runs
bin/verify-artifact-contract.sh before
writing the ZIP — a ZIP that exists is a ZIP somebody can upload, so the
contract has to be what decides whether one is written.
The free ZIP carries no sources and no build files. It holds the built
public/ bundles and the PHP; the JavaScript sources under resources/*/src,
package.json, composer.json, the lockfiles and every Vite config stay in
this repository, and readme.txt's Source code section links it
(npm ci && npm run build:free rebuilds public/). .distignore names each one,
and the artifact contract fails the build if any of them ships, or if readme.txt
stops linking the repository. resources/ itself still ships its runtime data
(playbooks, templates, rules, collections, the phone country list, the block's
block.json).
The runtime autoloader is packages/autoload.php, not Composer's.
wp-scoper is configured in
composer.json (extra.wp-scoper, prefix WConvert\Deps, target packages/)
and writes it on composer install, PSR-4 for src/ plus any prefixed package.
extra.wp-scoper.packages is empty today. Action Scheduler is deliberately not
in it: it is a shared library that version-negotiates with other copies on the
site, so it ships under vendor/woocommerce/action-scheduler and is loaded by
path from wconvert.php, and it is the only thing left in vendor/ (Composer's
own autoloader is stripped). bin/autoload-template.php is the fallback if
wp-scoper ever writes nothing. The one production install command is
composer dist.
Pro has its own composer.json, for one package: the licence SDK
(ADR 0143).
veronalabs/wp-premium-sdk is pinned to an exact beta and fetched over HTTPS
from its public repository; wp-scoper copies it into pro/packages/ under
WConvert\Pro\Deps, and pro/src/autoload.php requires that classmap. So a
checkout needs composer install -d pro before Pro boots or its tests run, and
CI does the same. The build installs it in the stage like free's, strips
vendor/ whole (Composer reinstalls the unscoped original there), and the
artifact contract's check (g) refuses a Pro ZIP without the scoped copy — and a
free ZIP with any copy.
Translations are wp.org language packs. There is no Domain Path and no
load_plugin_textdomain() — WordPress loads the wconvert domain from
wp-content/languages/plugins/ just in time.
Pro is one tier with every module (ADR 0146).
bin/build.sh pro writes one ZIP, dist/wconvert-pro-v{VERSION}.zip — the same
shape as free's, with no tier in the name.
The store (Nexus, my.wconvert.io) sells WConvert Pro as plans — single-site,
5-sites, unlimited — which it calls tiers, and keeps one release per
(catalog item, tier_slug, version) (ADR 0143).
So release-pro.yml posts the one Pro ZIP once for each slug in the nexus
environment's NEXUS_PLAN_SLUGS, with tier_slug set to the plan. Nexus keys
the release on that field, never on the file name. Plan slugs never appear in
tiers.json, which ships in free.
A module is a directory under pro/modules/, holding its own module.json.
tiers.json names every one of them, and the artifact contract
checks the Pro ZIP carries exactly those directories.
The third of ADR 0029's three programs, and the one whose subject is a build:
- the free artifact contains no path under Pro's plugin directory — including
in
vendor/composer/'s generated autoload map, which is a leak the source contract structurally cannot see becausevendor/does not exist until build time; - the free artifact contains its un-minified source tree and the files that
rebuild it — the npm and Composer manifests and free's Vite configs, none of
which may name a
pro/path — which is what makesreadme.txt's source and build claims true by construction, plus itslicense.txt; - no premium design ships in the free ZIP — the trialware gate, issue #7;
- the Pro artifact carries every module
tiers.jsonnames, and no other — a Pro ZIP missing one installs, replaces the loader, and shows the same locked cards free shows; - the free artifact carries no licensing SDK. A licence gates updates and
support and never a feature (ADR 0015),
so the code that reads one is Pro's alone — and like the autoload map, this is
checked at the artifact because
vendor/is generated. Every Pro artifact carries it, scoped underWConvert\Pro\Depsinpackages/, with novendor/(ADR 0143).
It takes a staged tree and no flags, and it is not told which plugin it is
looking at: the tree holds exactly one plugin main file and that is the answer
(bin/plugin-identity.php). Zero is not a plugin;
both is one artifact carrying the other inside it, which is the leak.
Each is a program under bin/, called from both workflows, so the list of who
may publish exists once rather than once per workflow — and so every condition
can be tested. tests/unit/Contract/ReleaseGuardTest.php runs all five against
fixture trees, fixture tags and a fixture git repository.
| # | Condition | Program |
|---|---|---|
| 1 | The publisher is on the allowlist | bin/check-publisher.sh |
| 2 | The tag names this plugin and is shaped like a version | bin/check-release-tag.php |
| 3 | The tag is on the default branch | bin/check-tag-on-branch.sh |
| 4 | Every statement of the version agrees | bin/check-release-tag.php |
| 5 | Pro's WCONVERT_MIN_CORE ≤ the highest published free version |
bin/check-min-core.php |
Condition 4 is three statements for free, not one: the Version: header,
WCONVERT_VERSION, and readme.txt's Stable tag:. The constant is what
runs; the stable tag is what wp.org serves. Checking one of the three is
checking the one nobody installs.
Condition 5 is the one worth reading twice. The anchor is the version
wp.org is serving, read from its plugin information API by
bin/published-free-version.sh — not the
version in the working tree. Anchored to the repo it would pass the exact
release it exists to catch: Pro 1.3 requiring core 1.3 while free 1.3 is still
in the wp.org queue. The comparison itself is
WConvert\Pro\Boot\MinCoreCheck, borrowed whole, so the release guard ranks
versions exactly the way every install will.
Two consequences follow, and neither is a bug: free ships first, always — until free is live on wp.org there is no version any install can be running, so no Pro release can pass — and a wp.org outage blocks a Pro release. That is the trade a fail-closed check makes.
Pinned to the exact version in .github/plugin-check-version, blocking on
error and reporting warning.
WordPress/plugin-check-action is deliberately not used: it has no input
for the checker's version and always installs the latest from wp.org, so
pinning its SHA pins the wrapper rather than the gate.
bin/plugin-check.sh does the same sequence with
--version= and asserts afterwards that the version it asked for is the one it
got.
bin/plugin-check.sh dist/stage/plain/wconvert "$(cat .github/plugin-check-version)"It needs Docker — @wordpress/env starts a real WordPress and runs wp.org's own
checker inside it. .github/workflows/plugin-check-drift.yml runs the latest
against main weekly, compares it to the pin by finding code, and opens an
issue on anything new.
The free plugin passes this gate. The 1.0.0 run
(#96, Plugin Check 2.1.0)
reports zero errors and three warnings, all one finding: trademarked_term
says the name and the slug contain wc, which wp.org reserves for
WooCommerce. That is a question for the plugin review team, not a code fix —
ask for the wconvert slug explicitly when submitting. As of
#60 it was zero errors and
eighteen warnings. The first run against a real staged tree
reported 53 errors — escaping, i18n and WordPress.DB.PreparedSQL findings in
src/ and resources/playbooks/, none of them introduced by the release
workflow. Thirty-one were fixed outright.
The last twenty-one were one sniff in one file, and are suppressed per line
with their reason. WordPress.DB.PreparedSQL matches the literal variable
$wpdb, and src/Database/WpdbConnection.php holds the handle as
$this->wpdb, so every prepared query in it reported five findings apiece — one
per argument — and no restructuring reaches the sniff without costing something
the class exists for. The argument is written out above the first of them, for
the wp.org reviewer who asks the same question.
An --ignore-codes list is still refused, and the two are not one
concession a layer apart. A list on the runner sits away from the code and
applies to files nobody was thinking about when it was written; an annotation is
scoped to a single line, visible where the question arises, and disappears when
the code moves.
ADR 0029 records
which form is sanctioned and why, and bin/plugin-check.sh gained no flag.
Free and Pro require WordPress 6.8 or later and PHP 8.1 or later. The bundled Action Scheduler 4.1.0 sets the WordPress minimum. Normal activation, admin boot and editor smoke checks passed on WordPress 6.8 and 7.1.1; see the compatibility review. The artifact check compares the packaged dependency requirement with the plugin header, and verifies that the Free readme agrees.
WConvert\Database\Connection takes its SQL as a literal-string and its
table as a separate argument, passed to $wpdb->prepare() through the %i
identifier placeholder that WordPress 6.2 added. That is what makes an injected
table or column name unexpressible rather than merely discouraged: PHPStan
rejects any query a variable helped build, at the call site.
WordPress scans wp-content/plugins/ exactly one level deep, so Pro is not
found at wconvert/pro/. Symlink it beside free:
ln -s "$PWD/pro" ../wconvert-proPro needs its scoped licence SDK first (composer install -d pro), or it
fatals on load. Then boot a throwaway WordPress with both mounted, needing no
database:
npx @wp-playground/cli server --workers=1 \
--mount "$PWD:/wordpress/wp-content/plugins/wconvert" \
--mount "$PWD/pro:/wordpress/wp-content/plugins/wconvert-pro"--workers=1 is not optional: Playground's default six worker threads all
write one SQLite file and corrupt it, which surfaces as intermittent 500s that
read like flaky tests rather than a broken database. It is a server flag only
— the php subcommand below rejects it.
Two bin/verify-*.php scripts assert what a recording stub structurally cannot:
that after the swap nothing can put free's script back. WP_Dependencies is
a graph, and a dequeued-but-still-registered handle is resolved and printed as
the dependency of anything queued — so a stub that re-implemented all_deps()
would make itself the authority on what WordPress does.
# runner.php, mounted at /scratch:
# require_once '/wordpress/wp-load.php';
# WConvert\Bootstrap::container()->resolve(WConvert\Database\Installer::class)->install();
# require '/wordpress/wp-content/plugins/wconvert/bin/verify-admin-replacement.php';
npx @wp-playground/cli php --php=8.1 \
--mount "$PWD:/wordpress/wp-content/plugins/wconvert" \
--mount "$PWD/pro:/wordpress/wp-content/plugins/wconvert-pro" \
--mount "$PWD/../mu:/wordpress/wp-content/mu-plugins" \
--mount "$PWD/../scratch:/scratch" \
-- /scratch/runner.phpThe mu-plugin requires both plugin files, because activate_plugin() takes
effect on the next request — a script that activates and carries on runs
against a WordPress that loaded neither.
bin/verify-loader-replacement.php is the same shape for the front-end loader.
Both decline rather than fail when Pro is absent or its bundle is missing:
the replacement is the thing under test, so a site without it is one the script
has nothing to report on.
And the degrade rule is worth running by hand, because it is the one that
protects a merchant from a bad unpack: move pro/public/admin/ aside and load
the WConvert screen. Free's bundle must still be there — dequeuing free's while
pointing at a bundle that is not there renders a blank <div> with a 404 in a
console nobody has open.
The MailPoet [[Destination]] has two cases and both are worth running: it is
ready where MailPoet is installed and unavailable where it is not, and the
second is the majority of real sites. bin/verify-destinations.php asserts
whichever one it is booted into, so the two runs are one script and two
blueprints.
MailPoet is not installed in any Local site. Pull it in rather than hunting for a copy:
{
"$schema": "https://playground.wordpress.net/blueprint-schema.json",
"preferredVersions": { "php": "8.1", "wp": "latest" },
"steps": [
{
"step": "installPlugin",
"pluginData": { "resource": "wordpress.org/plugins", "slug": "mailpoet" },
"options": { "activate": true }
}
]
}Pass it with --blueprint, and leave the step out — or the flag off — for the
absent case. MailPoet runs on Playground's SQLite despite being a Doctrine ORM
application, so this needs no MySQL either.
Playground has no WP-CLI, so wp eval-file is not available and the scripts are
run through @wp-playground/cli php instead. Two things bite, both of them
once:
The plugin has to be loaded, and activating it mid-request does not do that.
activate_plugin() takes effect on the next request, so a script that
activates and then carries on runs against a WordPress that never loaded
WConvert. Mount an mu-plugin that requires wconvert.php instead — mu-plugins
load before regular plugins and before plugins_loaded, so WConvert's own hook
ordering is untouched.
Loading is not activating, so the tables are not there. Installer::install()
runs on register_activation_hook, and upgradeIfNeeded() runs on admin_init
— neither of which a CLI script reaches. Call the installer explicitly before
requiring the script, or every check downstream fails against a missing schema
rather than against the thing under test.
# runner.php, mounted at /scratch:
# require_once '/wordpress/wp-load.php';
# WConvert\Bootstrap::container()->get(WConvert\Database\Installer::class)->install();
# require '/wordpress/wp-content/plugins/wconvert/bin/verify-destinations.php';
npx @wp-playground/cli php --php=8.1 \
--blueprint "$PWD/../scratch/with-mailpoet.json" \
--mount "$PWD:/wordpress/wp-content/plugins/wconvert" \
--mount "$PWD/../mu:/wordpress/wp-content/mu-plugins" \
--mount "$PWD/../scratch:/scratch" \
-- /scratch/runner.php--php=8.1 rather than the default, because 8.1 is this plugin's floor and the
point of the exercise is to run on it.
From WSMS 8 — PHP 8.1+, DI container, service providers, Vite + React admin, PHPStan, PHPUnit. Read it; copy it; never modify it. No code is shared, and neither release cycle constrains the other (ADR 0030).
See the Content lock setup guide for the two block workflows, screenshots, supported content and troubleshooting.
Use Content lock for a public bonus, checklist, or article section revealed after form submission. In Display rules → Placement choose Content lock, use a complete inline capture journey, then publish. In your WordPress post/page, add WConvert Lock from here after the public introduction. Choose the Campaign in the block settings sidebar, then write ordinary blocks below the divider. Everything below it, including content added later, belongs to the lock. Moving or removing the divider preserves those content blocks.
For a bonus followed by a public conclusion, use WConvert Content lock instead and put only the bonus inside that section. Both blocks use the sidebar Campaign picker, with Change and Clear actions for an existing selection. Use one lock per page. No separate end marker is needed for the section block.
The classic editor equivalent is
[wconvert_content_lock id="CAMPAIGN_ID"]Your content[/wconvert_content_lock].
Keep both shortcode tags around the complete region; do not split them between widgets.
Test the published page in a fresh browser, submit, then reload. Access is remembered for 30 days for that Campaign family in the same browser. Reuse the same Campaign on several pages to share that access. Another device or an existing subscription elsewhere is not recognized. Display rules, frequency limits, unavailable Pro, missing JavaScript or technical capture failures can leave the content readable. Ordinary field validation keeps the form available for correction.
Support is for standard post/page content with text, static images, lists, tables
and links. Place media embeds, interactive forms and scripts outside. After
changing campaigns or plugin availability, clear page/CDN caches and check the real
page; cached HTML can retain its previous payload. Review the Inspector and Privacy
Data map for the active behavior and site-scoped wcv_unlock1: storage.
This is promotional gating: HTML and download URLs are public. Use a dedicated access-control system for private files or paid membership. Review your excerpts, feeds and search presentation; WConvert does not generate restricted-content schema or guarantee indexing outcomes. The research and implementation plan records competitor documentation, supported scenarios and deferred capabilities.