mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
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:
@@ -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'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ timeRequired: PT4M
|
||||
canonical: https://www.sim.ai/blog/series-a
|
||||
featured: true
|
||||
draft: false
|
||||
technical: false
|
||||
---
|
||||
|
||||

|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -161,6 +161,7 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg
|
||||
wordCount,
|
||||
draft: fm.draft,
|
||||
featured: fm.featured ?? false,
|
||||
technical: fm.technical,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user