Add real Offer/AggregateOffer pricing schema to /pricing/ - #20552
Conversation
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.
Pre-merge Review — Last updated 2026-08-03T12:48:37ZTip Summary: This push (093da04) fixes the one remaining blocker from the last review: the Review confidence:
Investigation log
🔍 Verification trail13 claims extracted · 7 verified · 0 unverifiable · 0 contradicted · 1 framing-drift
🚨 Outstanding in this PRNo outstanding findings.
|
|
Your site preview for commit 093da04 is ready! 🎉 http://www-testing-pulumi-docs-origin-pr-20552-093da04e.s3-website.us-west-2.amazonaws.com Changed pages: |
Lighthouse Performance ReportCommit: 093da04 | Metric definitions
|
CamSoper
left a comment
There was a problem hiding this comment.
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
-
lowPricedisagrees with the Neo branch. The/neo/branch directly above floors at"40"withofferCount: 3, deliberately excluding the free tier. This branch will emitlowPrice: 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. -
Type drift. The Neo branch emits prices as strings (
"40"); this branch emits numbers viafloat. 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.
…iliation Responds to @CamSoper's review on PR #20552: - Attach billing period via schema.org's UnitPriceSpecification.billingDuration (QuantitativeValue, unitCode MON) for tiers whose price_label indicates a monthly base price, derived from the tier's own price_label rather than hardcoded. Google's structured-data guidance documents no billing-period / subscription concept for Offer and states it uses offers.price while ignoring offers.priceSpecification, so this does not change Google rich results - it is schema.org-correct markup for AI/LLM parsers reading tier pricing, and directly reflects the actual monthly-plus-usage pricing model. - Explicitly disclose the base-price-plus-usage model in each Offer's own description (subtitle + note + unit), rather than modeling consumption via an undocumented CompoundPriceSpecification shape. - Drop non-numeric tiers (Business Critical / "Custom") from the offer list entirely instead of emitting an invalid priceless Offer; fixes offerCount. - Reconcile the /neo/ AggregateOffer with /pricing/ by deriving lowPrice, highPrice, and offerCount from the pricing page's own tier data (falling back to corrected hardcoded values if that lookup fails), so the two pages can never disagree again. Neo is available on the free Individual tier (5M tokens/month), so the floor is corrected from 40 to 0, and the stale comment claiming Neo starts at Team tier is removed. - All prices now emit as JSON numbers in both branches (previously the /neo/ branch used strings). 🧠 This PR was created by [workprentice](https://github.com/workprentice) on behalf of the Pulumi SEO/AEO agent.
|
@claude #new-review |
|
Thanks for the detailed review - pushed a fix (9f146cc) addressing all three points: 1. Billing period (the primary blocker). I did the research you asked for before touching the shape. Findings:
2. lowPrice mismatch. You're right to flag it, and it's worse than a mismatch - the Neo branch's 3. Type drift. Fixed - both branches now emit prices as JSON numbers. Confirmed every JSON-LD example in Google's docs uses a bare Number even though their prose permits either. One more thing worth surfacing: Google's SoftwareApplication rich-result eligibility requires both Requested a fresh automated review as well. 🧠 This reply was posted by workprentice on behalf of the Pulumi SEO/AEO agent. |
|
🤖 Review updated on @workprentice[bot]'s request. |
Addresses the outstanding Claude review finding on commit 9f146cc: since Google ignores offers.priceSpecification when offers.price is present, the plain-text description was the only field a Google-facing (or any priceSpecification-ignoring) parser would read, and it did not carry the billing-period qualifier. Lead the description with the tier price_label (e.g. "$40/month base") for monthly tiers so the qualifier survives regardless of which shape a given consumer respects. Also trim trailing periods from each description part before joining so subtitle/note/unit no longer double-punctuate, and expand the /neo/ branch comment to make explicit why its fallback lowPrice=0/offerCount=3 is internally consistent (mirrors exactly what the /pricing/ derivation computes from the same live tier data).
|
@CamSoper Pushed a small follow-up (812ce17) addressing one more gap in the billing-period fix: Since Google's structured-data guidance reads This commit leads the description with the tier's own #update-review |
|
All three review points from @CamSoper have been addressed in commits 9f146cc and 812ce17 (billing-period Offer schema + Neo/lowPrice fix). CI is green (Install deps and build site, Run Example Code Tests, Update Changelog all passing). The |
|
🤖 Review updated on @workprentice[bot]'s request. |
The description price-prefix line piped a value into Hugo's replace
function: `string .price | replace "$" ""`. Hugo's replace signature
is `replace INPUT OLD NEW` (input first), and Go text/template pipes a
value into the LAST argument of the next command, so this actually
evaluated as replace("$", "", string(.price)) -> INPUT="$", OLD="",
NEW=<price>. strings.Replace with an empty old string inserts the
replacement at every rune boundary, so a single-rune input like "$"
produced a doubled, garbled string (verified empirically with a
minimal Hugo render: '$40' -> '$$40$$40/month base' instead of
'$40/month base').
This file already computes $cleanPrice earlier in the same range scope
using the correct explicit-argument idiom, and by this point it has
already passed the numeric gate at the top of the branch, so it is
guaranteed to be a clean digits-only string. Reusing it instead of
re-deriving the same strip removes the pipe entirely (so this bug
class cannot recur here), keeps the description's price in permanent
agreement with Offer.price since both now come from the same value,
and follows the file's own established explicit-argument idiom used
for strings.TrimSuffix elsewhere in this branch (whose signature takes
the input last, unlike replace, which is why the two idioms look
similar but only one is pipe-safe).
Trade-off disclosed: $cleanPrice also strips thousands separators, so
a hypothetical four-figure monthly tier (e.g. $4,800) would render as
$4800 in the description while the page displays $4,800. No current
tier is affected (the highest monthly tier is $400), and keeping the
description in exact agreement with the numeric Offer.price is the
more defensible property for a machine-readable field.
No other instance of this pipe-unsafe replace pattern exists elsewhere
in the codebase; the only other piped usages are replaceRE, whose
signature takes the input last, so those are correct as written.
|
@CamSoper Following up with a fix for the defect the automated review flagged on commit The bug (line 110, Hugo's The fix: replaced the broken pipe with the Note the file has both idioms present: Verification: the S3 preview bucket isn't reachable from my environment (403 on every path), so rather than rely on a preview I built a minimal Hugo harness using the exact partial logic and the exact tier data from
No One trade-off worth flagging: @claude #update-review 🧠 This PR was updated by workprentice on behalf of @CamSoper's review request. |
|
🤖 Review updated on @workprentice[bot]'s request. |
…mes (#20630) * fix(schema): mirror <title_tag> instead of sidebar .Title in JSON-LD names WebPage.name, HowTo.name, CollectionPage.name/ItemList children, and the tutorials Course/ItemList names were all built from Hugo's .Title, which is the SHORT left-nav sidebar label, not the real page title. head.html already builds the actual <title> tag from title_tag when present (falling back to .Title) — this brings the schema graph in line with that same precedence so the structured data matches what the browser tab and search snippet actually show. 668 content files declare title_tag. Before this fix those pages collapsed to only 584 distinct WebPage.name values (133 pages emitting a name duplicated elsewhere); after, 667 of 668 are distinct. Changed: - layouts/partials/schema/graph-builder.html: WebPage.name - layouts/partials/schema/collectors/howto-entity.html: HowTo.name and its description fallback - layouts/partials/schema/collectors/collection-entity.html: CollectionPage.name and the child ListItem.name in its ItemList - layouts/partials/schema/content/course-list.html: Course.name (per tutorial listed) and the tutorials-hub ItemList.name Deliberately left unchanged (documented in PR body): blog-entity.html headline, video-entity.html (zero exposure), product-entity.html (conflicts with open PR #20552). * fix(schema): keep short title in description fallback sentences, narrow comment Addresses low-confidence review findings: the meta_desc/description printf fallbacks in howto-entity.html and course-list.html now interpolate .Title (the short page title) rather than the title_tag-preferring name, avoiding broken sentences when title_tag is phrased as a full sentence or question. Also narrows graph-builder.html's new comment, since head.html's title precedence isn't quite what it described (blog term/category/series overrides, and a site-name suffix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: workprentice <257153108+workprentice@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…oud-labeling Conflict in the JSON-LD product collector: both sides replaced the hardcoded Neo AggregateOffer with a derived one, but master derived it from the `tiers:` frontmatter this branch had already moved into data/pulumi_pricing.yaml. Resolved to master's semantics on this branch's data source — Neo starts on the free Individual edition, so the aggregate spans every edition with a numeric price ($0–$400, three offers) rather than only the paid ones, which is what my side had assumed. Two of master's new templates read the same retired frontmatter and would have gone silently dead here, so they're ported to the data file rather than left to render nothing: - The `/pricing/` per-edition `Offer` schema from #20552. It now walks `$px.editions` and reads the same fields off each card (price, price_label, subtitle, note, unit, cta), keeping master's descriptions, its UnitPriceSpecification billing period, and its exclusion of the "Custom" edition. Emits the same three offers as master intended. - `layouts/page/pricing.md`, the markdown rendition. Editions come from the cards; the comparison tables come from the groups/categories tree with pricing/value.html normalizing each cell, so `_check`/`_blank` sentinels become ✓ and —. Hidden features stay out, as on the page itself. Co-Authored-By: Claude <noreply@anthropic.com>
Problem
The
/pricing/page'sSoftwareApplicationJSON-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: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."offershad apriceCurrencybut noprice. 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.htmlemits a real array of per-tierOfferobjects 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:$productNamenow prefers a newschema_namefrontmatter field over.Title, falling back to the old behavior for every other product page. Setschema_name: Pulumioncontent/pricing/_index.mdonly — no behavior change anywhere else.tiers.trialed.items— currently just/pricing/. It ranges over the page's own tier list and:Offerper tier (name,description,priceCurrency,availability,url) using the tier'scta.hrefwhen absolute, else the pricing URL.$/,and parses the price; when it's numeric, adds apricefield and folds it into anAggregateOffer(lowPrice/highPrice/offerCount) wrapping all tier offers — same idiom the file already uses for Neo'sAggregateOffer.Offeris emitted with nopricefield — never fabricated.Offerrather than emitting a brokenAggregateOffer.tiersdata) 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
hugobuild andmake lintare blocked in this environment (no Node/Yarn toolchain for asset fingerprinting / markdown-lint scripts) — a known, pre-existing gap, not related to this change.{{/}}delimiter count balanced (91/91), and every newif/rangehas a matchingend(12/12), matching the file's existing style throughout.merge $schema (dict ...), theAggregateOffershape) mirrors an idiom already present and working in this same file (the NeoAggregateOfferbranch).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.