Skip to content

Commit 836ceda

Browse files
authored
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
1 parent 0640c0b commit 836ceda

12 files changed

Lines changed: 157 additions & 35 deletions

File tree

apps/sim/app/(landing)/blog/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from 'next'
22
import { getAllPostMeta } from '@/lib/blog/registry'
33
import { BLOG_SECTION, buildCollectionPageJsonLd, buildIndexMetadata } from '@/lib/blog/seo'
4+
import { selectVisiblePosts } from '@/lib/content/index-list'
45
import { ContentIndexPage } from '@/app/(landing)/components'
56

67
/**
@@ -35,7 +36,10 @@ export default async function BlogIndex({
3536
posts={posts}
3637
page={pageNum}
3738
tag={tag}
38-
collectionJsonLd={buildCollectionPageJsonLd()}
39+
collectionJsonLd={buildCollectionPageJsonLd(
40+
selectVisiblePosts(posts, { tag, page: pageNum }),
41+
{ tag, page: pageNum }
42+
)}
3943
/>
4044
)
4145
}

apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { ChipLink } from '@sim/emcn'
22
import Image from 'next/image'
33
import Link from 'next/link'
4+
import { paginateContentPosts } from '@/lib/content/index-list'
45
import type { ContentMeta } from '@/lib/content/schema'
56
import { Cta } from '@/app/(landing)/components/cta/cta'
67
import { JsonLd } from '@/app/(landing)/components/json-ld'
78

8-
const POSTS_PER_PAGE = 20
9-
const FEATURED_COUNT = 3
10-
119
interface ContentIndexPageProps {
1210
/** Route base path, e.g. `/blog` or `/library`. */
1311
basePath: string
@@ -36,25 +34,7 @@ export function ContentIndexPage({
3634
tag,
3735
collectionJsonLd,
3836
}: ContentIndexPageProps) {
39-
const filtered = tag ? posts.filter((p) => p.tags.includes(tag)) : posts
40-
const dateSorted = [...filtered].sort(
41-
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
42-
)
43-
44-
// Featured posts render once, in the page-1 featured row, regardless of
45-
// where their date would otherwise place them — so they're carved out of
46-
// the paginated pool up front rather than re-sorted to the front of page 1
47-
// (which left them still reachable, and duplicated, on a later page).
48-
const explicitlyFeatured = dateSorted.filter((p) => p.featured).slice(0, FEATURED_COUNT)
49-
const featuredPosts =
50-
explicitlyFeatured.length > 0 ? explicitlyFeatured : dateSorted.slice(0, FEATURED_COUNT)
51-
const featuredSlugs = new Set(featuredPosts.map((p) => p.slug))
52-
const paginated = dateSorted.filter((p) => !featuredSlugs.has(p.slug))
53-
54-
const totalPages = Math.max(1, Math.ceil(paginated.length / POSTS_PER_PAGE))
55-
const start = (page - 1) * POSTS_PER_PAGE
56-
const featured = page === 1 ? featuredPosts : []
57-
const remaining = paginated.slice(start, start + POSTS_PER_PAGE)
37+
const { featured, remaining, totalPages } = paginateContentPosts(posts, { tag, page })
5838

5939
const pageHref = (targetPage: number) =>
6040
`${basePath}?page=${targetPage}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`

apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function ContentPostPage({
3434
const Article = post.Content
3535

3636
return (
37-
<article className='w-full bg-[var(--bg)]' itemScope itemType='https://schema.org/TechArticle'>
37+
<article className='w-full bg-[var(--bg)]' itemScope itemType='https://schema.org/BlogPosting'>
3838
<JsonLd data={graphJsonLd} />
3939
<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'>
4040
<div className='mb-6'>
@@ -66,7 +66,10 @@ export function ContentPostPage({
6666
>
6767
{post.title}
6868
</h1>
69-
<p className='mt-4 text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'>
69+
<p
70+
className='mt-4 text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'
71+
itemProp='description'
72+
>
7073
{post.description}
7174
</p>
7275
</div>

apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const SITE_JSON_LD = {
2222
caption: 'Sim Logo',
2323
},
2424
image: { '@id': `${SITE_URL}#logo` },
25+
brand: { '@type': 'Brand', name: 'Sim' },
2526
sameAs: [
2627
'https://x.com/simdotai',
2728
'https://github.com/simstudioai/sim',
@@ -31,6 +32,7 @@ const SITE_JSON_LD = {
3132
contactPoint: {
3233
'@type': 'ContactPoint',
3334
contactType: 'customer support',
35+
url: `${SITE_URL}/contact`,
3436
availableLanguage: ['en'],
3537
},
3638
},

apps/sim/app/(landing)/library/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Metadata } from 'next'
2+
import { selectVisiblePosts } from '@/lib/content/index-list'
23
import { getAllPostMeta } from '@/lib/library/registry'
34
import { buildCollectionPageJsonLd, buildIndexMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
45
import { ContentIndexPage } from '@/app/(landing)/components'
@@ -35,7 +36,10 @@ export default async function LibraryIndex({
3536
posts={posts}
3637
page={pageNum}
3738
tag={tag}
38-
collectionJsonLd={buildCollectionPageJsonLd()}
39+
collectionJsonLd={buildCollectionPageJsonLd(
40+
selectVisiblePosts(posts, { tag, page: pageNum }),
41+
{ tag, page: pageNum }
42+
)}
3943
/>
4044
)
4145
}

apps/sim/content/blog/series-a/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ timeRequired: PT4M
1616
canonical: https://www.sim.ai/blog/series-a
1717
featured: true
1818
draft: false
19+
technical: false
1920
---
2021

2122
![Sim team photo](/blog/series-a/team.jpg)

apps/sim/lib/blog/seo.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ export function buildPostGraphJsonLd(post: ContentMeta) {
2525
return buildPostGraphJsonLdGeneric(post, BLOG_SECTION)
2626
}
2727

28-
export function buildCollectionPageJsonLd() {
29-
return buildCollectionPageJsonLdGeneric(BLOG_SECTION)
28+
export function buildCollectionPageJsonLd(
29+
posts: ContentMeta[],
30+
filter?: { tag?: string; page?: number }
31+
) {
32+
return buildCollectionPageJsonLdGeneric(BLOG_SECTION, posts, filter)
3033
}
3134

3235
export function buildIndexMetadata(input: { tag?: string; pageNum: number }) {

apps/sim/lib/content/index-list.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { ContentMeta } from '@/lib/content/schema'
2+
3+
export const POSTS_PER_PAGE = 20
4+
export const FEATURED_COUNT = 3
5+
6+
interface PaginatedContentPosts {
7+
featured: ContentMeta[]
8+
remaining: ContentMeta[]
9+
totalPages: number
10+
}
11+
12+
/**
13+
* Reproduces `ContentIndexPage`'s render logic for a given `tag`/`page`
14+
* combination: tag-filtered, date-sorted, with up to `FEATURED_COUNT`
15+
* featured posts (explicit `post.featured` first, falling back to the most
16+
* recent) carved out of the paginated pool and returned only on page 1.
17+
* Shared by `ContentIndexPage` itself and `selectVisiblePosts` below, so
18+
* every consumer stays in lockstep with what's actually rendered.
19+
*/
20+
export function paginateContentPosts(
21+
posts: ContentMeta[],
22+
{ tag, page }: { tag?: string; page: number }
23+
): PaginatedContentPosts {
24+
const filtered = tag ? posts.filter((p) => p.tags.includes(tag)) : posts
25+
const dateSorted = [...filtered].sort(
26+
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
27+
)
28+
29+
const explicitlyFeatured = dateSorted.filter((p) => p.featured).slice(0, FEATURED_COUNT)
30+
const featuredPosts =
31+
explicitlyFeatured.length > 0 ? explicitlyFeatured : dateSorted.slice(0, FEATURED_COUNT)
32+
const featuredSlugs = new Set(featuredPosts.map((p) => p.slug))
33+
const paginated = dateSorted.filter((p) => !featuredSlugs.has(p.slug))
34+
35+
const totalPages = Math.max(1, Math.ceil(paginated.length / POSTS_PER_PAGE))
36+
const start = (page - 1) * POSTS_PER_PAGE
37+
38+
return {
39+
featured: page === 1 ? featuredPosts : [],
40+
remaining: paginated.slice(start, start + POSTS_PER_PAGE),
41+
totalPages,
42+
}
43+
}
44+
45+
/**
46+
* Flat, render-order post list (featured then remaining) for a given
47+
* `tag`/`page` combination - used to keep `buildCollectionPageJsonLd`'s
48+
* `mainEntity` ItemList in sync with the posts actually visible on a
49+
* filtered or paginated index URL, instead of the full unfiltered catalog.
50+
*/
51+
export function selectVisiblePosts(
52+
posts: ContentMeta[],
53+
options: { tag?: string; page: number }
54+
): ContentMeta[] {
55+
const { featured, remaining } = paginateContentPosts(posts, options)
56+
return [...featured, ...remaining]
57+
}

apps/sim/lib/content/registry-factory.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg
161161
wordCount,
162162
draft: fm.draft,
163163
featured: fm.featured ?? false,
164+
technical: fm.technical,
164165
}
165166
})
166167
)

apps/sim/lib/content/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ export const ContentFrontmatterSchema = z
4242
canonical: z.string().url(),
4343
draft: z.boolean().default(false),
4444
featured: z.boolean().default(false),
45+
/**
46+
* Whether this post covers technical/developer content (architecture,
47+
* implementation, how-tos). Drives whether `TechArticle` is included
48+
* alongside `BlogPosting` in the post's JSON-LD `@type` — general
49+
* announcements (funding, company news) should set this to `false`.
50+
*/
51+
technical: z.boolean().default(true),
4552
})
4653
.strict()
4754

@@ -70,6 +77,7 @@ export interface ContentMeta {
7077
canonical: string
7178
draft: boolean
7279
featured: boolean
80+
technical: boolean
7381
}
7482

7583
export interface ContentPost extends ContentMeta {

0 commit comments

Comments
 (0)