Write blocks
Turn React components into blocks for the editor, following the rules that keep them working everywhere.
A block is a Puck component: a React component plus the fields the editor shows for it. Add it to blocks in goodfellow.config.tsx. To start from a shadcn component, goodfellow blocks add writes the block for you, with fields from its props (see Any shadcn component as a block).
// blocks/mass-times.tsx
import { classNameField, cx } from "@goodfellow-cms/react";
import type { ComponentConfig } from "@puckeditor/core";
export interface MassTimesProps {
times: Array<{ day: string; time: string }>;
className: string;
}
export const MassTimes: ComponentConfig<MassTimesProps> = {
label: "Mass times",
fields: {
times: {
type: "array",
label: "Times",
arrayFields: { day: { type: "text", label: "Day" }, time: { type: "text", label: "Time" } },
},
className: classNameField,
},
defaultProps: { times: [{ day: "Sunday", time: "10:00 am" }], className: "" },
render: ({ times, className }) => (
<dl className={cx("grid grid-cols-2 gap-2", className)}>
{times.map((item) => (
<div key={item.day} className="contents">
<dt className="font-semibold">{item.day}</dt>
<dd>{item.time}</dd>
</div>
))}
</dl>
),
};Rules
- Never rename a block's key in
blocks, or any of its props. Content files refer to blocks and props by name. - CSS classes: give every block
className: classNameFieldand apply it to the outermost element. It's the block's Style setting in the editor, kept as Tailwind classes (readBlockStyle()andwithBlockStyle()in@goodfellow-cms/coreread and write them). Combine classes withcx(), withclassNamelast, so an editor's classes override the block's own. A block with analignsetting of its own keeps it, and its Style offers no text alignment. - Words to find it by: editors can search the block list. To have your block found by words besides its label, name and group, add them with
withKeywords(block, ["times", "schedule"])from@goodfellow-cms/react, lowercase. - Its icon: the block list shows each block's icon, here and in editor plugins such as Gutenberg's. Give your block one with
withIcon(block, icon), whereiconis the icon's shapes on a 24-unit grid, as Lucide's icon data is:withIcon(MassTimes, [["circle", { cx: "12", cy: "12", r: "10" }], ["path", { d: "M12 6v6l4 2" }]]), copied from an icon's SVG on lucide.dev. Only plain shapes (paths, circles, lines, rectangles and the like) and the numbers that place them are kept, so an icon can't load or run anything. Blocks without one show a box. - Named parts: to let the site's theme restyle a block, give its parts names with
useParts():const part = useParts()at the start ofrender, thenclassName={part("gf-times-day", "font-semibold")}, with what's chosen for the block, such as its alignment, andclassNameafter the block's own classes. Say what owners call each part withwithParts(block, { "gf-times-day": "Day" }), so Colors & fonts → Blocks' parts offers them. Start names withgf-and your block's name, so they don't clash with built-in blocks' parts, and never rename or remove one, since themes refer to them. - Complete class names: write Tailwind classes as whole strings, using lookup tables for options (
{ sm: "gap-3", md: "gap-6" }), or Tailwind won't find them. - Theme classes: style with the theme's classes, such as
bg-primary,text-muted-foreground,font-headingandrounded-lg, rather than fixed colors, so blocks follow the site's settings, in dark mode too. Tailwind'sdark:classes apply only on sites with dark mode turned on, when they're shown dark, so a light-only site never uses them, even on a computer set to dark. - Site data: read menus, settings and collections with
useSite(), never by copying them into props. - Links and images: render them with
SiteLinkandSiteImagefrom@goodfellow-cms/react, not<a>and<img>, so they follow the site's base path and usenext/linkandnext/imagein Next.js. Other root-relative addresses, such as a CSS background, go throughwithBase(url, useSite().base). - Media fields: a prop holding an uploaded file's address uses
mediaField(), which adds the media library's chooser. - No hooks in
render, other thanuseSite()anduseParts(), which reads it: in Next.js, blocks render as Server Components. Interactive parts go in Client Components. - Labels for everyone: fields' labels and options are for people who aren't developers: "Space above and below", not "padding-y".
In the editor
The admin panel's own page, which signs editors in and publishes, never runs a block's code. Blocks run in the editor, which the admin panel shows in a frame sandboxed away from it, so no block can reach editors' sign-in or AI keys. The editor's previews, and the pages rendered to check a publish, run in frames like it. There, a block:
- Can't fetch anything, or store anything in the browser: the frame's Content Security Policy refuses requests, and a sandboxed page has no storage. Work that needs the network belongs in
resolveDatawhen the site is built, as the Calendar block's feeds do, or in a Client Component, which runs on the live site's pages as usual. - Shares the page with Puck's controls. The site's CSS is kept to the canvas, and its media queries on the page's width become container queries on the canvas's, so the editor's Phone and Tablet buttons show the layout at those widths. Viewport units, such as
100vh, still measure the editor's frame. - Runs only the site's own scripts: a block can't load a script from another address in the editor. On the live site, nothing changes.
Blocks for collections
In a collection's page design, Puck's metadata holds the item being shown (metadata.entry) and its collection (metadata.collection), for resolveFields and resolveData; useSite().entry has the item while rendering. Wrap blocks that only make sense there in templateOnly(), which leaves them out of the other editors.
A block can give the page it's on pages of its own, as Calendar does for its months and Collection list for its later pages: wrap it in withPages(block, (props, { content, page, today }) => [...]) from @goodfellow-cms/core, returning each page's suffix (its address after the page's own, such as page/2), a title added to the page's, and what it shows (month, page or choice). Builds write them, and the block reads which one it's showing from useSite().view, when view.block is its own id. Only the first block on a page that adds pages gets them.
A block can also repeat the blocks inside it once for each of a collection's items, as Collection loop does: repeatsForEntries(block, { design, items, entries }) from @goodfellow-cms/react names the slot that holds the design, the slot the copies go into, and a function that picks the items. The copies are made where pages are rendered, with each item's values in its placeholders, and never saved.
Custom fields don't say what they hold, so give them metadata: { ai: … } (an AiFieldHint from @goodfellow-cms/ai) for the AI assistant to fill them in.