# 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={}>
);
}
```
```tsx
// src/app/blog/blog-list.tsx (Client wrapper)
"use client";
import { PostList, CategoryFilter, SortButton, BlogPagination, useBlog, type Post } from "@blog";
import { NextBlogProvider } from "@blog/next";
import { blogConfig } from "@/lib/blog-config";
export function BlogList({ initialPosts }: { initialPosts: Post[] }) {
return (
);
}
function BlogListBody() {
const { state, actions, meta } = useBlog();
return (
<>
>
);
}
```
```tsx
// src/app/blog/[slug]/page.tsx
import { BlogPostPage } from "@blog/next-rsc";
import { generateBlogPostStaticParams } from "@blog/server";
import { blogConfig } from "@/lib/blog-config";
export const revalidate = 604800;
export function generateStaticParams() {
return generateBlogPostStaticParams(blogConfig);
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// BlogPostPage: auto-emits blog.post.view telemetry via after()
// All BlogPostLayout slot props are supported for customization
return ;
}
```
### Next.js metadata and OG images
`generateBlogPostMetadata` builds a Next.js `Metadata` object with title, description, and OG tags. Wire it into `generateMetadata` in your post route:
```tsx
// src/app/blog/[slug]/page.tsx
import { generateBlogPostMetadata } from "@blog/next";
import { blogConfig } from "@/lib/blog-config";
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return generateBlogPostMetadata(blogConfig, slug, {
// Optional base metadata merged with post-specific fields
title: { template: "%s | Blog" },
});
}
```
`createBlogOpenGraphImage` is a factory that builds a route handler for `opengraph-image.tsx`:
```tsx
// src/app/blog/[slug]/opengraph-image.tsx
import { createBlogOpenGraphImage } from "@blog/next-og";
import { blogConfig } from "@/lib/blog-config";
export const alt = "Post cover image";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default createBlogOpenGraphImage({
config: blogConfig,
siteName: "My Blog",
styles: {
gradientFallback: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
accentColor: "#c7d2fe",
titleColor: "#ffffff",
},
});
```
### Standard Site discovery links
`BlogPostPage` / `BlogPostLayout` emit these article-page links when the
post has `atUri` and the config has `sequoia.publicationUri`:
```html
```
`BlogListPage` passes `config.sequoia.publicationUri` through to
`BlogListLayout`, so publication home pages emit:
```html
```
If composing `BlogListLayout` directly, pass `publicationUri` yourself.
### Composing components directly
Route helpers are conveniences. For custom layouts, compose the parts yourself — everything is exported from `@blog`:
**List page:**
```tsx
import { BlogProvider, PostList, CategoryFilter, SortButton, BlogPagination, useBlog } from "@blog";
```
**Post page with custom layout:**
```tsx
import {
BlogPostLayout,
BlogPostMeta,
BlogPostCover,
BlogPostBody,
FeaturedSidebar,
FeaturedPostCard,
} from "@blog";
// Use the composable defaults as building blocks
}
sidebar={}
/>;
```
### Non-Next.js frameworks (Vite SPA, bhvr, etc.)
`@blog` is React-only; only `@blog/next` and `@blog/rsc` touch
framework-specific machinery. A Vite SPA pattern:
```tsx
// 1. Fetch posts wherever your data layer runs — Hono route, RSC, or
// client-side via config.feed.fetchAllDocuments().
import { getPaginatedPosts, getFeaturedPosts } from "@blog/server";
// 2. Render the layouts from @blog. No async, no server-only deps.
import { BlogProvider, BlogListLayout, PostList, ... } from "@blog";
function BlogPage({ initialPosts, featured }) {
return (
}
publicationUri={blogConfig.sequoia.publicationUri}
>
history.pushState({}, "", url)}
>
);
}
```
The image adapter and Bluesky avatar resolver work the same way — no
Next.js dependency.
### Telemetry
```ts
import { useBlogTelemetry, consoleTelemetry, noopTelemetry } from "@blog";
import type { BlogTelemetry } from "@blog";
// In a client component inside the BlogProvider tree:
const telemetry = useBlogTelemetry();
telemetry.logClientEvent("blog.post.read_more", { post_slug, post_title });
```
## Telemetry Model
`consoleTelemetry` writes one structured **wide event** per call to stdout — a
JSON-serialized record with `eventName`, `timestamp`, `source`, and an
arbitrary `data` payload. This matches the
[wide-events / canonical log line pattern](https://loggingsucks.com/): one
context-rich entry per operation, not many narrow ones, so production
debugging can answer questions like _"which posts are getting `read_more`
clicks?"_ without grepping.
Consumers swap in their own adapter via `BlogConfig.telemetry`:
```ts
import type { BlogTelemetry } from "@blog";
import { logClientEvent, logServerEvent } from "@/lib/axiom"; // example
export const blogTelemetry: BlogTelemetry = {
logClientEvent: (name, data) => logClientEvent(name, data),
logServerEvent: async (name, data) => {
await logServerEvent(name, data);
},
};
```
**Never log PII.** Use stable identifiers (`post_slug`, not author email) and
hash sensitive content. Server-side adapters are responsible for flushing
(e.g. `await axiomLogger.flush()` inside `logServerEvent`) since server
functions can't depend on React context teardown.
## Theming Model
Three layers, each addressing a different concern:
1. **Tailwind theme tokens** — every submodule component is styled with
semantic tokens (`bg-primary`, `text-foreground`, `border-border`, etc.).
Retuning the consumer's CSS variables re-themes the whole blog without
touching the submodule.
2. **Sequoia CSS variables** — `config.sequoia.commentTheme` and
`subscribeTheme` flow inline to `` and
`` as `--sequoia-*` overrides on top of sensible
defaults.
3. **`className` overrides** — most shipped components accept `className`
for last-mile tweaks; the submodule merges them with `cn` from the
consumer's `@/lib/utils`.
## What stays in each consumer
| Path | Role |
| ----------------------------------------- | ------------------------------------------------------------------------- |
| `src/content/blog/posts.ts` | Static `postsData` passed to `createAtprotoFeed` as `staticFallback` |
| `src/content/blog/categories.ts` | Site categories + `tagToCategoryMap` for AT-protocol tag routing |
| `src/content/blog/authors.ts` | `defaultAuthor` (and any others) |
| `src/content/blog/*.md` | Local markdown bodies for offline fallback |
| `src/lib/cover-images.ts` | `StaticImageData` map passed as `coverImageMap` |
| `src/lib/blog-config.ts` _(new)_ | Single `createBlogConfig` call wiring the above |
| `src/lib/blog-telemetry.ts` _(optional)_ | `BlogTelemetry` adapter on top of the site's logging backend |
| `src/components/header.tsx`, `footer.tsx` | Site chrome |
| `src/components/sequoia-comments.ts` | `sequoia add sequoia-comments`-generated custom-element registration |
| `src/components/sequoia-subscribe.ts` | `sequoia add sequoia-subscribe`-generated custom-element registration |
| `src/app/blog/page.tsx` | Server route — `` wrapping the client list |
| `src/app/blog/blog-list.tsx` _(new)_ | `"use client"` wrapper — `` + interactive list parts |
| `src/app/blog/[slug]/page.tsx` | ~10 lines — `` + `generateBlogPostStaticParams` |
| `src/app/api/revalidate/route.ts` | Sequoia webhook handler — untouched |
| `tailwind.config.ts` | `content` glob extended to include `./atproto-presskit/src/**/*.{ts,tsx}` |
| `tsconfig.json` | Path aliases `@blog` and `@blog/*` added (see step 4) |
| `package.json` | `dependencies` / `patchedDependencies` managed by `sync-deps.mjs` |
| `next.config.ts` | Untouched (site-specific redirects stay) |
## Updating
```bash
git submodule update --remote
node atproto-presskit/scripts/sync-deps.mjs
bun install
```
`atproto-presskit/package.json` is the source of truth for `react-markdown`,
`remark-gfm`, `rehype-pretty-code`, `shiki`, `@heroicons/react`,
`lucide-react`, and `sequoia-cli` (with its patch). Consumers don't need
to manage these directly — `sync-deps.mjs` merges them into the
consumer's `package.json`.
## Patching sequoia-cli
`sequoia-cli@0.5.6` is pinned via `patchedDependencies` to add three fields
to every `site.standard.document` record published by `bunx sequoia publish`:
- **`content`** — the raw markdown body wrapped in the `at.markpub.markdown`
lexicon ([markpub.at](https://markpub.at)) so renderers can format the
post directly instead of working from the stripped `textContent`
fallback. Reads `flavor` / `renderingRules` from a new `markpub` block
in `sequoia.json` (defaults: `commonmark` / `markdown-it`).
- **`contributors`** — single-entry array carrying the publishing agent's
DID, optionally with `role` and `displayName` from a `contributor` block
in `sequoia.json`.
- **Smart `textContent` excerpt** — replaces upstream's hard 10,000-char
slice with a truncation that cuts before the first `---` line within the
first 5,000 chars, else at the midpoint of the third paragraph; then
appends `Continue reading at [{display}]({canonicalUrl})`. Opt in with
`"smartExcerpt": true` in `sequoia.json`.
Each feature has a self-contained design doc in `patch.md/`:
- `patch.md/sequoia-add-markpub-content.md`
- `patch.md/sequoia-add-contributors.md`
- `patch.md/sequoia-add-smart-excerpt.md`
All three slices compile to a single `patches/sequoia-cli@0.5.6.patch`,
applied automatically by `bun install` via the `patchedDependencies` entry
in `package.json`. The `.patch` file is the unit bun consumes — slices in
`patch.md/` are docs, not separately applicable.
### Iterating on the patch
```bash
bun patch sequoia-cli
# edit node_modules/sequoia-cli/dist/index.js
bun patch --commit node_modules/sequoia-cli
```
`bun patch` copies the package out of the global cache so
`node_modules/sequoia-cli` is writable; `--commit` diffs the writable
copy against the cached original and regenerates
`patches/sequoia-cli@0.5.6.patch`.
To remove the patch entirely: delete the `patchedDependencies` entry from
`package.json` and the `patches/sequoia-cli@*.patch` file, then
`bun install`.
## Development inside the submodule
The `example/` workspace is a minimal real consumer (Next.js, mirroring the
Integration snippet above) that lets the submodule's own `tsc`, ESLint,
and tests resolve `@/components/ui/*` and `@/lib/utils`. `bun install` at
the submodule root installs both workspaces.
```bash
bun install
bun run check # eslint + tsc + prettier --check + vitest
bun run format # prettier --write
bun run test # vitest run
```
Caches live in `node_modules/.cache/` (ESLint, Prettier, tsc, Vitest).