fix(blog): emit valid schema.org JSON-LD in blog SEO sections#1643
Conversation
…stListing Both sections were serializing the raw page object into the ld+json script: SeoBlogPost emitted a non-schema.org "BlogPostPage" node and SeoBlogPostListing spread the posts array into an object, producing invalid markup that Google ignores. Add blog/utils/jsonLD.ts with a toBlogPosting helper following https://developers.google.com/search/docs/appearance/structured-data/article and emit a BlogPosting node on post pages and a Blog node with blogPost items on listing pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tagging OptionsShould a new tag be published when this PR is merged?
|
|
Warning Review limit reached
Next review available in: 49 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughBlog detail and listing SEO loaders now use shared schema utilities to generate BlogPosting, Blog, and breadcrumb JSON-LD. Canonical URLs can use request fallbacks or configured bases, publisher metadata is supported, and serialized JSON-LD is escaped before script embedding. ChangesBlog JSON-LD generation
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Request
participant BlogLoader
participant JSONLDUtilities
participant Seo
Request->>BlogLoader: provide request URL
BlogLoader->>JSONLDUtilities: map posts and build breadcrumbs
JSONLDUtilities-->>BlogLoader: return JSON-LD nodes
BlogLoader->>Seo: pass jsonLDs
Seo-->>Request: embed escaped JSON-LD scripts
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
All reported issues were addressed across 3 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…Url in SEO JSON-LD
- Drop empty AggregateRating/InteractionCounter stubs: only emit
aggregateRating when ratingValue + ratingCount/reviewCount are set and
interactionStatistic when userInteractionCount is set, since bare
{"@type": ...} objects fail the Rich Results Test.
- Emit publisher (Organization with optional logo/url) on BlogPosting and
Blog nodes, configured app-wide via the new publisher state in mod.ts.
- Add optional dateModified to BlogPost and emit it when present.
- Add canonicalBaseUrl app state that overrides the origin of
url/mainEntityOfPage, otherwise built from the request host.
- Emit a BreadcrumbList on post and listing pages, derived from the
canonical pathname: intermediate items resolve real category names
(falling back to humanized slugs) with absolute item URLs; the last
item carries the real page name and omits item.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Google's Article guidelines accept Person or Organization as author. Add an optional type field to Author (defaults to Person) and emit it as the author @type; jobTitle/worksFor are Person-only properties and are skipped for organizations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…JSON-LD serialization - Apply canonicalBaseUrl to the canonical link as well, so <link rel=canonical> and the JSON-LD url/mainEntityOfPage always point at the same host. - Escape "<" (plus U+2028/U+2029) when serializing jsonLDs in the Seo component: values containing "</script>" could break out of the ld+json script tag via dangerouslySetInnerHTML. The unicode escapes parse back to the same JSON. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@blog/utils/jsonLD.ts`:
- Around line 27-34: Update withCanonicalBase to parse url against a dummy base
so relative paths do not throw, then replace only the origin using
canonicalBaseUrl while preserving the full path, query parameters, and hash.
Keep the existing behavior of returning url when canonicalBaseUrl is absent.
- Around line 89-122: Update toBreadcrumbList so relative url values are parsed
with a valid fallback base instead of allowing new URL(url) to throw. Ensure
generated non-final item links use the resolved absolute origin, while
preserving existing pathname, category-name, and final-item behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0f170949-d3ba-4168-84b1-3c1d4a300e2f
📒 Files selected for processing (6)
blog/mod.tsblog/sections/Seo/SeoBlogPost.tsxblog/sections/Seo/SeoBlogPostListing.tsxblog/types.tsblog/utils/jsonLD.tswebsite/components/Seo.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- blog/sections/Seo/SeoBlogPostListing.tsx
…lative canonical URLs - Only emit timeRequired for finite positive readTime values; negative or Infinity produced invalid ISO 8601 durations (PT-5M, PTInfinityM). - Resolve configured canonicals against the request URL in both SEO sections: a relative canonical previously reached new URL() in toBreadcrumbList and threw, breaking the page render. It also makes the emitted canonical link absolute, as Google requires. - withCanonicalBase now resolves relative URLs against the canonical base and preserves query/hash instead of dropping them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
blog/sections/Seo/SeoBlogPost.tsxandblog/sections/Seo/SeoBlogPostListing.tsxwere serializing the raw page object straight into the<script type="application/ld+json">tag:{"@type": "BlogPostPage", post: {...}, seo: {...}}—BlogPostPageis not a schema.org type and none of the nested fields (title,excerpt,slug, ...) are recognized properties, so Google ignored the markup entirely.@typeat all, plus a{ ...jsonLD?.posts }spread of an array into an object, producing garbage like{"0": {...}, "1": {...}}.Fix
Following Google's Article structured data guidelines:
blog/utils/jsonLD.tswith atoBlogPostinghelper mapping aBlogPostto aBlogPostingnode:headline,description,image,datePublished,dateModified,author(asPersonwithjobTitle/worksForwhen available),publisher,articleSection,timeRequired(ISO 8601 duration),url/mainEntityOfPage, andaggregateRating/interactionStatisticwhen present. Postcontent(raw HTML) is excluded by construction, so the old strip-content workaround is gone.BlogPostingplus aBreadcrumbList, using the canonical URL (falling back to the request URL) asmainEntityOfPage.Blognode withname,description,url,publisherand ablogPostarray ofBlogPostingitems, plus aBreadcrumbList.The top-level
"@context": "https://schema.org"is added bywebsite/components/Seo.tsxat serialization time, as before.Validity guards
AggregateRatingrequiresratingValue+ratingCount/reviewCount, andInteractionCounterrequiresuserInteractionCount. Posts carrying empty{"@type": ...}stubs would fail the Rich Results Test, so these keys are only emitted when the required data is present.New app/type fields
BlogPost.dateModified(optional,@format date) → emitted asdateModifiedto signal content freshness.publisher({ name, logo?, url? }) → emitted as anOrganizationnode onBlogPosting(post page) andBlog(listing).canonicalBaseUrl→ overrides the origin ofurl/mainEntityOfPage, which are otherwise built from the request host (useful behind proxies/multiple domains).BreadcrumbList
Derived from the canonical pathname. Intermediate segments resolve real category names when known (falling back to humanized slugs) and always carry absolute
itemURLs; the last item uses the real page name (post title / category name) and omitsitem, as allowed by Google:{ "@type": "BreadcrumbList", "itemListElement": [ { "@type": "ListItem", "position": 1, "name": "Inspira", "item": "https://loja.example.com.br/inspira" }, { "@type": "ListItem", "position": 2, "name": "Blog", "item": "https://loja.example.com.br/inspira/blog" }, { "@type": "ListItem", "position": 3, "name": "Dicas", "item": "https://loja.example.com.br/inspira/blog/dicas" }, { "@type": "ListItem", "position": 4, "name": "Mitos e verdades sobre o uso do micro-ondas no dia a dia" } ] }Sample
BlogPostingoutput for a post page:{ "@context": "https://schema.org", "@type": "BlogPosting", "headline": "Mitos e verdades sobre o uso do micro-ondas no dia a dia", "description": "Resumo", "image": ["https://cdn.site.com/img.png"], "datePublished": "2026-06-01", "dateModified": "2026-07-10", "author": [{ "@type": "Person", "name": "Ana" }], "publisher": { "@type": "Organization", "name": "Acme", "url": "https://loja.example.com.br", "logo": { "@type": "ImageObject", "url": "https://cdn.site.com/logo.png" } }, "articleSection": ["Dicas"], "url": "https://loja.example.com.br/inspira/blog/dicas/mitos-e-verdades", "mainEntityOfPage": { "@type": "WebPage", "@id": "https://loja.example.com.br/inspira/blog/dicas/mitos-e-verdades" } }🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes