Files
simstudioai--sim/apps/sim/lib/content/seo.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

367 lines
10 KiB
TypeScript

import type { Metadata } from 'next'
import type { Author, ContentMeta } from '@/lib/content/schema'
import { SITE_URL } from '@/lib/core/utils/urls'
import { withFilteredNoindex } from '@/lib/landing/seo'
/**
* Identifies the content section a post/collection belongs to, so the
* generic SEO builders below can emit section-correct breadcrumbs and
* collection metadata without hardcoding "Blog"/"/blog" anywhere.
*/
export interface ContentSection {
/** Display name, e.g. "Blog" or "Library". */
name: string
/** Route base path, e.g. "/blog" or "/library". */
basePath: string
/** Collection-page description used in `CollectionPage` JSON-LD. */
description: string
}
export function buildPostMetadata(post: ContentMeta): Metadata {
const base = new URL(post.canonical)
const baseUrl = `${base.protocol}//${base.host}`
return {
title: post.title,
description: post.description,
keywords: post.tags,
authors: (post.authors && post.authors.length > 0 ? post.authors : [post.author]).map((a) => ({
name: a.name,
url: a.url,
})),
creator: post.author.name,
publisher: 'Sim',
robots: post.draft
? { index: false, follow: false, googleBot: { index: false, follow: false } }
: { index: true, follow: true, googleBot: { index: true, follow: true } },
alternates: { canonical: post.canonical },
openGraph: {
title: post.title,
description: post.description,
url: post.canonical,
siteName: 'Sim',
locale: 'en_US',
type: 'article',
publishedTime: post.date,
modifiedTime: post.updated ?? post.date,
authors: (post.authors && post.authors.length > 0 ? post.authors : [post.author]).map(
(a) => a.name
),
tags: post.tags,
images: [
{
url: post.ogImage.startsWith('http') ? post.ogImage : `${baseUrl}${post.ogImage}`,
width: 1200,
height: 630,
alt: post.ogAlt || post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
images: [post.ogImage],
creator: post.author.url?.includes('x.com') ? `@${post.author.xHandle || ''}` : undefined,
site: '@simdotai',
},
other: {
'article:published_time': post.date,
'article:modified_time': post.updated ?? post.date,
'article:author': post.author.name,
'article:section': 'Technology',
},
}
}
export function buildArticleJsonLd(post: ContentMeta) {
return {
'@type': 'TechArticle',
url: post.canonical,
headline: post.title,
description: post.description,
image: [
{
'@type': 'ImageObject',
url: post.ogImage,
width: 1200,
height: 630,
caption: post.ogAlt || post.title,
},
],
datePublished: post.date,
dateModified: post.updated ?? post.date,
wordCount: post.wordCount,
proficiencyLevel: 'Beginner',
author: (post.authors && post.authors.length > 0 ? post.authors : [post.author]).map((a) => ({
'@type': 'Person',
name: a.name,
url: a.url,
...(a.url ? { sameAs: [a.url] } : {}),
})),
publisher: {
'@type': 'Organization',
name: 'Sim',
url: SITE_URL,
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/logo/primary/medium.png`,
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': post.canonical,
},
keywords: post.tags.join(', '),
about: (post.about || []).map((a) => ({ '@type': 'Thing', name: a })),
isAccessibleForFree: true,
timeRequired: post.timeRequired,
articleSection: 'Technology',
inLanguage: 'en-US',
speakable: {
'@type': 'SpeakableSpecification',
cssSelector: ['[itemprop="headline"]', '[itemprop="description"]'],
},
}
}
export function buildBreadcrumbJsonLd(post: ContentMeta, section: ContentSection) {
return {
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{
'@type': 'ListItem',
position: 2,
name: section.name,
item: `${SITE_URL}${section.basePath}`,
},
{ '@type': 'ListItem', position: 3, name: post.title, item: post.canonical },
],
}
}
export function buildFaqJsonLd(items: { q: string; a: string }[] | undefined) {
if (!items || items.length === 0) return null
return {
'@type': 'FAQPage',
mainEntity: items.map((it) => ({
'@type': 'Question',
name: it.q,
acceptedAnswer: { '@type': 'Answer', text: it.a },
})),
}
}
export function buildPostGraphJsonLd(post: ContentMeta, section: ContentSection) {
const graph: Record<string, unknown>[] = [
buildArticleJsonLd(post),
buildBreadcrumbJsonLd(post, section),
]
const faq = buildFaqJsonLd(post.faq)
if (faq) {
graph.push(faq)
}
return {
'@context': 'https://schema.org',
'@graph': graph,
}
}
/**
* Filtered/paginated index variants render genuinely different lists, but
* only the bare index is indexable — same policy as the integrations and
* models catalogs — so canonical always points at the unfiltered index and
* the variant itself is noindexed rather than asking Google to index every
* tag/page permutation.
*/
export function buildIndexMetadata(
section: ContentSection,
{ tag, pageNum }: { tag?: string; pageNum: number }
): Metadata {
const titleParts = [section.name]
if (tag) titleParts.push(tag)
if (pageNum > 1) titleParts.push(`Page ${pageNum}`)
const title = titleParts.join(' | ')
const description = tag
? `Sim ${section.name.toLowerCase()} posts tagged "${tag}": ${section.description}`
: section.description
const canonical = `${SITE_URL}${section.basePath}`
const isFiltered = Boolean(tag) || pageNum > 1
return withFilteredNoindex(
{
title,
description,
alternates: { canonical },
openGraph: {
title: `${title} | Sim`,
description,
url: canonical,
siteName: 'Sim',
locale: 'en_US',
type: 'website',
images: [
{
url: `${SITE_URL}/logo/primary/medium.png`,
width: 1200,
height: 630,
alt: `Sim ${section.name}`,
},
],
},
twitter: {
card: 'summary_large_image',
title: `${title} | Sim`,
description,
site: '@simdotai',
},
},
isFiltered
)
}
export function buildTagsMetadata(section: ContentSection): Metadata {
const canonical = `${SITE_URL}${section.basePath}/tags`
const description = `Browse Sim ${section.name.toLowerCase()} posts by topic: AI agents, workflows, integrations, and more.`
return {
title: 'Tags',
description,
alternates: { canonical },
openGraph: {
title: `${section.name} Tags | Sim`,
description,
url: canonical,
siteName: 'Sim',
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary',
title: `${section.name} Tags | Sim`,
description,
site: '@simdotai',
},
}
}
export function buildTagsBreadcrumbJsonLd(section: ContentSection) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{
'@type': 'ListItem',
position: 2,
name: section.name,
item: `${SITE_URL}${section.basePath}`,
},
{
'@type': 'ListItem',
position: 3,
name: 'Tags',
item: `${SITE_URL}${section.basePath}/tags`,
},
],
}
}
export function buildAuthorMetadata(
section: ContentSection,
id: string,
author?: Author
): Metadata {
const name = author?.name ?? 'Author'
const canonical = `${SITE_URL}${section.basePath}/authors/${id}`
const description = `Read articles by ${name} on the Sim ${section.name.toLowerCase()}.`
return {
title: `${name} | Sim ${section.name}`,
description,
alternates: { canonical },
openGraph: {
title: `${name} | Sim ${section.name}`,
description,
url: canonical,
siteName: 'Sim',
type: 'profile',
...(author?.avatarUrl
? { images: [{ url: author.avatarUrl, width: 400, height: 400, alt: name }] }
: {}),
},
twitter: {
card: 'summary',
title: `${name} | Sim ${section.name}`,
description,
site: '@simdotai',
...(author?.xHandle ? { creator: `@${author.xHandle}` } : {}),
},
}
}
export function buildAuthorGraphJsonLd(section: ContentSection, author: Author) {
return {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Person',
name: author.name,
url: `${SITE_URL}${section.basePath}/authors/${author.id}`,
sameAs: author.url ? [author.url] : [],
image: author.avatarUrl,
worksFor: {
'@type': 'Organization',
name: 'Sim',
url: SITE_URL,
},
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{
'@type': 'ListItem',
position: 2,
name: section.name,
item: `${SITE_URL}${section.basePath}`,
},
{
'@type': 'ListItem',
position: 3,
name: author.name,
item: `${SITE_URL}${section.basePath}/authors/${author.id}`,
},
],
},
],
}
}
export function buildCollectionPageJsonLd(section: ContentSection) {
return {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `Sim ${section.name}`,
url: `${SITE_URL}${section.basePath}`,
description: section.description,
publisher: {
'@type': 'Organization',
name: 'Sim',
url: SITE_URL,
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/logo/primary/medium.png`,
},
},
inLanguage: 'en-US',
isPartOf: {
'@type': 'WebSite',
name: 'Sim',
url: SITE_URL,
},
}
}