chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
'use client'
import { Code } from '@sim/emcn'
interface CodeBlockProps {
code: string
language: 'javascript' | 'json' | 'python'
}
export function CodeBlock({ code, language }: CodeBlockProps) {
return (
<div className='dark w-full overflow-hidden rounded-md border border-[var(--border)] bg-[var(--code-bg)] text-sm'>
<Code.Viewer
code={code}
showGutter
language={language}
className='[&_pre]:!pb-0 m-0 rounded-none border-0 bg-transparent'
/>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
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(--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(--text-primary)]' itemProp='name'>
{it.q}
</h3>
<div itemScope itemType='https://schema.org/Answer' itemProp='acceptedAnswer'>
<p className='text-[19px] text-[var(--text-subtle)] leading-relaxed' itemProp='text'>
{it.a}
</p>
</div>
</div>
))}
</div>
</section>
)
}
+154
View File
@@ -0,0 +1,154 @@
import clsx from 'clsx'
import type { MDXRemoteProps } from 'next-mdx-remote/rsc'
import { CodeBlock } from '@/lib/content/code'
import { ContentImage } from '@/app/(landing)/components/content-image'
export const mdxComponents: MDXRemoteProps['components'] = {
img: (props: any) => (
<ContentImage
src={props.src}
alt={props.alt || ''}
width={props.width ? Number(props.width) : 800}
height={props.height ? Number(props.height) : 450}
className={props.className}
/>
),
h2: ({ children, className, ...props }: any) => (
<h2
{...props}
style={{ fontSize: '30px', marginTop: '3rem', marginBottom: '1.5rem' }}
className={clsx('font-medium text-[var(--text-primary)] leading-tight', className)}
>
{children}
</h2>
),
h3: ({ children, className, ...props }: any) => (
<h3
{...props}
style={{ fontSize: '24px', marginTop: '1.5rem', marginBottom: '0.75rem' }}
className={clsx('font-medium text-[var(--text-primary)] leading-tight', className)}
>
{children}
</h3>
),
h4: ({ children, className, ...props }: any) => (
<h4
{...props}
style={{ fontSize: '19px', marginTop: '1.5rem', marginBottom: '0.75rem' }}
className={clsx('font-medium text-[var(--text-primary)] leading-tight', className)}
>
{children}
</h4>
),
p: (props: any) => (
<p
{...props}
style={{ fontSize: '19px', marginBottom: '1.5rem', fontWeight: '400' }}
className={clsx('text-[var(--text-body)] leading-relaxed', props.className)}
/>
),
ul: (props: any) => (
<ul
{...props}
style={{ fontSize: '19px', marginBottom: '1rem', fontWeight: '400' }}
className={clsx(
'list-outside list-disc pl-6 text-[var(--text-body)] leading-relaxed',
props.className
)}
/>
),
ol: (props: any) => (
<ol
{...props}
style={{ fontSize: '19px', marginBottom: '1rem', fontWeight: '400' }}
className={clsx(
'list-outside list-decimal pl-6 text-[var(--text-body)] leading-relaxed',
props.className
)}
/>
),
li: (props: any) => <li {...props} className={clsx('mb-1', props.className)} />,
strong: (props: any) => (
<strong
{...props}
className={clsx('font-semibold text-[var(--text-primary)]', 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) {
return <a {...props} className={clsx('text-inherit no-underline', props.className)} />
}
return (
<a
{...props}
className={clsx(
'font-medium text-[var(--text-primary)] underline hover:text-[var(--text-primary)]',
props.className
)}
/>
)
},
figure: (props: any) => (
<figure {...props} className={clsx('my-8 overflow-hidden rounded-lg', props.className)} />
),
hr: (props: any) => (
<hr
{...props}
className={clsx('my-8 border-[var(--border)]', props.className)}
style={{ marginBottom: '1.5rem' }}
/>
),
pre: (props: any) => {
const child = props.children
const isCodeBlock = child && typeof child === 'object' && child.props
if (isCodeBlock) {
const codeContent = child.props.children || ''
const className = child.props.className || ''
const language = className.replace('language-', '') || 'javascript'
const languageMap: Record<string, 'javascript' | 'json' | 'python'> = {
js: 'javascript',
jsx: 'javascript',
ts: 'javascript',
tsx: 'javascript',
typescript: 'javascript',
javascript: 'javascript',
json: 'json',
python: 'python',
py: 'python',
}
const mappedLanguage = languageMap[language.toLowerCase()] || 'javascript'
return (
<div className='not-prose my-6'>
<CodeBlock
code={typeof codeContent === 'string' ? codeContent.trim() : String(codeContent)}
language={mappedLanguage}
/>
</div>
)
}
return <pre {...props} className={clsx('my-4 overflow-x-auto rounded-lg', props.className)} />
},
code: (props: any) => {
if (!props.className) {
return (
<code
{...props}
className={clsx(
'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 }}
/>
)
}
return <code {...props} />
},
}
+276
View File
@@ -0,0 +1,276 @@
import fs from 'fs/promises'
import path from 'path'
import { cache } from 'react'
import matter from 'gray-matter'
import { compileMDX } from 'next-mdx-remote/rsc'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeSlug from 'rehype-slug'
import remarkGfm from 'remark-gfm'
import { mdxComponents } from '@/lib/content/mdx'
import type { Author, ContentMeta, ContentPost, TagWithCount } from '@/lib/content/schema'
import { AuthorSchema, ContentFrontmatterSchema } from '@/lib/content/schema'
import { byDateDesc, ensureContentDirs, toIsoDate } from '@/lib/content/utils'
/** Loads a post's custom MDX component overrides, keyed by slug. */
export type ContentComponentLoaders = Record<
string,
() => Promise<Record<string, React.ComponentType<any>>>
>
export interface ContentRegistryConfig {
/** Directory holding one folder per post (`<contentDir>/<slug>/index.mdx`). */
contentDir: string
/** Directory holding one JSON file per author, shared across sections. */
authorsDir: string
/** Per-slug custom MDX component overrides, merged over the base `mdxComponents` map. */
componentLoaders?: ContentComponentLoaders
}
export interface ContentRegistry {
getAllPostMeta: () => Promise<ContentMeta[]>
getPostBySlug: (slug: string) => Promise<ContentPost>
getAllTags: () => Promise<TagWithCount[]>
getRelatedPosts: (slug: string, limit?: number) => Promise<ContentMeta[]>
getNavPosts: () => Promise<Pick<ContentMeta, 'slug' | 'title' | 'ogImage'>[]>
invalidateCaches: () => void
}
function slugifyHeading(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
}
/**
* Author JSON is shared across every section (blog, library, ...) that
* points at the same `authorsDir`. Cache the parsed result per directory at
* module scope so multiple registry instantiations never re-read/re-parse
* the same author files.
*/
const authorsCacheByDir = new Map<string, Promise<Record<string, Author>>>()
async function loadAuthorsForDir(authorsDir: string): Promise<Record<string, Author>> {
const cached = authorsCacheByDir.get(authorsDir)
if (cached) return cached
const promise = (async () => {
await fs.mkdir(authorsDir, { recursive: true })
const files = await fs.readdir(authorsDir).catch(() => [])
const authors: Record<string, Author> = {}
for (const file of files) {
if (!file.endsWith('.json')) continue
const raw = await fs.readFile(path.join(authorsDir, file), 'utf-8')
const json = JSON.parse(raw)
const author = AuthorSchema.parse(json)
authors[author.id] = author
}
return authors
})()
authorsCacheByDir.set(authorsDir, promise)
return promise
}
/**
* Builds an independent content registry (frontmatter scanning, MDX
* compilation, tag/related-post derivation, in-memory caching) over a single
* content directory. Each call owns its own post-level cache state via
* closures, so separate instantiations (e.g. blog vs. library) never
* collide, while the shared author pool is cached once per `authorsDir`
* (see `loadAuthorsForDir`).
*/
export function createContentRegistry(config: ContentRegistryConfig): ContentRegistry {
const { contentDir, authorsDir, componentLoaders = {} } = config
const postComponentsRegistry: Record<string, Record<string, React.ComponentType>> = {}
let cachedMeta: ContentMeta[] | null = null
async function loadAuthors(): Promise<Record<string, Author>> {
return loadAuthorsForDir(authorsDir)
}
async function scanFrontmatters(): Promise<ContentMeta[]> {
if (cachedMeta) {
return cachedMeta
}
await ensureContentDirs(contentDir, authorsDir)
const entries = await fs.readdir(contentDir).catch(() => [])
const authorsMap = await loadAuthors()
const results = await Promise.all(
entries.map(async (slug): Promise<ContentMeta | null> => {
const postDir = path.join(contentDir, slug)
const stat = await fs.stat(postDir).catch(() => null)
if (!stat || !stat.isDirectory()) return null
const mdxPath = path.join(postDir, 'index.mdx')
const hasMdx = await fs
.stat(mdxPath)
.then((s) => s.isFile())
.catch(() => false)
if (!hasMdx) return null
const raw = await fs.readFile(mdxPath, 'utf-8')
const { data, content: mdxContent } = matter(raw)
const fm = ContentFrontmatterSchema.parse(data)
const wordCount = mdxContent
.replace(/```[\s\S]*?```/g, '')
.replace(/import\s+.*?from\s+['"].*?['"]/g, '')
.replace(/<[^>]+>/g, '')
.replace(/[#*_~`[\]()!|>-]/g, '')
.split(/\s+/)
.filter((w) => w.length > 0).length
const authors = fm.authors.map((id) => authorsMap[id]).filter(Boolean)
if (authors.length === 0) throw new Error(`Authors not found for "${slug}"`)
return {
slug: fm.slug,
title: fm.title,
description: fm.description,
date: toIsoDate(fm.date),
updated: fm.updated ? toIsoDate(fm.updated) : undefined,
author: authors[0],
authors,
readingTime: fm.readingTime,
tags: fm.tags,
ogImage: fm.ogImage,
canonical: fm.canonical,
ogAlt: fm.ogAlt,
about: fm.about,
timeRequired: fm.timeRequired,
faq: fm.faq,
wordCount,
draft: fm.draft,
featured: fm.featured ?? false,
}
})
)
cachedMeta = results.filter((result): result is ContentMeta => result !== null).sort(byDateDesc)
return cachedMeta
}
async function getAllPostMeta(): Promise<ContentMeta[]> {
return (await scanFrontmatters()).filter((p) => !p.draft)
}
/**
* Featured + 5 most recent posts for a navbar dropdown preview. Reserved
* for a future Blog/Library nav dropdown (same "defined but not yet wired
* up" pattern as `PLATFORM_MENU`/`SOLUTIONS_MENU` in
* `navbar/components/nav-menu-chip/constants.ts`); currently unconsumed.
*/
const getNavPosts = cache(
async (): Promise<Pick<ContentMeta, 'slug' | 'title' | 'ogImage'>[]> => {
const allPosts = await getAllPostMeta()
const featuredPost = allPosts.find((p) => p.featured) ?? allPosts[0]
if (!featuredPost) return []
const recentPosts = allPosts.filter((p) => p.slug !== featuredPost.slug).slice(0, 5)
return [featuredPost, ...recentPosts].map((p) => ({
slug: p.slug,
title: p.title,
ogImage: p.ogImage,
}))
}
)
async function getAllTags(): Promise<TagWithCount[]> {
const posts = await getAllPostMeta()
const counts: Record<string, number> = {}
for (const p of posts) {
for (const t of p.tags) counts[t] = (counts[t] || 0) + 1
}
return Object.entries(counts)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
}
async function loadPostComponents(slug: string): Promise<Record<string, React.ComponentType>> {
if (postComponentsRegistry[slug]) {
return postComponentsRegistry[slug]
}
const loader = componentLoaders[slug]
if (!loader) {
postComponentsRegistry[slug] = {}
return {}
}
try {
const postComponents = await loader()
postComponentsRegistry[slug] = postComponents
return postComponents
} catch {
postComponentsRegistry[slug] = {}
return {}
}
}
async function getPostBySlug(slug: string): Promise<ContentPost> {
const meta = await scanFrontmatters()
const found = meta.find((m) => m.slug === slug)
if (!found) throw new Error(`Post not found: ${slug}`)
const mdxPath = path.join(contentDir, slug, 'index.mdx')
const raw = await fs.readFile(mdxPath, 'utf-8')
const { content, data } = matter(raw)
const fm = ContentFrontmatterSchema.parse(data)
const postComponents = await loadPostComponents(slug)
const mergedComponents = { ...mdxComponents, ...postComponents }
const compiled = await compileMDX({
source: content,
components: mergedComponents as any,
options: {
parseFrontmatter: false,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap', properties: { className: 'anchor' } }],
],
},
},
})
const headings: { text: string; id: string }[] = []
const lines = content.split('\n')
for (const line of lines) {
const match = /^##\s+(.+)$/.exec(line.trim())
if (match) {
const text = match[1].trim()
headings.push({ text, id: slugifyHeading(text) })
}
}
return {
...found,
Content: () => (compiled as any).content,
updated: fm.updated ? toIsoDate(fm.updated) : found.updated,
headings,
}
}
async function getRelatedPosts(slug: string, limit = 3): Promise<ContentMeta[]> {
const posts = await getAllPostMeta()
const current = posts.find((p) => p.slug === slug)
if (!current) return []
const others = posts.filter((p) => p.slug !== slug)
return others
.map((p) => ({
post: p,
score: p.tags.filter((t) => current.tags.includes(t)).length,
}))
.sort((a, b) => b.score - a.score || byDateDesc(a.post, b.post))
.slice(0, limit)
.map((x) => x.post)
}
function invalidateCaches() {
cachedMeta = null
authorsCacheByDir.delete(authorsDir)
Object.keys(postComponentsRegistry).forEach((key) => delete postComponentsRegistry[key])
}
return {
getAllPostMeta,
getPostBySlug,
getAllTags,
getRelatedPosts,
getNavPosts,
invalidateCaches,
}
}
+79
View File
@@ -0,0 +1,79 @@
import { z } from 'zod'
export const AuthorSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
url: z.string().url().optional(),
xHandle: z.string().optional(),
avatarUrl: z.string().optional(), // allow relative or absolute
})
.strict()
export type Author = z.infer<typeof AuthorSchema>
/**
* 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),
description: z.string().min(20),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
authors: z.array(z.string()).min(1),
readingTime: z.number().int().positive().optional(),
tags: z.array(z.string()).default([]),
ogImage: z.string().min(1), // local path (e.g. /blog/<slug>/cover.jpg) - rendered via next/image without `unoptimized`
ogAlt: z.string().optional(),
about: z.array(z.string()).optional(),
timeRequired: z.string().optional(),
faq: z
.array(
z.object({
q: z.string().min(1),
a: z.string().min(1),
})
)
.optional(),
canonical: z.string().url(),
draft: z.boolean().default(false),
featured: z.boolean().default(false),
})
.strict()
export type ContentFrontmatter = z.infer<typeof ContentFrontmatterSchema>
export interface ContentMeta {
slug: string
title: string
description: string
date: string // ISO
updated?: string // ISO
author: Author
authors: Author[]
readingTime?: number
tags: string[]
ogImage: string
ogAlt?: string
about?: string[]
timeRequired?: string
faq?: { q: string; a: string }[]
wordCount?: number
canonical: string
draft: boolean
featured: boolean
}
export interface ContentPost extends ContentMeta {
Content: React.ComponentType
headings?: { text: string; id: string }[]
}
export interface TagWithCount {
tag: string
count: number
}
+366
View File
@@ -0,0 +1,366 @@
import type { Metadata } from 'next'
import type { Author, ContentMeta } from '@/lib/content/schema'
import { SITE_URL } from '@/lib/core/utils/urls'
import { withFilteredNoindex } from '@/lib/landing/seo'
/**
* Identifies the content section a post/collection belongs to, so the
* generic SEO builders below can emit section-correct breadcrumbs and
* collection metadata without hardcoding "Blog"/"/blog" anywhere.
*/
export interface ContentSection {
/** Display name, e.g. "Blog" or "Library". */
name: string
/** Route base path, e.g. "/blog" or "/library". */
basePath: string
/** Collection-page description used in `CollectionPage` JSON-LD. */
description: string
}
export function buildPostMetadata(post: ContentMeta): Metadata {
const base = new URL(post.canonical)
const baseUrl = `${base.protocol}//${base.host}`
return {
title: post.title,
description: post.description,
keywords: post.tags,
authors: (post.authors && post.authors.length > 0 ? post.authors : [post.author]).map((a) => ({
name: a.name,
url: a.url,
})),
creator: post.author.name,
publisher: 'Sim',
robots: post.draft
? { index: false, follow: false, googleBot: { index: false, follow: false } }
: { index: true, follow: true, googleBot: { index: true, follow: true } },
alternates: { canonical: post.canonical },
openGraph: {
title: post.title,
description: post.description,
url: post.canonical,
siteName: 'Sim',
locale: 'en_US',
type: 'article',
publishedTime: post.date,
modifiedTime: post.updated ?? post.date,
authors: (post.authors && post.authors.length > 0 ? post.authors : [post.author]).map(
(a) => a.name
),
tags: post.tags,
images: [
{
url: post.ogImage.startsWith('http') ? post.ogImage : `${baseUrl}${post.ogImage}`,
width: 1200,
height: 630,
alt: post.ogAlt || post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
images: [post.ogImage],
creator: post.author.url?.includes('x.com') ? `@${post.author.xHandle || ''}` : undefined,
site: '@simdotai',
},
other: {
'article:published_time': post.date,
'article:modified_time': post.updated ?? post.date,
'article:author': post.author.name,
'article:section': 'Technology',
},
}
}
export function buildArticleJsonLd(post: ContentMeta) {
return {
'@type': 'TechArticle',
url: post.canonical,
headline: post.title,
description: post.description,
image: [
{
'@type': 'ImageObject',
url: post.ogImage,
width: 1200,
height: 630,
caption: post.ogAlt || post.title,
},
],
datePublished: post.date,
dateModified: post.updated ?? post.date,
wordCount: post.wordCount,
proficiencyLevel: 'Beginner',
author: (post.authors && post.authors.length > 0 ? post.authors : [post.author]).map((a) => ({
'@type': 'Person',
name: a.name,
url: a.url,
...(a.url ? { sameAs: [a.url] } : {}),
})),
publisher: {
'@type': 'Organization',
name: 'Sim',
url: SITE_URL,
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/logo/primary/medium.png`,
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': post.canonical,
},
keywords: post.tags.join(', '),
about: (post.about || []).map((a) => ({ '@type': 'Thing', name: a })),
isAccessibleForFree: true,
timeRequired: post.timeRequired,
articleSection: 'Technology',
inLanguage: 'en-US',
speakable: {
'@type': 'SpeakableSpecification',
cssSelector: ['[itemprop="headline"]', '[itemprop="description"]'],
},
}
}
export function buildBreadcrumbJsonLd(post: ContentMeta, section: ContentSection) {
return {
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{
'@type': 'ListItem',
position: 2,
name: section.name,
item: `${SITE_URL}${section.basePath}`,
},
{ '@type': 'ListItem', position: 3, name: post.title, item: post.canonical },
],
}
}
export function buildFaqJsonLd(items: { q: string; a: string }[] | undefined) {
if (!items || items.length === 0) return null
return {
'@type': 'FAQPage',
mainEntity: items.map((it) => ({
'@type': 'Question',
name: it.q,
acceptedAnswer: { '@type': 'Answer', text: it.a },
})),
}
}
export function buildPostGraphJsonLd(post: ContentMeta, section: ContentSection) {
const graph: Record<string, unknown>[] = [
buildArticleJsonLd(post),
buildBreadcrumbJsonLd(post, section),
]
const faq = buildFaqJsonLd(post.faq)
if (faq) {
graph.push(faq)
}
return {
'@context': 'https://schema.org',
'@graph': graph,
}
}
/**
* Filtered/paginated index variants render genuinely different lists, but
* only the bare index is indexable — same policy as the integrations and
* models catalogs — so canonical always points at the unfiltered index and
* the variant itself is noindexed rather than asking Google to index every
* tag/page permutation.
*/
export function buildIndexMetadata(
section: ContentSection,
{ tag, pageNum }: { tag?: string; pageNum: number }
): Metadata {
const titleParts = [section.name]
if (tag) titleParts.push(tag)
if (pageNum > 1) titleParts.push(`Page ${pageNum}`)
const title = titleParts.join(' | ')
const description = tag
? `Sim ${section.name.toLowerCase()} posts tagged "${tag}": ${section.description}`
: section.description
const canonical = `${SITE_URL}${section.basePath}`
const isFiltered = Boolean(tag) || pageNum > 1
return withFilteredNoindex(
{
title,
description,
alternates: { canonical },
openGraph: {
title: `${title} | Sim`,
description,
url: canonical,
siteName: 'Sim',
locale: 'en_US',
type: 'website',
images: [
{
url: `${SITE_URL}/logo/primary/medium.png`,
width: 1200,
height: 630,
alt: `Sim ${section.name}`,
},
],
},
twitter: {
card: 'summary_large_image',
title: `${title} | Sim`,
description,
site: '@simdotai',
},
},
isFiltered
)
}
export function buildTagsMetadata(section: ContentSection): Metadata {
const canonical = `${SITE_URL}${section.basePath}/tags`
const description = `Browse Sim ${section.name.toLowerCase()} posts by topic: AI agents, workflows, integrations, and more.`
return {
title: 'Tags',
description,
alternates: { canonical },
openGraph: {
title: `${section.name} Tags | Sim`,
description,
url: canonical,
siteName: 'Sim',
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary',
title: `${section.name} Tags | Sim`,
description,
site: '@simdotai',
},
}
}
export function buildTagsBreadcrumbJsonLd(section: ContentSection) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{
'@type': 'ListItem',
position: 2,
name: section.name,
item: `${SITE_URL}${section.basePath}`,
},
{
'@type': 'ListItem',
position: 3,
name: 'Tags',
item: `${SITE_URL}${section.basePath}/tags`,
},
],
}
}
export function buildAuthorMetadata(
section: ContentSection,
id: string,
author?: Author
): Metadata {
const name = author?.name ?? 'Author'
const canonical = `${SITE_URL}${section.basePath}/authors/${id}`
const description = `Read articles by ${name} on the Sim ${section.name.toLowerCase()}.`
return {
title: `${name} | Sim ${section.name}`,
description,
alternates: { canonical },
openGraph: {
title: `${name} | Sim ${section.name}`,
description,
url: canonical,
siteName: 'Sim',
type: 'profile',
...(author?.avatarUrl
? { images: [{ url: author.avatarUrl, width: 400, height: 400, alt: name }] }
: {}),
},
twitter: {
card: 'summary',
title: `${name} | Sim ${section.name}`,
description,
site: '@simdotai',
...(author?.xHandle ? { creator: `@${author.xHandle}` } : {}),
},
}
}
export function buildAuthorGraphJsonLd(section: ContentSection, author: Author) {
return {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Person',
name: author.name,
url: `${SITE_URL}${section.basePath}/authors/${author.id}`,
sameAs: author.url ? [author.url] : [],
image: author.avatarUrl,
worksFor: {
'@type': 'Organization',
name: 'Sim',
url: SITE_URL,
},
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{
'@type': 'ListItem',
position: 2,
name: section.name,
item: `${SITE_URL}${section.basePath}`,
},
{
'@type': 'ListItem',
position: 3,
name: author.name,
item: `${SITE_URL}${section.basePath}/authors/${author.id}`,
},
],
},
],
}
}
export function buildCollectionPageJsonLd(section: ContentSection) {
return {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `Sim ${section.name}`,
url: `${SITE_URL}${section.basePath}`,
description: section.description,
publisher: {
'@type': 'Organization',
name: 'Sim',
url: SITE_URL,
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/logo/primary/medium.png`,
},
},
inLanguage: 'en-US',
isPartOf: {
'@type': 'WebSite',
name: 'Sim',
url: SITE_URL,
},
}
}
+24
View File
@@ -0,0 +1,24 @@
import fs from 'fs/promises'
export async function ensureContentDirs(contentDir: string, authorsDir: string) {
await fs.mkdir(contentDir, { recursive: true })
await fs.mkdir(authorsDir, { recursive: true })
}
export function toIsoDate(value: Date | string | number): string {
if (value instanceof Date) return value.toISOString()
return new Date(value).toISOString()
}
export function byDateDesc<T extends { date: string }>(a: T, b: T) {
return new Date(b.date).getTime() - new Date(a.date).getTime()
}
/** Most recent `updated ?? date` across a set of posts, or `undefined` if empty. */
export function latestModified<T extends { date: string; updated?: string }>(
posts: T[]
): Date | undefined {
return posts.length > 0
? new Date(Math.max(...posts.map((p) => new Date(p.updated ?? p.date).getTime())))
: undefined
}