@bison-lab/payload-core
v4.28.2
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
Maintainers
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/
pnpm add @bison-lab/payload-core @payloadcms/plugin-seo @payloadcms/plugin-redirects @bison-lab/tokens @bison-lab/fontsPeers: payload, @payloadcms/plugin-seo, and @payloadcms/plugin-redirects are required. @bison-lab/tokens
and @bison-lab/fonts are required because Theme is required. @payloadcms/ui,
next and react are needed for admin fields (./admin refreshes the
Media list with next/navigation, which Payload's admin already loads); @bison-lab/ui and
@payloadcms/live-preview-react are peers of the ./react entry.
Pin the plugin to the same version as payload; Payload releases them in
lockstep.
Ten entry points, and why
| Import | Contents | Runs where |
| ---------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| @bison-lab/payload-core | seoPlugin, createTheme, createBrandAssets, createRoles, createFeatures, createBlockLibrary, createPages, createUsers, createMedia, createRedirects, createQuestions, createTaxonomy, facetField, createAdminNav, adminNav, createNavigation, adminOnlyApiTab, documentTitleActions, listAlign, blockDrawer, richTextEditorFonts, 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/block-library | getSectionControls, sectionControlsFromLibrary, SectionControls, the catalogue readers, the island height message | Server. What a page route and the Block Settings fold import; it does not load the plugin or React. |
| @bison-lab/payload-core/taxonomy | facetField, FacetEntry | Anywhere. What a block config imports to tag content from a taxonomy; no plugin, no React, so a browser bundle can take it (@bison-lab/payload-blocks main entry reaches the docs island and Storybook). |
| @bison-lab/payload-core/block-thumbnails | captureThumbnails, thumbnailCss, NEUTRAL_THEME, thumbnailVersion; the bison-block-thumbnails bin | Node, beside Playwright. What a site's thumbnail script imports; no Payload, no React. |
| @bison-lab/payload-core/wireframe | Deprecated: the 4.15 wireframe generator and the bison-wireframes bin | Node. Kept for one release so a site's script keeps working; use ./block-thumbnails. Goes in the next major. |
| @bison-lab/payload-core/admin | Theme fields, the Roles and Features matrices, the Block library field, Admin nav, the Users Roles checklist, and the FAQ Library list | Admin. Referenced by import-map string; generate:importmap writes it. |
| @bison-lab/payload-core/react | BlockLibraryIsland; ThemePreview (legacy; Theme has no preview pane) | Client. What a Block library preview route wraps its sample in. |
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 { createRedirects, firstImageIn, seoPlugin } from "@bison-lab/payload-core";
import type { Page } from "@/payload-types";
export default buildConfig({
plugins: [
createRedirects(),
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.
Theme
Theme is required. The admin nav shows 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 (#FF7C6B). Success falls back to #83CEA2.
Changing a brand hex or This color is leaves the chips as they were;
Regenerate scale rewrites the ramp with the hex pinned at that step.
Success and Destructive family banners use the same scale steps as the
visitor Alert.
1. Register the Global. update defaults to canUseFeature("theme")
(Designer in the seed). Pass access.read to tighten who can read the
document. Omitting access still registers the Global. Features does
not install or remove it.
// payload.config.ts
import {
BRAND_ASSETS_SLUG,
createBrandAssets,
createFeatures,
createRoles,
createTheme,
} from "@bison-lab/payload-core";
import bisonConfig from "../bison.config.json";
export default buildConfig({
collections: [createBrandAssets()],
globals: [
createRoles(),
createFeatures(),
...createTheme({
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 fallbackIf 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.
richTextEditorFonts() sets the admin's Rich Text editor (and its
version diff) in var(--font-body) with normal tracking. Payload writes
that prose in var(--font-serif), so without it what an editor types reads
in a different face from the page and the live preview (BIS-168). It is one
admin provider (RICH_TEXT_EDITOR_FONTS) and needs the admin layout to
render the Theme head, which is what defines --font-body there. Headings
inside follow whatever the admin sets on h1–h6. Register it in
plugins, then payload generate:importmap.
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 matches createTheme: read defaults to
public so the live site can load the file; update defaults to
canUseFeature("theme") 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 (the Theme grant 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.
Removing a row removes that slug from every user in the same save, so a
later row saved under the same slug cannot hand it back. A user whose
only role it was is left with none. A save that would leave no user
holding Developer or a role with the Users tick is refused
(ROLES_LOCKOUT_MESSAGE), whether by deleting the row or unticking
Users. pruneUserRoles(req) strips every slug not on the saved Global;
call it once from a migration to clean users saved before this rule.
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, and Users (Users, Roles). Theme is not a row. Features
does not install or remove the Theme Global. 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. canUseFeature("theme")
is the Roles grant only. An empty Global falls back to the catalogue
defaults. The group slug pages is true when any child is.
createPages / createMedia use canUseFeature for create and update
(and admin.hidden). createTheme and createBrandAssets default
update to canUseFeature("theme"). That grant opens the Theme
document and brand-assets. A site may pass access.read to tighten
read. admin.hidden is presentation; Access Control is enforcement.
hideUnlessFeature("theme") hides Theme in the nav for a role with no
grant. getPublishedTheme and themeHead still return the saved
document. 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.
Block library
Settings → Block library is one card per installed option. Editors browse
package categories (Hero, Feature, Process, FAQ, Testimonials, Stats,
Content, Contact, CTA, Navigation) and switch a card on or off. Off is
editorial availability. Save is Payload's document chrome. The field is
BLOCK_LIBRARY_FIELD
(@bison-lab/payload-core/admin#BlockLibraryField): a category rail and
a gallery of islands. Each island is an iframe of previewPath(slug).
The rail (BIS-143) is admin-ui's CategoryRail: a search box over an
accordion. Each header reads Category (n), n counting every block in it,
switched-off ones included; an empty category has no header. One category
is open at a time and the gallery shows all of its cards in catalogue
order; closing the open header keeps those cards on screen. Under the open
header each block is a #<slug> link to its card (the card's id), and
the link for the card in view is marked as you scroll. A URL with
#<slug> opens that block's category and scrolls to its card, on load and
on every hash change. A query matches a block's label or slug, or a
category's name: every matching category opens, counts become matches, the
gallery shows the matching cards under their category headings, and an
empty result says so with a Clear search button. Clearing returns to the
category that was open; clicking a header mid-search opens that whole
category. At 768px and below the rail stacks above the gallery as the same
accordion. Islands load lazily.
That route, and filterOptions that hide an off slug, are the consuming
site (SPI-53). Islands are meant to render the sample under themeHead.
Wrap the sample in BlockLibraryIsland (@bison-lab/payload-core/react) and the card is as tall as the block (BIS-147). The island pads the sample evenly and posts its height to the admin window; the card takes it from its own frame on its own origin only, between a floor (an empty or position: fixed sample stays a visible card) and a ceiling (a vh-sized sample cannot grow the card forever). An island that never reports keeps the fixed 720px card.
megaMenu and navLink are header item blocks, not cards. Navigation's
card is floatingNav. While it is the only navbar, that card has no
switch: there is nothing else to turn on. A second header brings the
switches back, so an editor picks which one the site uses. A site extra is appended with blocks and appears
only in the category it names. A site may not add megaMenu or navLink
as cards.
Turning a card off distinguishes published from draft:
- The last hero or the last navbar is refused.
- Published — a live page (or any live document) holds the slug, or it is the published Header's navbar: refused. The message names the published documents and mentions any drafts; nothing is deleted.
- Drafts only — the switch opens a
ConfirmDialogthat names the drafts. Confirm turns the switch off; the Save that turns the slug off deletes its rows from those drafts, each written as a new draft version (payload.update({ draft: true })) inside the Save's transaction. Published versions are never written. Cancel leaves it on. - Unused — off, no dialog.
Save leaves the form clean: the field reads each row from Payload's own row fields and writes one column at a time, never the array path and never on load, so after a Save the button disables again, leaving the page asks nothing, and a switch flipped straight after a Save holds (BIS-135).
The server is the authority: the Save hook re-reads usage, refuses while
anything published holds the slug, and strips drafts otherwise, whether
the Save came from the admin or the API. The admin asks
GET /api/globals/block-library/usage?slug=… (same access as update)
before it turns a switch off. blockUsage and stripDrafts are exported.
A page whose only section was stripped stays a draft until a section is
added: publish still hits LAYOUT_REQUIRED_MESSAGE. A block embedded in
rich text (a Lexical block node) is stripped too. A refusal is a public
400, so the admin shows its message. A read that fails while checking
usage fails the check (the switch stays on) rather than answering from
part of the site. An editor who has a stripped draft open still holds the
row in their form; their next autosave writes it back, so turn a block off
when nobody is editing the drafts it names.
The Header's navbar is header.navbar when the Header document carries
one, otherwise the catalogue's first navbar (floatingNav). The package
Header has no such field yet; the picker arrives with a second navbar
package (BIS-132), so until then the published Header always holds
floatingNav.
import { createBlockLibrary } from "@bison-lab/payload-core";
createBlockLibrary({
previewPath: (slug) => `/block-library/${slug}`,
blocks: [{ slug: "ctaBand", label: "Doctor finder", category: "cta", kind: "section" }],
});access.update defaults to isAdmin. Pass access to change who may
edit availability. Then payload generate:importmap and payload migrate:create.
The Add Block drawer
Payload has no slot to replace its Add Block drawer, and three places open
it: the blocks field's Add button, Add below in a row's ⋯ menu, and the
Preview overlay. blockDrawer() (BIS-148) sets what that drawer reads on
every catalogue block, in every collection and global, so all three agree:
admin.group: the block's Block library category label, with the list ordered by category, then catalogue order. The drawer reads Hero, Feature, Process, … like the Block library. Blocks outside the catalogue (a Header'smegaMenuandnavLink) are left alone and go last, which is also where Payload draws ungrouped blocks.admin.images.thumbnail: a screenshot of the block's sample (generic copy and photos from@bison-lab/payload-blocks) under a neutral slate theme (BIS-151). The package ships one for each of its page blocks (block-library/thumbnails.generated.ts,resourcesincluded) and serves it fromGET /api/block-library/thumbnail/:slug, a root endpoint the plugin registers: signed-in users only,image/jpeg, cached for a year behind a?v=that changes with the picture. The admin's client config carries only that URL. A site extra passes its ownthumbnailURL on itscreateBlockLibraryentry (a file it serves, such as/block-thumbnails/ctaBand.jpgfrompublic/); it is kept in the Global's server-onlycustom. A block config that already sets a thumbnail orimageURLkeeps it.- Larger cards: one admin stylesheet (
BLOCK_DRAWER_CSS, mounted as theBLOCK_DRAWER_STYLESprovider) lays the drawer out three across, two at Payload's 1024px break, one at 768px, instead of Payload's six.
Which blocks the drawer offers is still the site's filterOptions from the
Block library; this plugin changes how they look, not which appear.
import { blockDrawer, createBlockLibrary } from "@bison-lab/payload-core";
export default buildConfig({
globals: [
createBlockLibrary({
previewPath: (slug) => `/block-library/${slug}`,
blocks: [{ slug: "ctaBand", label: "CTA band", category: "cta", thumbnail: "/block-thumbnails/ctaBand.jpg" }],
}),
],
plugins: [blockDrawer()],
});Run payload generate:importmap once for the stylesheet provider.
Screenshots come from bison-block-thumbnails, a bin on this package
that opens each block's Block library preview in a headless browser at
1280px, lays the neutral theme over the site's (thumbnailCss: the
NEUTRAL_THEME tokens from buildThemeCss, a system font stack, no
motion, dev-server chrome hidden), centers the block in a 3:2 box, and
saves a 960 × 640 JPEG (captureThumbnails, from the ./block-thumbnails
entry). Run it from the site, which supplies Playwright:
THUMBNAIL_EMAIL=… THUMBNAIL_PASSWORD=… pnpm exec bison-block-thumbnails \
--base http://localhost:3039 --slugs ctaBand,productMap \
--out-dir public/block-thumbnailsThe login (any user the preview route accepts) comes from the environment
and is required unless --no-login names a public route. --preview
changes the route pattern (/block-library/{slug} by default; a site can
add a query, such as ?thumbnail=1, to swap in generic stand-ins for data
its sample reads), and --selector the element that holds the sample
(BlockLibraryIsland's by default). --out-dir writes <slug>.jpg files;
--module writes a TypeScript module of base64 JPEGs (--export names
the constant), which is how this package ships its own. A preview that
answers an error status, or renders nothing the selector matches, fails
the run and writes nothing, so a sign-in wall or a mistyped slug never
becomes a block's picture. Rerun it when a block's layout or sample
changes. To refresh the package's own, run it with --module
src/block-library/thumbnails.generated.ts --export PACKAGE_THUMBNAILS
against a site's preview route with this release's payload-blocks
installed; the hero alias reuses the sweep hero's picture, since no site
installs it.
Section controls
Every page block carries the same four section settings (Background,
Spacing, Anchor, Hidden; withSectionSettings in payload-blocks). Which
of them a block offers is data, not code: four ticks under Enabled on
each card, stored on the row as sectionControls, so turning one control
off for one block is a Save, not a release. The words are the fold's; a
legend above the cards says what each one does. A
tick off hides the control in the editor and the renderer ignores its
stored value (payload-blocks, BIS-138). The four columns on every page
block stay; no page schema moves when a tick changes.
The row is seeded from the catalogue entry's sectionControls, and a key
the entry leaves out follows its kind: a hero offers Anchor only
(Background would fight the hero's own tone, Spacing is header clearance,
and a hidden hero is a published page with no opening), a section
offers all four, a navbar none and draws no ticks. The package blocks
are written out that way in the catalogue. A site extra passes its own the
same way it passes sample:
createBlockLibrary({
previewPath: (slug) => `/block-library/${slug}`,
blocks: [
{ slug: "ctaBand", label: "Doctor finder", category: "cta", kind: "section",
sectionControls: { spacing: false } },
],
});stampBlockLibrary keeps a stored tick and fills a row that has none from
the entry, so a block that arrives in a later release gets its default
without a migration beyond the new columns. validateBlockLibrary refuses
Hidden on a hero row; the card locks that tick. A disabled block keeps its
ticks; they matter again when it is re-enabled. Off is off for Admin;
Developer is not special here.
Reading them: sectionControlsFromLibrary(doc, extras?) maps every slug
to its controls: the stored ticks when the document holds the row (a site
extra's saved row included, without the site passing anything), else the
catalogue default; a slug in neither is absent from the map, and a
consumer treats that as every control. getSectionControls(payload) reads
the Global (access overridden) and maps it, for a page route to hand
RenderBlocks as sectionControls; it takes a site extra's catalogue
default from the registered Global's config
(blockLibraryEntriesFromConfig), so the Block Settings fold and the page
agree on every block. Both are on @bison-lab/payload-core/block-library,
so a page route never loads the SEO plugin for them. Adding the columns is
a schema change: run payload migrate:create.
Pages, users, and media
createPages, createUsers, createMedia, slugField,
createRedirects, adminOnlyApiTab, documentTitleActions, and listAlign ship from the same
React-free entry. Pages take the
site's hero and layout blocks, reserved-slug predicate, preview
path, and optional folderRoots (patients, providers, …). Parent
on the page editor is where the page lives: site root, those roots,
published CMS pages, folders created by earlier saves, or New folder….
The address is that folder plus a title leaf until first publish, then
locks. Every site has one required home page. Its pageKey is /, and
that key — not the title, and not a slug of home — is how the row is
found. The Address field and the slug column show /. The pages list
has a Required column with a lock on any row whose pageKey is set.
Home cannot be deleted, unpublished, or moved. Call ensureRootPage
from onInit and pass the hero and a section; the stored slug is an
internal token (ROOT_PAGE_SLUG) so a draft that has no address yet
does not collide with it. requiredKeys marks further pages the same
way (find-a-doctor); those keys are not paths. Preview of home calls
previewPath("/"). An empty string is still a page with no address.
Regenerate the import map so the Required cell and the slug cell
resolve. Layout carries the same standing copy as the hero — a page cannot
be published without a section — and Publish names that fix instead of
only Content → Layout. Title-follow does not wipe the folder prefix. A taken address is
refused — never rewritten to slug-2. Unlock opens Parent and a
"Page name in address" box (with Use title), and a parent or page-name
change writes a 308 the same way a typed slug change does (BIS-171). The
lock is a beforeValidate hook that keeps the stored address, not field
access.update: Payload renders a field with update denied as read-only
when the edit view loads, so Unlock could never open it. createRedirects()
wraps @payloadcms/plugin-redirects for pages only (permanent 308) and
collapses chains when a published slug changes. The plugin stores the
rows; the site still has to serve them. Pin the plugin to the same
version as payload, then payload generate:importmap,
payload generate:types, and payload migrate:create. 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.
listAlign() keeps a list column's header and cells together: a field
declares admin: { custom: { listAlign: "center" | "end" } } and the
plugin writes one stylesheet (listAlignCss) from every collection's
fields, mounted as the LIST_ALIGN provider. Payload's header holds a
full-width flex .sort-column, so text-align alone never moves it;
centered, the label shrinks to its word and the sort arrows hang outside
it, and end-aligned, the arrows lead. Never write a per-column selector
in a site stylesheet. The kit Table takes the same idea as
align={[…]}, one entry per column track. 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. The Header edit view is
@bison-lab/payload-blocks/admin#AdminWorkspace. Footer is the same
workspace on navigation-footer; footer is a group whose Field is
@bison-lab/payload-blocks/admin#FooterColumnsField. Columns hold
sections (a required section label plus links). Link-column desktop
widths add to 100% of the space beside the logo, not of the full footer.
Brand copy and social URLs sit on
the Logo column. The copyright line and "Built by Bison Lab." are
code-locked. A site that already generates the blocks admin map picks
both editors up with payload generate:importmap; until it does, those
slots render as nothing. bar stores hide on scroll, shared viewport,
and the default panel width, and is hidden in the admin. Featured-link
looks stay on chrome roles, not a variants array on this document. A
Footer consume runs payload generate:importmap, payload generate:types,
and payload migrate:create — existing footer.columns rows are
{ heading, links } and need a wrap into { desktop, sections }.
Link-column widths that used to include the logo in the 100% need a
remap (Normalize desktop). 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.
FAQ Library
createQuestions is one document per FAQ (question, richText answer, drafts)
with a kit List that replaces Payload's stock collection list. The answer is
Payload's default Lexical editor — the same one the pages rich-text block
uses; the kit dialog mounts it, not the stock document form. Hidden
answerText is the varchar the list searches; syncAnswerText (the
collection beforeChange hook, also exported) writes it whenever answer
is in the incoming data. A site that replaces hooks.beforeChange must
keep that hook or search stops matching answers. Do not contains on
answer — Postgres rejects ilike against jsonb. createTaxonomy
is a name-only terms collection, hidden from the nav; add and delete for
manage: true facets happen on that list. Pass Anatomy-like collections and
Topics through organizeBy — the package does not know "anatomy". Feature
slug questions. Used-on stays 0 until a later block stores relationships.
createTaxonomy({
slug: "topics",
labels: { singular: "Topic", plural: "Topics" },
access: { read: () => true, update: canUseFeature("questions") },
});
createQuestions({
organizeBy: [
{
name: "anatomy",
label: "Anatomy",
collection: "anatomy",
manage: false,
look: { global: "anatomy" },
},
{ name: "topics", label: "Topics", collection: "topics", manage: true },
],
});Each organizeBy entry's field is facetField(entry): a stock
relationship, hasMany, onto collection, with Payload's default
allowCreate, so "Add new" shows only to a user with create access on the
terms collection. It is exported from the main entry and from
./taxonomy; tag any other content from the same taxonomy with it
(createResourcesBlock({ facets }) in payload-blocks does). Only name,
label and collection are read.
The list view is @bison-lab/payload-core/admin#QuestionsList. It wraps
Page from @bison-lab/admin-ui so the column is Payload's --gutter-h,
the same inset Media and Pages use — not a mockup max-width. A later kit
list (Media) wraps the same Page. Run payload generate:importmap and
payload migrate:create. Add a Features extra
{ slug: "questions", label: "FAQ Library" } the same way Navigation is
added.
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,
the Pages slug lock fields, the Redirects collection, the
brand-assets collection fields, and the FAQ Library / taxonomy fields are columns
in every consuming site. A change to them is a schema change: say "run
payload migrate:create" in the changeset.
