From fbd8e919c6b175458658b244ee8d1eef58b6e177 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 11:19:54 -0700 Subject: [PATCH 1/3] fix(landing): complete Organization schema, add CollectionPage/BlogPosting JSON-LD, fix TechArticle rich-result eligibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/sim/app/(landing)/blog/page.tsx | 2 +- .../content-post-page/content-post-page.tsx | 11 ++++- .../site-structured-data.tsx | 2 + apps/sim/app/(landing)/library/page.tsx | 2 +- apps/sim/content/blog/series-a/index.mdx | 1 + apps/sim/lib/blog/seo.ts | 4 +- apps/sim/lib/content/registry-factory.ts | 1 + apps/sim/lib/content/schema.ts | 8 +++ apps/sim/lib/content/seo.ts | 49 +++++++++++++++++-- apps/sim/lib/library/seo.ts | 4 +- 10 files changed, 72 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/(landing)/blog/page.tsx b/apps/sim/app/(landing)/blog/page.tsx index 80ce791204b..5b35e66b229 100644 --- a/apps/sim/app/(landing)/blog/page.tsx +++ b/apps/sim/app/(landing)/blog/page.tsx @@ -35,7 +35,7 @@ export default async function BlogIndex({ posts={posts} page={pageNum} tag={tag} - collectionJsonLd={buildCollectionPageJsonLd()} + collectionJsonLd={buildCollectionPageJsonLd(posts)} /> ) } 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..e292137a36f 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,11 @@ export function ContentPostPage({ const Article = post.Content return ( -
+
@@ -66,7 +70,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..b4599a72970 100644 --- a/apps/sim/app/(landing)/library/page.tsx +++ b/apps/sim/app/(landing)/library/page.tsx @@ -35,7 +35,7 @@ export default async function LibraryIndex({ posts={posts} page={pageNum} tag={tag} - collectionJsonLd={buildCollectionPageJsonLd()} + collectionJsonLd={buildCollectionPageJsonLd(posts)} /> ) } 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..215f601cb76 100644 --- a/apps/sim/lib/blog/seo.ts +++ b/apps/sim/lib/blog/seo.ts @@ -25,8 +25,8 @@ export function buildPostGraphJsonLd(post: ContentMeta) { return buildPostGraphJsonLdGeneric(post, BLOG_SECTION) } -export function buildCollectionPageJsonLd() { - return buildCollectionPageJsonLdGeneric(BLOG_SECTION) +export function buildCollectionPageJsonLd(posts: ContentMeta[]) { + return buildCollectionPageJsonLdGeneric(BLOG_SECTION, posts) } export function buildIndexMetadata(input: { tag?: string; pageNum: number }) { 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..b2afa6bd4a4 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,7 +349,17 @@ export function buildAuthorGraphJsonLd(section: ContentSection, author: Author) } } -export function buildCollectionPageJsonLd(section: ContentSection) { +/** + * `mainEntity` lists the section's real, currently-published posts (sourced + * from the same `getAllPostMeta()` list the index page renders from — never + * a hardcoded list) so the collection's JSON-LD stays in sync with what's + * actually on the page. + */ +export function buildCollectionPageJsonLd(section: ContentSection, posts: ContentMeta[]) { + const dateSorted = [...posts].sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() + ) + return { '@context': 'https://schema.org', '@type': 'CollectionPage', @@ -362,5 +381,27 @@ export function buildCollectionPageJsonLd(section: ContentSection) { name: 'Sim', url: SITE_URL, }, + mainEntity: { + '@type': 'ItemList', + itemListElement: dateSorted.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..8fd07d21cb9 100644 --- a/apps/sim/lib/library/seo.ts +++ b/apps/sim/lib/library/seo.ts @@ -26,8 +26,8 @@ export function buildPostGraphJsonLd(post: ContentMeta) { return buildPostGraphJsonLdGeneric(post, LIBRARY_SECTION) } -export function buildCollectionPageJsonLd() { - return buildCollectionPageJsonLdGeneric(LIBRARY_SECTION) +export function buildCollectionPageJsonLd(posts: ContentMeta[]) { + return buildCollectionPageJsonLdGeneric(LIBRARY_SECTION, posts) } export function buildIndexMetadata(input: { tag?: string; pageNum: number }) { From 5a70bea2acfc8ed3ce99bac01f920b0d7de18a98 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 11:39:24 -0700 Subject: [PATCH 2/3] 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) --- apps/sim/app/(landing)/blog/page.tsx | 5 +- .../content-index-page/content-index-page.tsx | 24 +------- .../content-post-page/content-post-page.tsx | 6 +- apps/sim/app/(landing)/library/page.tsx | 5 +- apps/sim/lib/content/index-list.ts | 57 +++++++++++++++++++ 5 files changed, 68 insertions(+), 29 deletions(-) create mode 100644 apps/sim/lib/content/index-list.ts diff --git a/apps/sim/app/(landing)/blog/page.tsx b/apps/sim/app/(landing)/blog/page.tsx index 5b35e66b229..ad2d73e8b9b 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,9 @@ export default async function BlogIndex({ posts={posts} page={pageNum} tag={tag} - collectionJsonLd={buildCollectionPageJsonLd(posts)} + collectionJsonLd={buildCollectionPageJsonLd( + selectVisiblePosts(posts, { 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 e292137a36f..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,11 +34,7 @@ export function ContentPostPage({ const Article = post.Content return ( -
+
diff --git a/apps/sim/app/(landing)/library/page.tsx b/apps/sim/app/(landing)/library/page.tsx index b4599a72970..d37a37beeee 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,9 @@ export default async function LibraryIndex({ posts={posts} page={pageNum} tag={tag} - collectionJsonLd={buildCollectionPageJsonLd(posts)} + collectionJsonLd={buildCollectionPageJsonLd( + selectVisiblePosts(posts, { tag, page: pageNum }) + )} /> ) } 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] +} From 4182dd582d3c6df77d79e94991c9e841d478dafa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 11:51:56 -0700 Subject: [PATCH 3/3] 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 --- apps/sim/app/(landing)/blog/page.tsx | 3 ++- apps/sim/app/(landing)/library/page.tsx | 3 ++- apps/sim/lib/blog/seo.ts | 7 +++-- apps/sim/lib/content/seo.ts | 35 ++++++++++++++++++------- apps/sim/lib/library/seo.ts | 7 +++-- 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/(landing)/blog/page.tsx b/apps/sim/app/(landing)/blog/page.tsx index ad2d73e8b9b..e8a5aadc666 100644 --- a/apps/sim/app/(landing)/blog/page.tsx +++ b/apps/sim/app/(landing)/blog/page.tsx @@ -37,7 +37,8 @@ export default async function BlogIndex({ page={pageNum} tag={tag} collectionJsonLd={buildCollectionPageJsonLd( - selectVisiblePosts(posts, { tag, page: pageNum }) + selectVisiblePosts(posts, { tag, page: pageNum }), + { tag, page: pageNum } )} /> ) diff --git a/apps/sim/app/(landing)/library/page.tsx b/apps/sim/app/(landing)/library/page.tsx index d37a37beeee..b0b65c1ad90 100644 --- a/apps/sim/app/(landing)/library/page.tsx +++ b/apps/sim/app/(landing)/library/page.tsx @@ -37,7 +37,8 @@ export default async function LibraryIndex({ page={pageNum} tag={tag} collectionJsonLd={buildCollectionPageJsonLd( - selectVisiblePosts(posts, { tag, page: pageNum }) + selectVisiblePosts(posts, { tag, page: pageNum }), + { tag, page: pageNum } )} /> ) diff --git a/apps/sim/lib/blog/seo.ts b/apps/sim/lib/blog/seo.ts index 215f601cb76..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(posts: ContentMeta[]) { - return buildCollectionPageJsonLdGeneric(BLOG_SECTION, posts) +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/seo.ts b/apps/sim/lib/content/seo.ts index b2afa6bd4a4..df224f91963 100644 --- a/apps/sim/lib/content/seo.ts +++ b/apps/sim/lib/content/seo.ts @@ -350,21 +350,36 @@ export function buildAuthorGraphJsonLd(section: ContentSection, author: Author) } /** - * `mainEntity` lists the section's real, currently-published posts (sourced - * from the same `getAllPostMeta()` list the index page renders from — never - * a hardcoded list) so the collection's JSON-LD stays in sync with what's - * actually on the page. + * `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[]) { - const dateSorted = [...posts].sort( - (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() - ) +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', @@ -383,7 +398,7 @@ export function buildCollectionPageJsonLd(section: ContentSection, posts: Conten }, mainEntity: { '@type': 'ItemList', - itemListElement: dateSorted.map((post, index) => ({ + itemListElement: posts.map((post, index) => ({ '@type': 'ListItem', position: index + 1, url: post.canonical, diff --git a/apps/sim/lib/library/seo.ts b/apps/sim/lib/library/seo.ts index 8fd07d21cb9..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(posts: ContentMeta[]) { - return buildCollectionPageJsonLdGeneric(LIBRARY_SECTION, posts) +export function buildCollectionPageJsonLd( + posts: ContentMeta[], + filter?: { tag?: string; page?: number } +) { + return buildCollectionPageJsonLdGeneric(LIBRARY_SECTION, posts, filter) } export function buildIndexMetadata(input: { tag?: string; pageNum: number }) {