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.
@@ -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
|
||||
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "Emir Karabeg",
|
||||
"url": "https://x.com/emkara",
|
||||
"xHandle": "emkara",
|
||||
"avatarUrl": "/blog/authors/emir.jpg"
|
||||
"avatarUrl": "/authors/emir.jpg"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "Siddharth",
|
||||
"url": "https://x.com/sidganesan",
|
||||
"xHandle": "sidganesan",
|
||||
"avatarUrl": "/blog/authors/sid.jpg"
|
||||
"avatarUrl": "/authors/sid.jpg"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "Vikhyath Mondreti",
|
||||
"url": "https://github.com/icecrasher321",
|
||||
"xHandle": "icecrasher321",
|
||||
"avatarUrl": "/blog/authors/vik.jpg"
|
||||
"avatarUrl": "/authors/vik.jpg"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
#### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
#### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
#### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
#### Custom Integrations via MCP Protocol
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 }[]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 |