---
title: Autopub — SDK Specification
date: 2026-08-28
lang: en-US
author: Julien Béranger
model: Claude Opus 5
source: https://julienberanger.com/autopub-sdk-spec
---

# Autopub — SDK Specification

**Status:** draft v0.1 · **Date:** 2026-08-28 · **Author:** Julien Béranger
**Extracted from:** [julienbrg/personal-website](https://github.com/julienbrg/personal-website)

---

## 1. Purpose

Turn the blog engine currently embedded in this site into a reusable SDK that any
[Next.js](https://nextjs.org/docs) 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](#101-the-add-skill), the
[Neon connector](https://neon.com/docs/ai/neon-mcp-server), and
[Claude Code Routines](https://docs.claude.com/en/docs/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](#15-roadmap)).

### 1.1 The two design constraints

Everything in this spec follows from two facts:

1. **Adopters have different databases.** We ship [Neon](https://neon.com/docs/introduction) first
   because that is what this site runs on, but Postgres/Supabase/SQLite/plain-markdown users must be
   first-class. → the **data seam**, [§5](#5-data-seam--poststore).
2. **Adopters have different design systems.** We ship
   [Chakra UI v3](https://chakra-ui.com/docs/get-started/installation) first because that is what
   this site runs on, but Tailwind/shadcn/MUI/plain-CSS users must be first-class. → the **render
   seam**, [§6](#6-render-seam--renderpreset).

Neither seam may leak into the other, and neither may leak into
[`@autopub/core`](#4-autopubcore).

---

## 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](src/lib/markdown.ts) | 55 | `parseFrontmatter`, `extractLeadingHeading`, `isValidSlug` | `@autopub/core` — verbatim |
| [src/lib/posts.ts](src/lib/posts.ts) | 71 | `Post` types, `getPost`, `formatPostDate` | split: types → `core`, SQL → `adapter-neon`, format → `core` |
| [src/lib/db.ts](src/lib/db.ts) | 7 | Neon client | `@autopub/adapter-neon` |
| [src/components/PostContent.tsx](src/components/PostContent.tsx) | 196 | react-markdown → Chakra map | split: pipeline → `ui`, Chakra map → `preset-chakra` |
| [scripts/posts.ts](scripts/posts.ts) | 124 | `init` / `add` / `delete` / `list` | `@autopub/cli` |
| [src/app/\[slug\]/page.tsx](src/app/%5Bslug%5D/page.tsx) | 118 | route, `generateMetadata`, post header | `@autopub/next` |
| [src/theme/index.ts](src/theme/index.ts) | 7 | `brandColors` | stays in the consumer app |
| [~/.claude/skills/add/SKILL.md](file:///Users/ju/.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](src/lib/posts.ts#L1), the Chakra imports throughout
[PostContent.tsx](src/components/PostContent.tsx), and the site-specific `model` / `conversation`
frontmatter fields in [PostFrontmatter](src/lib/posts.ts#L4-L14).

---

## 3. Package map

Published under the [`@autopub`](https://www.npmjs.com/org/autopub) npm scope
(alternative if taken: `@w3hc/autopub`, matching [w3pk](https://www.npmjs.com/package/w3pk)).

| Package | Depends on | Peer deps | Purpose |
|---|---|---|---|
| [`@autopub/core`](#4-autopubcore) | — (zero runtime deps) | — | types, frontmatter parser, slug rules, date formatting |
| [`@autopub/next`](#7-nextjs-integration) | `core` | `next`, `react` | `createBlog()`, page/metadata/sitemap/RSS factories |
| [`@autopub/ui`](#6-render-seam--renderpreset) | `core`, `react-markdown`, `remark-gfm` | `react` | renderer pipeline + unstyled default preset + `autopub.css` |
| [`@autopub/adapter-neon`](#52-the-neon-adapter-v1) | `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`](#63-the-chakra-preset-v1) | `core`, `ui` | `@chakra-ui/react@^3` | **v1 reference preset** |
| `@autopub/preset-tailwind` | `core`, `ui` | — | Tailwind/shadcn class map |
| [`@autopub/cli`](#9-cli) | `core` | — | `autopub init/add/list/delete/pull/doctor` |
| [`@autopub/claude-plugin`](#10-claude-integration) | — | — | 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](https://publint.dev/) in CI.

---

## 4. `@autopub/core`

Zero dependencies. Runs in Node, edge, and the browser.

```ts
// @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](src/lib/markdown.ts) — they already have no dependencies and are already
  documented.
- `formatPostDate` generalises [src/lib/posts.ts:59-70](src/lib/posts.ts#L59-L70): 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](#101-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](#122-metadata-migration).

---

## 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](#9-cli) and by webhooks.

```ts
// @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](src/lib/posts.ts#L52) 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/testing` → `runStoreConformance(makeStore)`.

### 5.2 The Neon adapter (v1)

```ts
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`](https://www.npmjs.com/package/@neondatabase/serverless), which
is what [src/lib/db.ts](src/lib/db.ts) already uses — HTTP driver, so it works on
[Vercel Edge](https://vercel.com/docs/functions/runtimes/edge) and in
[Next.js Server Components](https://nextjs.org/docs/app/getting-started/server-and-client-components).

**Schema** (`init()`), a superset of [today's table](scripts/posts.ts#L14-L31):

```sql
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](scripts/posts.ts#L27-L29), 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

```ts
// @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](src/components/PostContent.tsx) that are *logic*,
not style — the internal-vs-external link branch at
[lines 44-64](src/components/PostContent.tsx#L44-L64), the fenced-vs-inline code detection at
[lines 120-122](src/components/PostContent.tsx#L120-L122), the `hr`-as-whitespace decision at
[line 116](src/components/PostContent.tsx#L116) — and pushes every colour and spacing token out to
the preset.

### 6.3 The Chakra preset (v1)

```ts
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](src/components/PostContent.tsx#L14-L184) almost verbatim, with
[brandColors](src/theme/index.ts) 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](src/components/ui/list.tsx), which is a
[Chakra snippet](https://chakra-ui.com/docs/components/list) 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](https://www.npmjs.com/package/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](package.json#L22)).

```ts
// 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

```ts
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"](https://github.com/julienbrg/personal-website/commit/ba013db)
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](src/app/%5Bslug%5D/page.tsx#L10) |
| `'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

```ts
// app/[slug]/page.tsx
import { blog } from '@/lib/blog'
export const dynamic = 'force-dynamic'
export const generateMetadata = blog.createPostMetadata()
export default blog.createPostPage()
```

```ts
// app/blog/page.tsx
import { blog } from '@/lib/blog'
export default blog.createIndexPage({ pageSize: 20 })
```

```ts
// 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](src/app/%5Bslug%5D/page.tsx) — and the post
header (author / model / conversation block,
[lines 68-104](src/app/%5Bslug%5D/page.tsx#L68-L104)) 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](#9-cli) and importable by the app so
config lives in one place:

```ts
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`](https://nodejs.org/api/process.html#processloadenvfilepath) (what
[scripts/posts.ts:104](scripts/posts.ts#L104) already does).

---

## 9. CLI

`npx @autopub/cli` / `pnpm autopub`. Supersedes [scripts/posts.ts](scripts/posts.ts) and
[`pnpm posts`](package.json#L17).

| 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](#101-the-add-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](scripts/posts.ts#L47-L63)) 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`](file:///Users/ju/.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](https://docs.claude.com/en/docs/claude-code/plugins) containing:

- `skills/add/SKILL.md` — generalised: `pnpm posts add` → `npx autopub add`, hard-coded paths and
  the `Julien Béranger` / `/huangshan.png` defaults read from
  [`autopub.config.ts`](#8-configuration-file) 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](https://claude.ai) with no local checkout, via the
[Neon MCP server](https://neon.com/docs/ai/neon-mcp-server): **Settings → Connectors → Browse
connectors → Neon**, then enable it per chat under **Add sources**
([setup guide](https://neon.com/docs/ai/connect-mcp-clients-to-neon)). In Claude Code the equivalent
is `claude mcp add --transport http neon https://mcp.neon.tech/mcp`
([guide](https://neon.com/guides/claude-code-mcp-neon)).

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](https://docs.claude.com/en/docs/claude-code/routines) — scheduled cloud
agents, shipped April 2026, still research preview
([The Register](https://www.theregister.com/2026/04/14/claude_code_routines/),
[MakerKit guide](https://makerkit.dev/blog/tutorials/claude-code-routines-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](https://claude.ai/code/routines). There are three tiers to be precise
about, and the SDK docs should name them:
[`/loop`](https://docs.claude.com/en/docs/claude-code/slash-commands) (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](https://tsdown.dev/) + [Changesets](https://github.com/changesets/changesets).** Not a
SaaS boilerplate. [next-forge](https://www.next-forge.com/) and
[Saasfly](https://github.com/saasfly/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](https://pnpm.io/workspaces) | already the package manager here; [pnpm-workspace.yaml](pnpm-workspace.yaml) exists |
| Task graph | [Turborepo](https://turbo.build/repo/docs) | only once >5 packages; skip at first |
| Bundler | **[tsdown](https://tsdown.dev/)** | [tsup](https://www.npmjs.com/package/tsup) is no longer actively maintained and now points at tsdown; [Rolldown](https://rolldown.rs/)-based, tsup-compatible options, ESM+CJS+`.d.ts`. Note [tsup is already a devDependency here](package.json#L54) — swap it |
| Types | [TypeScript 6](https://www.typescriptlang.org/docs/) | matches [this repo](package.json#L57) |
| Versioning | [Changesets](https://github.com/changesets/changesets) | per-package semver + generated changelogs across the workspace |
| Export correctness | [publint](https://publint.dev/) + [Are the Types Wrong?](https://arethetypeswrong.github.io/) | CI gate on the `exports` map and consumer type resolution |
| Dead weight | [Knip](https://knip.dev/) | catches unused deps before publish |
| Tests | [Vitest](https://vitest.dev/) + [Testing Library](https://testing-library.com/docs/react-testing-library/intro/) | store conformance + render snapshots |
| Docs | [Nextra](https://nextra.site/) or [Fumadocs](https://fumadocs.dev/) | the docs site is itself an Autopub adopter — dogfood |
| CI | [GitHub Actions](https://docs.github.com/en/actions) | matrix over adapters; `changesets/action` for release |
| Registry | npm + [provenance](https://docs.npmjs.com/generating-provenance-statements) | `npm publish --provenance` from CI |

Keep [`minimumReleaseAge`](pnpm-workspace.yaml) 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

```jsonc
{
  "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](#112-repo-layout), `core` + `adapter-fs` only.
   Port [markdown.ts](src/lib/markdown.ts) verbatim, add `slugify` and `parsePost`. Write the
   conformance suite against `adapter-fs`.
2. **`@autopub/adapter-neon`** — move [db.ts](src/lib/db.ts) and the four queries from
   [posts.ts](src/lib/posts.ts) / [scripts/posts.ts](scripts/posts.ts). Run the conformance suite
   against a [Neon branch](https://neon.com/docs/introduction/branching), not production.
3. **`@autopub/ui`** — port the pipeline from [PostContent.tsx](src/components/PostContent.tsx),
   write `unstyledPreset` and `autopub.css`. Snapshot-test against every post in
   [content/posts/](content/posts/).
4. **`@autopub/preset-chakra`** — port the Chakra map, inline the
   [list snippet](src/components/ui/list.tsx), parameterise
   [brandColors](src/theme/index.ts). Diff renders against the current site: **must be
   pixel-identical**.
5. **`@autopub/next`** — `createBlog()` and the factories, from
   [app/\[slug\]/page.tsx](src/app/%5Bslug%5D/page.tsx).
6. **`@autopub/cli`** — port [scripts/posts.ts](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](src/lib/posts.ts), [db.ts](src/lib/db.ts),
   [PostContent.tsx](src/components/PostContent.tsx), [scripts/posts.ts](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`](#8-configuration-file) 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](src/lib/posts.ts#L54)); `@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](README.md) of this repo, `unstyledPreset` output must pass
[axe](https://www.deque.com/axe/) and keep heading order intact — note today's map renders markdown
`h1` as an `h2` ([PostContent.tsx:15](src/components/PostContent.tsx#L15)) precisely to avoid two
`h1`s on the page.

---

## 14. Quality gates

Every PR: `typecheck` · `lint` ([ESLint 9](eslint.config.mjs) + [jsx-a11y](https://www.npmjs.com/package/eslint-plugin-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](#103-routines--scheduled-agents)), `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

1. **Scope name** — is [`@autopub`](https://www.npmjs.com/org/autopub) free on npm? Fallback
   `@w3hc/autopub` alongside [w3pk](https://www.npmjs.com/package/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](#102-the-neon-connector-in-browser-claude).
   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](file:///Users/ju/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

```ts
// 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 */ }
```

```ts
// 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](package.json) · [src/lib/posts.ts](src/lib/posts.ts) ·
[src/lib/markdown.ts](src/lib/markdown.ts) · [src/lib/db.ts](src/lib/db.ts) ·
[src/components/PostContent.tsx](src/components/PostContent.tsx) ·
[src/app/\[slug\]/page.tsx](src/app/%5Bslug%5D/page.tsx) · [scripts/posts.ts](scripts/posts.ts) ·
[src/theme/index.ts](src/theme/index.ts) · [pnpm-workspace.yaml](pnpm-workspace.yaml) ·
[README.md](README.md) · [GitHub](https://github.com/julienbrg/personal-website)

**Stack:** [Next.js](https://nextjs.org/docs) · [React](https://react.dev/) ·
[Chakra UI v3](https://chakra-ui.com/docs/get-started/installation) ·
[react-markdown](https://github.com/remarkjs/react-markdown) ·
[remark-gfm](https://github.com/remarkjs/remark-gfm) ·
[next-themes](https://www.npmjs.com/package/next-themes) ·
[Neon serverless driver](https://www.npmjs.com/package/@neondatabase/serverless)

**Tooling:** [pnpm workspaces](https://pnpm.io/workspaces) · [tsdown](https://tsdown.dev/) ·
[Rolldown](https://rolldown.rs/) · [Changesets](https://github.com/changesets/changesets) ·
[publint](https://publint.dev/) · [Are the Types Wrong?](https://arethetypeswrong.github.io/) ·
[Knip](https://knip.dev/) · [Vitest](https://vitest.dev/) · [Turborepo](https://turbo.build/repo/docs) ·
[Nextra](https://nextra.site/) · [Fumadocs](https://fumadocs.dev/) ·
[npm provenance](https://docs.npmjs.com/generating-provenance-statements)

**Neon:** [docs](https://neon.com/docs/introduction) ·
[MCP server](https://neon.com/docs/ai/neon-mcp-server) ·
[connect MCP clients](https://neon.com/docs/ai/connect-mcp-clients-to-neon) ·
[Claude Code + Neon guide](https://neon.com/guides/claude-code-mcp-neon) ·
[branching](https://neon.com/docs/introduction/branching)

**Claude:** [Claude Code docs](https://docs.claude.com/en/docs/claude-code/overview) ·
[Routines](https://docs.claude.com/en/docs/claude-code/routines) ·
[routines console](https://claude.ai/code/routines) ·
[plugins](https://docs.claude.com/en/docs/claude-code/plugins) ·
[skills](https://docs.claude.com/en/docs/claude-code/skills) ·
[the local /add skill](file:///Users/ju/.claude/skills/add/SKILL.md) ·
[Routines guide (MakerKit)](https://makerkit.dev/blog/tutorials/claude-code-routines-guide) ·
[The Register on Routines](https://www.theregister.com/2026/04/14/claude_code_routines/)
