Skip to content

ENHANCEMENT: Product/Offer JSON-LD structured data for membership levels - #3735

Open
flintfromthebasement wants to merge 8 commits into
strangerstudios:devfrom
flintfromthebasement:enhancement/membership-level-structured-data
Open

ENHANCEMENT: Product/Offer JSON-LD structured data for membership levels#3735
flintfromthebasement wants to merge 8 commits into
strangerstudios:devfrom
flintfromthebasement:enhancement/membership-level-structured-data

Conversation

@flintfromthebasement

@flintfromthebasement flintfromthebasement commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

All Submissions:

Changes proposed in this Pull Request:

Adds membership-level structured data (JSON-LD) to PMPro core so levels are machine-readable for Google product snippets and AI shopping/browsing agents. Also adds an optional Level Image setting on the edit level page, used as the Product image in the structured data.

New file: includes/structured-data.php (plus Level Image field in adminpages/levels/edit-level.php / save-level.php and the hide toggle in adminpages/advancedsettings.php)

Where it outputs:

  • Levels page / [pmpro_levels] / pmpro/levels-pageItemList of Products
  • Checkout with a single level → one Product + Offer (uses checkout-adjusted level incl. discount-adjusted price)
  • Pages with one or more pmpro/single-level blocks → Product(s) / ItemList

Does not auto-output for:

  • [pmpro_membership_level] field shortcodes (not a full product surface)
  • Multi-level / MMPU checkout bundles (filter: pmpro_structured_data_multi_level_checkout_context)
  • Confirmation/account/etc.
  • Non-singular views

Schema choices (doc-checked against Google Product + schema.org):

  • Product + nested Offer (product snippets track; not full merchant-listing with shipping/returns)
  • Offer.price priority: initial_payment if > 0 → else trial_amount if a paid trial → else recurring billing_amount if free-to-start (including free trials) → else 0
  • Recurring: UnitPriceSpecification with priceComponentType: Subscription, billingIncrement, unitCode (DAY/WEE/MON/ANN), optional billingDuration when billing_limit > 0
  • Trials: the recurring UnitPriceSpecification is still emitted, with billingStart indicating how many periods pass before the regular rate begins
  • Offer.availability: InStock
  • Organization on Offer.seller (not Product.provider)
  • image: Level Image (level meta, set on the edit level page) → site icon → custom logo → omitted (image is only required for merchant listings, not product snippets)
  • Stable @id without discount codes; discount codes never appear in crawlable offer URLs
  • JSON encoded with JSON_HEX_TAG|AMP|APOS|QUOT

Settings:

  • Advanced Settings → Other Settings → “Hide membership level structured data (JSON-LD)”.
  • Edit Level → General Information → “Level Image” (optional; stored as an attachment ID in level meta; not shown at checkout).

Filters: pmpro_structured_data_enabled, pmpro_structured_data_context, pmpro_structured_data_schema, pmpro_structured_data_offer, pmpro_structured_data_seller, pmpro_structured_data_item_list_schema, pmpro_structured_data_product_image, pmpro_structured_data_multi_level_checkout_context.

Known limitation: WordPress canonicals strip query args, so checkout/?pmpro_level=N URLs all declare the bare checkout page as canonical and Google may consolidate them. The stable SEO surfaces are the levels page (ItemList) and dedicated per-level landing pages using the pmpro/single-level block; treat checkout JSON-LD as best-effort. See PR comments for details.

How to test the changes in this Pull Request:

  1. View source on Membership Levels → ItemList JSON-LD with each signup-eligible level.
  2. Checkout a recurring level → Product; Offer has price + priceSpecification (unitCode MON/ANN/…).
  3. Checkout a free level → "price": "0".
  4. Level with a paid trial → Offer.price uses trial_amount (or initial if set); recurring priceSpecification present with billingStart. Level with a free trial → Offer.price uses the recurring billing_amount (not 0).
  5. Discount code at checkout → Offer.price reflects adjusted level; offer URL does not include the discount code (privacy).
  6. Edit a level → set a Level Image → JSON-LD image uses it. Remove it → falls back to site icon / custom logo, or omits image when neither exists.
  7. Advanced → hide structured data → no PMPro JSON-LD.
  8. Homepage / blog posts without PMPro blocks → no PMPro membership JSON-LD.

Other information:

  • Explanation included
  • Tested on basement flintdev.test

Plan + PR reviewed by Codex gpt-5.6-sol; PR Review Council run processed (trial pricing major + nits fixed in follow-up commits). Follow-up commits from review (trial pricing, image fallback, Level Image setting, InStock availability) verified against a local site across one-time, recurring, multi-year-cycle, free-to-start, finite-limit, trial, and discount-adjusted levels — see PR comments.

Changelog entry

ENHANCEMENT: Added Product/Offer JSON-LD structured data for membership levels on levels and checkout pages, with a setting to disable output.
ENHANCEMENT: Added an optional Level Image setting on the edit level page, used as the product image in membership level structured data.

flintfromthebasement and others added 3 commits July 20, 2026 20:08
… levels

Emit schema.org Product + Offer markup on levels listing and checkout pages
(and Single Membership Level blocks) so membership pricing is machine-readable
for Google product snippets and AI agents. Includes a settings toggle to hide
output when another SEO plugin already covers schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address Codex PR review: free-to-start levels advertise recurring price,
stable product @id without discount codes, no discount leakage in URLs,
idempotent print guard, stricter context validation, currency decimal places.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…data

- Trial levels use trial_amount for Offer.price and omit incomplete recurring priceSpecification
- has_block gate before parse_blocks on every singular page
- Normalize cycle_period casing for unit codes
- Only scan post content on is_singular()
- Drop dead discount-in-URL filter branch

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

Copy link
Copy Markdown
Member

This idea came up before. Funny thing, I was having Flint look into the "Agents Ready" stuff and he kind of accidentally did a pass RE how to do the structured data for membership levels. So I said, go ahead and make the PR.

This looks good. I'm testing the output in Google's tester here: https://search.google.com/test/rich-results

We can pull onto our live site for more testing.

Someone should double/triple check the pricing logic (picking the price when there are trials/etc is a little tricky, but I think the default logic there is sound).

We can second guess the filters. They seem okay for me. One of them is around a "level image", which isn't a thing. But an image for the structured data seems very important (for showing up in Google shopping) and so people will want to insert images into the structured data to make it look good there. In fact, we should consider bundling this with a featured image for membership levels feature (use it here and as the og:image when viewing a checkout for a single level).

Google Rich Results Test marks missing image as a critical Product error.
Fallback chain: site icon → custom logo → bundled PMPro banner. Also add
Brand and a short description fallback. Filter still overrides image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dparker1005 dparker1005 added this to the 3.9 milestone Aug 18, 2026
dparker1005 and others added 3 commits August 19, 2026 15:43
Image is only required for Google merchant listing experiences, not the
product snippets this markup targets. Publishing the bundled PMPro banner
as a customer site's product image misrepresents the marked-up content,
so omit the image when the site has no icon or logo. Per-level images can
plug into the existing filter/fallback later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Offer.price now only uses trial_amount when it is nonzero, falling through
to the free-to-start rule so a paid membership with a free trial is not
marked up as a free product. The recurring UnitPriceSpecification is now
always emitted (matching its docblock), with billingStart indicating how
many periods pass before the regular rate begins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an optional Level Image media selector to the edit level page,
stored as an attachment ID in level meta. Structured data uses it as the
Product image ahead of the site icon / custom logo fallback.

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

Copy link
Copy Markdown
Member

I did a full review of this PR and pushed three follow-up commits. Overall the implementation was solid — output is correctly gated, safely encoded, discount codes never leak into crawlable URLs, and I verified the emitted schema across one-time, recurring, multi-year-cycle, free-to-start, finite-limit, and discount-adjusted levels on a test site. Summary of the changes I pushed:

6e4a088 — Image fallback no longer uses the PMPro banner. An image is only required for Google's merchant listing experience, not the product snippets track this PR targets (it's recommended there). The Rich Results Test error that motivated the banner fallback comes from the merchant-listings section of the test. Publishing our own marketing banner as the product image on customer sites would misrepresent the marked-up content (and be the identical image across every site using the fallback), so the chain is now: site icon → custom logo → omit.

4eb9113 — Trial levels now publish the ongoing rate. Previously a level like "$0 now, free first month, then $50/month" emitted price: "0" with no other pricing info — Google's docs treat price: 0 as "available without payment," and PMPro's own level cost text always displays the recurring amount, so the schema contradicted the page. Now trial_amount is only used for Offer.price when nonzero (falling through to the existing free-to-start rule), and the recurring UnitPriceSpecification is always emitted, with billingStart marking how many periods pass before the regular rate begins.

d32373f — Level Image setting. Following up on the image discussion above: levels now have an optional image, selected from the media library on the edit level screen and stored as an attachment ID in level meta. Structured data uses it ahead of the site icon/logo fallback. The hint text notes it isn't shown at checkout. This also sets us up to reuse it as og:image on checkout later.

One known limitation to be aware of when testing on the live site: WordPress canonicals strip query args, so every checkout/?pmpro_level=N URL declares the bare checkout page as its canonical — and the bare checkout URL emits the default level's product (often a free one). Google may consolidate the per-level checkout URLs accordingly, so checkout-page rich results will be inconsistent. The stable surfaces are the levels page (ItemList) and dedicated per-level landing pages using the Single Membership Level block — the latter is the architecturally correct "product page" for Google and worth recommending in docs. If Search Console shows checkout URLs getting consolidated away, a self-referencing canonical filter could be a follow-up.

Also note the PR description is now slightly stale on two points: trials no longer omit the recurring priceSpecification, and there is now a default image source (level image → site icon → custom logo).

InStock is the conventional availability value shopping tools key on;
OnlineOnly mainly signals absence of in-store purchase, which is the odd
one out for product snippets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants