# atproto-presskit React blog kit backed by AT Protocol, distributed as a git submodule. Lifts the blog UX chain from a host site so multiple sibling blogs can share one implementation while keeping their own posts, categories, authors, and telemetry. ## What it ships - **`createAtprotoFeed`** — Sequoia-standard AT Protocol feed (PLC resolution, `listRecords`, in-flight memoization, static fallback) - **Filtering primitives** — `filterSortAndPaginatePosts`, match-score sorting, OR-logic category filter - **`BlogProvider` + `useBlog`** — URL-state-driven context (categories, page, sort) with optional SSR-supplied initial posts - **Components** — `PostList`, `CategoryFilter`, `SortButton`, `BlogPagination`, `AuthorCard`, `ReadMoreContent`, `MarkdownContent`, `BlueskyEmbed`, `BlueskyComments`, `Subscribe`, `CollapsibleComments`, `ScrollLogger` - **Layout components** — `BlogListLayout`, `BlogPostLayout` with slot-based composition and sensible defaults; `BlogPostMeta`, `BlogPostCover`, `BlogPostBody` (composable regions); `FeaturedSidebar`, `FeaturedPostCard` (related posts sidebar) - **RSC route helpers** — `BlogListPage`, `BlogPostPage` (async; exported from `@blog/rsc`; framework-agnostic React Server Components) - **Server utilities** — `getFeaturedPosts`, `getPaginatedPosts`, `getPublishedPosts`, `getPostsByCategories` (filters); `generateBlogPostStaticParams`, `generateBlogListStaticParams` (static-params helpers) — all from `@blog/server`; plain Node async functions, callable from any server runtime - **Next.js bindings** — `NextBlogProvider`, `nextImageAdapter` (exported from `@blog/next`; bundles `useRouter`/`useSearchParams`/`next/image` into one wrapper) - **Image platform adapter** — `BlogImageContext` + `useBlogImage` + `BlogImage` wrapper; default is plain ``, Next consumers swap in `nextImageAdapter` via `BlogConfig.imageComponent` - **Bluesky avatar resolver** — `AuthorAvatar` + `useBlueskyAvatar(did)`; client-side fetches `app.bsky.actor.profile` and renders the live avatar on top of `Author.imageUrl` fallback - **`BlogTelemetry` adapter** — pluggable interface; ships with `consoleTelemetry` as the default and `noopTelemetry` as an opt-out - **Next.js metadata helpers** — `generateBlogPostMetadata` (builds Next.js `Metadata` with OG tags for a post); `BlogPostPage` (wraps `BlogPostLayout`, auto-emits `blog.post.view` telemetry via `after()`) - **Standard Site discovery links** — article pages emit `site.standard.document` and `site.standard.publication` links; publication home pages can emit `site.standard.publication` - **Next.js OG image factory** — `createBlogOpenGraphImage` (builds `opengraph-image.tsx` routes with optional cover fallback) Each consumer site provides its own DID, categories, authors, cover images, and (optional) telemetry backend. ## Integration ```bash # 1. Add as a submodule (Next.js shown; adapt path aliases for other frameworks) git submodule add https://tangled.org/russ.fugl.dev/atproto-presskit atproto-presskit # 2. Sync deps + patchedDependencies into the consumer's package.json, then install node atproto-presskit/scripts/sync-deps.mjs bun install # 3. Install required shadcn primitives (skip any already present) bunx shadcn@latest add button toggle pagination ``` **4. Add path aliases** in `tsconfig.json`: ```jsonc { "compilerOptions": { "paths": { "@/*": ["./src/*"], // already present — consumer's existing alias "@blog": ["./atproto-presskit/src/index"], // barrel: import { x } from "@blog" "@blog/*": ["./atproto-presskit/src/*"], // deep reaches into submodule files }, }, } ``` **5. Extend Tailwind `content`** so submodule classes are emitted: ```ts content: ["./src/**/*.{ts,tsx}", "./atproto-presskit/src/**/*.{ts,tsx}"]; ``` **6. Wire the publish script** in `package.json`: ```jsonc { "scripts": { "publish": "BLOG_REVALIDATE_URL=https://www.example.com/api/revalidate bash atproto-presskit/scripts/publish.sh", }, } ``` `REVALIDATE_SECRET` continues to come from the consumer's `.env`. **7. Create config files in the consumer:** - `src/lib/blog-config.ts` — composes `createBlogConfig({ ... })` from consumer-owned data - `src/lib/blog-telemetry.ts` (optional) — implements `BlogTelemetry`, wrapping whatever logging backend the site uses (Axiom, Convex, etc.). If omitted, `consoleTelemetry` emits structured wide events to stdout. After updating the submodule (`git submodule update --remote`), re-run `node atproto-presskit/scripts/sync-deps.mjs && bun install` so any version bumps in `atproto-presskit/package.json` flow into the consumer's `package.json`. ## Public API Four barrels, layered so non-React-Server-Component frameworks can pick up just the pieces they need: - **`@blog`** — universal/client-safe: config + types, `createAtprotoFeed`, `BlogProvider` + `useBlog`, all rendering components, layout components (`BlogListLayout`, `BlogPostLayout`), telemetry adapters, filter primitives, image-context, Bluesky avatar resolver. Safe to import from any context, any framework. - **`@blog/server`** — plain Node async utilities: server post filters (`getFeaturedPosts`, `getPaginatedPosts`, `getPublishedPosts`, `getPostsByCategories`), static-params helpers (`generateBlogPostStaticParams`, `generateBlogListStaticParams`). No framework directives — call from any server runtime (Next.js RSCs, Hono routes, SvelteKit endpoints, Express handlers). The `markdown-cache` loader (`loadMarkdownContent` / `getMarkdownFromCache` / `clearMarkdownCache`) still ships but is **not** re-exported from this barrel — import it directly from `@blog/markdown-cache`. It is kept out because its `process.cwd()` fs read makes bundlers trace the whole consuming project into the server bundle (same reasoning as the barrel split below). - **`@blog/rsc`** — framework-agnostic React Server Components: `BlogListPage`, `BlogPostPage` (async wrappers that fetch then render the layouts). Works with any RSC runtime (Next.js App Router, Waku, future Vite RSC). Non-RSC SPAs should compose the layouts from `@blog` directly. - **`@blog/next`** — client-safe Next.js bindings: `NextBlogProvider` (wraps `BlogProvider` with `useRouter`/`useSearchParams`/`nextImageAdapter`), `nextImageAdapter`, `generateBlogPostMetadata` (builds `Metadata` with OG tags). Safe to import from client components. - **`@blog/next-rsc`** — async RSC route helpers: `BlogListPage`, `BlogPostPage` (slot-aware; auto-emits `blog.post.view` telemetry via `next/server`'s `after()`). Server-only; import from server route files. - **`@blog/next-og`** — `createBlogOpenGraphImage` (factory for `opengraph-image.tsx` routes). Server-only; pulls in `next/og` → `@vercel/og` → `sharp`. Import from `opengraph-image.tsx` route files. Why three entries instead of one barrel: when a client component imports a barrel, Next statically traces every re-exported module into the client bundle. Mixing `"use client"` exports with modules that pull server-only Next APIs (`next/server`, `next/og`) causes the client build to fail on `fs`/`child_process`/`after`. Keeping server-only surface in dedicated entries avoids the trace. Prefer the barrels over `@blog/...` subpath imports — except `markdown-cache`, which is intentionally subpath-only (`@blog/markdown-cache`) for the bundler-tracing reason noted above. ### Layout composition `BlogPostLayout` ships with a slot-based composition system. Each visible region — meta, cover, title, body, sidebar, comments — has a slot prop with a sensible default. Consumers replace what they want; defaults handle the rest. Pass `null` to omit a region entirely; omit the prop to use the default. **Slot props:** ```ts export interface BlogPostLayoutProps { post: Post | null | undefined; slug: string; config?: BlogConfig; // Required for the default sidebar; pass null if your sidebar slot doesn't need it // Slots — each defaults to the corresponding region's default component. // Pass `null` to omit, `undefined` (or omit) to use default. meta?: ReactNode | null; // default: cover?: ReactNode | null; // default: title?: ReactNode | null; // default:

{post.title}

body?: ReactNode | null; // default: sidebar?: ReactNode | null; // default: comments?: ReactNode | null; // default: post.atUri ? : null beforeArticle?: ReactNode; // unstyled slot above the
afterArticle?: ReactNode; // unstyled slot below the article + sidebar grid className?: string; classNames?: { article?: string; grid?: string; // 2-col grid when sidebar + comments both present sidebarColumn?: string; commentsColumn?: string; }; } ``` **Default region components** (exported from `@blog`): - `` — renders date + `` - `` — renders cover image with PDS-first + `coverImageMap` fallback; returns null if neither available - `` — wraps `` in `` - `` — async server component; renders sidebar of related posts - `` — individual related-post card with date, categories, author Consumers can import and wrap these to extend their styling without reimplementing: ```tsx import { BlogPostLayout, BlogPostBody, FeaturedSidebar, cn } from "@blog"; // Default: all regions populated from config defaults // Omit cover, customize sidebar } classNames={{ sidebarColumn: "lg:col-span-2" }} /> // Wrap the default body with custom styling } /> ``` ### Config ```ts import { createBlogConfig, createAtprotoFeed, consoleTelemetry } from "@blog"; export const blogConfig = createBlogConfig({ basePath: "/blog", siteOrigin: "https://example.com", // optional; used by generateBlogPostMetadata for canonical URLs postsPerPage: 5, feed: createAtprotoFeed({ did: "did:plc:...", defaultAuthor, categories, tagToCategoryMap, staticFallback: postsData, }), categories, defaultAuthor, sequoia: { publicationUri: "at://did:plc:.../site.standard.publication/...", commentTheme: { "--sequoia-accent-color": "#4f46e5" }, // optional subscribeTheme: { "--sequoia-accent-color": "#4f46e5" }, // optional }, coverImageMap, // optional Record imageComponent, // optional ComponentType; default telemetry: blogTelemetry, // optional; defaults to consoleTelemetry notFound: NotFoundPage, // optional override for missing-post page }); ``` `Author` accepts either `imageUrl`, `did`, or both. When `did` is supplied, `` resolves the live `app.bsky.actor.profile` avatar client-side (with module-level caching across the page) and falls back to `imageUrl` until it loads. Even on SSR'd pages the server-rendered HTML uses the static fallback; the live avatar hydrates after mount, so there's no SSR/PDS coupling. ### Pages `BlogListPage` is a Server Component that renders hero + featured posts and leaves a `children` slot for the consumer's interactive list. `BlogConfig` contains non-serializable values (`feed` adapter, `telemetry`) and cannot cross the RSC boundary, so the interactive list lives in a thin `"use client"` wrapper that constructs `` itself. ```tsx // src/app/blog/page.tsx (Server Component) import { BlogListPage } from "@blog/next-rsc"; import { blogConfig } from "@/lib/blog-config"; import Header from "@/components/header"; import Footer from "@/components/footer"; import { BlogList } from "./blog-list"; export default async function Page() { const initialPosts = await blogConfig.feed.fetchAllDocuments(); return ( } footer={