Skip to content

Add real Offer/AggregateOffer pricing schema to /pricing/ - #20552

Open
workprentice[bot] wants to merge 1 commit into
masterfrom
seo/pricing-offer-schema
Open

Add real Offer/AggregateOffer pricing schema to /pricing/#20552
workprentice[bot] wants to merge 1 commit into
masterfrom
seo/pricing-offer-schema

Conversation

@workprentice

@workprentice workprentice Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Problem

The /pricing/ page's SoftwareApplication JSON-LD (the schema block Google's Software App rich result and AI engines read to answer "how much does X cost") had two commercial-intent defects, both confirmed live:

  1. name: "Pricing" — the page title leaked into the product-entity name. Crawlers and LLMs parsing this node are told the product is literally called "Pricing."
  2. offers had a priceCurrency but no price. A currency with no amount is meaningless to a rich result and gives an LLM nothing to cite when asked what Pulumi costs — despite the same page's own frontmatter already containing the full, authoritative per-tier price list (Individual $0, Team $40/mo, Enterprise $400/mo, Business Critical custom).

The homepage already proves the correct shape is achievable: its graph-builder.html emits a real array of per-tier Offer objects with names and prices. /pricing/, the one page whose entire purpose is communicating price, was the one page not doing this.

Fix

layouts/partials/schema/collectors/product-entity.html:

  1. Name. $productName now prefers a new schema_name frontmatter field over .Title, falling back to the old behavior for every other product page. Set schema_name: Pulumi on content/pricing/_index.md only — no behavior change anywhere else.
  2. Offers. Added a branch (alongside the existing Neo and generic-fallback branches) that fires only when a page defines tiers.trialed.items — currently just /pricing/. It ranges over the page's own tier list and:
    • Emits a real Offer per tier (name, description, priceCurrency, availability, url) using the tier's cta.href when absolute, else the pricing URL.
    • Strips $/, and parses the price; when it's numeric, adds a price field and folds it into an AggregateOffer (lowPrice/highPrice/offerCount) wrapping all tier offers — same idiom the file already uses for Neo's AggregateOffer.
    • When the price is non-numeric (Business Critical's "Custom"), the Offer is emitted with no price field — never fabricated.
    • If no tier turns out to have a numeric price at all, falls back to the pre-existing generic priceless Offer rather than emitting a broken AggregateOffer.
  3. Every other product page (Neo, and all pages without tiers data) is unaffected — same output as before.

No prices were invented; every number comes from the page's own already-published frontmatter, matching the pattern already live on the homepage.

Verification

  • Local full hugo build and make lint are blocked in this environment (no Node/Yarn toolchain for asset fingerprinting / markdown-lint scripts) — a known, pre-existing gap, not related to this change.
  • Verified template well-formedness by hand: {{/}} delimiter count balanced (91/91), and every new if/range has a matching end (12/12), matching the file's existing style throughout.
  • Every new construct (merge $schema (dict ...), the AggregateOffer shape) mirrors an idiom already present and working in this same file (the Neo AggregateOffer branch).
  • CI's "Install deps and build site" job is the real Hugo-render gate across the full ~2,900 URL site and will catch anything a hand review missed.
  • Will re-fetch https://www.pulumi.com/pricing/ after merge to confirm the rendered JSON-LD carries real per-tier prices.

🧠 This PR was created by workprentice on behalf of the Pulumi SEO/AEO agent's operator.

The pricing page's SoftwareApplication JSON-LD named the product
'Pricing' (leaked from the page title) and emitted a single Offer
with a currency but no price, so AI engines and Google's Software
App rich result had no answer for 'how much does Pulumi cost'.

- Add a schema_name frontmatter override (Pulumi) so the entity name
  no longer defaults to the page title; additive, no effect on other
  product pages.
- When a page defines tiers.trialed.items (currently only /pricing/),
  build a real per-tier Offer for each plan (Individual $0, Team $40,
  Enterprise $400, Business Critical custom - left priceless, never
  fabricated) plus an AggregateOffer with lowPrice/highPrice/offerCount,
  mirroring the Offer-array pattern already used on the homepage.
- Non-tiered product pages keep the existing generic Offer fallback
  unchanged.
@github-actions github-actions Bot added review:triaging Claude Triage is currently classifying the PR domain:website PR touches marketing, pricing, legal, or competitive landing pages domain:mixed PR touches more than one domain domain:infra PR touches workflows, scripts, infra, Makefile, or build config review:in-progress Claude review is currently running and removed review:triaging Claude Triage is currently classifying the PR labels Jul 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Pre-merge Review — Last updated 2026-07-29T12:21:54Z

Tip

Summary: This PR adds real schema.org Offer/AggregateOffer structured data to the /pricing/ page. It extends the product-entity schema collector with a new branch that builds one Offer per pricing tier from the page's own tiers.trialed.items data (Individual, Team, Enterprise, Business Critical) and wraps them in an AggregateOffer with lowPrice/highPrice computed from the numeric tier prices; a schema_name: Pulumi front-matter key is added so the schema entity is named "Pulumi" rather than the page title "Pricing". Because this is SEO/structured-data markup (invisible to human readers), "reader success" here means search engines ingesting valid, non-fabricated pricing — a blocker would be malformed JSON-LD, fabricated prices, or a template error breaking the build. The template handles this well: non-numeric prices (Business Critical's "Custom") are excluded from the low/high computation rather than coerced, and there's a fallback for tiers with no numeric price. Passes that ran: template logic read-through against the live tier data, schema.org value verification, and a front-matter sweep.

Review confidence:

Dimension Level Notes
mechanics MEDIUM Template logic reviewed by reading (Hugo functions and control flow are valid; branch ordering is correct — /pricing/ falls through to the new tiers.trialed.items branch). The preflight build was skipped (classified content-only), so template render is exercised by the full Hugo build in build-and-deploy.yml, not here.
facts HIGH
Investigation log
  • Cross-sibling reads: not run (not in a templated section)
  • External claim verification: 3 of 6 claims verified (0 unverifiable, 0 contradicted) · 4 specialists (numerical, cross-reference, capability, framing); 0 cross-specialist corroborations · routed: 0 inline, 3 Pass 1, 0 Pass 2, 3 Pass 3 (verified 3, contradicted 0, unverifiable 0).
  • Cited-claim spot-checks: not run (no cited claims)
  • Frontmatter sweep: ran on body + meta_desc
  • Temporal-trigger sweep: not run (no trigger words)
  • Code execution: not run (no static/programs/ change)
  • Code-examples checks: not run (no fenced code blocks in content files)
  • Editorial-balance pass: not run (not under content/blog/)
🚨 Outstanding ⚠️ Low-confidence 💡 Pre-existing ✅ Resolved
0 0 0 0

🔍 Verification trail

6 claims extracted · 3 verified · 0 unverifiable · 0 contradicted
  • L69 in layouts/partials/schema/collectors/product-entity.html "{{ $offerURL := 'https://www.pulumi.com/pricing/' }}" → ➖ not-a-claim (evidence: This is a Hugo template variable assignment ($offerURL := "https://www.pulumi.com/pricing/") used as a default fallback URL when building schema.org Offer/AggregateOffer structured data for the pricing page. It's implementation/templatin…; source: repo:layouts/partials/schema/collectors/product-entity.html)
  • L78 in layouts/partials/schema/collectors/product-entity.html "'availability' 'https://schema.org/InStock'" → ✅ verified (evidence: Schema.org's own Offer example uses this exact pattern: itemprop="availability" href="https://schema.org/InStock" to indicate an item is in stock, confirming "https://schema.org/InStock" is the correct canonical va…; source: https://schema.org/InStock)
  • L96 in layouts/partials/schema/collectors/product-entity.html "'availability' 'https://schema.org/InStock'" → ✅ verified (evidence: Schema.org's own documentation and Google's structured data guide both use "https://schema.org/InStock" as the canonical value for the "availability" property, e.g. Google's example: ""availability": "https://schema.org/InSt…; source: https://schema.org/InStock)
  • L97 in layouts/partials/schema/collectors/product-entity.html "'url' 'https://www.pulumi.com/pricing/'" → ➖ not-a-claim (evidence: This is a hardcoded "url" field inside a Go template building JSON-LD structured data (schema.org Offer/AggregateOffer) for SEO purposes, pointing to Pulumi's own pricing page (https://www.pulumi.com/pricing/). It's a self-referential inte…; source: repo:layouts/partials/schema/collectors/product-entity.html)
  • L105 in layouts/partials/schema/collectors/product-entity.html "'availability' 'https://schema.org/InStock'" → ✅ verified (evidence: Schema.org's own Offer example uses this exact markup: "<link itemprop="availability" href="https://schema.org/InStock\" />In stock", confirming https://schema.org/InStock is the correct canonical value for the availability proper…; source: https://schema.org/InStock)
  • L106 in layouts/partials/schema/collectors/product-entity.html "'url' 'https://www.pulumi.com/pricing/'" → ➖ not-a-claim (evidence: This is a hardcoded URL string used as the offers.url field in a JSON-LD schema.org template on the pricing/product pages, pointing to the pricing page itself (https://www.pulumi.com/pricing/). It's structural template data, not a falsif…; source: repo:layouts/partials/schema/collectors/product-entity.html)

🚨 Outstanding in this PR

No outstanding findings in this PR.

⚠️ Low-confidence

No low-confidence findings.

💡 Pre-existing issues in touched files (optional)

No pre-existing issues in touched files.

✅ Resolved since last review

No items resolved since the last review.

📜 Review history

  • 2026-07-29T12:21:54Z — Reviewed the pricing-page schema.org Offer/AggregateOffer collector change; schema.org availability values verified, tier-data mapping sound, no blockers. (891c67b)

Need a re-review? Want to dispute a finding? Mention @claude and include #update-review.
(For ad-hoc questions or fixes, just @claude — no hashtag.)

@github-actions github-actions Bot added review:no-blockers Claude review completed cleanly; outstanding is empty and removed review:in-progress Claude review is currently running labels Jul 29, 2026
@pulumi-bot

Copy link
Copy Markdown
Collaborator

Your site preview for commit 891c67b is ready! 🎉

http://www-testing-pulumi-docs-origin-pr-20552-891c67bb.s3-website.us-west-2.amazonaws.com

Changed pages:

@pulumi-bot

Copy link
Copy Markdown
Collaborator

Lighthouse Performance Report

Commit: 891c67b | Metric definitions

Page Device Score FCP LCP TBT CLS SI
Homepage Mobile 🟡 53 2.9s 5.7s 530ms 0.072 6.6s
Homepage Desktop 🟢 92 0.8s 1.0s 188ms 0.014 1.4s
Install Pulumi Mobile 🟡 59 6.1s 9.6s 12ms 0.053 6.1s
Install Pulumi Desktop 🟡 86 1.3s 1.9s 0ms 0.015 1.4s
AWS Get Started Mobile 🟡 56 6.0s 9.5s 17ms 0.071 8.1s
AWS Get Started Desktop 🟡 82 1.3s 1.9s 0ms 0.019 2.9s

@CamSoper CamSoper left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on the schema semantics, not the template logic — the tier mapping, the "Custom" exclusion, and the fallback are all handled well.

The prices publish without a billing period

For Team, this emits:

{ "@type": "Offer", "name": "Team", "priceCurrency": "USD", "price": 40 }

The page says $40 /month base, "Includes 40 Credits", "additional usage billed on demand". A bare price with no priceSpecification denotes an unqualified flat price, so the machine-readable claim becomes "Pulumi Team costs $40" against an annual floor of $480 before usage billing. Enterprise reads as $400 flat against $4,800/yr.

The HTML readers see /month base right next to the number. Google's rich results and LLM crawlers don't — they only get the JSON-LD, which is the entire point of the PR.

Please carry the billing period in the structured data: a priceSpecification with a UnitPriceSpecification expressing the monthly period, rather than (or in addition to) the bare price. Confirm the exact property set against Google's current subscription / merchant structured-data guidance before implementing — I don't want a guessed shape here.

Worth deciding at the same time whether the consumption component can be represented at all, or whether these Offers should be scoped to the base price with that stated explicitly. Publishing a base price as if it were the total is the failure mode to avoid.

Two smaller items

  1. lowPrice disagrees with the Neo branch. The /neo/ branch directly above floors at "40" with offerCount: 3, deliberately excluding the free tier. This branch will emit lowPrice: 0, offerCount: 4. Both are defensible, but two AggregateOffers for the same product family shouldn't disagree on the floor. Pick one and make them match.

  2. Type drift. The Neo branch emits prices as strings ("40"); this branch emits numbers via float. Both are valid schema.org, but align them within the file.

The schema_name: Pulumi key and the blast radius are fine — content/pricing/_index.md is the only page in the repo with either schema_type: product or tiers.trialed.items, so nothing else changes behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain:infra PR touches workflows, scripts, infra, Makefile, or build config domain:mixed PR touches more than one domain domain:website PR touches marketing, pricing, legal, or competitive landing pages review:no-blockers Claude review completed cleanly; outstanding is empty

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants