chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user