d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
367 lines
10 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
}
|