Skip to content

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

Merged
CamSoper merged 4 commits into
masterfrom
seo/pricing-offer-schema
Aug 4, 2026
Merged

Add real Offer/AggregateOffer pricing schema to /pricing/#20552
CamSoper merged 4 commits 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

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Pre-merge Review — Last updated 2026-08-03T12:48:37Z

Tip

Summary: This push (093da04) fixes the one remaining blocker from the last review: the price_label fold-in at layouts/partials/schema/collectors/product-entity.html L110 previously piped string .price into replace "$" "", which — because Go template pipes pass the piped value as the last argument while Hugo's replace takes INPUT OLD NEW — evaluated backwards and (with an empty OLD) inserted the price at every rune boundary of the 1-rune input "$", corrupting the live /pricing/ JSON-LD description for both paid tiers ("$$40$$40/month base...", "$$400$$400/month base..."). The fix reuses $cleanPrice, the already-computed, correctly-ordered variable that feeds Offer.price two lines below — replacing a piped, argument-order-ambiguous call with a plain positional substitution that has no room for the same bug class.

Review confidence:

Dimension Level Notes
mechanics HIGH Confirmed $cleanPrice (replace (replace (string .price) "$" "") "," "", L94) is in scope at L110 (same range block) and that printf "$%s%s" $cleanPrice .price_label is a plain positional substitution — no pipe reordering is possible here, unlike the previous call. Hand-traced against this PR's actual frontmatter (content/pricing/_index.md): Team $cleanPrice="40" + price_label="/month base""$40/month base"; Enterprise "400" + "/month base""$400/month base". Grepped the full file and this PR's diff for any other `
facts HIGH No new external claims in this commit; prior verification stands.
Investigation log
  • Cross-sibling reads: not run (not in a templated section)
  • External claim verification: 7 of 13 claims verified (0 unverifiable, 0 contradicted, 1 framing-drift) · 4 specialists (numerical, cross-reference, capability, framing); 1 cross-specialist corroborations · routed: 1 inline, 6 Pass 1, 0 Pass 2, 6 Pass 3 (verified 6, contradicted 0, unverifiable 0).
  • Cited-claim spot-checks: not run (no cited claims)
  • Frontmatter sweep: re-checked content/pricing/_index.md tiers.trialed.items[].price/price_label against the fixed description logic (no frontmatter changes in this commit)
  • 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 1

🔍 Verification trail

13 claims extracted · 7 verified · 0 unverifiable · 0 contradicted · 1 framing-drift
  • L59-76 "the /neo/ branch derives $lowPrice/$highPrice/$offerCount from site.GetPage "/pricing"'s own tiers.trialed.items" → 🤝 matches the /pricing/ branch's own AggregateOffer (L154-163): both compute lowPrice: 0, highPrice: 400, offerCount: 3 from the same source data
  • L62 "{{ $pricingPage := site.GetPage "/pricing" }}" → ➖ not-a-claim (template lookup, not a falsifiable assertion; source: repo:layouts/partials/schema/collectors/product-entity.html)
  • L83 "'availability' 'https://schema.org/InStock'" → ✅ verified (source: https://schema.org/InStock)
  • L84 "'url' 'https://www.pulumi.com/pricing/'" → ➖ not-a-claim (self-referential URL; source: repo:layouts/partials/schema/collectors/product-entity.html)
  • L125 "'price' $priceNum" (Team: 40, Enterprise: 400) → 🌀 framing-drift (framing: the number matches the page's own frontmatter, but publishing it as a bare Offer.price states a recurring, usage-supplemented base price as if it were the total — see 📜 Review history, resolved-by-mitigation via the description fold-in this PR ships)
  • L127 "'availability' 'https://schema.org/InStock'" → ✅ verified (source: https://schema.org/InStock)
  • L128 "'url' $offerURL" → ➖ not-a-claim (per-tier CTA URL or pricing-page fallback; source: repo:content/pricing/_index.md)
  • L132-135 "Google's structured data guidance documents no billing-period / subscription concept for Offer and states it uses offers.price and ignores offers.priceSpecification" → ✅ verified (Google Merchant Center structured-data docs: "If you use both the offers.price and offers.priceSpecification properties to encode an active price, Google will use the price provided through the offers.price property and ignore the offers.priceSpecification property"; source: https://developers.google.com/search/docs/appearance/structured-data/merchant-listing)
  • L141-145 "'billingDuration' (dict '@type' 'QuantitativeValue' 'value' 1 'unitCode' 'MON')" → ✅ verified (schema.org's billingDuration accepts Duration, Number, or QuantitativeValue; MON is the correct UN/CEFACT code for month; source: https://schema.org/billingDuration, https://schema.org/UnitPriceSpecification)
  • L160 "'availability' 'https://schema.org/InStock'" → ✅ verified (source: https://schema.org/InStock)
  • L161 "'url' 'https://www.pulumi.com/pricing/'" → ➖ not-a-claim (source: repo:layouts/partials/schema/collectors/product-entity.html)
  • L169 "'availability' 'https://schema.org/InStock'" → ✅ verified (source: https://schema.org/InStock)
  • L170 "'url' 'https://www.pulumi.com/pricing/'" → ➖ not-a-claim (source: repo:layouts/partials/schema/collectors/product-entity.html)

🚨 Outstanding in this PR

No outstanding findings.

⚠️ Low-confidence

No low-confidence findings this round.

💡 Pre-existing issues in touched files (optional)

No pre-existing issues in touched files.

✅ Resolved since last review

  • price_label pipe-argument-order bug(string .price | replace "$" "") piped the price into replace "$" "", which Go/Hugo's argument-binding rules evaluate backwards (replace(INPUT="$", OLD="", NEW=price)), and an empty OLD inserts NEW at every rune boundary of the 1-rune "$" input — corrupting Offer.description to "$$40$$40/month base..." / "$$400$$400/month base...". Fixed by reusing $cleanPrice, the file's existing correctly-ordered variable, in place of the broken pipe. Hand-verified against this PR's actual tier frontmatter: Team → "$40/month base", Enterprise → "$400/month base", no $$ in either. (resolved in 093da04)

📜 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)
  • 2026-07-31T13:01:17Z — Re-reviewed after fix push addressing @CamSoper's requested changes (1 new commit, 9f146cc): Neo/pricing lowPrice reconciliation and price-type drift resolved; billing-period fix incomplete (Google ignores priceSpecification when price is present, and the new description doesn't carry price_label either) — see 🚨 Outstanding. (9f146cc)
  • 2026-08-01T15:45:00Z — Re-reviewed after fix push responding to @workprentice[bot]'s mention (1 new commit, 812ce17): double-punctuation join and Neo-fallback-comment items resolved; the price_label fix introduces a Go/Hugo template pipe-argument-order bug that corrupts Offer.description for both paid tiers — see 🚨 Outstanding. (812ce17)
  • 2026-08-03T12:48:37Z — Re-reviewed after fix push responding to @workprentice[bot]'s mention (1 new commit, 093da04): the pipe-argument-order bug is fixed by reusing $cleanPrice; hand-verified against live tier frontmatter, no $$ in either paid-tier description; no new findings. (093da04)

  • Refresh this review — comment @claude #update-review. Say what you fixed, or which finding you dispute and why; both work in the same mention.
  • Ask for anything else — comment @claude with no hashtag (questions, one-off fixes). Leaves this review untouched.

Important

Please don't hide, resolve, or delete this comment! It breaks things!

📖 How pre-merge review works — the full lifecycle, short-circuits, and escape hatches.

@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

pulumi-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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:

@pulumi-bot

pulumi-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Lighthouse Performance Report

Commit: 093da04 | Metric definitions

Page Device Score FCP LCP TBT CLS SI
Homepage Mobile 🔴 43 3.2s 5.5s 1944ms 0.061 4.4s
Homepage Desktop 🟡 73 0.8s 1.4s 343ms 0.039 2.8s
Install Pulumi Mobile 🟡 56 6.2s 9.6s 118ms 0.053 8.4s
Install Pulumi Desktop 🟡 78 1.4s 2.1s 0ms 0.006 3.1s
AWS Get Started Mobile 🔴 48 6.0s 9.4s 60ms 0.246 6.0s
AWS Get Started Desktop 🟡 79 1.4s 2.0s 0ms 0.043 3.1s

@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.

…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.
@workprentice

workprentice Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@claude #new-review

@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention and removed review:no-blockers Claude review completed cleanly; outstanding is empty labels Jul 31, 2026
@workprentice

workprentice Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

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:

  • Google's structured-data guidance (merchant listings, product snippets, and software apps) documents no billing-period, subscription, or recurring-charge concept at all - I checked exhaustively and there is no billingDuration/billingIncrement/billingStart, and no mention of "subscription" or "recurring" anywhere in the property tables. More importantly, Google's own docs state it uses offers.price and ignores offers.priceSpecification when both are present. So there is no Google-documented shape to match, and adding one will not change Google rich results either way.
  • schema.org itself (current dev docs) does document UnitPriceSpecification.billingDuration, explicitly for modeling "the contractual duration of a subscription or payment plan," typed as a QuantitativeValue/Number with a UN/CEFACT unitCode. schema.org's own worked example uses unitCode: "ANN" (annual fee); there's no monthly worked example, so the unitCode: "MON" value I used is derived by direct application of the documented property, not copied from a primary source - flagging that honestly rather than presenting it as a verified pattern.
  • Net: I attached priceSpecification (UnitPriceSpecification with price, priceCurrency, billingDuration: {QuantitativeValue, value: 1, unitCode: "MON"}) to each monthly tier, driven off the tier's own price_label field (never hardcoded which tiers are monthly). This is schema.org-correct and useful for AI/LLM parsers reading tier pricing, but won't move Google rich results since Google ignores it there.
  • On the consumption/usage component: I decided not to model it via CompoundPriceSpecification since Google documents that shape nowhere and schema.org's own guidance for it is thin. Instead I scoped each Offer to the base price and made the disclosure explicit in the Offer's own description (subtitle + note + unit, e.g. "Everything you need to get started. Up to 500 resources, additional usage billed on demand. Includes 40 Credits") so the machine-readable claim itself states it's a base price with usage billed on demand, rather than implying an all-inclusive flat price.

2. lowPrice mismatch. You're right to flag it, and it's worse than a mismatch - the Neo branch's lowPrice: "40" is factually wrong. Neo is available on the free Individual tier (5M tokens/month included, $3/M tokens beyond that on paid tiers) - confirmed in the pricing page's own Individual tier features, the page's comparison table, content/product/neo.md, and live production at pulumi.com/pricing. The stale comment ("Neo is part of paid tiers, starting with Team tier") is gone. To make sure the two pages can never drift apart again, the /neo/ AggregateOffer now derives lowPrice/highPrice/offerCount from the pricing page's own tier data via site.GetPage "/pricing", falling back to corrected hardcoded values (0/400/3) only if that lookup fails.

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 offers.price and an aggregateRating or review, neither of which either page has, and AggregateOffer isn't a documented Google shape for SoftwareApplication at all. So I'd frame all of this as schema correctness + AEO/LLM-parsing quality rather than something that unlocks a Google rich result - flagging the missing rating/review as a known, out-of-scope gap rather than silently implying this closes the loop on rich results.

Requested a fresh automated review as well.


🧠 This reply was posted by workprentice on behalf of the Pulumi SEO/AEO agent.

@workprentice

workprentice Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@claude #update-review — pushed fix 9f146cc addressing the billing-period, lowPrice/Neo reconciliation, and price-type findings from @CamSoper's review. Please re-review against the new commit.

@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Jul 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @workprentice[bot]'s request.

@github-actions github-actions Bot added review:outstanding-issues Claude review completed; outstanding has author-actionable findings and removed review:in-progress Claude review is currently running labels Jul 31, 2026
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).
@github-actions github-actions Bot removed the review:outstanding-issues Claude review completed; outstanding has author-actionable findings label Jul 31, 2026
@github-actions github-actions Bot added the review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention label Jul 31, 2026
@workprentice

workprentice Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@CamSoper Pushed a small follow-up (812ce17) addressing one more gap in the billing-period fix:

Since Google's structured-data guidance reads offers.price and ignores offers.priceSpecification when price is present, the plain-text Offer description is the only field a Google-facing (or any priceSpecification-ignoring) parser will actually see. The prior commit attached priceSpecification.billingDuration correctly, but the description itself didn't carry the billing-period qualifier - so a parser that only reads price + description would still see an unqualified $40.

This commit leads the description with the tier's own price_label for monthly tiers (e.g. "$40/month base"), derived the same way as the priceSpecification attachment - never hardcoded per tier. Also trimmed trailing periods from each description part before joining, so subtitle/note/unit no longer double-punctuate, and expanded the /neo/ branch's fallback comment to spell out why lowPrice: 0 / offerCount: 3 is internally consistent with the /pricing/ derivation (both exclude the non-numeric "Custom" tier from the count, both use the same 3 numeric tiers).

#update-review

@workprentice

workprentice Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

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 reviewDecision: CHANGES_REQUESTED shown on this PR reflects the pre-fix state and hasn't refreshed against the superseded commits. Re-requesting review — please take another look when you have a moment. @claude #update-review

@workprentice
workprentice Bot requested a review from CamSoper August 1, 2026 12:09
@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Review updated on @workprentice[bot]'s request.

@github-actions github-actions Bot added review:outstanding-issues Claude review completed; outstanding has author-actionable findings and removed review:in-progress Claude review is currently running labels Aug 1, 2026
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.
@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention and removed review:outstanding-issues Claude review completed; outstanding has author-actionable findings labels Aug 3, 2026
@workprentice

workprentice Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@CamSoper Following up with a fix for the defect the automated review flagged on commit 812ce17f (introduced while addressing your billing-period/description feedback).

The bug (line 110, layouts/partials/schema/collectors/product-entity.html):

{{ $descParts = $descParts | append (printf "$%s%s" (string .price | replace "$" "") .price_label) }}

Hugo's replace takes INPUT OLD NEW (input first), but Go template pipelines pass the piped value as the last argument. So string .price | replace "$" "" actually evaluated as replace(INPUT="$", OLD="", NEW="$40"). strings.Replace with an empty old inserts new at every rune boundary, so for the 1-rune input "$" this produced "$40$$40", and the outer printf "$%s%s" prepended another $ — the Team tier description would have shipped as "$$40$$40/month base. …" in the live /pricing/ JSON-LD. CI stayed green throughout because the output is valid JSON with wrong text, so nothing gates on it.

The fix: replaced the broken pipe with the $cleanPrice variable already computed and numeric-gated earlier in the same loop (this file already uses $cleanPrice for Offer.price, so reusing it also guarantees the description price can never disagree with the schema price):

{{ $descParts = $descParts | append (printf "$%s%s" $cleanPrice .price_label) }}

Note the file has both idioms present: strings.TrimSuffix "." .subtitle (suffix-first, so pipe-safe) sits right next to the broken replace (input-first, so pipe-unsafe) — same partial, two Hugo string funcs with opposite argument order. I grepped layouts/ for the same | replace pattern elsewhere in this file and this PR's diff; no other instance of it exists here.

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 content/pricing/_index.md at this PR's head commit (093da04e). Rendered description output:

Tier Rendered description
Individual Free forever for individuals. No credit card required
Team $40/month base. Everything you need to get started. Up to 500 resources, additional usage billed on demand. Includes 40 Credits
Enterprise $400/month base. Security and collaboration for large teams. Up to 2,000 resources, additional usage billed on demand. Includes 400 Credits
Business Critical excluded (non-numeric Custom price, no fabricated Offer emitted)

No $$ anywhere in the output. This matches the intended output exactly, and your three original points (billing period, Neo/lowPrice reconciliation, price type) remain addressed as resolved in the earlier commits.

One trade-off worth flagging: $cleanPrice also strips thousands separators, so a hypothetical $4,800 tier would render as $4800 in the description text while the page itself shows $4,800. No current tier is affected (highest monthly tier is $400), and keeping the description's price in lockstep with Offer.price seemed like the more defensible property for a machine-readable field than preserving comma formatting. Happy to revisit if you'd prefer the comma preserved.

@claude #update-review


🧠 This PR was updated by workprentice on behalf of @CamSoper's review request.

@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🤖 Review updated on @workprentice[bot]'s request.

@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 Aug 3, 2026
CamSoper pushed a commit that referenced this pull request Aug 3, 2026
…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>
@CamSoper
CamSoper merged commit 1aaddac into master Aug 4, 2026
13 checks passed
@CamSoper
CamSoper deleted the seo/pricing-offer-schema branch August 4, 2026 00:04
jeffmerrick added a commit that referenced this pull request Aug 4, 2026
…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>
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