Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/sim/app/(landing)/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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 }
)}
/>
)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)}` : ''}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'>
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -31,6 +32,7 @@ const SITE_JSON_LD = {
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer support',
url: `${SITE_URL}/contact`,
availableLanguage: ['en'],
},
},
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/app/(landing)/library/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 change: 1 addition & 0 deletions apps/sim/content/blog/series-a/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions apps/sim/lib/blog/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
57 changes: 57 additions & 0 deletions apps/sim/lib/content/index-list.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions apps/sim/lib/content/registry-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg
wordCount,
draft: fm.draft,
featured: fm.featured ?? false,
technical: fm.technical,
}
})
)
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/content/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -70,6 +77,7 @@ export interface ContentMeta {
canonical: string
draft: boolean
featured: boolean
technical: boolean
}

export interface ContentPost extends ContentMeta {
Expand Down
66 changes: 61 additions & 5 deletions apps/sim/lib/content/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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}` : ''}`

Comment thread
waleedlatif1 marked this conversation as resolved.
return {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `Sim ${section.name}`,
url: `${SITE_URL}${section.basePath}`,
url,
description: section.description,
publisher: {
'@type': 'Organization',
Expand All @@ -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 } : {}),
},
},
})),
},
}
}
7 changes: 5 additions & 2 deletions apps/sim/lib/library/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
Loading