refactor(blog): split AEO/GEO content into a new /library section (#5516)

* refactor(blog): split AEO/GEO content into a new /library section

Blog was mixing hand-written editorial posts with listicle/comparison/how-to
posts optimized for answer engines. Extract a generic content engine
(lib/content) shared by both sections, move the 6 AEO/GEO posts into a new
/library route tree, and keep /blog editorial-only.

- lib/content: generic registry factory, MDX components, SEO builders
- lib/blog + lib/library: thin per-section instantiations over the shared engine
- app/(landing)/library: mirrors the blog route tree via shared Content*Page components
- app/sitemap.ts, app/robots.ts, navbar: updated for the new section
- next.config.ts: permanent redirects from moved /blog/<slug> URLs to /library/<slug>

* fix(blog): thread route id through buildAuthorMetadata for canonical URL

Author pages for unknown/unmatched ids fell back to author?.id, which is
undefined when no post matches — collapsing canonical/OG URLs to
{basePath}/authors/ instead of {basePath}/authors/{id}. Pass the route id
explicitly so canonical always resolves correctly.

* fix(blog): address Greptile review — RSS lastBuildDate, shared author cache, dead-export docs

- RSS lastBuildDate now uses updated ?? date so edits after publication are
  reflected, matching the per-item pubDate semantics (blog + library)
- Author JSON is now cached once per authorsDir at module scope instead of
  once per registry instantiation, since blog and library point at the same
  directory
- Documented getNavPosts as reserved-but-unwired, matching the existing
  PLATFORM_MENU/SOLUTIONS_MENU convention in the navbar

* fix(blog): dedupe featured posts from ContentIndexPage pagination pool

A featured post older than POSTS_PER_PAGE would appear both on page 1's
featured row (sorted to the front) and again on its natural date-sorted
page, since only page 1 excluded featured posts from the remaining list.
Carve featured posts out of the paginated pool up front so pagination stays
consistent and duplicate-free across all pages.

* fix(blog): RSS lastBuildDate reflects the whole feed, not just the newest item

lastBuildDate only looked at items[0]'s updated/date, so revising an older
post already in the feed wouldn't advance it. Extract the max-across-posts
logic sitemap.ts already had into a shared lib/content/utils helper
(latestModified) and use it in both RSS routes and the sitemap, so a
revision to any feed item is reflected.
This commit is contained in:
Waleed
2026-07-08 13:55:54 -07:00
committed by GitHub
parent 4cd1461b9e
commit 004955d934
91 changed files with 1962 additions and 1204 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
# Landing Page - Build & Optimization Instructions
This route group owns `/` and the entire public marketing surface - the home page, platform/solutions pages, pricing, legal, and the marketing subroutes (`/blog`, `/models`, `/integrations`, `/demo`, `/partners`, `/changelog`). Read this file in full before adding or changing anything here. Positioning and language rules live in `.claude/rules/constitution.md`; SEO/GEO rules in `.claude/rules/landing-seo-geo.md`. Both apply to every file in this directory.
This route group owns `/` and the entire public marketing surface - the home page, platform/solutions pages, pricing, legal, and the marketing subroutes (`/blog`, `/library`, `/models`, `/integrations`, `/demo`, `/partners`, `/changelog`). Read this file in full before adding or changing anything here. Positioning and language rules live in `.claude/rules/constitution.md`; SEO/GEO rules in `.claude/rules/landing-seo-geo.md`. Both apply to every file in this directory.
`/blog` (editorial/company-voice posts) and `/library` (AEO/GEO content - listicles, comparisons, how-tos) are two separate route trees over one shared engine: `apps/sim/lib/content/` (generic registry factory, MDX components, SEO builders) instantiated per-section by the thin `apps/sim/lib/blog/` and `apps/sim/lib/library/` modules, rendered through the shared `Content*Page` components in `components/`. Content lives in `apps/sim/content/blog/` and `apps/sim/content/library/`; both share `apps/sim/content/authors/`. Adding a post to either section, or adding a new content section entirely, must reuse this engine - never hand-roll a divergent registry or page layout. Every new marketing subroute (including any future content section) needs `app/sitemap.ts` and `app/robots.ts` updated, same as `/blog` and `/library` were.
## What this is
+2 -51
View File
@@ -1,54 +1,5 @@
import { Skeleton } from '@sim/emcn'
import { ContentPostLoading } from '@/app/(landing)/components'
export default function BlogPostLoading() {
return (
<article className='w-full bg-[var(--bg)]'>
<div 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'>
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
<Skeleton className='aspect-[450/360] w-full rounded-[5px] bg-[var(--surface-hover)]' />
</div>
<div className='flex flex-1 flex-col justify-between'>
<div>
<Skeleton className='h-[44px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-2 h-[44px] w-[80%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-4 h-[18px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-2 h-[18px] w-[70%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<div className='mt-6 flex items-center gap-6'>
<Skeleton className='h-[12px] w-[100px] rounded-[4px] bg-[var(--surface-hover)]' />
<div className='flex items-center gap-2'>
<Skeleton className='size-[20px] rounded-full bg-[var(--surface-hover)]' />
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
</div>
</div>
</div>
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px]'>
<div className='mx-20 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
<div className='mx-auto max-w-[900px] px-6 py-16'>
<div className='space-y-4'>
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--border)]' />
</article>
)
return <ContentPostLoading />
}
+10 -150
View File
@@ -1,14 +1,8 @@
import { Avatar, AvatarFallback, AvatarImage } from '@sim/emcn'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { BLOG_SECTION, buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { ShareButton } from '@/app/(landing)/blog/[slug]/share-button'
import { BackLink } from '@/app/(landing)/components'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { ContentPostPage } from '@/app/(landing)/components'
export const dynamicParams = false
@@ -32,150 +26,16 @@ export const revalidate = 86400
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPostBySlug(slug)
const Article = post.Content
const graphJsonLd = buildPostGraphJsonLd(post)
const related = await getRelatedPosts(slug, 3)
return (
<article className='w-full bg-[var(--bg)]' itemScope itemType='https://schema.org/TechArticle'>
<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'>
<BackLink href='/blog' label='Back to Blog' />
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
<div className='relative w-full overflow-hidden rounded-[5px]'>
<Image
src={post.ogImage}
alt={post.title}
width={450}
height={360}
className='h-auto w-full'
sizes='(max-width: 768px) 100vw, 450px'
priority
itemProp='image'
unoptimized
/>
</div>
</div>
<div className='flex flex-1 flex-col justify-between'>
<div>
<h1
className='text-balance text-[28px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em] sm:text-[36px] md:text-[44px] lg:text-[52px]'
itemProp='headline'
>
{post.title}
</h1>
<p className='mt-4 text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'>
{post.description}
</p>
</div>
<div className='mt-6 flex items-center gap-6'>
<time
className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'
dateTime={post.date}
itemProp='datePublished'
>
{new Date(post.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</time>
<meta itemProp='dateModified' content={post.updated ?? post.date} />
<div className='flex items-center gap-3'>
{(post.authors || [post.author]).map((a) => (
<div key={a?.name} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-5'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] hover:text-[var(--text-primary)]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
<div className='ml-auto'>
<ShareButton url={`${getBaseUrl()}/blog/${slug}`} title={post.title} />
</div>
</div>
</div>
</div>
</header>
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
<div className='border-[var(--border)] border-x'>
<div className='mx-auto max-w-[900px] px-6 py-16' itemProp='articleBody'>
<div className='prose prose-lg max-w-none prose-blockquote:border-[var(--border-1)] prose-hr:border-[var(--border)] prose-headings:font-season prose-a:text-[var(--text-primary)] prose-blockquote:text-[var(--text-muted)] prose-code:text-[var(--text-primary)] prose-headings:text-[var(--text-primary)] prose-li:text-[var(--text-body)] prose-p:text-[var(--text-body)] prose-strong:text-[var(--text-primary)] prose-headings:tracking-[-0.02em]'>
<Article />
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
</div>
</div>
{related.length > 0 && (
<>
<div className='h-px w-full bg-[var(--border)]' />
<nav aria-label='Related posts' className='flex flex-col sm:flex-row'>
{related.map((p) => (
<Link
key={p.slug}
href={`/blog/${p.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
loading='lazy'
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
})}
</span>
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
</nav>
</>
)}
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--border)]' />
<meta itemProp='publisher' content='Sim' />
<meta itemProp='inLanguage' content='en-US' />
<meta itemProp='keywords' content={post.tags.join(', ')} />
{post.wordCount && <meta itemProp='wordCount' content={String(post.wordCount)} />}
</article>
<ContentPostPage
basePath={BLOG_SECTION.basePath}
backLabel='Back to Blog'
post={post}
related={related}
graphJsonLd={buildPostGraphJsonLd(post)}
shareUrl={`${getBaseUrl()}${BLOG_SECTION.basePath}/${slug}`}
/>
)
}
@@ -1,25 +1,5 @@
import { Skeleton } from '@sim/emcn'
const SKELETON_POST_COUNT = 4
import { ContentAuthorLoading } from '@/app/(landing)/components'
export default function AuthorLoading() {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<div className='mb-6 flex items-center gap-3'>
<Skeleton className='size-[40px] rounded-full bg-[var(--surface-hover)]' />
<Skeleton className='h-[32px] w-[160px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{Array.from({ length: SKELETON_POST_COUNT }).map((_, i) => (
<div key={i} className='overflow-hidden rounded-lg border border-[var(--border)]'>
<Skeleton className='h-[160px] w-full rounded-none bg-[var(--surface-hover)]' />
<div className='p-3'>
<Skeleton className='mb-1 h-[12px] w-[80px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
))}
</div>
</main>
)
return <ContentAuthorLoading />
}
+11 -107
View File
@@ -1,9 +1,7 @@
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { SITE_URL } from '@/lib/core/utils/urls'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { BLOG_SECTION, buildAuthorGraphJsonLd, buildAuthorMetadata } from '@/lib/blog/seo'
import { ContentAuthorPage } from '@/app/(landing)/components'
export const revalidate = 3600
@@ -14,115 +12,21 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
const author = posts[0]?.author
const name = author?.name ?? 'Author'
return {
title: `${name} | Sim Blog`,
description: `Read articles by ${name} on the Sim blog.`,
alternates: { canonical: `${SITE_URL}/blog/authors/${id}` },
openGraph: {
title: `${name} | Sim Blog`,
description: `Read articles by ${name} on the Sim blog.`,
url: `${SITE_URL}/blog/authors/${id}`,
siteName: 'Sim',
type: 'profile',
...(author?.avatarUrl
? { images: [{ url: author.avatarUrl, width: 400, height: 400, alt: name }] }
: {}),
},
twitter: {
card: 'summary',
title: `${name} | Sim Blog`,
description: `Read articles by ${name} on the Sim blog.`,
site: '@simdotai',
...(author?.xHandle ? { creator: `@${author.xHandle}` } : {}),
},
}
return buildAuthorMetadata(id, posts[0]?.author)
}
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
const author = posts[0]?.author
if (!author) {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<h1 className='text-[32px] text-[var(--text-primary)]'>Author not found</h1>
</main>
)
}
const graphJsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Person',
name: author.name,
url: `${SITE_URL}/blog/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: 'Blog', item: `${SITE_URL}/blog` },
{
'@type': 'ListItem',
position: 3,
name: author.name,
item: `${SITE_URL}/blog/authors/${author.id}`,
},
],
},
],
}
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<JsonLd data={graphJsonLd} />
<div className='mb-6 flex items-center gap-3'>
{author.avatarUrl ? (
<Image
src={author.avatarUrl}
alt={author.name}
width={40}
height={40}
className='rounded-full'
unoptimized
/>
) : null}
<h1 className='text-[32px] text-[var(--text-primary)] leading-tight'>{author.name}</h1>
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{posts.map((p) => (
<Link key={p.slug} href={`/blog/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-[var(--border)]'>
<Image
src={p.ogImage}
alt={p.title}
width={600}
height={315}
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-[var(--text-muted)] text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className='text-[var(--text-primary)] text-sm leading-tight'>{p.title}</div>
</div>
</div>
</Link>
))}
</div>
</main>
<ContentAuthorPage
basePath={BLOG_SECTION.basePath}
authorName={author?.name}
authorAvatarUrl={author?.avatarUrl}
posts={posts}
graphJsonLd={author ? buildAuthorGraphJsonLd(author) : undefined}
/>
)
}
+2 -54
View File
@@ -1,57 +1,5 @@
import { Skeleton } from '@sim/emcn'
import { ContentIndexLoading } from '@/app/(landing)/components'
export default function BlogLoading() {
return (
<section className='bg-[var(--bg)]'>
<div className='mx-auto w-full max-w-[1460px]'>
{/* Header skeleton */}
<div className='px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<Skeleton className='mb-5 h-[20px] w-[60px] rounded-md bg-[var(--surface-hover)]' />
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
<Skeleton className='h-[40px] w-[240px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[18px] w-[320px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
{/* Content area with vertical border rails */}
<div className='mx-20 mt-8 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
<div className='h-px w-full bg-[var(--border)]' />
{/* Featured skeleton */}
<div className='flex max-sm:flex-col'>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className='flex flex-1 flex-col gap-4 border-[var(--border)] p-6 max-sm:border-t max-sm:first:border-t-0 md:border-l md:first:border-l-0'
>
<Skeleton className='aspect-video w-full rounded-[5px] bg-[var(--surface-hover)]' />
<div className='flex flex-col gap-2'>
<Skeleton className='h-[12px] w-[60px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[20px] w-[80%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
))}
</div>
<div className='h-px w-full bg-[var(--border)]' />
{/* List skeleton */}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i}>
<div className='flex items-center gap-6 p-6'>
<Skeleton className='hidden h-[14px] w-[120px] rounded-[4px] bg-[var(--surface-hover)] md:block' />
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<Skeleton className='h-[18px] w-[70%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-[90%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<Skeleton className='hidden h-[80px] w-[140px] rounded-[5px] bg-[var(--surface-hover)] sm:block' />
</div>
<div className='h-px w-full bg-[var(--border)]' />
</div>
))}
</div>
</div>
</section>
)
return <ContentIndexLoading />
}
+15 -228
View File
@@ -1,20 +1,12 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { getAllPostMeta } from '@/lib/blog/registry'
import { buildCollectionPageJsonLd } from '@/lib/blog/seo'
import { SITE_URL } from '@/lib/core/utils/urls'
import { withFilteredNoindex } from '@/lib/landing/seo'
import { Cta } from '@/app/(landing)/components'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { BLOG_SECTION, buildCollectionPageJsonLd, buildIndexMetadata } from '@/lib/blog/seo'
import { ContentIndexPage } from '@/app/(landing)/components'
/**
* Filtered/paginated 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 page and the
* variant itself is noindexed rather than asking Google to index every
* tag/page permutation.
* bare index is indexable — see `buildIndexMetadata` in `@/lib/content/seo`
* for the shared noindex policy.
*/
export async function generateMetadata({
searchParams,
@@ -23,49 +15,7 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { page, tag } = await searchParams
const pageNum = Math.max(1, Number(page || 1))
const titleParts = ['Blog']
if (tag) titleParts.push(tag)
if (pageNum > 1) titleParts.push(`Page ${pageNum}`)
const title = titleParts.join(' | ')
const description = tag
? `Sim blog posts tagged "${tag}": insights and guides for building AI agents.`
: 'Announcements, insights, and guides from Sim, the open-source AI workspace, for building, deploying, and managing AI agents.'
const canonical = `${SITE_URL}/blog`
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 Blog',
},
],
},
twitter: {
card: 'summary_large_image',
title: `${title} | Sim`,
description,
site: '@simdotai',
},
},
isFiltered
)
return buildIndexMetadata({ tag, pageNum })
}
export default async function BlogIndex({
@@ -75,180 +25,17 @@ export default async function BlogIndex({
}) {
const { page, tag } = await searchParams
const pageNum = Math.max(1, Number(page || 1))
const perPage = 20
const all = await getAllPostMeta()
const filtered = tag ? all.filter((p) => p.tags.includes(tag)) : all
const sorted =
pageNum === 1
? filtered.sort((a, b) => {
if (a.featured && !b.featured) return -1
if (!a.featured && b.featured) return 1
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
: filtered
const totalPages = Math.max(1, Math.ceil(sorted.length / perPage))
const start = (pageNum - 1) * perPage
const posts = sorted.slice(start, start + perPage)
const featured = pageNum === 1 ? posts.slice(0, 3) : []
const remaining = pageNum === 1 ? posts.slice(3) : posts
const collectionJsonLd = buildCollectionPageJsonLd()
const posts = await getAllPostMeta()
return (
<>
<section className='bg-[var(--bg)]'>
<JsonLd data={collectionJsonLd} />
{/* Section header */}
<div 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='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
<h1 className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'>
Latest from Sim
</h1>
<p className='max-w-[540px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
Announcements, insights, and guides for building AI agents.
</p>
</div>
</div>
{/* Full-width top line */}
<div className='mt-8 h-px w-full bg-[var(--border)]' />
{/* Content area with vertical border rails */}
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
<div className='border-[var(--border)] border-x'>
{/* Featured posts */}
{featured.length > 0 && (
<>
<nav aria-label='Featured posts' className='flex flex-col sm:flex-row'>
{featured.map((p, index) => (
<Link
key={p.slug}
href={`/blog/${p.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
priority={index < 3}
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
})}
</span>
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
</nav>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
{remaining.map((p) => (
<div key={p.slug}>
<Link
href={`/blog/${p.slug}`}
className='group flex items-start gap-6 p-6 transition-colors hover:bg-[var(--surface-hover)] md:items-center'
>
{/* Date */}
<span className='hidden w-[120px] shrink-0 pt-1 text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:block'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
{/* Title + description */}
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:hidden'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<h3 className='text-[var(--text-primary)] text-base leading-tight tracking-[-0.01em] lg:text-lg'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
{/* Image */}
<div className='relative hidden h-[80px] w-[140px] shrink-0 overflow-hidden rounded-[5px] sm:block'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='140px'
className='object-cover'
unoptimized
/>
</div>
</Link>
<div className='h-px w-full bg-[var(--border)]' />
</div>
))}
{/* Pagination */}
{totalPages > 1 && (
<nav aria-label='Pagination' className='px-6 py-8'>
<div className='flex items-center justify-center gap-3'>
{pageNum > 1 && (
<ChipLink
href={`/blog?page=${pageNum - 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
rel='prev'
className='border border-[var(--border-1)]'
>
Previous
</ChipLink>
)}
<span className='text-[var(--text-muted)] text-sm'>
Page {pageNum} of {totalPages}
</span>
{pageNum < totalPages && (
<ChipLink
href={`/blog?page=${pageNum + 1}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`}
rel='next'
className='border border-[var(--border-1)]'
>
Next
</ChipLink>
)}
</div>
</nav>
)}
</div>
</div>
{/* Full-width bottom line - overlaps last inner divider to avoid double border */}
<div className='-mt-px h-px w-full bg-[var(--border)]' />
</section>
<div className='mt-[120px] max-sm:mt-16 max-lg:mt-[88px]'>
<Cta />
</div>
</>
<ContentIndexPage
basePath={BLOG_SECTION.basePath}
heading='Latest from Sim'
subheading={BLOG_SECTION.description}
posts={posts}
page={pageNum}
tag={tag}
collectionJsonLd={buildCollectionPageJsonLd()}
/>
)
}
+2 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { getAllPostMeta } from '@/lib/blog/registry'
import { latestModified } from '@/lib/content/utils'
import { SITE_URL } from '@/lib/core/utils/urls'
export const revalidate = 3600
@@ -8,8 +9,7 @@ export async function GET() {
const posts = await getAllPostMeta()
const items = posts.slice(0, 50)
const site = SITE_URL
const lastBuildDate =
items.length > 0 ? new Date(items[0].date).toUTCString() : new Date().toUTCString()
const lastBuildDate = (latestModified(items) ?? new Date()).toUTCString()
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
+2 -17
View File
@@ -1,20 +1,5 @@
import { Skeleton } from '@sim/emcn'
const SKELETON_TAG_COUNT = 12
import { ContentTagsLoading } from '@/app/(landing)/components'
export default function TagsLoading() {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<Skeleton className='mb-6 h-[32px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
<div className='flex flex-wrap gap-3'>
{Array.from({ length: SKELETON_TAG_COUNT }).map((_, i) => (
<Skeleton
key={i}
className='h-[30px] rounded-full bg-[var(--surface-hover)]'
style={{ width: `${60 + (i % 4) * 24}px` }}
/>
))}
</div>
</main>
)
return <ContentTagsLoading />
}
+8 -50
View File
@@ -1,59 +1,17 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { getAllTags } from '@/lib/blog/registry'
import { SITE_URL } from '@/lib/core/utils/urls'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { BLOG_SECTION, buildTagsBreadcrumbJsonLd, buildTagsMetadata } from '@/lib/blog/seo'
import { ContentTagsPage } from '@/app/(landing)/components'
export const metadata: Metadata = {
title: 'Tags',
description: 'Browse Sim blog posts by topic: AI agents, workflows, integrations, and more.',
alternates: { canonical: `${SITE_URL}/blog/tags` },
openGraph: {
title: 'Blog Tags | Sim',
description: 'Browse Sim blog posts by topic: AI agents, workflows, integrations, and more.',
url: `${SITE_URL}/blog/tags`,
siteName: 'Sim',
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary',
title: 'Blog Tags | Sim',
description: 'Browse Sim blog posts by topic: AI agents, workflows, integrations, and more.',
site: '@simdotai',
},
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: `${SITE_URL}/blog` },
{ '@type': 'ListItem', position: 3, name: 'Tags', item: `${SITE_URL}/blog/tags` },
],
}
export const metadata: Metadata = buildTagsMetadata()
export default async function TagsIndex() {
const tags = await getAllTags()
return (
<section className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<JsonLd data={breadcrumbJsonLd} />
<h1 className='mb-6 text-[32px] text-[var(--text-primary)] leading-tight'>Browse by tag</h1>
<div className='flex flex-wrap gap-3'>
<ChipLink href='/blog' className='border border-[var(--border-1)]'>
All
</ChipLink>
{tags.map((t) => (
<ChipLink
key={t.tag}
href={`/blog?tag=${encodeURIComponent(t.tag)}`}
className='border border-[var(--border-1)]'
>
{t.tag} ({t.count})
</ChipLink>
))}
</div>
</section>
<ContentTagsPage
basePath={BLOG_SECTION.basePath}
tags={tags}
breadcrumbJsonLd={buildTagsBreadcrumbJsonLd()}
/>
)
}
@@ -0,0 +1,26 @@
import { Skeleton } from '@sim/emcn'
const AUTHOR_POST_SKELETON_COUNT = 4
/** Shared loading skeleton for a content section's author-profile route. */
export function ContentAuthorLoading() {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<div className='mb-6 flex items-center gap-3'>
<Skeleton className='size-[40px] rounded-full bg-[var(--surface-hover)]' />
<Skeleton className='h-[32px] w-[160px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{Array.from({ length: AUTHOR_POST_SKELETON_COUNT }).map((_, i) => (
<div key={i} className='overflow-hidden rounded-lg border border-[var(--border)]'>
<Skeleton className='h-[160px] w-full rounded-none bg-[var(--surface-hover)]' />
<div className='p-3'>
<Skeleton className='mb-1 h-[12px] w-[80px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
))}
</div>
</main>
)
}
@@ -0,0 +1,76 @@
import Image from 'next/image'
import Link from 'next/link'
import type { ContentMeta } from '@/lib/content/schema'
import { JsonLd } from '@/app/(landing)/components/json-ld'
interface ContentAuthorPageProps {
/** Route base path, e.g. `/blog` or `/library`. */
basePath: string
authorName?: string
authorAvatarUrl?: string
/** Posts already filtered down to this author. */
posts: ContentMeta[]
graphJsonLd?: Record<string, unknown>
}
/** Shared author-profile layout for a content section. */
export function ContentAuthorPage({
basePath,
authorName,
authorAvatarUrl,
posts,
graphJsonLd,
}: ContentAuthorPageProps) {
if (!authorName) {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<h1 className='text-[32px] text-[var(--text-primary)]'>Author not found</h1>
</main>
)
}
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
{graphJsonLd && <JsonLd data={graphJsonLd} />}
<div className='mb-6 flex items-center gap-3'>
{authorAvatarUrl ? (
<Image
src={authorAvatarUrl}
alt={authorName}
width={40}
height={40}
className='rounded-full'
unoptimized
/>
) : null}
<h1 className='text-[32px] text-[var(--text-primary)] leading-tight'>{authorName}</h1>
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{posts.map((p) => (
<Link key={p.slug} href={`${basePath}/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-[var(--border)]'>
<Image
src={p.ogImage}
alt={p.title}
width={600}
height={315}
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-[var(--text-muted)] text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className='text-[var(--text-primary)] text-sm leading-tight'>{p.title}</div>
</div>
</div>
</Link>
))}
</div>
</main>
)
}
@@ -0,0 +1,2 @@
export { ContentAuthorLoading } from './content-author-loading'
export { ContentAuthorPage } from './content-author-page'
@@ -3,9 +3,9 @@
import { useState } from 'react'
import { cn } from '@sim/emcn'
import NextImage from 'next/image'
import { Lightbox } from '@/app/(landing)/blog/components/lightbox'
import { Lightbox } from '@/app/(landing)/components/lightbox'
interface BlogImageProps {
interface ContentImageProps {
src: string
alt?: string
width?: number
@@ -13,7 +13,17 @@ interface BlogImageProps {
className?: string
}
export function BlogImage({ src, alt = '', width = 800, height = 450, className }: BlogImageProps) {
/**
* Click-to-zoom image renderer used by MDX content (blog and library posts
* both compile through the same `mdxComponents` map in `@/lib/content/mdx`).
*/
export function ContentImage({
src,
alt = '',
width = 800,
height = 450,
className,
}: ContentImageProps) {
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
return (
@@ -0,0 +1 @@
export { ContentImage } from './content-image'
@@ -0,0 +1,57 @@
import { Skeleton } from '@sim/emcn'
const FEATURED_SKELETON_COUNT = 3
const LIST_SKELETON_COUNT = 5
/** Shared loading skeleton for a content section's index/list route. */
export function ContentIndexLoading() {
return (
<section className='bg-[var(--bg)]'>
<div className='mx-auto w-full max-w-[1460px]'>
<div className='px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<Skeleton className='mb-5 h-[20px] w-[60px] rounded-md bg-[var(--surface-hover)]' />
<div className='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
<Skeleton className='h-[40px] w-[240px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[18px] w-[320px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
<div className='mx-20 mt-8 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
<div className='h-px w-full bg-[var(--border)]' />
<div className='flex max-sm:flex-col'>
{Array.from({ length: FEATURED_SKELETON_COUNT }).map((_, i) => (
<div
key={i}
className='flex flex-1 flex-col gap-4 border-[var(--border)] p-6 max-sm:border-t max-sm:first:border-t-0 md:border-l md:first:border-l-0'
>
<Skeleton className='aspect-video w-full rounded-[5px] bg-[var(--surface-hover)]' />
<div className='flex flex-col gap-2'>
<Skeleton className='h-[12px] w-[60px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[20px] w-[80%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
))}
</div>
<div className='h-px w-full bg-[var(--border)]' />
{Array.from({ length: LIST_SKELETON_COUNT }).map((_, i) => (
<div key={i}>
<div className='flex items-center gap-6 p-6'>
<Skeleton className='hidden h-[14px] w-[120px] rounded-[4px] bg-[var(--surface-hover)] md:block' />
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<Skeleton className='h-[18px] w-[70%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-[90%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<Skeleton className='hidden h-[80px] w-[140px] rounded-[5px] bg-[var(--surface-hover)] sm:block' />
</div>
<div className='h-px w-full bg-[var(--border)]' />
</div>
))}
</div>
</div>
</section>
)
}
@@ -0,0 +1,207 @@
import { ChipLink } from '@sim/emcn'
import Image from 'next/image'
import Link from 'next/link'
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
heading: string
subheading: string
/** All published posts for the section, unfiltered and unsorted. */
posts: ContentMeta[]
page: number
tag?: string
collectionJsonLd: Record<string, unknown>
}
/**
* Shared index/list layout for a content section (blog or library): section
* header, featured-post row, remaining posts list, and pagination. Both
* sections render this exact layout, parameterized by `basePath` and copy —
* see `.claude/rules/landing-seo-geo.md` for the filtered/paginated noindex
* policy this pairs with (`buildIndexMetadata` in `@/lib/content/seo`).
*/
export function ContentIndexPage({
basePath,
heading,
subheading,
posts,
page,
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 pageHref = (targetPage: number) =>
`${basePath}?page=${targetPage}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`
return (
<>
<section className='bg-[var(--bg)]'>
<JsonLd data={collectionJsonLd} />
<div 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='flex flex-col gap-4 md:flex-row md:items-end md:justify-between'>
<h1 className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'>
{heading}
</h1>
<p className='max-w-[540px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
{subheading}
</p>
</div>
</div>
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
<div className='border-[var(--border)] border-x'>
{featured.length > 0 && (
<>
<nav aria-label='Featured posts' className='flex flex-col sm:flex-row'>
{featured.map((p, index) => (
<Link
key={p.slug}
href={`${basePath}/${p.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
priority={index < 3}
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
})}
</span>
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
</nav>
<div className='h-px w-full bg-[var(--border)]' />
</>
)}
{remaining.map((p) => (
<div key={p.slug}>
<Link
href={`${basePath}/${p.slug}`}
className='group flex items-start gap-6 p-6 transition-colors hover:bg-[var(--surface-hover)] md:items-center'
>
<span className='hidden w-[120px] shrink-0 pt-1 text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:block'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:hidden'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<h3 className='text-[var(--text-primary)] text-base leading-tight tracking-[-0.01em] lg:text-lg'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
<div className='relative hidden h-[80px] w-[140px] shrink-0 overflow-hidden rounded-[5px] sm:block'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='140px'
className='object-cover'
unoptimized
/>
</div>
</Link>
<div className='h-px w-full bg-[var(--border)]' />
</div>
))}
{totalPages > 1 && (
<nav aria-label='Pagination' className='px-6 py-8'>
<div className='flex items-center justify-center gap-3'>
{page > 1 && (
<ChipLink
href={pageHref(page - 1)}
rel='prev'
className='border border-[var(--border-1)]'
>
Previous
</ChipLink>
)}
<span className='text-[var(--text-muted)] text-sm'>
Page {page} of {totalPages}
</span>
{page < totalPages && (
<ChipLink
href={pageHref(page + 1)}
rel='next'
className='border border-[var(--border-1)]'
>
Next
</ChipLink>
)}
</div>
</nav>
)}
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--border)]' />
</section>
<div className='mt-[120px] max-sm:mt-16 max-lg:mt-[88px]'>
<Cta />
</div>
</>
)
}
@@ -0,0 +1,2 @@
export { ContentIndexLoading } from './content-index-loading'
export { ContentIndexPage } from './content-index-page'
@@ -0,0 +1,55 @@
import { Skeleton } from '@sim/emcn'
/** Shared loading skeleton for a content section's post-detail route. */
export function ContentPostLoading() {
return (
<article className='w-full bg-[var(--bg)]'>
<div 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'>
<Skeleton className='h-[16px] w-[100px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
<Skeleton className='aspect-[450/360] w-full rounded-[5px] bg-[var(--surface-hover)]' />
</div>
<div className='flex flex-1 flex-col justify-between'>
<div>
<Skeleton className='h-[44px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-2 h-[44px] w-[80%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-4 h-[18px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-2 h-[18px] w-[70%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<div className='mt-6 flex items-center gap-6'>
<Skeleton className='h-[12px] w-[100px] rounded-[4px] bg-[var(--surface-hover)]' />
<div className='flex items-center gap-2'>
<Skeleton className='size-[20px] rounded-full bg-[var(--surface-hover)]' />
<Skeleton className='h-[12px] w-[80px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
</div>
</div>
</div>
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px]'>
<div className='mx-20 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
<div className='mx-auto max-w-[900px] px-6 py-16'>
<div className='space-y-4'>
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--border)]' />
</article>
)
}
@@ -0,0 +1,178 @@
import { Avatar, AvatarFallback, AvatarImage } from '@sim/emcn'
import Image from 'next/image'
import Link from 'next/link'
import { FAQ } from '@/lib/content/faq'
import type { ContentMeta, ContentPost } from '@/lib/content/schema'
import { BackLink } from '@/app/(landing)/components/back-link'
import { JsonLd } from '@/app/(landing)/components/json-ld'
import { ShareButton } from '@/app/(landing)/components/share-button'
interface ContentPostPageProps {
/** Route base path, e.g. `/blog` or `/library`. */
basePath: string
/** Label for the back-to-index link, e.g. "Back to Blog". */
backLabel: string
post: ContentPost
related: ContentMeta[]
graphJsonLd: Record<string, unknown>
shareUrl: string
}
/**
* Shared post-detail layout for a content section (blog or library): header
* with cover image/title/authors/share, MDX article body + FAQ, and related
* posts. Both sections render this exact layout, parameterized by `basePath`.
*/
export function ContentPostPage({
basePath,
backLabel,
post,
related,
graphJsonLd,
shareUrl,
}: ContentPostPageProps) {
const Article = post.Content
return (
<article className='w-full bg-[var(--bg)]' itemScope itemType='https://schema.org/TechArticle'>
<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'>
<BackLink href={basePath} label={backLabel} />
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
<div className='relative w-full overflow-hidden rounded-[5px]'>
<Image
src={post.ogImage}
alt={post.title}
width={450}
height={360}
className='h-auto w-full'
sizes='(max-width: 768px) 100vw, 450px'
priority
itemProp='image'
unoptimized
/>
</div>
</div>
<div className='flex flex-1 flex-col justify-between'>
<div>
<h1
className='text-balance text-[28px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em] sm:text-[36px] md:text-[44px] lg:text-[52px]'
itemProp='headline'
>
{post.title}
</h1>
<p className='mt-4 text-[var(--text-body)] text-base leading-[150%] tracking-[0.02em] sm:text-lg'>
{post.description}
</p>
</div>
<div className='mt-6 flex items-center gap-6'>
<time
className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'
dateTime={post.date}
itemProp='datePublished'
>
{new Date(post.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</time>
<meta itemProp='dateModified' content={post.updated ?? post.date} />
<div className='flex items-center gap-3'>
{(post.authors || [post.author]).map((a) => (
<div key={a?.name} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-5'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] hover:text-[var(--text-primary)]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
<div className='ml-auto'>
<ShareButton url={shareUrl} title={post.title} />
</div>
</div>
</div>
</div>
</header>
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
<div className='border-[var(--border)] border-x'>
<div className='mx-auto max-w-[900px] px-6 py-16' itemProp='articleBody'>
<div className='prose prose-lg max-w-none prose-blockquote:border-[var(--border-1)] prose-hr:border-[var(--border)] prose-headings:font-season prose-a:text-[var(--text-primary)] prose-blockquote:text-[var(--text-muted)] prose-code:text-[var(--text-primary)] prose-headings:text-[var(--text-primary)] prose-li:text-[var(--text-body)] prose-p:text-[var(--text-body)] prose-strong:text-[var(--text-primary)] prose-headings:tracking-[-0.02em]'>
<Article />
{post.faq && post.faq.length > 0 ? <FAQ items={post.faq} /> : null}
</div>
</div>
{related.length > 0 && (
<>
<div className='h-px w-full bg-[var(--border)]' />
<nav aria-label='Related posts' className='flex flex-col sm:flex-row'>
{related.map((p) => (
<Link
key={p.slug}
href={`${basePath}/${p.slug}`}
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
loading='lazy'
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
year: '2-digit',
})}
</span>
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
</nav>
</>
)}
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--border)]' />
<meta itemProp='publisher' content='Sim' />
<meta itemProp='inLanguage' content='en-US' />
<meta itemProp='keywords' content={post.tags.join(', ')} />
{post.wordCount && <meta itemProp='wordCount' content={String(post.wordCount)} />}
</article>
)
}
@@ -0,0 +1,2 @@
export { ContentPostLoading } from './content-post-loading'
export { ContentPostPage } from './content-post-page'
@@ -0,0 +1,21 @@
import { Skeleton } from '@sim/emcn'
const TAG_SKELETON_COUNT = 12
/** Shared loading skeleton for a content section's tags route. */
export function ContentTagsLoading() {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<Skeleton className='mb-6 h-[32px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
<div className='flex flex-wrap gap-3'>
{Array.from({ length: TAG_SKELETON_COUNT }).map((_, i) => (
<Skeleton
key={i}
className='h-[30px] rounded-full bg-[var(--surface-hover)]'
style={{ width: `${60 + (i % 4) * 24}px` }}
/>
))}
</div>
</main>
)
}
@@ -0,0 +1,34 @@
import { ChipLink } from '@sim/emcn'
import type { TagWithCount } from '@/lib/content/schema'
import { JsonLd } from '@/app/(landing)/components/json-ld'
interface ContentTagsPageProps {
/** Route base path, e.g. `/blog` or `/library`. */
basePath: string
tags: TagWithCount[]
breadcrumbJsonLd: Record<string, unknown>
}
/** Shared "browse by tag" layout for a content section. */
export function ContentTagsPage({ basePath, tags, breadcrumbJsonLd }: ContentTagsPageProps) {
return (
<section className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<JsonLd data={breadcrumbJsonLd} />
<h1 className='mb-6 text-[32px] text-[var(--text-primary)] leading-tight'>Browse by tag</h1>
<div className='flex flex-wrap gap-3'>
<ChipLink href={basePath} className='border border-[var(--border-1)]'>
All
</ChipLink>
{tags.map((t) => (
<ChipLink
key={t.tag}
href={`${basePath}?tag=${encodeURIComponent(t.tag)}`}
className='border border-[var(--border-1)]'
>
{t.tag} ({t.count})
</ChipLink>
))}
</div>
</section>
)
}
@@ -0,0 +1,2 @@
export { ContentTagsLoading } from './content-tags-loading'
export { ContentTagsPage } from './content-tags-page'
@@ -1,5 +1,10 @@
export { BackLink } from './back-link'
export { ChevronArrow } from './chevron-arrow'
export { ContentAuthorLoading, ContentAuthorPage } from './content-author-page'
export { ContentImage } from './content-image'
export { ContentIndexLoading, ContentIndexPage } from './content-index-page'
export { ContentPostLoading, ContentPostPage } from './content-post-page'
export { ContentTagsLoading, ContentTagsPage } from './content-tags-page'
export { Cta } from './cta/cta'
export { Features } from './features'
export { Footer } from './footer'
@@ -9,6 +14,7 @@ export type { JsonLdData } from './json-ld'
export { JsonLd } from './json-ld'
export { LandingShell } from './landing-shell'
export { Lifecycle } from './lifecycle'
export { Lightbox } from './lightbox'
export { LogoShell } from './logo-shell'
export { Mothership } from './mothership/mothership'
export { Navbar } from './navbar'
@@ -21,6 +27,7 @@ export type {
} from './platform-page'
export { PlatformPage } from './platform-page'
export { ProductDemo } from './product-demo'
export { ShareButton } from './share-button'
export { SiteStructuredData } from './site-structured-data'
export type {
SolutionsCardConfig,
@@ -0,0 +1 @@
export { Lightbox } from './lightbox'
@@ -41,7 +41,7 @@ export const PLATFORM_MENU: NavMenu = {
}
/**
* The Resources menu - learning and reference surfaces. Five items in a
* The Resources menu - learning and reference surfaces. Six items in a
* three-column grid. Docs is the one off-site link.
*/
export const RESOURCES_MENU: NavMenu = {
@@ -58,6 +58,11 @@ export const RESOURCES_MENU: NavMenu = {
description: 'Ideas, news, and deep dives',
href: '/blog',
},
{
title: 'Library',
description: 'Comparisons, how-tos, and roundups',
href: '/library',
},
{
title: 'Changelog',
description: 'Everything we just shipped',
@@ -0,0 +1 @@
export { ShareButton } from './share-button'
@@ -0,0 +1,5 @@
import { ContentPostLoading } from '@/app/(landing)/components'
export default function LibraryPostLoading() {
return <ContentPostLoading />
}
@@ -0,0 +1,41 @@
import type { Metadata } from 'next'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/library/registry'
import { buildPostGraphJsonLd, buildPostMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
import { ContentPostPage } from '@/app/(landing)/components'
export const dynamicParams = false
export async function generateStaticParams() {
const posts = await getAllPostMeta()
return posts.map((p) => ({ slug: p.slug }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const post = await getPostBySlug(slug)
return buildPostMetadata(post)
}
export const revalidate = 86400
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPostBySlug(slug)
const related = await getRelatedPosts(slug, 3)
return (
<ContentPostPage
basePath={LIBRARY_SECTION.basePath}
backLabel='Back to Library'
post={post}
related={related}
graphJsonLd={buildPostGraphJsonLd(post)}
shareUrl={`${getBaseUrl()}${LIBRARY_SECTION.basePath}/${slug}`}
/>
)
}
@@ -0,0 +1,5 @@
import { ContentAuthorLoading } from '@/app/(landing)/components'
export default function AuthorLoading() {
return <ContentAuthorLoading />
}
@@ -0,0 +1,32 @@
import type { Metadata } from 'next'
import { getAllPostMeta } from '@/lib/library/registry'
import { buildAuthorGraphJsonLd, buildAuthorMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
import { ContentAuthorPage } from '@/app/(landing)/components'
export const revalidate = 3600
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
return buildAuthorMetadata(id, posts[0]?.author)
}
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
const author = posts[0]?.author
return (
<ContentAuthorPage
basePath={LIBRARY_SECTION.basePath}
authorName={author?.name}
authorAvatarUrl={author?.avatarUrl}
posts={posts}
graphJsonLd={author ? buildAuthorGraphJsonLd(author) : undefined}
/>
)
}
+11
View File
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react'
/**
* Library route segment. The shared landing layout owns the chrome (navbar,
* footer, site-wide JSON-LD, scroll port); this layout only provides the
* `<main>` landmark for every library page. Library pages emit their own
* page-specific JSON-LD.
*/
export default function LibraryLayout({ children }: { children: ReactNode }) {
return <main id='main-content'>{children}</main>
}
@@ -0,0 +1,5 @@
import { ContentIndexLoading } from '@/app/(landing)/components'
export default function LibraryLoading() {
return <ContentIndexLoading />
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from 'next'
import { getAllPostMeta } from '@/lib/library/registry'
import { buildCollectionPageJsonLd, buildIndexMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
import { ContentIndexPage } from '@/app/(landing)/components'
/**
* Filtered/paginated variants render genuinely different lists, but only the
* bare index is indexable — see `buildIndexMetadata` in `@/lib/content/seo`
* for the shared noindex policy.
*/
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ page?: string; tag?: string }>
}): Promise<Metadata> {
const { page, tag } = await searchParams
const pageNum = Math.max(1, Number(page || 1))
return buildIndexMetadata({ tag, pageNum })
}
export default async function LibraryIndex({
searchParams,
}: {
searchParams: Promise<{ page?: string; tag?: string }>
}) {
const { page, tag } = await searchParams
const pageNum = Math.max(1, Number(page || 1))
const posts = await getAllPostMeta()
return (
<ContentIndexPage
basePath={LIBRARY_SECTION.basePath}
heading='The Sim Library'
subheading={LIBRARY_SECTION.description}
posts={posts}
page={pageNum}
tag={tag}
collectionJsonLd={buildCollectionPageJsonLd()}
/>
)
}
@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server'
import { latestModified } from '@/lib/content/utils'
import { SITE_URL } from '@/lib/core/utils/urls'
import { getAllPostMeta } from '@/lib/library/registry'
import { LIBRARY_SECTION } from '@/lib/library/seo'
export const revalidate = 3600
export async function GET() {
const posts = await getAllPostMeta()
const items = posts.slice(0, 50)
const site = SITE_URL
const lastBuildDate = (latestModified(items) ?? new Date()).toUTCString()
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Sim ${LIBRARY_SECTION.name}</title>
<link>${site}</link>
<description>${LIBRARY_SECTION.description}</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<atom:link href="${site}${LIBRARY_SECTION.basePath}/rss.xml" rel="self" type="application/rss+xml" />
${items
.map(
(p) => `
<item>
<title><![CDATA[${p.title}]]></title>
<link>${p.canonical}</link>
<guid>${p.canonical}</guid>
<pubDate>${new Date(p.date).toUTCString()}</pubDate>
<description><![CDATA[${p.description}]]></description>
${(p.authors || [p.author])
.map((a) => `<author><![CDATA[${a.name}${a.url ? ` (${a.url})` : ''}]]></author>`)
.join('\n')}
${p.tags.map((t) => `<category><![CDATA[${t}]]></category>`).join('\n ')}
</item>`
)
.join('')}
</channel>
</rss>`
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
})
}
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import { SITE_URL } from '@/lib/core/utils/urls'
import { getAllPostMeta } from '@/lib/library/registry'
export const revalidate = 3600
export async function GET() {
const posts = await getAllPostMeta()
const base = SITE_URL
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${posts
.map(
(p) => `<url>
<loc>${p.canonical}</loc>
<image:image>
<image:loc>${p.ogImage.startsWith('http') ? p.ogImage : `${base}${p.ogImage}`}</image:loc>
<image:title><![CDATA[${p.title}]]></image:title>
<image:caption><![CDATA[${p.description}]]></image:caption>
</image:image>
</url>`
)
.join('\n')}
</urlset>`
return new NextResponse(xml, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
})
}
@@ -0,0 +1,5 @@
import { ContentTagsLoading } from '@/app/(landing)/components'
export default function TagsLoading() {
return <ContentTagsLoading />
}
@@ -0,0 +1,17 @@
import type { Metadata } from 'next'
import { getAllTags } from '@/lib/library/registry'
import { buildTagsBreadcrumbJsonLd, buildTagsMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
import { ContentTagsPage } from '@/app/(landing)/components'
export const metadata: Metadata = buildTagsMetadata()
export default async function TagsIndex() {
const tags = await getAllTags()
return (
<ContentTagsPage
basePath={LIBRARY_SECTION.basePath}
tags={tags}
breadcrumbJsonLd={buildTagsBreadcrumbJsonLd()}
/>
)
}
+5 -1
View File
@@ -16,6 +16,10 @@ const DISALLOWED_PATHS = [
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: DISALLOWED_PATHS },
sitemap: [`${SITE_URL}/sitemap.xml`, `${SITE_URL}/blog/sitemap-images.xml`],
sitemap: [
`${SITE_URL}/sitemap.xml`,
`${SITE_URL}/blog/sitemap-images.xml`,
`${SITE_URL}/library/sitemap-images.xml`,
],
}
}
+46 -25
View File
@@ -1,7 +1,10 @@
import type { MetadataRoute } from 'next'
import { getAllPostMeta } from '@/lib/blog/registry'
import { getAllPostMeta as getAllBlogPostMeta } from '@/lib/blog/registry'
import type { ContentMeta } from '@/lib/content/schema'
import { latestModified } from '@/lib/content/utils'
import { SITE_URL } from '@/lib/core/utils/urls'
import { INTEGRATIONS, INTEGRATIONS_UPDATED_AT } from '@/lib/integrations'
import { getAllPostMeta as getAllLibraryPostMeta } from '@/lib/library/registry'
import {
ALL_COMPETITORS,
getLatestVerifiedDate,
@@ -9,22 +12,38 @@ import {
} from '@/app/(landing)/comparison/utils'
import { ALL_CATALOG_MODELS, MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
/** One sitemap entry per author, timestamped by their most recently updated post. */
function buildAuthorPages(posts: ContentMeta[], basePath: string): MetadataRoute.Sitemap {
const authorsMap = new Map<string, Date>()
for (const p of posts) {
for (const author of p.authors ?? [p.author]) {
const postDate = new Date(p.updated ?? p.date)
const existing = authorsMap.get(author.id)
if (!existing || postDate > existing) {
authorsMap.set(author.id, postDate)
}
}
}
return [...authorsMap.entries()].map(([id, date]) => ({
url: `${SITE_URL}${basePath}/authors/${id}`,
lastModified: date,
}))
}
/**
* Generate the public sitemap by composing static landing pages with the
* dynamic catalogs (blog posts, authors, integrations, model providers, and
* individual models). Per-integration entries are emitted under
* `/integrations/{slug}` to match the landing route at
* dynamic catalogs (blog posts, library posts, authors, integrations, model
* providers, and individual models). Per-integration entries are emitted
* under `/integrations/{slug}` to match the landing route at
* `app/(landing)/integrations/[slug]`; slugs are guaranteed unique
* by the catalog generator in `scripts/generate-docs.ts`.
*/
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = SITE_URL
const posts = await getAllPostMeta()
const [posts, libraryPosts] = await Promise.all([getAllBlogPostMeta(), getAllLibraryPostMeta()])
const latestPostDate =
posts.length > 0
? new Date(Math.max(...posts.map((p) => new Date(p.updated ?? p.date).getTime())))
: undefined
const latestPostDateValue = latestModified(posts)
const latestLibraryPostDate = latestModified(libraryPosts)
const modelTimes = MODEL_PROVIDERS_WITH_CATALOGS.flatMap((provider) =>
provider.models.map((model) => new Date(model.pricing.updatedAt).getTime())
@@ -72,15 +91,23 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
},
{
url: `${baseUrl}/blog`,
lastModified: latestPostDate,
lastModified: latestPostDateValue,
},
{
url: `${baseUrl}/blog/tags`,
lastModified: latestPostDate,
lastModified: latestPostDateValue,
},
{
url: `${baseUrl}/library`,
lastModified: latestLibraryPostDate,
},
{
url: `${baseUrl}/library/tags`,
lastModified: latestLibraryPostDate,
},
{
url: `${baseUrl}/changelog`,
lastModified: latestPostDate,
lastModified: latestPostDateValue,
},
{
url: `${baseUrl}/integrations`,
@@ -104,21 +131,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
url: p.canonical,
lastModified: new Date(p.updated ?? p.date),
}))
const authorPages = buildAuthorPages(posts, '/blog')
const authorsMap = new Map<string, Date>()
for (const p of posts) {
for (const author of p.authors ?? [p.author]) {
const postDate = new Date(p.updated ?? p.date)
const existing = authorsMap.get(author.id)
if (!existing || postDate > existing) {
authorsMap.set(author.id, postDate)
}
}
}
const authorPages: MetadataRoute.Sitemap = [...authorsMap.entries()].map(([id, date]) => ({
url: `${baseUrl}/blog/authors/${id}`,
lastModified: date,
const libraryPages: MetadataRoute.Sitemap = libraryPosts.map((p) => ({
url: p.canonical,
lastModified: new Date(p.updated ?? p.date),
}))
const libraryAuthorPages = buildAuthorPages(libraryPosts, '/library')
const integrationPages: MetadataRoute.Sitemap = INTEGRATIONS.map((integration) => ({
url: `${baseUrl}/integrations/${integration.slug}`,
@@ -164,6 +183,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
...staticPages,
...blogPages,
...authorPages,
...libraryPages,
...libraryAuthorPages,
...integrationPages,
...providerPages,
...modelEntries,
+1 -1
View File
@@ -3,5 +3,5 @@
"name": "Emir Karabeg",
"url": "https://x.com/emkara",
"xHandle": "emkara",
"avatarUrl": "/blog/authors/emir.jpg"
"avatarUrl": "/authors/emir.jpg"
}
+1 -1
View File
@@ -3,5 +3,5 @@
"name": "Siddharth",
"url": "https://x.com/sidganesan",
"xHandle": "sidganesan",
"avatarUrl": "/blog/authors/sid.jpg"
"avatarUrl": "/authors/sid.jpg"
}
+1 -1
View File
@@ -3,5 +3,5 @@
"name": "Vikhyath Mondreti",
"url": "https://github.com/icecrasher321",
"xHandle": "icecrasher321",
"avatarUrl": "/blog/authors/vik.jpg"
"avatarUrl": "/authors/vik.jpg"
}
+1 -1
View File
@@ -3,5 +3,5 @@
"name": "Waleed Latif",
"url": "https://x.com/typingwala",
"xHandle": "typingwala",
"avatarUrl": "/blog/authors/waleed.jpg"
"avatarUrl": "/authors/waleed.jpg"
}
@@ -8,8 +8,8 @@ authors:
- emir
readingTime: 14
tags: [AI Agents, Use Cases, Automation, Sim]
ogImage: /blog/ai-agent-ideas/cover.jpg
canonical: https://www.sim.ai/blog/ai-agent-ideas
ogImage: /library/ai-agent-ideas/cover.jpg
canonical: https://www.sim.ai/library/ai-agent-ideas
draft: false
---
@@ -8,8 +8,8 @@ authors:
- emir
readingTime: 13
tags: [AI Agents, Chatbots, AI Workspace, Sim]
ogImage: /blog/ai-agent-vs-chatbot/cover.jpg
canonical: https://www.sim.ai/blog/ai-agent-vs-chatbot
ogImage: /library/ai-agent-vs-chatbot/cover.jpg
canonical: https://www.sim.ai/library/ai-agent-vs-chatbot
draft: false
---
@@ -8,8 +8,8 @@ authors:
- emir
readingTime: 13
tags: [AI Agents, RPA, Enterprise Automation, Sim]
ogImage: /blog/ai-agents-vs-rpa/cover.jpg
canonical: https://www.sim.ai/blog/ai-agents-vs-rpa
ogImage: /library/ai-agents-vs-rpa/cover.jpg
canonical: https://www.sim.ai/library/ai-agents-vs-rpa
draft: false
---
@@ -8,8 +8,8 @@ authors:
- emir
readingTime: 14
tags: [Zapier Alternatives, Workflow Automation, AI Agents, Sim]
ogImage: /blog/best-zapier-alternatives/cover.jpg
canonical: https://www.sim.ai/blog/best-zapier-alternatives
ogImage: /library/best-zapier-alternatives/cover.jpg
canonical: https://www.sim.ai/library/best-zapier-alternatives
draft: false
---
@@ -8,8 +8,8 @@ authors:
- waleed
readingTime: 13
tags: [AI Agents, Tutorial, No-Code, Sim, Workflow Automation]
ogImage: /blog/how-to-create-an-ai-agent/cover.jpg
canonical: https://www.sim.ai/blog/how-to-create-an-ai-agent
ogImage: /library/how-to-create-an-ai-agent/cover.jpg
canonical: https://www.sim.ai/library/how-to-create-an-ai-agent
draft: false
---
@@ -8,8 +8,8 @@ authors:
- emir
readingTime: 9
tags: [AI Agents, Workflow Automation, OpenAI AgentKit, n8n, Sim, MCP]
ogImage: /blog/openai-vs-n8n-vs-sim/cover.jpg
canonical: https://www.sim.ai/blog/openai-vs-n8n-vs-sim
ogImage: /library/openai-vs-n8n-vs-sim/cover.jpg
canonical: https://www.sim.ai/library/openai-vs-n8n-vs-sim
draft: false
---
@@ -19,7 +19,7 @@ When building AI agent workflows, developers often evaluate multiple platforms t
OpenAI AgentKit is a set of building blocks designed to help developers take AI agents from prototype to production. Built on top of the OpenAI Responses API, it provides a structured approach to building and deploying intelligent agents.
![OpenAI AgentKit workflow interface](/blog/openai-vs-n8n-vs-sim/openai.png)
![OpenAI AgentKit workflow interface](/library/openai-vs-n8n-vs-sim/openai.png)
### Core Features
@@ -31,7 +31,7 @@ AgentKit provides a visual canvas where developers can design and build agents.
ChatKit enables developers to embed chat interfaces to run workflows directly within their applications. It includes custom widgets that you can create and integrate, with the ability to preview interfaces right in the workflow builder before deployment.
![OpenAI AgentKit custom widgets interface](/blog/openai-vs-n8n-vs-sim/widgets.png)
![OpenAI AgentKit custom widgets interface](/library/openai-vs-n8n-vs-sim/widgets.png)
#### Comprehensive Evaluation System
@@ -65,7 +65,7 @@ While AgentKit is powerful for building agents, it has some limitations:
n8n is a workflow automation platform that excels at connecting various services and APIs together. While it started as a general automation tool, n8n has evolved to support AI agent workflows alongside its traditional integration capabilities.
![n8n workflow automation interface](/blog/openai-vs-n8n-vs-sim/n8n.png)
![n8n workflow automation interface](/library/openai-vs-n8n-vs-sim/n8n.png)
### Core Capabilities
@@ -117,13 +117,13 @@ Sim is a fully open-source platform (Apache 2.0 license) specifically designed f
Sim provides an intuitive drag-and-drop canvas where developers can build complex AI agent workflows visually. The platform supports sophisticated agent architectures, including multi-agent systems, conditional logic, loops, and parallel execution paths. Additionally, Sim's built-in AI Copilot can assist you directly in the editor, helping you build and modify workflows faster with intelligent suggestions and explanations.
![Sim visual workflow builder with AI agent blocks](/blog/openai-vs-n8n-vs-sim/sim.png)
![Sim visual workflow builder with AI agent blocks](/library/openai-vs-n8n-vs-sim/sim.png)
#### AI Copilot for Workflow Building
Sim includes an intelligent in-editor AI assistant that helps you build and edit workflows faster. Copilot can explain complex concepts, suggest best practices, and even make changes to your workflow when you approve them. Using the @ context menu, you can reference workflows, blocks, knowledge bases, documentation, and execution logs—giving Copilot the full context it needs to provide accurate, relevant assistance. This dramatically accelerates workflow development compared to building from scratch.
![Sim AI Copilot assisting with workflow development](/blog/openai-vs-n8n-vs-sim/copilot.png)
![Sim AI Copilot assisting with workflow development](/library/openai-vs-n8n-vs-sim/copilot.png)
#### 80+ Built-in Integrations
@@ -149,7 +149,7 @@ Sim's native knowledge base goes far beyond simple document storage. Powered by
Sim provides enterprise-grade logging that captures every detail of workflow execution. Track workflow runs with execution IDs, view block-level logs with precise timing and duration metrics, monitor token usage and costs per execution, and debug failures with detailed error traces and trace spans. The logging system integrates with Copilot—you can reference execution logs directly in your Copilot conversations to understand what happened and troubleshoot issues. This level of observability is essential for production AI agents where understanding behavior and debugging issues quickly is critical.
![Sim execution logs and monitoring dashboard](/blog/openai-vs-n8n-vs-sim/logs.png)
![Sim execution logs and monitoring dashboard](/library/openai-vs-n8n-vs-sim/logs.png)
#### Custom Integrations via MCP Protocol
+16 -213
View File
@@ -1,221 +1,24 @@
import fs from 'fs/promises'
import path from 'path'
import { cache } from 'react'
import matter from 'gray-matter'
import { compileMDX } from 'next-mdx-remote/rsc'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
import { mdxComponents } from '@/lib/blog/mdx'
import type { BlogMeta, BlogPost, TagWithCount } from '@/lib/blog/schema'
import { AuthorSchema, BlogFrontmatterSchema } from '@/lib/blog/schema'
import { AUTHORS_DIR, BLOG_DIR, byDateDesc, ensureContentDirs, toIsoDate } from '@/lib/blog/utils'
import { createContentRegistry } from '@/lib/content/registry-factory'
const postComponentsRegistry: Record<string, Record<string, React.ComponentType>> = {}
const BLOG_DIR = path.join(process.cwd(), 'content', 'blog')
const AUTHORS_DIR = path.join(process.cwd(), 'content', 'authors')
let cachedMeta: BlogMeta[] | null = null
let cachedAuthors: Record<string, any> | null = null
async function loadAuthors(): Promise<Record<string, any>> {
if (cachedAuthors) return cachedAuthors
await ensureContentDirs()
const files = await fs.readdir(AUTHORS_DIR).catch(() => [])
const authors: Record<string, any> = {}
for (const file of files) {
if (!file.endsWith('.json')) continue
const raw = await fs.readFile(path.join(AUTHORS_DIR, file), 'utf-8')
const json = JSON.parse(raw)
const author = AuthorSchema.parse(json)
authors[author.id] = author
}
cachedAuthors = authors
return authors
}
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
}
async function scanFrontmatters(): Promise<BlogMeta[]> {
if (cachedMeta) {
return cachedMeta
}
await ensureContentDirs()
const entries = await fs.readdir(BLOG_DIR).catch(() => [])
const authorsMap = await loadAuthors()
const results = await Promise.all(
entries.map(async (slug): Promise<BlogMeta | null> => {
const postDir = path.join(BLOG_DIR, slug)
const stat = await fs.stat(postDir).catch(() => null)
if (!stat || !stat.isDirectory()) return null
const mdxPath = path.join(postDir, 'index.mdx')
const hasMdx = await fs
.stat(mdxPath)
.then((s) => s.isFile())
.catch(() => false)
if (!hasMdx) return null
const raw = await fs.readFile(mdxPath, 'utf-8')
const { data, content: mdxContent } = matter(raw)
const fm = BlogFrontmatterSchema.parse(data)
const wordCount = mdxContent
.replace(/```[\s\S]*?```/g, '')
.replace(/import\s+.*?from\s+['"].*?['"]/g, '')
.replace(/<[^>]+>/g, '')
.replace(/[#*_~`[\]()!|>-]/g, '')
.split(/\s+/)
.filter((w) => w.length > 0).length
const authors = fm.authors.map((id) => authorsMap[id]).filter(Boolean)
if (authors.length === 0) throw new Error(`Authors not found for "${slug}"`)
return {
slug: fm.slug,
title: fm.title,
description: fm.description,
date: toIsoDate(fm.date),
updated: fm.updated ? toIsoDate(fm.updated) : undefined,
author: authors[0],
authors,
readingTime: fm.readingTime,
tags: fm.tags,
ogImage: fm.ogImage,
canonical: fm.canonical,
ogAlt: fm.ogAlt,
about: fm.about,
timeRequired: fm.timeRequired,
faq: fm.faq,
wordCount,
draft: fm.draft,
featured: fm.featured ?? false,
}
})
)
cachedMeta = results.filter((result): result is BlogMeta => result !== null).sort(byDateDesc)
return cachedMeta
}
export async function getAllPostMeta(): Promise<BlogMeta[]> {
return (await scanFrontmatters()).filter((p) => !p.draft)
}
export const getNavBlogPosts = cache(
async (): Promise<Pick<BlogMeta, 'slug' | 'title' | 'ogImage'>[]> => {
const allPosts = await getAllPostMeta()
const featuredPost = allPosts.find((p) => p.featured) ?? allPosts[0]
if (!featuredPost) return []
const recentPosts = allPosts.filter((p) => p.slug !== featuredPost.slug).slice(0, 5)
return [featuredPost, ...recentPosts].map((p) => ({
slug: p.slug,
title: p.title,
ogImage: p.ogImage,
}))
}
)
export async function getAllTags(): Promise<TagWithCount[]> {
const posts = await getAllPostMeta()
const counts: Record<string, number> = {}
for (const p of posts) {
for (const t of p.tags) counts[t] = (counts[t] || 0) + 1
}
return Object.entries(counts)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const BLOG_COMPONENT_LOADERS: Record<
string,
() => Promise<Record<string, React.ComponentType<any>>>
> = {
/** Posts that ship custom MDX component overrides alongside their content. */
const BLOG_COMPONENT_LOADERS = {
enterprise: () => import('@/content/blog/enterprise/components'),
'v0-5': () => import('@/content/blog/v0-5/components'),
}
async function loadPostComponents(slug: string): Promise<Record<string, React.ComponentType>> {
if (postComponentsRegistry[slug]) {
return postComponentsRegistry[slug]
}
const blogRegistry = createContentRegistry({
contentDir: BLOG_DIR,
authorsDir: AUTHORS_DIR,
componentLoaders: BLOG_COMPONENT_LOADERS,
})
const loader = BLOG_COMPONENT_LOADERS[slug]
if (!loader) {
postComponentsRegistry[slug] = {}
return {}
}
try {
const postComponents = await loader()
postComponentsRegistry[slug] = postComponents
return postComponents
} catch {
postComponentsRegistry[slug] = {}
return {}
}
}
export async function getPostBySlug(slug: string): Promise<BlogPost> {
const meta = await scanFrontmatters()
const found = meta.find((m) => m.slug === slug)
if (!found) throw new Error(`Post not found: ${slug}`)
const mdxPath = path.join(BLOG_DIR, slug, 'index.mdx')
const raw = await fs.readFile(mdxPath, 'utf-8')
const { content, data } = matter(raw)
const fm = BlogFrontmatterSchema.parse(data)
const postComponents = await loadPostComponents(slug)
const mergedComponents = { ...mdxComponents, ...postComponents }
const compiled = await compileMDX({
source: content,
components: mergedComponents as any,
options: {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap', properties: { className: 'anchor' } }],
],
},
},
})
const headings: { text: string; id: string }[] = []
const lines = content.split('\n')
for (const line of lines) {
const match = /^##\s+(.+)$/.exec(line.trim())
if (match) {
const text = match[1].trim()
headings.push({ text, id: slugify(text) })
}
}
return {
...found,
Content: () => (compiled as any).content,
updated: fm.updated ? toIsoDate(fm.updated) : found.updated,
headings,
}
}
export function invalidateBlogCaches() {
cachedMeta = null
cachedAuthors = null
Object.keys(postComponentsRegistry).forEach((key) => delete postComponentsRegistry[key])
}
export async function getRelatedPosts(slug: string, limit = 3): Promise<BlogMeta[]> {
const posts = await getAllPostMeta()
const current = posts.find((p) => p.slug === slug)
if (!current) return []
const others = posts.filter((p) => p.slug !== slug)
const scored = others
.map((p) => ({
post: p,
score: p.tags.filter((t) => current.tags.includes(t)).length,
}))
.sort((a, b) => b.score - a.score || byDateDesc(a.post, b.post))
.slice(0, limit)
.map((x) => x.post)
return scored
}
export const getAllPostMeta = blogRegistry.getAllPostMeta
export const getPostBySlug = blogRegistry.getPostBySlug
export const getAllTags = blogRegistry.getAllTags
export const getRelatedPosts = blogRegistry.getRelatedPosts
export const getNavBlogPosts = blogRegistry.getNavPosts
export const invalidateBlogCaches = blogRegistry.invalidateCaches
+42 -164
View File
@@ -1,172 +1,50 @@
import type { Metadata } from 'next'
import type { BlogMeta } from '@/lib/blog/schema'
import { SITE_URL } from '@/lib/core/utils/urls'
import type { Author, ContentMeta } from '@/lib/content/schema'
import type { ContentSection } from '@/lib/content/seo'
import {
buildArticleJsonLd,
buildAuthorGraphJsonLd as buildAuthorGraphJsonLdGeneric,
buildAuthorMetadata as buildAuthorMetadataGeneric,
buildCollectionPageJsonLd as buildCollectionPageJsonLdGeneric,
buildFaqJsonLd,
buildIndexMetadata as buildIndexMetadataGeneric,
buildPostGraphJsonLd as buildPostGraphJsonLdGeneric,
buildPostMetadata,
buildTagsBreadcrumbJsonLd as buildTagsBreadcrumbJsonLdGeneric,
buildTagsMetadata as buildTagsMetadataGeneric,
} from '@/lib/content/seo'
export function buildPostMetadata(post: BlogMeta): 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 const BLOG_SECTION: ContentSection = {
name: 'Blog',
basePath: '/blog',
description: 'Announcements, insights, and guides for building AI agents.',
}
export function buildArticleJsonLd(post: BlogMeta) {
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 { buildArticleJsonLd, buildFaqJsonLd, buildPostMetadata }
export function buildBreadcrumbJsonLd(post: BlogMeta) {
return {
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: `${SITE_URL}/blog` },
{ '@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: BlogMeta) {
const graph: Record<string, unknown>[] = [buildArticleJsonLd(post), buildBreadcrumbJsonLd(post)]
const faq = buildFaqJsonLd(post.faq)
if (faq) {
graph.push(faq)
}
return {
'@context': 'https://schema.org',
'@graph': graph,
}
export function buildPostGraphJsonLd(post: ContentMeta) {
return buildPostGraphJsonLdGeneric(post, BLOG_SECTION)
}
export function buildCollectionPageJsonLd() {
return {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Sim Blog',
url: `${SITE_URL}/blog`,
description: 'Announcements, insights, and guides for building AI agents.',
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,
},
}
return buildCollectionPageJsonLdGeneric(BLOG_SECTION)
}
export function buildIndexMetadata(input: { tag?: string; pageNum: number }) {
return buildIndexMetadataGeneric(BLOG_SECTION, input)
}
export function buildTagsMetadata() {
return buildTagsMetadataGeneric(BLOG_SECTION)
}
export function buildTagsBreadcrumbJsonLd() {
return buildTagsBreadcrumbJsonLdGeneric(BLOG_SECTION)
}
export function buildAuthorMetadata(id: string, author?: Author) {
return buildAuthorMetadataGeneric(BLOG_SECTION, id, author)
}
export function buildAuthorGraphJsonLd(author: Author) {
return buildAuthorGraphJsonLdGeneric(BLOG_SECTION, author)
}
-27
View File
@@ -1,27 +0,0 @@
import fs from 'fs/promises'
import path from 'path'
export const BLOG_DIR = path.join(process.cwd(), 'content', 'blog')
export const AUTHORS_DIR = path.join(process.cwd(), 'content', 'authors')
export async function ensureContentDirs() {
await fs.mkdir(BLOG_DIR, { recursive: true })
await fs.mkdir(AUTHORS_DIR, { recursive: true })
}
export function toIsoDate(value: Date | string | number): string {
if (value instanceof Date) return value.toISOString()
return new Date(value).toISOString()
}
export function byDateDesc<T extends { date: string }>(a: T, b: T) {
return new Date(b.date).getTime() - new Date(a.date).getTime()
}
export function stripMdxExtension(file: string) {
return file.replace(/\.mdx?$/i, '')
}
export function isRelativeUrl(url: string) {
return url.startsWith('/')
}
@@ -9,7 +9,7 @@ interface CodeBlockProps {
export function CodeBlock({ code, language }: CodeBlockProps) {
return (
<div className='dark w-full overflow-hidden rounded-md border border-[var(--landing-bg-elevated)] bg-[var(--code-bg)] text-sm'>
<div className='dark w-full overflow-hidden rounded-md border border-[var(--border)] bg-[var(--code-bg)] text-sm'>
<Code.Viewer
code={code}
showGutter
@@ -2,11 +2,11 @@ export function FAQ({ items }: { items: { q: string; a: string }[] }) {
if (!items || items.length === 0) return null
return (
<section className='mt-12' itemScope itemType='https://schema.org/FAQPage'>
<h2 className='mb-4 font-medium text-[24px] text-[var(--landing-text)]'>FAQ</h2>
<h2 className='mb-4 font-medium text-[24px] text-[var(--text-primary)]'>FAQ</h2>
<div className='space-y-6'>
{items.map((it, i) => (
<div key={i} itemScope itemType='https://schema.org/Question' itemProp='mainEntity'>
<h3 className='mb-2 font-medium text-[20px] text-[var(--landing-text)]' itemProp='name'>
<h3 className='mb-2 font-medium text-[20px] text-[var(--text-primary)]' itemProp='name'>
{it.q}
</h3>
<div itemScope itemType='https://schema.org/Answer' itemProp='acceptedAnswer'>
@@ -1,11 +1,11 @@
import clsx from 'clsx'
import type { MDXRemoteProps } from 'next-mdx-remote/rsc'
import { CodeBlock } from '@/lib/blog/code'
import { BlogImage } from '@/app/(landing)/blog/components/blog-image'
import { CodeBlock } from '@/lib/content/code'
import { ContentImage } from '@/app/(landing)/components/content-image'
export const mdxComponents: MDXRemoteProps['components'] = {
img: (props: any) => (
<BlogImage
<ContentImage
src={props.src}
alt={props.alt || ''}
width={props.width ? Number(props.width) : 800}
@@ -17,7 +17,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
<h2
{...props}
style={{ fontSize: '30px', marginTop: '3rem', marginBottom: '1.5rem' }}
className={clsx('font-medium text-[var(--landing-text)] leading-tight', className)}
className={clsx('font-medium text-[var(--text-primary)] leading-tight', className)}
>
{children}
</h2>
@@ -26,7 +26,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
<h3
{...props}
style={{ fontSize: '24px', marginTop: '1.5rem', marginBottom: '0.75rem' }}
className={clsx('font-medium text-[var(--landing-text)] leading-tight', className)}
className={clsx('font-medium text-[var(--text-primary)] leading-tight', className)}
>
{children}
</h3>
@@ -35,7 +35,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
<h4
{...props}
style={{ fontSize: '19px', marginTop: '1.5rem', marginBottom: '0.75rem' }}
className={clsx('font-medium text-[var(--landing-text)] leading-tight', className)}
className={clsx('font-medium text-[var(--text-primary)] leading-tight', className)}
>
{children}
</h4>
@@ -44,7 +44,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
<p
{...props}
style={{ fontSize: '19px', marginBottom: '1.5rem', fontWeight: '400' }}
className={clsx('text-[var(--landing-text-muted)] leading-relaxed', props.className)}
className={clsx('text-[var(--text-body)] leading-relaxed', props.className)}
/>
),
ul: (props: any) => (
@@ -52,7 +52,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
{...props}
style={{ fontSize: '19px', marginBottom: '1rem', fontWeight: '400' }}
className={clsx(
'list-outside list-disc pl-6 text-[var(--landing-text-muted)] leading-relaxed',
'list-outside list-disc pl-6 text-[var(--text-body)] leading-relaxed',
props.className
)}
/>
@@ -62,7 +62,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
{...props}
style={{ fontSize: '19px', marginBottom: '1rem', fontWeight: '400' }}
className={clsx(
'list-outside list-decimal pl-6 text-[var(--landing-text-muted)] leading-relaxed',
'list-outside list-decimal pl-6 text-[var(--text-body)] leading-relaxed',
props.className
)}
/>
@@ -71,10 +71,12 @@ export const mdxComponents: MDXRemoteProps['components'] = {
strong: (props: any) => (
<strong
{...props}
className={clsx('font-semibold text-[var(--landing-text)]', props.className)}
className={clsx('font-semibold text-[var(--text-primary)]', props.className)}
/>
),
em: (props: any) => <em {...props} className={clsx('text-[#bbb] italic', props.className)} />,
em: (props: any) => (
<em {...props} className={clsx('text-[var(--text-muted)] italic', props.className)} />
),
a: (props: any) => {
const isAnchorLink = props.className?.includes('anchor')
if (isAnchorLink) {
@@ -84,7 +86,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
<a
{...props}
className={clsx(
'font-medium text-[var(--landing-text)] underline hover:text-white',
'font-medium text-[var(--text-primary)] underline hover:text-[var(--text-primary)]',
props.className
)}
/>
@@ -96,7 +98,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
hr: (props: any) => (
<hr
{...props}
className={clsx('my-8 border-[var(--landing-bg-elevated)]', props.className)}
className={clsx('my-8 border-[var(--border)]', props.className)}
style={{ marginBottom: '1.5rem' }}
/>
),
@@ -140,7 +142,7 @@ export const mdxComponents: MDXRemoteProps['components'] = {
<code
{...props}
className={clsx(
'rounded bg-[var(--landing-bg-elevated)] px-1.5 py-0.5 font-mono font-normal text-[0.9em] text-[var(--landing-text)]',
'rounded bg-[var(--surface-hover)] px-1.5 py-0.5 font-mono font-normal text-[0.9em] text-[var(--text-primary)]',
props.className
)}
style={{ fontWeight: 400 }}
+276
View File
@@ -0,0 +1,276 @@
import fs from 'fs/promises'
import path from 'path'
import { cache } from 'react'
import matter from 'gray-matter'
import { compileMDX } from 'next-mdx-remote/rsc'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
import { mdxComponents } from '@/lib/content/mdx'
import type { Author, ContentMeta, ContentPost, TagWithCount } from '@/lib/content/schema'
import { AuthorSchema, ContentFrontmatterSchema } from '@/lib/content/schema'
import { byDateDesc, ensureContentDirs, toIsoDate } from '@/lib/content/utils'
/** Loads a post's custom MDX component overrides, keyed by slug. */
export type ContentComponentLoaders = Record<
string,
() => Promise<Record<string, React.ComponentType<any>>>
>
export interface ContentRegistryConfig {
/** Directory holding one folder per post (`<contentDir>/<slug>/index.mdx`). */
contentDir: string
/** Directory holding one JSON file per author, shared across sections. */
authorsDir: string
/** Per-slug custom MDX component overrides, merged over the base `mdxComponents` map. */
componentLoaders?: ContentComponentLoaders
}
export interface ContentRegistry {
getAllPostMeta: () => Promise<ContentMeta[]>
getPostBySlug: (slug: string) => Promise<ContentPost>
getAllTags: () => Promise<TagWithCount[]>
getRelatedPosts: (slug: string, limit?: number) => Promise<ContentMeta[]>
getNavPosts: () => Promise<Pick<ContentMeta, 'slug' | 'title' | 'ogImage'>[]>
invalidateCaches: () => void
}
function slugifyHeading(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
}
/**
* Author JSON is shared across every section (blog, library, ...) that
* points at the same `authorsDir`. Cache the parsed result per directory at
* module scope so multiple registry instantiations never re-read/re-parse
* the same author files.
*/
const authorsCacheByDir = new Map<string, Promise<Record<string, Author>>>()
async function loadAuthorsForDir(authorsDir: string): Promise<Record<string, Author>> {
const cached = authorsCacheByDir.get(authorsDir)
if (cached) return cached
const promise = (async () => {
await fs.mkdir(authorsDir, { recursive: true })
const files = await fs.readdir(authorsDir).catch(() => [])
const authors: Record<string, Author> = {}
for (const file of files) {
if (!file.endsWith('.json')) continue
const raw = await fs.readFile(path.join(authorsDir, file), 'utf-8')
const json = JSON.parse(raw)
const author = AuthorSchema.parse(json)
authors[author.id] = author
}
return authors
})()
authorsCacheByDir.set(authorsDir, promise)
return promise
}
/**
* Builds an independent content registry (frontmatter scanning, MDX
* compilation, tag/related-post derivation, in-memory caching) over a single
* content directory. Each call owns its own post-level cache state via
* closures, so separate instantiations (e.g. blog vs. library) never
* collide, while the shared author pool is cached once per `authorsDir`
* (see `loadAuthorsForDir`).
*/
export function createContentRegistry(config: ContentRegistryConfig): ContentRegistry {
const { contentDir, authorsDir, componentLoaders = {} } = config
const postComponentsRegistry: Record<string, Record<string, React.ComponentType>> = {}
let cachedMeta: ContentMeta[] | null = null
async function loadAuthors(): Promise<Record<string, Author>> {
return loadAuthorsForDir(authorsDir)
}
async function scanFrontmatters(): Promise<ContentMeta[]> {
if (cachedMeta) {
return cachedMeta
}
await ensureContentDirs(contentDir, authorsDir)
const entries = await fs.readdir(contentDir).catch(() => [])
const authorsMap = await loadAuthors()
const results = await Promise.all(
entries.map(async (slug): Promise<ContentMeta | null> => {
const postDir = path.join(contentDir, slug)
const stat = await fs.stat(postDir).catch(() => null)
if (!stat || !stat.isDirectory()) return null
const mdxPath = path.join(postDir, 'index.mdx')
const hasMdx = await fs
.stat(mdxPath)
.then((s) => s.isFile())
.catch(() => false)
if (!hasMdx) return null
const raw = await fs.readFile(mdxPath, 'utf-8')
const { data, content: mdxContent } = matter(raw)
const fm = ContentFrontmatterSchema.parse(data)
const wordCount = mdxContent
.replace(/```[\s\S]*?```/g, '')
.replace(/import\s+.*?from\s+['"].*?['"]/g, '')
.replace(/<[^>]+>/g, '')
.replace(/[#*_~`[\]()!|>-]/g, '')
.split(/\s+/)
.filter((w) => w.length > 0).length
const authors = fm.authors.map((id) => authorsMap[id]).filter(Boolean)
if (authors.length === 0) throw new Error(`Authors not found for "${slug}"`)
return {
slug: fm.slug,
title: fm.title,
description: fm.description,
date: toIsoDate(fm.date),
updated: fm.updated ? toIsoDate(fm.updated) : undefined,
author: authors[0],
authors,
readingTime: fm.readingTime,
tags: fm.tags,
ogImage: fm.ogImage,
canonical: fm.canonical,
ogAlt: fm.ogAlt,
about: fm.about,
timeRequired: fm.timeRequired,
faq: fm.faq,
wordCount,
draft: fm.draft,
featured: fm.featured ?? false,
}
})
)
cachedMeta = results.filter((result): result is ContentMeta => result !== null).sort(byDateDesc)
return cachedMeta
}
async function getAllPostMeta(): Promise<ContentMeta[]> {
return (await scanFrontmatters()).filter((p) => !p.draft)
}
/**
* Featured + 5 most recent posts for a navbar dropdown preview. Reserved
* for a future Blog/Library nav dropdown (same "defined but not yet wired
* up" pattern as `PLATFORM_MENU`/`SOLUTIONS_MENU` in
* `navbar/components/nav-menu-chip/constants.ts`); currently unconsumed.
*/
const getNavPosts = cache(
async (): Promise<Pick<ContentMeta, 'slug' | 'title' | 'ogImage'>[]> => {
const allPosts = await getAllPostMeta()
const featuredPost = allPosts.find((p) => p.featured) ?? allPosts[0]
if (!featuredPost) return []
const recentPosts = allPosts.filter((p) => p.slug !== featuredPost.slug).slice(0, 5)
return [featuredPost, ...recentPosts].map((p) => ({
slug: p.slug,
title: p.title,
ogImage: p.ogImage,
}))
}
)
async function getAllTags(): Promise<TagWithCount[]> {
const posts = await getAllPostMeta()
const counts: Record<string, number> = {}
for (const p of posts) {
for (const t of p.tags) counts[t] = (counts[t] || 0) + 1
}
return Object.entries(counts)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
}
async function loadPostComponents(slug: string): Promise<Record<string, React.ComponentType>> {
if (postComponentsRegistry[slug]) {
return postComponentsRegistry[slug]
}
const loader = componentLoaders[slug]
if (!loader) {
postComponentsRegistry[slug] = {}
return {}
}
try {
const postComponents = await loader()
postComponentsRegistry[slug] = postComponents
return postComponents
} catch {
postComponentsRegistry[slug] = {}
return {}
}
}
async function getPostBySlug(slug: string): Promise<ContentPost> {
const meta = await scanFrontmatters()
const found = meta.find((m) => m.slug === slug)
if (!found) throw new Error(`Post not found: ${slug}`)
const mdxPath = path.join(contentDir, slug, 'index.mdx')
const raw = await fs.readFile(mdxPath, 'utf-8')
const { content, data } = matter(raw)
const fm = ContentFrontmatterSchema.parse(data)
const postComponents = await loadPostComponents(slug)
const mergedComponents = { ...mdxComponents, ...postComponents }
const compiled = await compileMDX({
source: content,
components: mergedComponents as any,
options: {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap', properties: { className: 'anchor' } }],
],
},
},
})
const headings: { text: string; id: string }[] = []
const lines = content.split('\n')
for (const line of lines) {
const match = /^##\s+(.+)$/.exec(line.trim())
if (match) {
const text = match[1].trim()
headings.push({ text, id: slugifyHeading(text) })
}
}
return {
...found,
Content: () => (compiled as any).content,
updated: fm.updated ? toIsoDate(fm.updated) : found.updated,
headings,
}
}
async function getRelatedPosts(slug: string, limit = 3): Promise<ContentMeta[]> {
const posts = await getAllPostMeta()
const current = posts.find((p) => p.slug === slug)
if (!current) return []
const others = posts.filter((p) => p.slug !== slug)
return others
.map((p) => ({
post: p,
score: p.tags.filter((t) => current.tags.includes(t)).length,
}))
.sort((a, b) => b.score - a.score || byDateDesc(a.post, b.post))
.slice(0, limit)
.map((x) => x.post)
}
function invalidateCaches() {
cachedMeta = null
authorsCacheByDir.delete(authorsDir)
Object.keys(postComponentsRegistry).forEach((key) => delete postComponentsRegistry[key])
}
return {
getAllPostMeta,
getPostBySlug,
getAllTags,
getRelatedPosts,
getNavPosts,
invalidateCaches,
}
}
@@ -12,7 +12,12 @@ export const AuthorSchema = z
export type Author = z.infer<typeof AuthorSchema>
export const BlogFrontmatterSchema = z
/**
* Frontmatter schema shared by every content section (blog, library, and any
* future section). Section-specific behavior lives in the section's registry
* instantiation, not in this schema.
*/
export const ContentFrontmatterSchema = z
.object({
slug: z.string().min(1),
title: z.string().min(5),
@@ -40,9 +45,9 @@ export const BlogFrontmatterSchema = z
})
.strict()
export type BlogFrontmatter = z.infer<typeof BlogFrontmatterSchema>
export type ContentFrontmatter = z.infer<typeof ContentFrontmatterSchema>
export interface BlogMeta {
export interface ContentMeta {
slug: string
title: string
description: string
@@ -61,10 +66,9 @@ export interface BlogMeta {
canonical: string
draft: boolean
featured: boolean
sourcePath?: string
}
export interface BlogPost extends BlogMeta {
export interface ContentPost extends ContentMeta {
Content: React.ComponentType
headings?: { text: string; id: string }[]
}
+366
View File
@@ -0,0 +1,366 @@
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,
},
}
}
+24
View File
@@ -0,0 +1,24 @@
import fs from 'fs/promises'
export async function ensureContentDirs(contentDir: string, authorsDir: string) {
await fs.mkdir(contentDir, { recursive: true })
await fs.mkdir(authorsDir, { recursive: true })
}
export function toIsoDate(value: Date | string | number): string {
if (value instanceof Date) return value.toISOString()
return new Date(value).toISOString()
}
export function byDateDesc<T extends { date: string }>(a: T, b: T) {
return new Date(b.date).getTime() - new Date(a.date).getTime()
}
/** Most recent `updated ?? date` across a set of posts, or `undefined` if empty. */
export function latestModified<T extends { date: string; updated?: string }>(
posts: T[]
): Date | undefined {
return posts.length > 0
? new Date(Math.max(...posts.map((p) => new Date(p.updated ?? p.date).getTime())))
: undefined
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
import path from 'path'
import { createContentRegistry } from '@/lib/content/registry-factory'
const LIBRARY_DIR = path.join(process.cwd(), 'content', 'library')
const AUTHORS_DIR = path.join(process.cwd(), 'content', 'authors')
const libraryRegistry = createContentRegistry({
contentDir: LIBRARY_DIR,
authorsDir: AUTHORS_DIR,
})
export const getAllPostMeta = libraryRegistry.getAllPostMeta
export const getPostBySlug = libraryRegistry.getPostBySlug
export const getAllTags = libraryRegistry.getAllTags
export const getRelatedPosts = libraryRegistry.getRelatedPosts
export const getNavLibraryPosts = libraryRegistry.getNavPosts
export const invalidateLibraryCaches = libraryRegistry.invalidateCaches
+51
View File
@@ -0,0 +1,51 @@
import type { Author, ContentMeta } from '@/lib/content/schema'
import type { ContentSection } from '@/lib/content/seo'
import {
buildArticleJsonLd,
buildAuthorGraphJsonLd as buildAuthorGraphJsonLdGeneric,
buildAuthorMetadata as buildAuthorMetadataGeneric,
buildCollectionPageJsonLd as buildCollectionPageJsonLdGeneric,
buildFaqJsonLd,
buildIndexMetadata as buildIndexMetadataGeneric,
buildPostGraphJsonLd as buildPostGraphJsonLdGeneric,
buildPostMetadata,
buildTagsBreadcrumbJsonLd as buildTagsBreadcrumbJsonLdGeneric,
buildTagsMetadata as buildTagsMetadataGeneric,
} from '@/lib/content/seo'
export const LIBRARY_SECTION: ContentSection = {
name: 'Library',
basePath: '/library',
description:
'Comparisons, how-tos, and roundups for teams evaluating and building AI agents with Sim.',
}
export { buildArticleJsonLd, buildFaqJsonLd, buildPostMetadata }
export function buildPostGraphJsonLd(post: ContentMeta) {
return buildPostGraphJsonLdGeneric(post, LIBRARY_SECTION)
}
export function buildCollectionPageJsonLd() {
return buildCollectionPageJsonLdGeneric(LIBRARY_SECTION)
}
export function buildIndexMetadata(input: { tag?: string; pageNum: number }) {
return buildIndexMetadataGeneric(LIBRARY_SECTION, input)
}
export function buildTagsMetadata() {
return buildTagsMetadataGeneric(LIBRARY_SECTION)
}
export function buildTagsBreadcrumbJsonLd() {
return buildTagsBreadcrumbJsonLdGeneric(LIBRARY_SECTION)
}
export function buildAuthorMetadata(id: string, author?: Author) {
return buildAuthorMetadataGeneric(LIBRARY_SECTION, id, author)
}
export function buildAuthorGraphJsonLd(author: Author) {
return buildAuthorGraphJsonLdGeneric(LIBRARY_SECTION, author)
}
+20
View File
@@ -409,6 +409,26 @@ const nextConfig: NextConfig = {
permanent: true,
})
/**
* AEO/GEO-style posts (listicles, comparisons, how-tos) were split out of
* `/blog` into the dedicated `/library` section so `/blog` stays
* editorial-only. Preserve previously indexed URLs for the moved posts.
*/
for (const slug of [
'best-zapier-alternatives',
'ai-agents-vs-rpa',
'ai-agent-vs-chatbot',
'openai-vs-n8n-vs-sim',
'ai-agent-ideas',
'how-to-create-an-ai-agent',
]) {
redirects.push({
source: `/blog/${slug}`,
destination: `/library/${slug}`,
permanent: true,
})
}
return redirects
},
async rewrites() {

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Before

Width:  |  Height:  |  Size: 999 KiB

After

Width:  |  Height:  |  Size: 999 KiB

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Before

Width:  |  Height:  |  Size: 452 KiB

After

Width:  |  Height:  |  Size: 452 KiB

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 374 KiB

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 387 KiB

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 523 KiB