fix(landing): complete Organization schema, add CollectionPage/BlogPosting JSON-LD, fix TechArticle rich-result eligibility (#5638)

* fix(landing): complete Organization schema, add CollectionPage/BlogPosting JSON-LD, fix TechArticle rich-result eligibility

- Organization schema (site-structured-data.tsx): add brand and
  contactPoint.url (verified against the real /contact route); all other
  fields were already correct and verified against the Footer's sameAs
  links. foundingDate/legalName/address omitted — not verifiable from
  anything in this repo.
- CollectionPage (blog + library index): buildCollectionPageJsonLd now
  takes the real posts list (same getAllPostMeta() the index page already
  renders from) and emits a mainEntity ItemList of BlogPosting stubs
  instead of omitting mainEntity entirely.
- BlogPosting + TechArticle (post detail template): Google's Article
  rich-result eligibility only recognizes Article/NewsArticle/BlogPosting
  — a bare TechArticle type isn't in that allowlist. buildArticleJsonLd
  now emits a multi-type @type array (["BlogPosting","TechArticle"]) for
  genuinely technical posts, and "BlogPosting" alone for posts that are
  general announcements, via a new `technical` frontmatter flag (defaults
  true; set to false on the series-a funding-announcement post, the one
  post with no technical content). Also fixed a real markup/schema
  mismatch: the article's `speakable.cssSelector` referenced
  `[itemprop="description"]`, but no element carried that itemProp —
  added it to the description paragraph. TechArticle/BlogPosting image
  URLs are now made absolute (previously relative paths, invalid for
  crawlers).
- FAQPage: audited every LandingFAQ usage (/models, /models/[provider],
  /models/[provider]/[model], /integrations, /integrations/[slug],
  /comparison, /comparison/[provider]) — all already emit matching
  FAQPage JSON-LD sourced from the same data passed to LandingFAQ; no
  gaps found, no changes needed.

* fix(landing): match microdata itemType and scope collection JSON-LD to visible posts

Address Greptile/Cursor review on PR #5638:
- itemType now always resolves to BlogPosting (matching Greptile's exact
  suggested fix), since the JSON-LD graph already carries the richer
  TechArticle type via a multi-type array and the microdata path doesn't
  need to duplicate that distinction
- buildCollectionPageJsonLd now receives the same tag-filtered/paginated
  post subset ContentIndexPage actually renders, instead of the full
  unfiltered catalog, via a new shared selectVisiblePosts/paginateContentPosts
  helper in lib/content/index-list.ts (also de-duplicates the pagination
  logic that previously lived only in ContentIndexPage)

* fix(landing): match CollectionPage ItemList order and url to the visible filtered/paginated variant

Address round-2 Cursor Bugbot findings on PR #5638:
- buildCollectionPageJsonLd no longer re-sorts the given posts by date -
  ordering is now solely owned by the caller (selectVisiblePosts), so
  featured-row-first render order matches ItemList position order
- buildCollectionPageJsonLd now takes an optional {tag, page} filter
  descriptor and reflects it in the emitted url, instead of always
  pointing at the bare section index regardless of which filtered/
  paginated variant's posts are actually listed in mainEntity
This commit is contained in:
Waleed
2026-07-13 11:55:39 -07:00
committed by GitHub
parent 0640c0bbac
commit 836ceda3a7
12 changed files with 157 additions and 35 deletions
+5 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { getAllPostMeta } from '@/lib/blog/registry'
import { BLOG_SECTION, buildCollectionPageJsonLd, buildIndexMetadata } from '@/lib/blog/seo'
import { selectVisiblePosts } from '@/lib/content/index-list'
import { ContentIndexPage } from '@/app/(landing)/components'
/**
@@ -35,7 +36,10 @@ export default async function BlogIndex({
posts={posts}
page={pageNum}
tag={tag}
collectionJsonLd={buildCollectionPageJsonLd()}
collectionJsonLd={buildCollectionPageJsonLd(
selectVisiblePosts(posts, { tag, page: pageNum }),
{ tag, page: pageNum }
)}
/>
)
}
@@ -1,13 +1,11 @@
import { ChipLink } from '@sim/emcn'
import Image from 'next/image'
import Link from 'next/link'
import { paginateContentPosts } from '@/lib/content/index-list'
import type { ContentMeta } from '@/lib/content/schema'
import { Cta } from '@/app/(landing)/components/cta/cta'
import { JsonLd } from '@/app/(landing)/components/json-ld'
const POSTS_PER_PAGE = 20
const FEATURED_COUNT = 3
interface ContentIndexPageProps {
/** Route base path, e.g. `/blog` or `/library`. */
basePath: string
@@ -36,25 +34,7 @@ export function ContentIndexPage({
tag,
collectionJsonLd,
}: ContentIndexPageProps) {
const filtered = tag ? posts.filter((p) => p.tags.includes(tag)) : posts
const dateSorted = [...filtered].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
// Featured posts render once, in the page-1 featured row, regardless of
// where their date would otherwise place them — so they're carved out of
// the paginated pool up front rather than re-sorted to the front of page 1
// (which left them still reachable, and duplicated, on a later page).
const explicitlyFeatured = dateSorted.filter((p) => p.featured).slice(0, FEATURED_COUNT)
const featuredPosts =
explicitlyFeatured.length > 0 ? explicitlyFeatured : dateSorted.slice(0, FEATURED_COUNT)
const featuredSlugs = new Set(featuredPosts.map((p) => p.slug))
const paginated = dateSorted.filter((p) => !featuredSlugs.has(p.slug))
const totalPages = Math.max(1, Math.ceil(paginated.length / POSTS_PER_PAGE))
const start = (page - 1) * POSTS_PER_PAGE
const featured = page === 1 ? featuredPosts : []
const remaining = paginated.slice(start, start + POSTS_PER_PAGE)
const { featured, remaining, totalPages } = paginateContentPosts(posts, { tag, page })
const pageHref = (targetPage: number) =>
`${basePath}?page=${targetPage}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`
@@ -34,7 +34,7 @@ export function ContentPostPage({
const Article = post.Content
return (
<article className='w-full bg-[var(--bg)]' itemScope itemType='https://schema.org/TechArticle'>
<article className='w-full bg-[var(--bg)]' itemScope itemType='https://schema.org/BlogPosting'>
<JsonLd data={graphJsonLd} />
<header className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<div className='mb-6'>
@@ -66,7 +66,10 @@ export function ContentPostPage({
>
{post.title}
</h1>
<p className='mt-4 text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'>
<p
className='mt-4 text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'
itemProp='description'
>
{post.description}
</p>
</div>
@@ -22,6 +22,7 @@ const SITE_JSON_LD = {
caption: 'Sim Logo',
},
image: { '@id': `${SITE_URL}#logo` },
brand: { '@type': 'Brand', name: 'Sim' },
sameAs: [
'https://x.com/simdotai',
'https://github.com/simstudioai/sim',
@@ -31,6 +32,7 @@ const SITE_JSON_LD = {
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer support',
url: `${SITE_URL}/contact`,
availableLanguage: ['en'],
},
},
+5 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import { selectVisiblePosts } from '@/lib/content/index-list'
import { getAllPostMeta } from '@/lib/library/registry'
import { buildCollectionPageJsonLd, buildIndexMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
import { ContentIndexPage } from '@/app/(landing)/components'
@@ -35,7 +36,10 @@ export default async function LibraryIndex({
posts={posts}
page={pageNum}
tag={tag}
collectionJsonLd={buildCollectionPageJsonLd()}
collectionJsonLd={buildCollectionPageJsonLd(
selectVisiblePosts(posts, { tag, page: pageNum }),
{ tag, page: pageNum }
)}
/>
)
}
+1
View File
@@ -16,6 +16,7 @@ timeRequired: PT4M
canonical: https://www.sim.ai/blog/series-a
featured: true
draft: false
technical: false
---
![Sim team photo](/blog/series-a/team.jpg)
+5 -2
View File
@@ -25,8 +25,11 @@ export function buildPostGraphJsonLd(post: ContentMeta) {
return buildPostGraphJsonLdGeneric(post, BLOG_SECTION)
}
export function buildCollectionPageJsonLd() {
return buildCollectionPageJsonLdGeneric(BLOG_SECTION)
export function buildCollectionPageJsonLd(
posts: ContentMeta[],
filter?: { tag?: string; page?: number }
) {
return buildCollectionPageJsonLdGeneric(BLOG_SECTION, posts, filter)
}
export function buildIndexMetadata(input: { tag?: string; pageNum: number }) {
+57
View File
@@ -0,0 +1,57 @@
import type { ContentMeta } from '@/lib/content/schema'
export const POSTS_PER_PAGE = 20
export const FEATURED_COUNT = 3
interface PaginatedContentPosts {
featured: ContentMeta[]
remaining: ContentMeta[]
totalPages: number
}
/**
* Reproduces `ContentIndexPage`'s render logic for a given `tag`/`page`
* combination: tag-filtered, date-sorted, with up to `FEATURED_COUNT`
* featured posts (explicit `post.featured` first, falling back to the most
* recent) carved out of the paginated pool and returned only on page 1.
* Shared by `ContentIndexPage` itself and `selectVisiblePosts` below, so
* every consumer stays in lockstep with what's actually rendered.
*/
export function paginateContentPosts(
posts: ContentMeta[],
{ tag, page }: { tag?: string; page: number }
): PaginatedContentPosts {
const filtered = tag ? posts.filter((p) => p.tags.includes(tag)) : posts
const dateSorted = [...filtered].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
const explicitlyFeatured = dateSorted.filter((p) => p.featured).slice(0, FEATURED_COUNT)
const featuredPosts =
explicitlyFeatured.length > 0 ? explicitlyFeatured : dateSorted.slice(0, FEATURED_COUNT)
const featuredSlugs = new Set(featuredPosts.map((p) => p.slug))
const paginated = dateSorted.filter((p) => !featuredSlugs.has(p.slug))
const totalPages = Math.max(1, Math.ceil(paginated.length / POSTS_PER_PAGE))
const start = (page - 1) * POSTS_PER_PAGE
return {
featured: page === 1 ? featuredPosts : [],
remaining: paginated.slice(start, start + POSTS_PER_PAGE),
totalPages,
}
}
/**
* Flat, render-order post list (featured then remaining) for a given
* `tag`/`page` combination - used to keep `buildCollectionPageJsonLd`'s
* `mainEntity` ItemList in sync with the posts actually visible on a
* filtered or paginated index URL, instead of the full unfiltered catalog.
*/
export function selectVisiblePosts(
posts: ContentMeta[],
options: { tag?: string; page: number }
): ContentMeta[] {
const { featured, remaining } = paginateContentPosts(posts, options)
return [...featured, ...remaining]
}
+1
View File
@@ -161,6 +161,7 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg
wordCount,
draft: fm.draft,
featured: fm.featured ?? false,
technical: fm.technical,
}
})
)
+8
View File
@@ -42,6 +42,13 @@ export const ContentFrontmatterSchema = z
canonical: z.string().url(),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
/**
* Whether this post covers technical/developer content (architecture,
* implementation, how-tos). Drives whether `TechArticle` is included
* alongside `BlogPosting` in the post's JSON-LD `@type` — general
* announcements (funding, company news) should set this to `false`.
*/
technical: z.boolean().default(true),
})
.strict()
@@ -70,6 +77,7 @@ export interface ContentMeta {
canonical: string
draft: boolean
featured: boolean
technical: boolean
}
export interface ContentPost extends ContentMeta {
+61 -5
View File
@@ -73,16 +73,25 @@ export function buildPostMetadata(post: ContentMeta): Metadata {
}
}
/**
* Google's Article rich-result eligibility only recognizes `Article`,
* `NewsArticle`, and `BlogPosting` — a bare `TechArticle` type is not in that
* allowlist, so it silently loses rich-result eligibility. `BlogPosting` is
* therefore always included; `TechArticle` is layered on via a multi-type
* `@type` array (the standard schema.org way to say "this is both") only for
* posts that are genuinely technical/developer content (`post.technical`) —
* general announcements (funding, company news) get `BlogPosting` alone.
*/
export function buildArticleJsonLd(post: ContentMeta) {
return {
'@type': 'TechArticle',
'@type': post.technical ? ['BlogPosting', 'TechArticle'] : 'BlogPosting',
url: post.canonical,
headline: post.title,
description: post.description,
image: [
{
'@type': 'ImageObject',
url: post.ogImage,
url: post.ogImage.startsWith('http') ? post.ogImage : `${SITE_URL}${post.ogImage}`,
width: post.ogImageWidth ?? 1200,
height: post.ogImageHeight ?? 630,
caption: post.ogAlt || post.title,
@@ -91,7 +100,7 @@ export function buildArticleJsonLd(post: ContentMeta) {
datePublished: post.date,
dateModified: post.updated ?? post.date,
wordCount: post.wordCount,
proficiencyLevel: 'Beginner',
...(post.technical ? { proficiencyLevel: 'Beginner' } : {}),
author: (post.authors && post.authors.length > 0 ? post.authors : [post.author]).map((a) => ({
'@type': 'Person',
name: a.name,
@@ -340,12 +349,37 @@ export function buildAuthorGraphJsonLd(section: ContentSection, author: Author)
}
}
export function buildCollectionPageJsonLd(section: ContentSection) {
/**
* `mainEntity` lists exactly the posts passed in, in the given order - the
* caller (currently `selectVisiblePosts`) is responsible for sourcing them
* from the same `getAllPostMeta()` list the index page renders from and for
* ordering them to match the visible layout (e.g. featured-row-first), so
* this function never re-sorts and can't diverge from what's on the page.
*
* `tag`/`page` describe which filtered/paginated variant `posts` came from,
* so `url` reflects the actual page these `posts` are visible on rather than
* always the bare section index - the same variant is `noindex`ed (see
* `buildIndexMetadata`), but the graph still shouldn't attribute a partial
* list to the unfiltered collection URL.
*/
export function buildCollectionPageJsonLd(
section: ContentSection,
posts: ContentMeta[],
{ tag, page }: { tag?: string; page?: number } = {}
) {
const params = [
page && page > 1 ? `page=${page}` : null,
tag ? `tag=${encodeURIComponent(tag)}` : null,
]
.filter(Boolean)
.join('&')
const url = `${SITE_URL}${section.basePath}${params ? `?${params}` : ''}`
return {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `Sim ${section.name}`,
url: `${SITE_URL}${section.basePath}`,
url,
description: section.description,
publisher: {
'@type': 'Organization',
@@ -362,5 +396,27 @@ export function buildCollectionPageJsonLd(section: ContentSection) {
name: 'Sim',
url: SITE_URL,
},
mainEntity: {
'@type': 'ItemList',
itemListElement: posts.map((post, index) => ({
'@type': 'ListItem',
position: index + 1,
url: post.canonical,
item: {
'@type': 'BlogPosting',
headline: post.title,
description: post.description,
url: post.canonical,
datePublished: post.date,
dateModified: post.updated ?? post.date,
image: post.ogImage.startsWith('http') ? post.ogImage : `${SITE_URL}${post.ogImage}`,
author: {
'@type': 'Person',
name: post.author.name,
...(post.author.url ? { url: post.author.url } : {}),
},
},
})),
},
}
}
+5 -2
View File
@@ -26,8 +26,11 @@ export function buildPostGraphJsonLd(post: ContentMeta) {
return buildPostGraphJsonLdGeneric(post, LIBRARY_SECTION)
}
export function buildCollectionPageJsonLd() {
return buildCollectionPageJsonLdGeneric(LIBRARY_SECTION)
export function buildCollectionPageJsonLd(
posts: ContentMeta[],
filter?: { tag?: string; page?: number }
) {
return buildCollectionPageJsonLdGeneric(LIBRARY_SECTION, posts, filter)
}
export function buildIndexMetadata(input: { tag?: string; pageNum: number }) {