Autopub — SDK Specification
Julien Béranger
+ Claude Opus 5
Status: draft v0.1 · Date: 2026-08-28 · Author: Julien Béranger Extracted from: julienbrg/personal-website
1. Purpose
Turn the blog engine currently embedded in this site into a reusable SDK that any
Next.js App Router project can install, point at its own database, and
style with its own design system — while keeping the AI-native publishing workflow
(the /add skill, the
Neon connector, and
Claude Code Routines) that makes it fast to
write with.
Non-goals for v1: a CMS admin UI, comments, multi-tenant auth, RSS/newsletter delivery, WYSIWYG editing. Those are candidates for v2+ (see §15).
1.1 The two design constraints
Everything in this spec follows from two facts:
- Adopters have different databases. We ship Neon first because that is what this site runs on, but Postgres/Supabase/SQLite/plain-markdown users must be first-class. → the data seam, §5.
- Adopters have different design systems. We ship Chakra UI v3 first because that is what this site runs on, but Tailwind/shadcn/MUI/plain-CSS users must be first-class. → the render seam, §6.
Neither seam may leak into the other, and neither may leak into
@autopub/core.
2. What exists today
The whole blog is ~400 lines in five files. This is the extraction surface:
| File | Lines | Role | Destination package |
|---|---|---|---|
| src/lib/markdown.ts | 55 | parseFrontmatter, extractLeadingHeading, isValidSlug | @autopub/core — verbatim |
| src/lib/posts.ts | 71 | Post types, getPost, formatPostDate | split: types → core, SQL → adapter-neon, format → core |
| src/lib/db.ts | 7 | Neon client | @autopub/adapter-neon |
| src/components/PostContent.tsx | 196 | react-markdown → Chakra map | split: pipeline → ui, Chakra map → preset-chakra |
| scripts/posts.ts | 124 | init / add / delete / list | @autopub/cli |
| src/app/[slug]/page.tsx | 118 | route, generateMetadata, post header | @autopub/next |
| src/theme/index.ts | 7 | brandColors | stays in the consumer app |
| ~/.claude/skills/add/SKILL.md | — | authoring workflow | @autopub/claude-plugin |
Three couplings block reuse as-is: the direct sql import in
src/lib/posts.ts:1, the Chakra imports throughout
PostContent.tsx, and the site-specific model / conversation
frontmatter fields in PostFrontmatter.
3. Package map
Published under the @autopub npm scope
(alternative if taken: @w3hc/autopub, matching w3pk).
| Package | Depends on | Peer deps | Purpose |
|---|---|---|---|
@autopub/core | — (zero runtime deps) | — | types, frontmatter parser, slug rules, date formatting |
@autopub/next | core | next, react | createBlog(), page/metadata/sitemap/RSS factories |
@autopub/ui | core, react-markdown, remark-gfm | react | renderer pipeline + unstyled default preset + autopub.css |
@autopub/adapter-neon | core | @neondatabase/serverless | v1 reference adapter |
@autopub/adapter-postgres | core | pg | any Postgres (RDS, Supabase, local) |
@autopub/adapter-fs | core | — | markdown files on disk, no DB — the "just try it" path |
@autopub/adapter-sqlite | core | better-sqlite3 | @libsql/client | local / Turso |
@autopub/preset-chakra | core, ui | @chakra-ui/react@^3 | v1 reference preset |
@autopub/preset-tailwind | core, ui | — | Tailwind/shadcn class map |
@autopub/cli | core | — | autopub init/add/list/delete/pull/doctor |
@autopub/claude-plugin | — | — | the /add skill + /autopub-* commands, installable in any repo |
Rule: an adopter using Postgres + Tailwind must never download
@neondatabase/serverless or @chakra-ui/react. Enforced by keeping adapters and presets in
separate packages (not subpath exports of one package) and by
publint in CI.
4. @autopub/core
Zero dependencies. Runs in Node, edge, and the browser.
// @autopub/core
/** Fields every Autopub post has. Anything else goes in `meta`. */
export interface PostFrontmatter {
title: string
description?: string
date?: string // ISO 8601, YYYY-MM-DD
locale?: string // BCP 47 or OG form; normalised by `toOpenGraphLocale`
image?: string
imageAlt?: string
author?: string
tags?: string[]
draft?: boolean
}
/** `TMeta` is the escape hatch for site-specific frontmatter. */
export interface Post<TMeta = Record<string, string>> extends PostFrontmatter {
slug: string
content: string
meta: TMeta
createdAt?: string
updatedAt?: string
}
/** What a list view needs — never carries `content`. */
export type PostSummary<TMeta = Record<string, string>> =
Omit<Post<TMeta>, 'content'>
export function isValidSlug(slug: string): boolean
export function slugify(input: string): string // NEW — accent-stripping, from the /add skill
export function parseFrontmatter(raw: string): { data: Record<string, string>; content: string }
export function extractLeadingHeading(content: string): { heading?: string; body: string }
/** Frontmatter → Post, applying core/meta split and defaults. */
export function parsePost(
raw: string,
opts: { slug: string; knownMetaKeys?: string[] }
): Post
export function formatPostDate(
date: string,
opts?: { locale?: string; timeZone?: string; capitalize?: boolean }
): string
export function readingTime(content: string): { minutes: number; words: number } // NEW
export function toOpenGraphLocale(locale: string): string // 'fr' | 'fr-FR' → 'fr_FR'
export class AutopubError extends Error { code: 'INVALID_SLUG' | 'NOT_FOUND' | 'STORE_ERROR' | ... }Notes:
parseFrontmatterandextractLeadingHeadingmove over unchanged from src/lib/markdown.ts — they already have no dependencies and are already documented.formatPostDategeneralises src/lib/posts.ts:59-70: the hard-coded'fr-FR'becomes a parameter, and the capitalisation pass becomescapitalize(defaulttrue, preserving today's "Mardi 25 août 2025" output).slugifylifts the algorithm currently living only in prose in step 2 of the/addskill, so the CLI, the skill, and the SDK agree.model/conversation— this site's fields — leave the core type and land inmeta. See §12.2.
5. Data seam — PostStore
5.1 The interface
Every adapter implements this and nothing more. Read methods are required; write methods are optional and used only by the CLI and by webhooks.
// @autopub/core
export interface ListOptions {
limit?: number
offset?: number
order?: 'date' | 'created' | 'title'
direction?: 'asc' | 'desc'
tag?: string
locale?: string
includeDrafts?: boolean // default false
}
export interface PostStore<TMeta = Record<string, string>> {
readonly name: string
/** Returns null for unknown or invalid slugs — never throws on a bad slug. */
get(slug: string): Promise<Post<TMeta> | null>
list(options?: ListOptions): Promise<PostSummary<TMeta>[]>
count(options?: Pick<ListOptions, 'tag' | 'locale' | 'includeDrafts'>): Promise<number>
// Write side — optional; `createBlog()` works read-only without them.
upsert?(post: Post<TMeta>): Promise<void>
remove?(slug: string): Promise<boolean>
init?(): Promise<void> // create/migrate schema
health?(): Promise<{ ok: boolean; detail?: string }> // powers `autopub doctor`
}Contract rules
get()MUST validate withisValidSlug()and returnnullrather than querying — this is the injection guard that src/lib/posts.ts:52 already implements.list()MUST NOT returncontent.- Adapters MUST NOT import anything from
@autopub/uior@autopub/next. - Unknown columns/keys round-trip through
metauntouched. - All adapters MUST pass the shared conformance suite,
@autopub/core/testing→runStoreConformance(makeStore).
5.2 The Neon adapter (v1)
import { neonStore } from '@autopub/adapter-neon'
const store = neonStore({
connectionString: process.env.DATABASE_URL!, // required
table: 'posts', // default 'posts'
schema: 'public', // default 'public'
})Built on @neondatabase/serverless, which
is what src/lib/db.ts already uses — HTTP driver, so it works on
Vercel Edge and in
Next.js Server Components.
Schema (init()), a superset of today's table:
CREATE TABLE IF NOT EXISTS posts (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
date TEXT,
locale TEXT,
image TEXT,
image_alt TEXT,
author TEXT,
tags TEXT[],
draft BOOLEAN NOT NULL DEFAULT false,
meta JSONB NOT NULL DEFAULT '{}'::jsonb, -- model, conversation, anything else
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS posts_date_idx ON posts (date DESC NULLS LAST);init() is idempotent and additive (ADD COLUMN IF NOT EXISTS), exactly like
scripts/posts.ts:27-29, so it doubles as the migration path for this
site's existing table.
5.3 Writing another adapter
Three methods and a mapper. The full @autopub/adapter-fs is expected to be ~80 lines: read
content/posts/*.md, parsePost() each, sort by date. Documented as the template in
docs/adapters.md, with the conformance suite as the definition of done.
6. Render seam — RenderPreset
6.1 The layered escape hatches
Four levels, each an opt-out of the one above:
| Level | What you write | Who it's for |
|---|---|---|
| 0 | nothing — import @autopub/ui/autopub.css | wants it to just look fine |
| 1 | CSS variables (--autopub-accent, …) | has brand colours, no framework |
| 2 | preset={chakraPreset({ accent })} | on Chakra / Tailwind / MUI |
| 3 | components={{ h2: MyHeading }} | needs one node to be special |
| 4 | blog.getPost() + your own JSX | wants total control |
Level 3 overrides merge over the preset, per node — you never have to re-specify a preset to change one element.
6.2 The interface
// @autopub/ui
import type { Components } from 'react-markdown'
export interface RenderPreset {
readonly name: string
/** react-markdown node overrides — same shape as today's map in PostContent.tsx */
components: Partial<Components>
/** Optional chrome around the article body */
Article?: React.ComponentType<{ children: React.ReactNode }>
PostHeader?: React.ComponentType<PostHeaderProps>
PostList?: React.ComponentType<{ posts: PostSummary[]; hrefFor(slug: string): string }>
/** CSS custom properties applied to the root element */
tokens?: Record<`--autopub-${string}`, string>
}
export function PostContent(props: {
content: string
preset?: RenderPreset // default: unstyledPreset
components?: Partial<Components> // merged over preset.components
remarkPlugins?: PluggableList // default [remarkGfm]
rehypePlugins?: PluggableList
className?: string
}): JSX.Element
export function PostHeader(props: PostHeaderProps): JSX.Element
export function PostList(props: PostListProps): JSX.Element
export const unstyledPreset: RenderPreset // semantic HTML + `autopub-*` classes
export function mergePresets(base: RenderPreset, over: Partial<RenderPreset>): RenderPreset@autopub/ui keeps the parts of PostContent.tsx that are logic,
not style — the internal-vs-external link branch at
lines 44-64, the fenced-vs-inline code detection at
lines 120-122, the hr-as-whitespace decision at
line 116 — and pushes every colour and spacing token out to
the preset.
6.3 The Chakra preset (v1)
import { chakraPreset } from '@autopub/preset-chakra'
const preset = chakraPreset({
accent: '#45a2f8', // today's brandColors.accent
primary: '#8c1c84',
proseLineHeight: '1.9',
headingGlow: true, // the h2 textShadow at PostContent.tsx#L31
})This is today's components map almost verbatim, with
brandColors turned into arguments. @chakra-ui/react@^3 is a peer
dependency — the package is unusable without Chakra, and that is correct.
One dependency to break during extraction: the preset currently imports ListRoot / ListItem from
src/components/ui/list.tsx, which is a
Chakra snippet local to this repo. The preset must
inline them or use Chakra.List.Root directly — it cannot depend on a consumer's @/components/ui.
6.4 Token contract
Presets and the stylesheet agree on one set of names:
--autopub-accent --autopub-prose-line-height
--autopub-primary --autopub-prose-max-width
--autopub-fg --autopub-block-gap
--autopub-fg-muted --autopub-radius
--autopub-bg-subtle --autopub-code-bg
--autopub-border --autopub-font-monoThe stylesheet defines the full light palette on bare :root, redefines only what changes under
@media (prefers-color-scheme: dark) guarded as :root:not([data-theme="light"]), and again under
:root[data-theme="dark"] — so it works with
next-themes (already a dependency here) in both
directions.
7. Next.js integration
@autopub/next is where the data seam and the render seam meet. Targets Next 15+ App Router
(this site is on Next 16.3).
// lib/blog.ts — the one file an adopter writes
import { createBlog } from '@autopub/next'
import { neonStore } from '@autopub/adapter-neon'
import { chakraPreset } from '@autopub/preset-chakra'
export const blog = createBlog({
store: neonStore({ connectionString: process.env.DATABASE_URL! }),
preset: chakraPreset({ accent: '#45a2f8', primary: '#8c1c84' }),
basePath: '/', // this site serves posts at /<slug>
locale: 'fr-FR',
siteName: 'Julien Beranger',
siteUrl: 'https://julienberanger.com',
defaultImage: '/huangshan.png',
caching: { mode: 'dynamic' }, // see §7.2
})7.1 What createBlog() returns
interface Blog<TMeta> {
// data
getPost(slug: string): Promise<Post<TMeta> | null>
listPosts(options?: ListOptions): Promise<PostSummary<TMeta>[]>
// route factories — each returns something you `export` from a route file
createPostPage(opts?: { renderHeader?; renderFooter?; notFound? }): NextPage
createPostMetadata(): (props: { params: Promise<{ slug: string }> }) => Promise<Metadata>
createIndexPage(opts?: { pageSize?: number }): NextPage
createStaticParams(): () => Promise<{ slug: string }[]>
createSitemap(): () => Promise<MetadataRoute.Sitemap>
createFeed(opts?: { format?: 'rss' | 'atom' | 'json' }): RouteHandler
createRevalidateRoute(opts: { secret: string }): RouteHandler // §7.2
createOgImage(opts?: { fonts? }): RouteHandler // ImageResponse
createWebhookRoute(opts: { secret: string }): RouteHandler // remote publish, §10.3
// components, pre-bound to the configured preset
PostContent: (props: { content: string }) => JSX.Element
PostHeader: (props: { post: Post<TMeta> }) => JSX.Element
PostList: (props: { posts: PostSummary<TMeta>[] }) => JSX.Element
}7.2 Caching
This is exactly the problem commit ba013db "fix db polling"
just dealt with, so the SDK makes it an explicit choice rather than a hidden default:
caching.mode | Behaviour | When |
|---|---|---|
'dynamic' | export const dynamic = 'force-dynamic' — every request hits the DB | edit-in-DB-see-it-now, today's behaviour |
'isr' | export const revalidate = N | high traffic |
'tags' | cacheTag('autopub:post:<slug>') + createRevalidateRoute() | best of both — publish pings the route, cache drops |
'static' | generateStaticParams(), build-time only | fully static export |
'tags' is the recommended default for new adopters; this site keeps 'dynamic' on migration so
behaviour is unchanged.
7.3 Adopter's route files
// app/[slug]/page.tsx
import { blog } from '@/lib/blog'
export const dynamic = 'force-dynamic'
export const generateMetadata = blog.createPostMetadata()
export default blog.createPostPage()// app/blog/page.tsx
import { blog } from '@/lib/blog'
export default blog.createIndexPage({ pageSize: 20 })// app/feed.xml/route.ts
import { blog } from '@/lib/blog'
export const GET = blog.createFeed({ format: 'rss' })Three lines to replace the current 118-line page — and the post
header (author / model / conversation block,
lines 68-104) comes back as renderHeader, since it reads
meta.model and meta.conversation.
8. Configuration file
autopub.config.ts at the project root, read by the CLI and importable by the app so
config lives in one place:
import { defineConfig } from '@autopub/core'
export default defineConfig({
store: { adapter: '@autopub/adapter-neon', options: { connectionString: process.env.DATABASE_URL } },
content: { dir: 'content/posts', images: 'public' },
site: { url: 'https://julienberanger.com', name: 'Julien Beranger', locale: 'fr-FR' },
defaults: { author: 'Julien Béranger', image: '/huangshan.png' },
meta: { keys: ['model', 'conversation'] }, // extra frontmatter → Post.meta
})Env resolution order: explicit option → process.env → .env via
process.loadEnvFile (what
scripts/posts.ts:104 already does).
9. CLI
npx @autopub/cli / pnpm autopub. Supersedes scripts/posts.ts and
pnpm posts.
| Command | Behaviour |
|---|---|
autopub init | create/migrate schema via store.init(); scaffold autopub.config.ts and route files |
autopub add <file.md> | parse → validate → upsert. --slug, --dry-run, --draft |
autopub list | slug · date · title · draft |
autopub delete <slug> | store.remove(), with confirmation |
autopub pull [slug] | DB → content/posts/*.md (round-trip, restores a lost local copy) |
autopub images <file.md> | copy local images into public/, rewrite refs — step 4 of the skill |
autopub doctor | store.health(), env check, schema drift, orphaned images |
autopub open <slug> | print/open the live URL |
add stays an upsert (ON CONFLICT (slug) DO UPDATE, as
scripts/posts.ts:47-63) so re-running after a typo fix is safe. add
writes to a live database — the CLI prints the resolved frontmatter and asks for confirmation unless
--yes is passed.
10. Claude integration
The reason this SDK is worth publishing rather than just refactoring: the authoring loop is already agentic, and nothing on npm packages that loop.
10.1 The /add skill
~/.claude/skills/add/SKILL.md already encodes the
full import workflow: locate the repo, read the source markdown from anywhere on disk, derive a
valid slug, fill frontmatter defaults (author, date, and the running model — e.g. Claude Opus 5),
migrate local images into public/, stage a gitignored copy under content/posts/, confirm, then
publish.
It ships as @autopub/claude-plugin, a
Claude Code plugin containing:
skills/add/SKILL.md— generalised:pnpm posts add→npx autopub add, hard-coded paths and theJulien Béranger//huangshan.pngdefaults read fromautopub.config.tsinsteadcommands/autopub-list.md,commands/autopub-pull.md,commands/autopub-doctor.mdagents/editor.md— a review pass over a draft before publishing
Installed with /plugin install @autopub/claude-plugin, so any adopter gets /add in their own
repo on day one. The current skill's Julien-specific steps stay working via config, not via a fork.
10.2 The Neon connector (in-browser Claude)
Because posts live in Postgres rather than in git, they are editable from
claude.ai with no local checkout, via the
Neon MCP server: Settings → Connectors → Browse
connectors → Neon, then enable it per chat under Add sources
(setup guide). In Claude Code the equivalent
is claude mcp add --transport http neon https://mcp.neon.tech/mcp
(guide).
What that buys an Autopub user, from a phone or a browser tab:
- "fix the typo in the third paragraph of
rukh-roadmap" →UPDATE posts SET content = … - "list posts with no description" → an editorial to-do list
- "unpublish
kidwatch" →draft = true
This is why the schema is deliberately flat and human-legible — one row per post, markdown in a
TEXT column — rather than a normalised block model. An LLM with SQL access can edit it safely.
The SDK documents this as a supported workflow, with a read-only role recommended for exploration
and autopub doctor to catch a malformed hand-edit.
10.3 Routines / Scheduled agents
Claude Code Routines — scheduled cloud
agents, shipped April 2026, still research preview
(The Register,
MakerKit guide) — run a saved
prompt on Anthropic's infrastructure on a cron, an API call, or a GitHub event, with no machine of
yours awake. Created with /schedule in the CLI, New routine → Remote in the desktop app, or at
claude.ai/code/routines. There are three tiers to be precise
about, and the SDK docs should name them:
/loop (session-scoped), desktop
Scheduled Tasks (local), and cloud Routines (managed).
Autopub ships routine templates in templates/routines/:
| Routine | Cadence | Does |
|---|---|---|
scheduled-publish | hourly | publish posts whose date has arrived and draft = true — a real editorial calendar, no infra |
link-check | weekly | crawl outbound links in all posts, open a GitHub issue on 404s |
draft-digest | Monday | list stale drafts and unfinished posts |
translate | on demand | duplicate a post into another locale with a translated body |
og-refresh | on publish | regenerate OG images for changed posts |
scheduled-publish is the one that makes Autopub structurally different from a git-based blog:
because the store is a database and the CLI is scriptable, scheduling a publish is a cron job over
a UPDATE posts SET draft = false — no rebuild, no redeploy, and (with caching.mode: 'tags') the
page is live within a request.
11. SDK base / template
11.1 Recommendation
Build the base by hand: pnpm workspaces + tsdown + Changesets. Not a SaaS boilerplate. next-forge and Saasfly are app templates — auth, billing, analytics — and Autopub is a library: their weight is all cost here. The library-publishing base is small enough to assemble correctly in an afternoon and every piece is justified below.
| Concern | Choice | Why |
|---|---|---|
| Monorepo | pnpm workspaces | already the package manager here; pnpm-workspace.yaml exists |
| Task graph | Turborepo | only once >5 packages; skip at first |
| Bundler | tsdown | tsup is no longer actively maintained and now points at tsdown; Rolldown-based, tsup-compatible options, ESM+CJS+.d.ts. Note tsup is already a devDependency here — swap it |
| Types | TypeScript 6 | matches this repo |
| Versioning | Changesets | per-package semver + generated changelogs across the workspace |
| Export correctness | publint + Are the Types Wrong? | CI gate on the exports map and consumer type resolution |
| Dead weight | Knip | catches unused deps before publish |
| Tests | Vitest + Testing Library | store conformance + render snapshots |
| Docs | Nextra or Fumadocs | the docs site is itself an Autopub adopter — dogfood |
| CI | GitHub Actions | matrix over adapters; changesets/action for release |
| Registry | npm + provenance | npm publish --provenance from CI |
Keep minimumReleaseAge supply-chain protection in the new repo — it is a
good default and costs nothing.
11.2 Repo layout
autopub/
├── package.json # private workspace root
├── pnpm-workspace.yaml
├── turbo.json # when it earns its place
├── .changeset/
├── packages/
│ ├── core/ # @autopub/core — zero deps
│ ├── next/ # @autopub/next
│ ├── ui/ # @autopub/ui
│ ├── adapter-neon/ # @autopub/adapter-neon
│ ├── adapter-fs/
│ ├── adapter-postgres/
│ ├── preset-chakra/
│ ├── preset-tailwind/
│ ├── cli/ # @autopub/cli — bin: autopub
│ └── claude-plugin/
├── examples/
│ ├── chakra-neon/ # mirrors this site — the migration proof
│ ├── tailwind-fs/ # no database at all
│ └── minimal/ # unstyled, adapter-fs, 3 files
├── docs/ # Nextra site, itself an adopter
└── templates/routines/ # §10.311.3 Package manifest shape
{
"name": "@autopub/ui",
"type": "module",
"sideEffects": ["*.css"],
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" },
"./autopub.css": "./dist/autopub.css",
"./package.json": "./package.json"
},
"files": ["dist"],
"peerDependencies": { "react": ">=18", "react-dom": ">=18" },
"publishConfig": { "access": "public", "provenance": true }
}Client components (PostContent and anything with state) carry 'use client' and tsdown is
configured to preserve the directive — a known sharp edge when bundling React libraries for the
App Router. examples/chakra-neon exists specifically to catch a regression there before release.
12. Migration plan for this site
Nine steps, roughly a day. Each is independently verifiable; the site keeps working throughout.
- Create
autopub/with the layout in §11.2,core+adapter-fsonly. Port markdown.ts verbatim, addslugifyandparsePost. Write the conformance suite againstadapter-fs. @autopub/adapter-neon— move db.ts and the four queries from posts.ts / scripts/posts.ts. Run the conformance suite against a Neon branch, not production.@autopub/ui— port the pipeline from PostContent.tsx, writeunstyledPresetandautopub.css. Snapshot-test against every post in content/posts/.@autopub/preset-chakra— port the Chakra map, inline the list snippet, parameterise brandColors. Diff renders against the current site: must be pixel-identical.@autopub/next—createBlog()and the factories, from app/[slug]/page.tsx.@autopub/cli— port scripts/posts.ts, addpull,doctor,images.- Migrate the database: add
tags,draft,meta; backfillmeta = jsonb_build_object('model', model, 'conversation', conversation); keep the old columns one release, then drop. - Flip this site to the SDK:
lib/blog.ts, three route files,renderHeaderfor the model/conversation block, delete posts.ts, db.ts, PostContent.tsx, scripts/posts.ts. Net line change in this repo: −480. - Publish
0.1.0with provenance; update the/addskill to callnpx autopub add.
12.1 Definition of done
https://julienberanger.com/rukh-roadmap renders byte-identically before and after, OG tags
included, and pnpm posts add still works through the aliased CLI.
12.2 Metadata migration
model and conversation are the test case for the whole meta design: after step 7 they live in
meta, this site declares them in autopub.config.ts as
meta.keys: ['model', 'conversation'], and types as Post<{ model?: string; conversation?: string }>.
If that feels natural, the escape hatch works; if it feels awkward, the core type is wrong and it is
cheap to fix before 0.1.0.
13. Compatibility
| Supported | Notes | |
|---|---|---|
| Next.js | 15.x, 16.x App Router | Pages Router unsupported |
| React | 18, 19 | RSC-first; PostContent is a client component |
| Node | 20+ | process.loadEnvFile needs 20.6+ |
| Runtimes | Node, Edge, Vercel, Cloudflare Workers | edge depends on the adapter — Neon HTTP driver yes, pg no |
| Chakra | v3 only (peer) | v2 has a different API surface; out of scope |
| Bundlers | Next/Turbopack, Vite | tested in examples/ |
Security: isValidSlug gates every read; adapters use parameterised queries only (Neon's tagged
template, as today); @autopub/ui does not enable
rehype-raw/dangerouslySetInnerHTML by default — raw HTML in markdown stays inert unless the
adopter opts in, and the docs say why. Consistent with the
WCAG 2.1 AA commitment of this repo, unstyledPreset output must pass
axe and keep heading order intact — note today's map renders markdown
h1 as an h2 (PostContent.tsx:15) precisely to avoid two
h1s on the page.
14. Quality gates
Every PR: typecheck · lint (ESLint 9 + jsx-a11y, as here) · test (conformance + render) ·
build · publint · attw · knip · examples/* build. Release: Changesets → npm with
provenance.
15. Roadmap
| Version | Contents |
|---|---|
| 0.1 | core, next, ui, adapter-neon, adapter-fs, preset-chakra, cli; this site migrated |
| 0.2 | adapter-postgres, preset-tailwind, claude-plugin, RSS + sitemap + OG images |
| 0.3 | routine templates (§10.3), caching: 'tags' + revalidate route, drafts & scheduled publishing |
| 0.4 | i18n post linking (translationOf), tags/archive pages, search adapter |
| 1.0 | stable PostStore + RenderPreset contracts, docs site, adapter-sqlite, create-autopub scaffolder |
| later | comments, newsletter export, web editor, MDX/component embeds |
16. Open questions
- Scope name — is
@autopubfree on npm? Fallback@w3hc/autopubalongside w3pk. - MDX — a real want, but it drags in a compiler and breaks the "markdown in a
TEXTcolumn, editable by an LLM over SQL" property from §10.2. Recommendation: stay markdown-only through 1.0, and if MDX arrives, make it a separate renderer package. - Does
@autopub/nextdeserve to exist, or shouldcreateBlog()live incorewith Next as an optional peer? Splitting it is cheap now and expensive later — keep the split. - Presets as packages vs. one
@autopub/presetswith subpaths — separate packages, so peer deps are honest. Revisit only if publishing overhead bites. - The real adoption test — the second consumer. avventura-v3 or the docs site should be it, and it should exist before 0.1.0 ships, otherwise this is a refactor wearing an SDK costume.
Appendix A — before / after
// BEFORE — src/app/[slug]/page.tsx, 118 lines
import { getPost, formatPostDate } from '@/lib/posts'
import PostContent from '@/components/PostContent'
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }) { /* 30 lines of OG/Twitter */ }
export default async function PostPage({ params }) { /* 60 lines of Chakra layout */ }// AFTER — app/[slug]/page.tsx, 4 lines
import { blog } from '@/lib/blog'
export const dynamic = 'force-dynamic'
export const generateMetadata = blog.createPostMetadata()
export default blog.createPostPage({ renderHeader: PostByline })Appendix B — links
This repo: package.json · src/lib/posts.ts · src/lib/markdown.ts · src/lib/db.ts · src/components/PostContent.tsx · src/app/[slug]/page.tsx · scripts/posts.ts · src/theme/index.ts · pnpm-workspace.yaml · README.md · GitHub
Stack: Next.js · React · Chakra UI v3 · react-markdown · remark-gfm · next-themes · Neon serverless driver
Tooling: pnpm workspaces · tsdown · Rolldown · Changesets · publint · Are the Types Wrong? · Knip · Vitest · Turborepo · Nextra · Fumadocs · npm provenance
Neon: docs · MCP server · connect MCP clients · Claude Code + Neon guide · branching
Claude: Claude Code docs · Routines · routines console · plugins · skills · the local /add skill · Routines guide (MakerKit) · The Register on Routines