`@floggy/cms` is a thin, fully typed client for your Floggy content. It fetches your posts, tags, SEO metadata, JSON-LD, and feeds and returns them fully typed. Zero runtime dependencies - it uses the platform `fetch`.

```install
npm install @floggy/cms
pnpm add @floggy/cms
bun add @floggy/cms
yarn add @floggy/cms
```

## `createClient(config)`

```ts
import { createClient } from "@floggy/cms";

const floggy = createClient({
  project: "projecta",                 // required: the Floggy username / project
  key: process.env.FLOGGY_API_KEY,     // optional: flg_ key for drafts / private content
  site: {                              // optional: used by feeds.*
    url: "blog.example.com",           // public origin; protocol auto-added if missing
    name: "Example",                   // defaults to the project name
    description: "My blog",            // defaults to "<name>'s blog"
    locale: "en",                      // defaults to "en"
  },
  fetch: customFetch,                  // optional: custom fetch implementation
});
```

`project` is required. `key` is only needed for non-public content; leave it out for a purely public blog. Returns a `FloggyClient` with four namespaces: `posts`, `tags`, `seo`, `feeds`.

## `posts`

### `posts.list(options?)` -> `Promise<{ posts, pageInfo }>`

Lists published posts. Always paginated (the SDK always sends `page`/`perPage`, so you always get the envelope).

| Option | Type | Default |
|--------|------|---------|
| `page` | `number` | `1` |
| `perPage` | `number` | `10` (capped at 50) |
| `tag` | `string` | - |
| `sort` | `"publishedAt:desc" \| "publishedAt:asc"` | `"publishedAt:desc"` |
| `format` | `"tiptap" \| "html" \| "markdown"` | `"tiptap"` |

`pageInfo` is `{ total, page, perPage, hasMore }`.

### `posts.get(slug, options?)` -> `Promise<PostDetail>`

Fetches one post. `PostDetail` is the post fields plus:

- `rendered` - html/markdown string when `format` is not `tiptap`, else `null`
- `content` - always the raw Tiptap JSON
- `adjacent` - `{ prev, next }`, each `{ slug, title } | null`
- `seo` - server-computed SEO block
- `jsonLd` - array of JSON-LD nodes

Options: `{ format?: "tiptap" | "html" | "markdown" }`.

### `posts.search(query, options?)` -> `Promise<Post[]>`

Full-text search across published posts (title + excerpt). Returns a flat array. Options: `{ format? }`.

## `tags`

### `tags.list()` -> `Promise<TagWithCount[]>`

Returns `[{ name, slug, count }]`, sorted by count descending.

## `seo`

### `seo.meta(post)` -> `Metadata`

Reshapes a post's `seo` block into a plain object compatible with Next.js `Metadata` and TanStack head builders: `title`, `description`, `keywords`, `alternates.canonical`, `robots`, `openGraph`, `twitter`. Framework-agnostic, no framework import. Synchronous.

### `seo.jsonLd(post)` -> `JsonLd[]`

Returns the post's JSON-LD nodes, ready to inject as `<script type="application/ld+json">`. Synchronous.

## `feeds`

Driven by `site` in the client config.

### `feeds.sitemap()` -> `Promise<string>`

Fetches all published posts and returns a sitemap.xml string.

### `feeds.rss()` -> `Promise<string>`

Returns an RSS 2.0 feed string (most recent 20 posts).

### `feeds.robots()` -> `string`

Returns a robots.txt string pointing at the sitemap and feed. Synchronous.

## Errors

Every non-2xx response throws a typed error.

```ts
import { FloggyError, RateLimitError } from "@floggy/cms";

try {
  await floggy.posts.get("missing");
} catch (err) {
  if (err instanceof RateLimitError) {
    console.log("retry after", err.retryAfter, "seconds");
  } else if (err instanceof FloggyError) {
    console.log(err.status, err.message, err.code);
  }
}
```

`FloggyError` carries `status`, `message`, `code`, `url`. `RateLimitError` (status 429) adds `retryAfter` (seconds, parsed from the `Retry-After` header).

## Next.js (App Router)

A complete post page with metadata, rendered HTML, and JSON-LD.

```tsx
// app/[slug]/page.tsx
import { createClient } from "@floggy/cms";

const floggy = createClient({ project: "projecta" });

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await floggy.posts.get(slug);
  return floggy.seo.meta(post); // title, description, openGraph, twitter, alternates.canonical, robots
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await floggy.posts.get(slug, { format: "html" });

  return (
    <article>
      <h1>{post.title}</h1>
      {floggy.seo.jsonLd(post).map((node, i) => (
        <script
          key={i}
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
        />
      ))}
      <div dangerouslySetInnerHTML={{ __html: post.rendered ?? "" }} />
    </article>
  );
}
```

### Drop-in feed routes

```ts
// app/sitemap.xml/route.ts
import { createClient } from "@floggy/cms";

const floggy = createClient({
  project: "projecta",
  site: { url: "blog.example.com", name: "Example", description: "My blog" },
});

export async function GET() {
  return new Response(await floggy.feeds.sitemap(), {
    headers: { "Content-Type": "application/xml" },
  });
}
```

```ts
// app/rss.xml/route.ts
import { floggy } from "@/lib/floggy"; // a shared client with `site` set

export async function GET() {
  return new Response(await floggy.feeds.rss(), {
    headers: { "Content-Type": "application/rss+xml" },
  });
}
```

```ts
// app/robots.txt/route.ts
import { floggy } from "@/lib/floggy";

export function GET() {
  return new Response(floggy.feeds.robots(), {
    headers: { "Content-Type": "text/plain" },
  });
}
```

Reuse one client across routes by exporting it from `lib/floggy.ts` with `site` configured, so the feed builders know your public origin.

## TanStack Start

`seo.meta` returns a plain object, so it slots straight into a route's `head`.

```tsx
// src/routes/$slug.tsx
import { createFileRoute } from "@tanstack/react-start";
import { createClient } from "@floggy/cms";

const floggy = createClient({ project: "projecta" });

export const Route = createFileRoute("/$slug")({
  loader: ({ params }) => floggy.posts.get(params.slug, { format: "html" }),
  head: ({ loaderData: post }) => {
    const meta = floggy.seo.meta(post);
    return {
      meta: [
        { title: meta.title },
        { name: "description", content: meta.description },
        { property: "og:title", content: meta.openGraph.title },
        { property: "og:image", content: meta.openGraph.images[0]?.url },
      ],
      links: [{ rel: "canonical", href: meta.alternates.canonical }],
    };
  },
  component: Post,
});

function Post() {
  const post = Route.useLoaderData();
  return <article dangerouslySetInnerHTML={{ __html: post.rendered ?? "" }} />;
}
```

Feed routes work the same way: a server route handler that returns `await floggy.feeds.sitemap()` / `rss()` / `floggy.feeds.robots()` with the right `Content-Type`.
