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

@bison-lab/payload-core

v3.21.0

Published

Site-agnostic Payload CMS configuration for Bison Lab sites: the SEO tab, the Theme global, the Roles Global, the brand-assets cupboard, and the metadata and theme readers for the pages they describe

Readme

@bison-lab/payload-core

Site-agnostic Payload CMS configuration for Bison Lab sites: the SEO tab on a collection, the Theme Global, the Roles Global, and the readers a page route and a root layout use. Collection factories join it here as they are extracted.

Full guide: https://payload.bisonlab.ai/site/seo/

pnpm add @bison-lab/payload-core @payloadcms/plugin-seo @bison-lab/tokens @bison-lab/fonts

Peers: payload and @payloadcms/plugin-seo are required. @bison-lab/tokens and @bison-lab/fonts are needed to adopt the Theme Global; @payloadcms/ui and react are needed for its admin fields; @bison-lab/ui and @payloadcms/live-preview-react are needed for the live theme preview. Pin the plugin to the same version as payload; Payload releases them in lockstep.

Six entry points, and why

| Import | Contents | Runs where | | ---------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | | @bison-lab/payload-core | seoPlugin, createTheme, createBrandAssets, createRoles, createFeatures, createPages, createUsers, createMedia, createAdminNav, adminNav, createNavigation, adminOnlyApiTab, documentTitleActions, seedTheme, seedRoles, seedFeatures, predicates, lookField, colorTokenField, import-map strings, title and text helpers, types | Node. What payload.config.ts imports; it loads the plugin. | | @bison-lab/payload-core/metadata | pageMetadata, the title helpers, the same types | Server. What a page route imports; it does not load the plugin. | | @bison-lab/payload-core/theme | getPublishedTheme, getPublishedIdentity, themeConfigFromDoc, themeHead, themeHeadFromDoc | Server. What a root layout imports; it does not load the plugin or React. | | @bison-lab/payload-core/navigation | getNavigation, the Header and Footer slugs | Server. What a site header imports; it does not load the plugin or React. | | @bison-lab/payload-core/admin | Theme fields, the Roles and Features matrices, Admin nav, and the Users Roles checklist | Admin. Referenced by import-map string; generate:importmap writes it. | | @bison-lab/payload-core/react | ThemePreview (legacy; Theme has no preview pane) | Client. Kept so an older site import does not break. |

What editors get

An SEO tab beside the page's Content tab, never below the block editor: an overview with character counts, meta title and description with Generate buttons, a share image from the media collection with its own Generate button when the site can name an image already on the page, a search-result preview, and a Hide this page from search engines switch, off by default.

Wiring a site

1. Configure the plugin. Every string it generates comes from here, so the site spells its name and its URL scheme once.

// payload.config.ts
import { firstImageIn, seoPlugin } from "@bison-lab/payload-core";
import type { Page } from "@/payload-types";

export default buildConfig({
  plugins: [
    seoPlugin<Page>({
      siteName: "Acme Clinics",
      urlFor: (doc) => (doc.slug ? absoluteUrl(pagePath(doc.slug)) : undefined),
      describeFrom: (doc) => doc.hero?.[0]?.body,
      imageFor: (doc) =>
        firstImageIn([...(doc.hero ?? []), ...(doc.layout ?? [])]),
    }),
  ],
});

collections defaults to ['pages'] and uploadsCollection to 'media'. urlFor answering undefined leaves the preview empty rather than pointing it at the home page while the slug is still blank. describeFrom and imageFor are optional; without them those Generate buttons fill in nothing (the image button does not appear at all).

2. Cut the share rendition. Add SHARE_IMAGE_SIZE to the upload collection so every image gets a 1200x630 card:

import { SHARE_IMAGE_SIZE } from "@bison-lab/payload-core";

export const Media: CollectionConfig = {
  slug: "media",
  upload: { imageSizes: [SHARE_IMAGE_SIZE] },
  fields: [{ name: "alt", type: "text", required: true }],
};

Then payload generate:types, payload generate:importmap (the plugin's admin components resolve by string key from the site's import map) and payload migrate:create.

3. Build the site's title template from the same helper. Next applies title.template to <title> alone, and pageMetadata needs the completed title for Open Graph, so both read one spelling:

// app/layout.tsx
import { titleTemplate } from "@bison-lab/payload-core/metadata";

export const metadata: Metadata = {
  title: { default: SITE_NAME, template: titleTemplate(SITE_NAME) },
};

4. Read the tab in the page route.

// app/[...slug]/page.tsx
import { pageMetadata } from "@bison-lab/payload-core/metadata";

export async function generateMetadata({ params }): Promise<Metadata> {
  const page = await getPage(params);
  if (!page) return {};
  return pageMetadata(page, {
    siteName: SITE_NAME,
    canonical: absoluteUrl(pagePath(page.slug)),
    absoluteUrl,
  });
}

pageMetadata returns <title>, description, canonical, Open Graph and Twitter with the share image (falling back to the original file when the rendition is missing), and noindex, nofollow when the page is hidden. A written meta title is absolute, so the layout's template does not append the site name twice. Fields the editor left empty are omitted rather than set to undefined, so the layout's own description and default share image carry through Next's metadata merge.

The sitemap is the site's: filter meta.noIndex out of it.

Adopting the Theme global

Theme in the admin nav is one Global (theme) in admin.group: "Theme". Colors, Typography, Appearance, and Identity are field tabs on that document, not custom views and not extra Globals. Save is Payload's Save on the whole form — every tab is hydrated from the live row, so an untouched tab does not revert. getPublishedTheme reads that same row. Document locking is off. Theme has no draft mode and no preview pane. Contrast is the automatic label on editable fills at 7:1; it is never enforced and lives in the Needs-attention modal, not on the page. bison.config.json is the seed a site starts from, not the source of truth. Pass destructive (or seed.brandDestructive); otherwise Theme uses the library’s DESTRUCTIVE_SCALE_HEX (#ef4444). Success falls back to #22c55e.

1. Register the Global. Access is required — Theme does not read the Roles or Features Global. Pass canUseFeature("theme") as update (sites can keep canManageBrand until they adopt the switchboard).

// payload.config.ts
import {
  BRAND_ASSETS_SLUG,
  createBrandAssets,
  canUseFeature,
  createFeatures,
  createRoles,
  createTheme,
  isAuthenticated,
} from "@bison-lab/payload-core";
import bisonConfig from "../bison.config.json";

export default buildConfig({
  collections: [
    createBrandAssets({
      access: { read: () => true, update: canUseFeature("brand-assets") },
    }),
  ],
  globals: [
    createRoles(),
    createFeatures(),
    ...createTheme({
      access: { read: isAuthenticated, update: canUseFeature("theme") },
      seed: bisonConfig,
      logo: { collection: BRAND_ASSETS_SLUG },
      identity: {
        fallback: {
          lockup: { url: "/logo-lockup.svg", alt: "Acme" },
          mark: { url: "/logo-mark.svg", alt: "Acme mark" },
        },
      },
      colorUsages: true,
      onPublish: async () => {
        revalidateTag("theme");
      },
    }),
  ],
});

Color usages. createTheme({ colorUsages }) is the only site wiring for "is this custom color assigned?" Pass true to scan every collection and global Payload has registered when Colors Delete runs — drafts included, blocks included. Payload internals (payload-*) and Theme itself are skipped, so the library row being deleted is not treated as a use, and a later Navigation or Territories global joins without a site change. A { collections, globals } list still names slugs when a site wants a narrower walk. Omit the option, or pass empty arrays, and every additional color is unused and deletes immediately. The site does not copy findColorTokens.

Then payload generate:importmap (the Theme fields resolve from @bison-lab/payload-core/admin), payload generate:types, and payload migrate:create.

2. Seed the row on deploy. A migration up() writes a published version so the site renders what bison-theme.css rendered before anyone opens the admin:

import { seedFeatures, seedRoles, seedTheme } from "@bison-lab/payload-core";
import bisonConfig from "../bison.config.json";

export async function up({ payload }) {
  await seedTheme(payload, bisonConfig);
  await seedRoles(payload); // pass the same extras as createRoles, if any
  await seedFeatures(payload); // pass the same extras as createFeatures, if any
}

3. Serve the catalogue. A Next route around serveFont from @bison-lab/fonts/server, listed in serverExternalPackages, at the path you pass as fontsBaseUrl (default /fonts).

4. Render the published theme in the root layout.

// app/layout.tsx
import { getPublishedIdentity, getPublishedTheme, themeHead } from "@bison-lab/payload-core/theme";
import bisonConfig from "../bison.config.json";

const theme = await getPublishedTheme(payload, bisonConfig);
const identity = await getPublishedIdentity(payload, {
  lockup: { url: "/logo-lockup.svg", alt: "Acme" },
  mark: { url: "/logo-mark.svg", alt: "Acme mark" },
});
const { css, preloads } = themeHead(theme, { identity });
// Custom Colors need the Theme document: themeHeadFromDoc(doc, bisonConfig, { identity })
// emits --coral-* and [data-look] so lookField keys paint.

// <link rel="preload" as="font" type="font/woff2" crossOrigin="" href={href} />
// <style dangerouslySetInnerHTML={{ __html: css }} />
// identity.lockup / identity.mark / identity.favicon — uploaded file or the fallback

If the admin layout renders the same head, the admin panel restyles too.

getPublishedTheme reads the live row and falls back to the seed when the Global is empty. Empty logo, favicon, and mobile-menu mark use the fallback the site passed into createTheme. There is no Theme preview pane.

Brand assets

Logo, favicon, and mobile-menu mark live in a locked cupboard, not in ordinary Media. Call createBrandAssets and point Theme at BRAND_ASSETS_SLUG. Access is the same shape as createTheme: read is typically public so the live site can load the file; update is canUseFeature("brand-assets") (or canManageBrand until the site adopts the switchboard) and covers create, update, delete, and version history. SVG preferred, PNG and ICO allowed; an SVG is sanitized on upload. Versions stay on so a replaced file can be rolled back.

Each row carries a label, usage notes, and required alt text so a logo has its accessible name. Then payload migrate:create.

Roles

Settings → Roles is an Admin-editable array. Rank is drag-to-reorder. Ticks are the catalogue rows released on Features. Developer is implicit — every catalogue feature, including ones not released. That row is visible only to a Developer, with every tick on and locked. Defaults ship in the package (Theme screens on Designer; Designer also has Users and Roles; Admin + Designer both store). An Admin may remove Admin, Designer, or Author after first run; only Developer stays. A site may pass extras into createRoles — each extra is a slug, a display label, and default grants — and an Admin can add further custom rows and edit any row’s name. Remaining seed slugs stay read-only. canManageBrand and the other predicates read the saved Global and fall back to that seed (plus extras). The package never writes developer onto a user row.

createRoles({
  extras: [{ role: "editor", label: "Editor", grants: ["content"] }],
});

Features

Settings → Features is a Developer-only release valve: one switch per catalogue row. Off hides the tick on Roles; Developer still has the feature. The catalogue is code, grouped for the UI: Pages (Content, Publish), Media, Theme (Colors, Typography, Appearance, Identity, Brand assets), and Users (Users, Roles). Features itself is not a row. The document API tab and Better Editor overlay settings are not rows either — they stay code-locked to Developer (adminOnlyApiTab, developerOnlyAccess / hideUnlessDeveloper). A site may pass extras into createFeatures — those rows appear only there, including the Navigation tick createNavigation reads:

createFeatures({
  extras: [
    { slug: "doctors", label: "Doctor search", group: "users" },
    { slug: "navigation", label: "Navigation" },
  ],
});

canUseFeature(slug) is true when the row is released and a held role is granted it, or when the login is Developer. An empty Global falls back to the catalogue defaults. Group slugs pages and theme are true when any child is. createPages / createMedia use canUseFeature for create and update (and admin.hidden). Theme and brand-assets still take access arguments — pass canUseFeature("theme") / canUseFeature("brand-assets") as update. admin.hidden is presentation; Access Control is enforcement. Nav hiding follows user.allowedFeatures on the JWT (computed at user read from the live Features and Roles Globals) so Payload's admin.hidden({ user }) matches the API.

Pages, users, and media

createPages, createUsers, createMedia, slugField, adminOnlyApiTab, and documentTitleActions ship from the same React-free entry. Pages take the site's hero and layout blocks, reserved-slug predicate, and preview path. Create follows the Content tick. Update without Publish is constrained to _status: draft — an Author can save a draft and cannot publish or edit a live page. Users sit under Settings next to Roles, take secureCookies, and offer whatever rows the Global (or the seed plus the same extras) has. users.roles and users.allowedFeatures set saveToJWT: true. The checklist labels are the editable names. adminOnlyApiTab shows the document API tab only to Developer. documentTitleActions is one admin provider that puts every document's primary actions on the title row — the same slot collection lists use for Create New — and hides the dead Edit tab when it has no sibling. One-list Globals pass documentCreateNew("roles") (or a site field path) so Create New uses those same words, with no icon. Overlay settings (Better Editor) take developerOnlyAccess and hideUnlessDeveloper on the site — the package does not add that plugin as a peer.

Admin nav

Settings → Admin nav is Developer chrome, not a Features row. A Developer adds, renames, deletes, and drags group headers and the collections or globals under each. An Admin never opens the editor. Every login can read the document so the sidebar can paint. Save writes immediately.

adminNav() sets admin.components.Nav to the package Nav. A consuming site must not write a Nav component. An empty document falls back to Payload's first-seen walk (collections, then globals, bucketed by admin.group). Once the document has groups, that list is the sidebar — omitted collections and globals stay off it. group: false stays off. resolveAdminNav is the layout source so a later skin (SPI-10) does not invent a second one.

import { adminNav, createAdminNav } from "@bison-lab/payload-core";

export default buildConfig({
  plugins: [adminNav()],
  globals: [createAdminNav(), createRoles(), createFeatures()],
});

Then payload generate:importmap and payload migrate:create.

Header and Footer

createNavigation({ blocks }) returns two Globals so each has its own Save / Publish / drafts. Header keeps slug navigation and table nav so an existing one-document row does not move. Footer is navigation-footer / navf — short because nested footer arrays hit Postgres's 63-character identifier cap the same way nav did. Both sit in admin.group: "Navigation" until a Developer moves them from Admin nav. One Features tick (navigation, passed as a createFeatures extra). Publish still needs the Publish tick. getNavigation({ payload, draft }) finds both and returns { header, footer, bar }.

import { createNavigation } from "@bison-lab/payload-core";
import { getNavigation } from "@bison-lab/payload-core/navigation";

globals: [
  ...createNavigation({
    blocks: [megaMenuBlock({ variants }), LinkBlock],
    preview: () => "/preview",
  }),
];

// In a site header — the /navigation entry keeps the SEO plugin off the layout.
const { header, footer, bar } = await getNavigation({ payload, draft });

header.items uses @bison-lab/payload-blocks/admin#NavItemsField in place of the stock blocks UI. A site that already generates the blocks admin map picks it up with payload generate:importmap; until it does, the field renders as nothing. Header Settings (bar) stores hide on scroll, shared viewport, and the default panel width. Featured-link looks stay on chrome roles, not a variants array on this document. A site without the consume bump (SPI-99) never sees the Settings tab — run payload generate:types and payload migrate:create after this schema change. getNavigation returns { header, footer, bar }.

A site that already stored footer columns on the nav document copies footer.columns onto the new global, then drops the footer fields from nav.

No generated types

The package cannot import a site's payload-types, so the shapes it reads (SeoMeta, SeoImageDoc, SeoPage, ThemeDoc, RolesDoc, AdminNavDoc, NavigationDoc, NavigationBar) are hand-written and structural. A generated Page, Media or Theme Global is assignable to them; nothing carries an index signature, since an interface will not assign to a type that has one.

Changing a field

noIndexField, the plugin's own fields, the Theme Global fields, the Roles Global fields, the Admin nav fields, the Header and Footer fields, and the brand-assets collection fields are columns in every consuming site. A change to them is a schema change: say "run payload migrate:create" in the changeset.