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:

  1. 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.
  2. 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:

FileLinesRoleDestination package
src/lib/markdown.ts55parseFrontmatter, extractLeadingHeading, isValidSlug@autopub/core — verbatim
src/lib/posts.ts71Post types, getPost, formatPostDatesplit: types → core, SQL → adapter-neon, format → core
src/lib/db.ts7Neon client@autopub/adapter-neon
src/components/PostContent.tsx196react-markdown → Chakra mapsplit: pipeline → ui, Chakra map → preset-chakra
scripts/posts.ts124init / add / delete / list@autopub/cli
src/app/[slug]/page.tsx118route, generateMetadata, post header@autopub/next
src/theme/index.ts7brandColorsstays in the consumer app
~/.claude/skills/add/SKILL.mdauthoring 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).

PackageDepends onPeer depsPurpose
@autopub/core— (zero runtime deps)types, frontmatter parser, slug rules, date formatting
@autopub/nextcorenext, reactcreateBlog(), page/metadata/sitemap/RSS factories
@autopub/uicore, react-markdown, remark-gfmreactrenderer pipeline + unstyled default preset + autopub.css
@autopub/adapter-neoncore@neondatabase/serverlessv1 reference adapter
@autopub/adapter-postgrescorepgany Postgres (RDS, Supabase, local)
@autopub/adapter-fscoremarkdown files on disk, no DB — the "just try it" path
@autopub/adapter-sqlitecorebetter-sqlite3 | @libsql/clientlocal / Turso
@autopub/preset-chakracore, ui@chakra-ui/react@^3v1 reference preset
@autopub/preset-tailwindcore, uiTailwind/shadcn class map
@autopub/clicoreautopub init/add/list/delete/pull/doctor
@autopub/claude-pluginthe /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:

  • parseFrontmatter and extractLeadingHeading move over unchanged from src/lib/markdown.ts — they already have no dependencies and are already documented.
  • formatPostDate generalises src/lib/posts.ts:59-70: the hard-coded 'fr-FR' becomes a parameter, and the capitalisation pass becomes capitalize (default true, preserving today's "Mardi 25 août 2025" output).
  • slugify lifts the algorithm currently living only in prose in step 2 of the /add skill, so the CLI, the skill, and the SDK agree.
  • model / conversation — this site's fields — leave the core type and land in meta. 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

  1. get() MUST validate with isValidSlug() and return null rather than querying — this is the injection guard that src/lib/posts.ts:52 already implements.
  2. list() MUST NOT return content.
  3. Adapters MUST NOT import anything from @autopub/ui or @autopub/next.
  4. Unknown columns/keys round-trip through meta untouched.
  5. All adapters MUST pass the shared conformance suite, @autopub/core/testingrunStoreConformance(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:

LevelWhat you writeWho it's for
0nothing — import @autopub/ui/autopub.csswants it to just look fine
1CSS variables (--autopub-accent, …)has brand colours, no framework
2preset={chakraPreset({ accent })}on Chakra / Tailwind / MUI
3components={{ h2: MyHeading }}needs one node to be special
4blog.getPost() + your own JSXwants 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-mono

The 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.modeBehaviourWhen
'dynamic'export const dynamic = 'force-dynamic' — every request hits the DBedit-in-DB-see-it-now, today's behaviour
'isr'export const revalidate = Nhigh traffic
'tags'cacheTag('autopub:post:<slug>') + createRevalidateRoute()best of both — publish pings the route, cache drops
'static'generateStaticParams(), build-time onlyfully 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.

CommandBehaviour
autopub initcreate/migrate schema via store.init(); scaffold autopub.config.ts and route files
autopub add <file.md>parse → validate → upsert. --slug, --dry-run, --draft
autopub listslug · 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 doctorstore.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 addnpx autopub add, hard-coded paths and the Julien Béranger / /huangshan.png defaults read from autopub.config.ts instead
  • commands/autopub-list.md, commands/autopub-pull.md, commands/autopub-doctor.md
  • agents/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/:

RoutineCadenceDoes
scheduled-publishhourlypublish posts whose date has arrived and draft = true — a real editorial calendar, no infra
link-checkweeklycrawl outbound links in all posts, open a GitHub issue on 404s
draft-digestMondaylist stale drafts and unfinished posts
translateon demandduplicate a post into another locale with a translated body
og-refreshon publishregenerate 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.

ConcernChoiceWhy
Monorepopnpm workspacesalready the package manager here; pnpm-workspace.yaml exists
Task graphTurborepoonly once >5 packages; skip at first
Bundlertsdowntsup 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
TypesTypeScript 6matches this repo
VersioningChangesetsper-package semver + generated changelogs across the workspace
Export correctnesspublint + Are the Types Wrong?CI gate on the exports map and consumer type resolution
Dead weightKnipcatches unused deps before publish
TestsVitest + Testing Librarystore conformance + render snapshots
DocsNextra or Fumadocsthe docs site is itself an Autopub adopter — dogfood
CIGitHub Actionsmatrix over adapters; changesets/action for release
Registrynpm + provenancenpm 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.3

11.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.

  1. Create autopub/ with the layout in §11.2, core + adapter-fs only. Port markdown.ts verbatim, add slugify and parsePost. Write the conformance suite against adapter-fs.
  2. @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.
  3. @autopub/ui — port the pipeline from PostContent.tsx, write unstyledPreset and autopub.css. Snapshot-test against every post in content/posts/.
  4. @autopub/preset-chakra — port the Chakra map, inline the list snippet, parameterise brandColors. Diff renders against the current site: must be pixel-identical.
  5. @autopub/nextcreateBlog() and the factories, from app/[slug]/page.tsx.
  6. @autopub/cli — port scripts/posts.ts, add pull, doctor, images.
  7. Migrate the database: add tags, draft, meta; backfill meta = jsonb_build_object('model', model, 'conversation', conversation); keep the old columns one release, then drop.
  8. Flip this site to the SDK: lib/blog.ts, three route files, renderHeader for the model/conversation block, delete posts.ts, db.ts, PostContent.tsx, scripts/posts.ts. Net line change in this repo: −480.
  9. Publish 0.1.0 with provenance; update the /add skill to call npx 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

SupportedNotes
Next.js15.x, 16.x App RouterPages Router unsupported
React18, 19RSC-first; PostContent is a client component
Node20+process.loadEnvFile needs 20.6+
RuntimesNode, Edge, Vercel, Cloudflare Workersedge depends on the adapter — Neon HTTP driver yes, pg no
Chakrav3 only (peer)v2 has a different API surface; out of scope
BundlersNext/Turbopack, Vitetested 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

VersionContents
0.1core, next, ui, adapter-neon, adapter-fs, preset-chakra, cli; this site migrated
0.2adapter-postgres, preset-tailwind, claude-plugin, RSS + sitemap + OG images
0.3routine templates (§10.3), caching: 'tags' + revalidate route, drafts & scheduled publishing
0.4i18n post linking (translationOf), tags/archive pages, search adapter
1.0stable PostStore + RenderPreset contracts, docs site, adapter-sqlite, create-autopub scaffolder
latercomments, newsletter export, web editor, MDX/component embeds

16. Open questions

  1. Scope name — is @autopub free on npm? Fallback @w3hc/autopub alongside w3pk.
  2. MDX — a real want, but it drags in a compiler and breaks the "markdown in a TEXT column, 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.
  3. Does @autopub/next deserve to exist, or should createBlog() live in core with Next as an optional peer? Splitting it is cheap now and expensive later — keep the split.
  4. Presets as packages vs. one @autopub/presets with subpaths — separate packages, so peer deps are honest. Revisit only if publishing overhead bites.
  5. 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