Files
simstudioai--sim/apps/sim/lib/content/registry-factory.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

277 lines
9.6 KiB
TypeScript

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,
}
}