diff --git a/apps/sim/app/(landing)/blog/page.tsx b/apps/sim/app/(landing)/blog/page.tsx index 80ce791204b..e8a5aadc666 100644 --- a/apps/sim/app/(landing)/blog/page.tsx +++ b/apps/sim/app/(landing)/blog/page.tsx @@ -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 } + )} /> ) } diff --git a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx index 3ccc43d148f..52f29a453c3 100644 --- a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx +++ b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx @@ -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)}` : ''}` diff --git a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx index 2e8aa3c84b5..e7ab328e016 100644 --- a/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx +++ b/apps/sim/app/(landing)/components/content-post-page/content-post-page.tsx @@ -34,7 +34,7 @@ export function ContentPostPage({ const Article = post.Content return ( -
+
@@ -66,7 +66,10 @@ export function ContentPostPage({ > {post.title} -

+

{post.description}

diff --git a/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx b/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx index 1ef9c5a1a6f..f3b93ca01fe 100644 --- a/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx +++ b/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx @@ -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'], }, }, diff --git a/apps/sim/app/(landing)/library/page.tsx b/apps/sim/app/(landing)/library/page.tsx index 7102cba4472..b0b65c1ad90 100644 --- a/apps/sim/app/(landing)/library/page.tsx +++ b/apps/sim/app/(landing)/library/page.tsx @@ -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 } + )} /> ) } diff --git a/apps/sim/content/blog/series-a/index.mdx b/apps/sim/content/blog/series-a/index.mdx index 069ca4daa1d..4d75ed6408e 100644 --- a/apps/sim/content/blog/series-a/index.mdx +++ b/apps/sim/content/blog/series-a/index.mdx @@ -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) diff --git a/apps/sim/lib/blog/seo.ts b/apps/sim/lib/blog/seo.ts index 3ed28831504..705f779509c 100644 --- a/apps/sim/lib/blog/seo.ts +++ b/apps/sim/lib/blog/seo.ts @@ -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 }) { diff --git a/apps/sim/lib/content/index-list.ts b/apps/sim/lib/content/index-list.ts new file mode 100644 index 00000000000..3020ef70dd7 --- /dev/null +++ b/apps/sim/lib/content/index-list.ts @@ -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] +} diff --git a/apps/sim/lib/content/registry-factory.ts b/apps/sim/lib/content/registry-factory.ts index 1cf29ae13c1..48038beeeef 100644 --- a/apps/sim/lib/content/registry-factory.ts +++ b/apps/sim/lib/content/registry-factory.ts @@ -161,6 +161,7 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg wordCount, draft: fm.draft, featured: fm.featured ?? false, + technical: fm.technical, } }) ) diff --git a/apps/sim/lib/content/schema.ts b/apps/sim/lib/content/schema.ts index 7c094d8b77c..7768263b935 100644 --- a/apps/sim/lib/content/schema.ts +++ b/apps/sim/lib/content/schema.ts @@ -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 { diff --git a/apps/sim/lib/content/seo.ts b/apps/sim/lib/content/seo.ts index c2c9622d049..df224f91963 100644 --- a/apps/sim/lib/content/seo.ts +++ b/apps/sim/lib/content/seo.ts @@ -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 } : {}), + }, + }, + })), + }, } } diff --git a/apps/sim/lib/library/seo.ts b/apps/sim/lib/library/seo.ts index 4932bc62441..adb29aac9f5 100644 --- a/apps/sim/lib/library/seo.ts +++ b/apps/sim/lib/library/seo.ts @@ -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 }) {