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

@machopost/payload-plugin

v0.1.4

Published

Payload CMS plugin exposing the machopost publishing contract: candidates, targets, and write-schema discovery.

Readme

@machopost/payload-plugin

The CMS-side counterparty for machopost Payload channels. Installing it in a Payload app exposes the machopost publishing contract, authenticated by the app's own API-key auth (Payload useAPIKey):

  • GET /api/machopost/candidates — the Publishable scopes the calling key may write to, as machopost connect-picker candidates. Default: one candidate for the whole site; multi-tenant apps supply a candidates resolver.
  • GET /api/machopost/targets?scope= — the Publish targets a scope offers: a name, a flat composer field schema, and media constraints, under a content-hashed schemaVersion echoed as the ETag (If-None-Match → 304).
  • POST /api/machopost/publish — the execute half: { deliveryId, scope, target, content: { caption, media }, fields } publishes into the CMS and returns { documentId, url? }. The plugin re-validates fields against the target schema (authoritative; machopost's own validation is best-effort UX), fetches each media pull-URL server-side into fresh per-delivery media docs, then creates the primary doc live. Idempotent per deliveryId.
  • GET /api/machopost/schema — generic write-input JSON Schema introspection per granted collection: create/update views pruned to client-settable fields, richText advertised as markdown, relationships as bare ids, ?locale= selection, and the same schemaVersion/ETag freshness model.
  • The agent surface (below): GET /api/machopost/list, GET /api/machopost/get, POST /api/machopost/write, and POST /api/machopost/ingest-media — generic scope-fenced reads and writes over the granted collections, for machopost's MCP/CLI agent tools. No delete, by doctrine.

The two concepts

Candidates answer the connect-time question: where can this key publish? When a user connects your CMS in machopost (base URL + API key), machopost fetches candidates and shows a picker; each selected candidate becomes one channel. A single-site app never writes this — the default is one whole-site candidate. Only a multi-tenant app (one instance hosting many sites/projects) supplies a resolver, and it answers per calling key, from data — never a hardcoded list.

Targets answer the compose-time question: what shapes can be published there? A target like "Blog post" is the curated form machopost's composer renders for the channel: the extra fields to fill and the media it accepts. Targets are declared, not inferred from your collections, because publishing one post may fan out across several collections (a media doc, then the post doc referencing it) and only your app knows that choreography — the target is the small publishing surface you choose to expose, not your storage schema.

When a target's fields are backed by one collection, derive them with targetFieldsFromCollection instead of redeclaring types by hand — curation stays manual, but types, required, and options can't drift from the real collection.

Install

Single-site app — no candidates; connecting a key yields one whole-site channel:

import { machopostPlugin } from "@machopost/payload-plugin";

export default buildConfig({
  // ...
  plugins: [
    machopostPlugin({
      authCollections: ["service-accounts"],
      targets: [
        {
          id: "blog-post",
          name: "Blog post",
          fields: {
            type: "object",
            properties: {
              title: { type: "string", description: "The post's headline." },
              excerpt: { type: "string" },
            },
            required: ["title"],
            additionalProperties: false,
          },
          media: { kinds: ["image"], maxCount: 1, required: false },
          publish: {
            collection: "posts",
            mediaCollection: "media",
            document: ({ content, fields, mediaIds }) => ({
              title: fields.title,
              excerpt: fields.excerpt,
              body: content.caption,
              hero: mediaIds[0] ?? null,
            }),
            url: ({ doc }) => `https://blog.example/posts/${doc.slug}`,
          },
        },
      ],
    }),
  ],
});

Multi-tenant app — a candidates resolver derives the calling key's scopes from real data, and targets may vary per scope:

machopostPlugin({
  authCollections: ["service-accounts"],
  candidates: async ({ req }) => {
    const { docs } = await req.payload.find({
      collection: "projects",
      where: { id: { in: req.user?.projects ?? [] } },
    });
    return docs.map((project) => ({
      id: String(project.id),
      name: project.title,
      avatarUrl: project.logoUrl ?? undefined,
    }));
  },
  targets: ({ scope }) => targetMenuFor(scope),
});

Deriving target fields from a collection

targetFieldsFromCollection is the preferred way to declare a target's fields when they're backed by a real collection. Pass the raw CollectionConfig you already have in scope plus the field names to expose, in menu order; it returns a complete fields schema derived from the collection's own definitions:

import { targetFieldsFromCollection } from "@machopost/payload-plugin";
import { Posts } from "./collections/Posts";

targets: [
  {
    id: "blog-post",
    name: "Blog post",
    fields: targetFieldsFromCollection(Posts, ["title", "excerpt"], {
      excerpt: { description: "Shown on the listing page." },
    }),
    media: { kinds: ["image"], maxCount: 1, required: false },
    publish: {
      collection: "posts",
      mediaCollection: "media",
      document: ({ fields, mediaIds }) => ({ ...fields, hero: mediaIds[0] }),
    },
  },
],

Mapping: text/textarea/email/codestring (hasMany text → string-array); numbernumber; checkboxboolean; select/radiostring + enum from the option values (hasMany select → string-array). required: true propagates to the schema's required; admin.description (or a plain-string label) becomes the field's description. Fields inside rows, collapsibles, and unnamed tabs are found — they're top-level data.

The optional third argument overrides description, maxItems, or enum per field; an enum override may only narrow the collection's options. Field types with no flat representation (richText, relationship, upload, group, array, blocks…) throw at config build time — declare those fields explicitly instead.

Adopting with an agent

The package ships an adoption skill — skills/machopost-payload-adoption/SKILL.md in the installed package — that walks an agent through this whole setup as a decision checklist: auth collection, single-site vs multi-tenant candidates, target curation, publish mapping, verification, connect handoff. Point your agent at it, or make it discoverable in the repo:

mkdir -p .claude/skills
ln -s ../../node_modules/@machopost/payload-plugin/skills/machopost-payload-adoption .claude/skills/machopost-payload-adoption

Whether or not an agent drives, the bundled verification proves the live contract end-to-end (auth statuses, response shapes, the ETag→304 dance, and an idempotent throwaway publish — everything it creates is deleted). With the dev server running:

MACHOPOST_VERIFY_AUTH_COLLECTION=<auth-collection-slug> \
  npx payload run node_modules/@machopost/payload-plugin/skills/machopost-payload-adoption/scripts/verify.mjs

It exits non-zero on any contract violation. If the auth collection has required fields the script cannot invent (relationships, arrays), or your candidates resolver derives scopes from key data, pass that data via MACHOPOST_VERIFY_KEY_DATA (JSON keyed by collection slug — documented with the other env knobs at the top of the script).

Options

| Option | Required | Description | | --- | --- | --- | | authCollections | yes | Slugs of the auth collections whose API keys may call the endpoints (Authorization: <collection> API-Key <key>). Other callers get 403. | | targets | yes | The Publish targets offered — a static array, or a resolver ({ scope, req }) when the menu varies per scope. Each carries its discovery shape plus a publish fan-out config (below). | | candidates | no | Resolver ({ req }) returning the scopes the calling key may write to. Omitted: one whole-site candidate named from the Payload config. | | grants | no | Resolver ({ req }) returning { collection, operations } grants for /api/machopost/schema and the agent surface. Omitted: the key doc's own grants array field, or nothing. | | scopeField | no | ({ collection }) → the field name holding the Publishable scope id on that collection's docs, or null for unscoped collections. Fences the agent surface's reads and writes to the calling channel's scope. Omitted: nothing is scope-fenced — correct for whole-site apps, required for multi-tenant apps. |

The agent surface

machopost exposes the CMS's granted write surface to agents (MCP tools, CLI subcommands) through four generic endpoints. All of them name a scope (the channel's Publishable scope) and a collection, and run under the app's own Payload access rules (overrideAccess: false) on top of the plugin's checks:

  • GET /api/machopost/list?scope=&collection=&limit=&page=&sort=&locale= — paginated docs at depth 0 (relationships stay bare ids, matching the introspected schemas). Page size caps at 100.
  • GET /api/machopost/get?scope=&collection=&id=&locale= — one doc, depth 0.
  • POST /api/machopost/write{ scope, collection, operation: "create"|"update", id?, data, locale? }{ documentId, doc }. Server- managed fields (id, createdAt, updatedAt, the delivery stamp) are rejected.
  • POST /api/machopost/ingest-media{ scope, collection, url, alt?, filename? } fetches a machopost pull-URL server-side into a new doc of a granted upload collection and returns { documentId, doc } for relationship fields.

Semantics:

  • Grants gate everything. Writes need the exact operation granted (create/update); reads need read — or any write grant, which implies read (an agent can always see what it can write). There is no delete operation (retraction-by-unpublish doctrine).
  • Auth collections are untouchable, whatever the grants say — writing them would let a key mint itself a broader one.
  • The scope fence (scopeField): scoped collections have list filtered, get/update answering 404 outside the scope, and creates get the scope injected server-side (a conflicting value in data is a 400). machopost always sends its channel's scope; the fence is what makes a write unable to land outside it.
  • richText accepts markdown. The plugin wraps every richText field with a beforeValidate hook converting string values markdown → Lexical (via the field's own editor config), so the shape /api/machopost/schema advertises holds on write. Non-string values pass through untouched, and apps without @payloadcms/richtext-lexical (an optional peer) never load the converter.

Publishing

A target's publish block is the fan-out only your app can know — which collections a publish touches and how the request becomes a doc. It never appears on the discovery wire:

| Key | Required | Description | | --- | --- | --- | | collection | yes | Where the primary doc is created; its id becomes the delivery's documentId. | | mediaCollection | when the target accepts media | Upload collection for ingested media docs. The post's per-media alt snapshot is written to each doc. If that collection's alt field is required, declare media: { ..., altRequired: true } on the target — the publish then refuses alt-less media with a readable error (and machopost blocks it at compose time) instead of failing on Payload's field validation mid-fan-out. | | document | yes | ({ deliveryId, scope, content, fields, mediaIds, req }) → the primary doc's data. mediaIds are the created media docs' ids, in request order. | | url | no | ({ doc, scope, req }) → a public permalink for the created doc, when the app can produce one. Omitted → the delivery has no external URL. |

Semantics the plugin owns, not the app:

  • Publish means make-it-live. On draft-enabled collections the created doc is forced to _status: "published". A draft-review workflow is a status field you declare as a target field, not a machopost concept.
  • Fresh media docs per delivery. Media is fetched from machopost's pull-URLs server-side and created as new docs every delivery — alt-text snapshots stay per-post, no cross-post doc sharing.
  • Idempotency per deliveryId. Every created doc is stamped in a hidden, unique machopostDeliveryId text field the plugin injects into each non-auth collection (pruned from /api/machopost/schema; expect a schema migration on install) — the primary doc carries the delivery id, media docs <deliveryId>#<index>. A replayed delivery finds the stamped primary doc and returns the original { documentId, url? } without creating anything; a retry after a crash mid-fan-out adopts the stamped media docs it finds instead of duplicating them; and because the stamp is unique, even concurrent retries cannot double-publish — machopost's delivery workflows retry, so this is load-bearing.
  • Failures create nothing. Invalid fields and media-constraint violations are rejected (400, with a human-readable detail machopost surfaces as the delivery's error) before anything is created; if creating the primary doc fails, the fan-out is rolled back (transaction where the adapter supports one, compensating deletes otherwise).