npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@floggy/cms

v0.5.0

Published

Floggy CMS SDK - the single dependency to build an SEO-complete blog on Floggy's headless content API

Downloads

1,590

Readme

@floggy/cms

The single dependency you install to build a blog on Floggy.

Floggy renders your content server-side (html, markdown, SEO metadata, JSON-LD, feeds) and embeds the finished output in its API responses. This SDK is a thin, fully typed client over that API, for reading and for writing. Zero runtime dependencies - it uses the platform fetch.

  • Read a published blog with no key: posts, tags, SEO metadata, JSON-LD, sitemap, RSS, robots.
  • Write with an flg_ key: create posts from Markdown, update, publish, schedule, unpublish, delete, bulk.
  • Preview an unpublished post by slug with posts.draft, the primitive behind a /drafts/[slug] route.
  • Convert Markdown to Tiptap and back with markdownToTiptap / tiptapToMarkdown, no dependencies.
  • Store structured content in Agent Collections: schemas, versions, labels, A/B variants.

Install

bun add @floggy/cms
# or
npm install @floggy/cms

Quickstart

A working blog index plus a post page, with SEO, in about 15 lines.

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

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

// Blog index
const { posts, pageInfo } = await floggy.posts.list({ page: 1, perPage: 10 });
for (const post of posts) {
  console.log(post.title, "/" + post.slug);
}

// Post page (rendered HTML + adjacent nav + SEO block + JSON-LD)
const post = await floggy.posts.get("hello-world", { format: "html" });
console.log(post.rendered);          // ready-to-inject HTML string
console.log(post.adjacent.prev);     // { slug, title } | null
const metadata = floggy.seo.meta(post); // plain Next.js-compatible Metadata object

Draft, preview, publish

// Needs an flg_ key with posts:write (and posts:read to preview). Server-side only.
const floggy = createClient({ project: "projecta", key: process.env.FLOGGY_KEY });

// Markdown in, draft out. `slug` comes from the title. Nothing is live yet.
const { id } = await floggy.posts.create({
  title: "Launch Day",
  excerpt: "We shipped.",
  tagNames: ["Changelog"],
  content: "## What changed\n\nWe shipped the new **editor**.",
});

// Preview by slug. Unpublished posts only: a published slug returns null.
const draft = await floggy.posts.draft("launch-day");
console.log(draft?.rendered);

await floggy.posts.publish(id);   // now, preserving a backdated draft's date
await floggy.posts.unpublish(id); // back to draft, date intact

Authenticated calls act on the key owner's blog, not on project. project only selects which blog the public reads come from.

Next.js App Router example

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

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

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

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await floggy.posts.get(params.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

// 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" },
  });
}

The same pattern works for floggy.feeds.rss() (application/rss+xml) and floggy.feeds.robots() (text/plain).

Configuration

createClient({
  project: "projecta",                 // required: the Floggy username/project
  key: process.env.FLOGGY_KEY,         // optional: flg_ key for drafts/private content
  baseUrl: "https://floggy-api.pehcastro.workers.dev", // optional: API base URL
  site: {                              // optional: used by feeds.*
    url: "blog.example.com",           // public origin (protocol auto-added)
    name: "Example",                   // defaults to project name
    description: "My blog",            // defaults to "<name>'s blog"
    locale: "en",                      // defaults to "en"
  },
  fetch: customFetch,                  // optional: custom fetch implementation
});

Auth

Public reads need no key. Pass an flg_ key to read drafts and private content (posts:read), to create, update, and publish (posts:write), or to delete (posts:delete). Use a Read-only key in any frontend - a leaked read key only exposes content that is already public. Never embed an Editor or Full-access key in a frontend: it can publish and delete your posts.

const floggy = createClient({ project: "projecta", key: process.env.FLOGGY_KEY });

Method reference

posts.list(options?) -> { posts, pageInfo }

Lists published posts. Always paginated.

| Option | Type | Default | Notes | |--------|------|---------|-------| | page | number | 1 | | | perPage | number | 10 | Capped at 50 | | tag | string | - | Filter by tag slug | | sort | "publishedAt:desc" \| "publishedAt:asc" | publishedAt:desc | | | format | "tiptap" \| "html" \| "markdown" | tiptap | |

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

posts.get(slug, options?) -> PostDetail

Fetches one post. Returns the post fields plus:

  • rendered - html/markdown string when format is not tiptap
  • adjacent - { prev, next } neighbouring posts
  • seo - server-computed SEO block
  • jsonLd - array of JSON-LD nodes

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

posts.search(query, options?) -> Post[]

Full-text search across published posts. Options: { format? }.

Your own posts (needs a key)

| Method | Scope | Returns | |--------|-------|---------| | posts.mine(options?) | posts:read | Every post you own, drafts included. Unpaginated, full content on every row. | | posts.getById(id, { format? }) | posts:read | One of your posts by id, draft or published. | | posts.draft(slug, { format? }) | posts:read | One unpublished post by slug, or null. Returns DraftPost (PostDetail without seo/jsonLd). | | posts.create(input) | posts:write | { id }. Defaults to a draft; content is Markdown unless you say otherwise. | | posts.update(id, patch) | posts:write | void. tagNames replaces the whole set. | | posts.publish(id, { at? }) | posts:write | void. Without at, reads first to preserve a backdated date. | | posts.unpublish(id) | posts:write | void. publishedAt is left intact. | | posts.delete(id) | posts:delete | void. | | posts.bulk(action, ids) | posts:write | { success, affected }. Up to 100 ids, delete included. |

Full signatures, option tables, and the gotchas (bulk publish has no date guard, posts.draft costs two requests) are in the SDK reference.

markdownToTiptap(md) / tiptapToMarkdown(doc) / slugify(title)

Standalone, pure, dependency-free. markdownToTiptap is what turns a Markdown content string into the Tiptap document Floggy stores; slugify is the rule posts.create uses when you omit slug. The supported Markdown subset is documented in the SDK reference.

tags.list() -> TagWithCount[]

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

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.

seo.jsonLd(post) -> JsonLd[]

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

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.

Collections

Agent Collections are versioned, labelled structured content stored under your account. Every collections call requires an flg_ key with the matching collections:read / collections:write / collections:delete scope. Collections are scoped to the key's owner, so there is no project in these calls.

New to them? The collections quickstart walks the same flow end to end.

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

const floggy = createClient({
  project: "projecta",
  key: process.env.FLOGGY_KEY, // needs collections:* scopes
});

// Create a collection
const collection = await floggy.collections.create({
  name: "Changelog",
  slug: "changelog",
  schema: {
    fields: [
      { name: "title", type: "string" },
      { name: "body", type: "richtext" },
    ],
    indexes: { title: "title", slug: "slug" },
  },
});

// Create an entry (a new version 1)
const entry = await floggy.collections.entries.create(collection.id, {
  slug: "v1-0-0",
  data: { title: "1.0.0", body: "First release." },
  tags: ["release"],
});

// Publish it: points the "production" label at that version
await floggy.collections.publish(collection.id, entry.id, entry.version);

// List entries (light rows; pass full: true to include data)
const { entries, pageInfo } = await floggy.collections.entries.list(collection.id, {
  perPage: 50,
  tag: "release",
});

// Read the published entry (defaults to the production version)
const published = await floggy.collections.entries.get(collection.id, entry.id);

Optimistic concurrency

Pass ifVersion on an update to reject a write that raced another change. A mismatch throws ConflictError carrying the server's currentVersion.

import { ConflictError } from "@floggy/cms";

try {
  await floggy.collections.entries.update(collection.id, entry.id, {
    data: { title: "1.0.1" },
    ifVersion: entry.version,
  });
} catch (err) {
  if (err instanceof ConflictError) {
    console.log("stale write, current version is", err.currentVersion);
  }
}

A/B testing with labels

Create one label per variant, then resolve each visitor to a stable variant with pick. It is a pure function: the same visitorId always maps to the same label, and per-label meta.weight controls the traffic split.

import { pick } from "@floggy/cms";

// Point two labels at two versions, weighted 3:1
await floggy.collections.labels.set(collection.id, entry.id, "hero-a", { version: 4, meta: { weight: 3 } });
await floggy.collections.labels.set(collection.id, entry.id, "hero-b", { version: 5, meta: { weight: 1 } });

const detail = await floggy.collections.entries.get(collection.id, entry.id);
const variant = pick(detail.labels, visitorId, { exclude: ["production"] });

const shown = await floggy.collections.entries.get(collection.id, entry.id, {
  label: variant ?? "production",
});

Building a sitemap from entries

sitemapFromEntries turns any list of items with a slug into a sitemap.xml string.

import { sitemapFromEntries } from "@floggy/cms";

const { entries } = await floggy.collections.entries.list(collection.id, { perPage: 50 });
const xml = sitemapFromEntries(entries, {
  baseUrl: "blog.example.com",
  path: (e) => `/changelog/${e.slug}`,
});

Method reference

  • collections.list() / create(input) / get(id) / update(id, input) / delete(id)
  • collections.entries.list(id, options?) -> { entries, pageInfo } (perPage capped at 50; full hydrates data)
  • collections.entries.get(id, entryId, { label?, version? }) -> EntryDetail
  • collections.entries.create(id, { slug?, data, tags?, label? }) (413 when data > 256KB). Entries are drafts unless you pass label: "production", which publishes in the same call. Requires @floggy/cms >= 0.2.1
  • collections.entries.update(id, entryId, { data?, tags?, slug?, ifVersion? }) (409 on ifVersion mismatch)
  • collections.entries.delete(id, entryId)
  • collections.entries.bulk(id, operation) -> { success, affected, errors } (max 50 items)
  • collections.versions.list(id, entryId) / get(id, entryId, version)
  • collections.labels.list(id, entryId) / set(id, entryId, name, { version, meta? }) / remove(id, entryId, name)
  • collections.publish(id, entryId, version) = set the "production" label

Errors

Every non-2xx response throws a typed error.

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

try {
  await floggy.posts.publish(id);
} catch (err) {
  if (err instanceof ScopeError) {
    console.log("this key needs:", err.required.join(", "));
  } else 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, and url. ScopeError (status 403) adds required, the scopes the endpoint asked for, so you can tell the key's owner exactly what to add. RateLimitError (status 429) adds retryAfter (seconds, parsed from the Retry-After header). ConflictError (status 409) is an optimistic-concurrency failure only, from an entry ifVersion mismatch, and adds currentVersion. A post slug that is already in use is also a 409 but stays a plain FloggyError with the message "Slug already used": catch it, pick another slug, retry. An oversized entry (data over 256KB) surfaces as a plain FloggyError with status 413.

Documentation

Full docs live at floggy.xyz/docs/cms:

  • Quickstart - list, fetch, render, and add SEO in about 15 lines.
  • SDK reference - every @floggy/cms method, with Next.js and TanStack Start examples.
  • API reference - the underlying REST endpoints (public reads + admin writes), for curl and non-SDK use.
  • Authentication - creating flg_ keys, the scope table, presets, and key-safety rules.
  • Webhooks - subscribe to post.published / post.updated and verify signatures.

Collections have their own set:

If you are a coding agent, start here: floggy.xyz/docs/llms.txt - the whole documentation set in one plain-text file.

Looking for the terminal client instead? That is @floggy/cli.

License

MIT