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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user