Blog
@plumix/plugin-blog makes four registrations: a post entry type, a hierarchical category taxonomy, a flat tag taxonomy, and a relatedPosts loader a theme declares on a template. Each of the four can be reshaped or switched off.
Overview
Section titled “Overview”The plugin exports a factory named blog, so you call it. Every option has a default and blog() on its own is a complete registration; the four options it takes — post, category, tag and relatedPosts — each reshape or drop one of the registrations above, and Reshaping what it registers covers them. It adds no database tables, so installing it needs no migration.
Nothing it registers is special. Every option it passes is one your own plugin could pass, so the rest of this page reads as a worked registration.
post is the standard non-hierarchical entry type. category classifies posts in a tree, so Baking can sit under Food. tag classifies posts in a flat list. Both taxonomies name post and nothing else, and post names both back. Registering a recipe type of your own puts no category and tag pickers on the recipe editor, because the editor builds its pickers from the entry type’s termTaxonomies list and a recipe type that does not name them has none.
Quickstart
Section titled “Quickstart”Install the package with whichever package manager the project uses:
pnpm add @plumix/plugin-blognpm install @plumix/plugin-blogyarn add @plumix/plugin-blogbun add @plumix/plugin-blogThen:
-
Add
blog()to thepluginsarray. No arguments needed; see Reshaping what it registers to change what it registers.import type { PlumixConfigInput } from "plumix";import { blog } from "@plumix/plugin-blog";import { recipes } from "./plugins/recipes";export const plugins: PlumixConfigInput["plugins"] = [recipes, blog()]; -
Restart the dev server.
Terminal window pnpm dev -
Write a post. The admin sidebar gains a Posts item under Entries, at
/_plumix/admin/entries/posts. Categories and Tags are not under it. They land in a Taxonomies group of their own further down, at/_plumix/admin/terms/categoryand/_plumix/admin/terms/tag. Publish a post and it serves at/posts/<slug>and appears on the site root.
The post entry type
Section titled “The post entry type”This is the registration the plugin makes, wrapped in a plugin of your own. The plugin passes translatable descriptors everywhere the sample passes a plain string, and its labels table carries thirteen keys where the sample shows two.
import { definePlugin } from "plumix/plugin";
export const example = definePlugin("example", { setup: (ctx) => { ctx.registerEntryType("post", { label: "Posts", labels: { singular: "Post", plural: "Posts" }, description: "Standard blog posts", supports: ["title", "editor", "excerpt", "revisions", "autosave"], versioning: { maxRevisions: 25, autosaveIntervalSeconds: 60 }, termTaxonomies: ["category", "tag"], isHierarchical: false, isPublic: true, hasArchive: false, rewrite: { slug: "posts" }, capabilityType: "post", menuIcon: "file-text", keywords: ["articles", "blog", "writing", "news"], }); },});Four of those options are worth reading closely.
hasArchive: false is the option most worth noticing, because it means no /posts route compiles. The site root already lists published entries, so a type archive would serve the same rows again under a second URL. The plugin leaves it off rather than ship the duplicate.
rewrite: { slug: "posts" } fixes the prefix on single posts. Without it the prefix would fall back to the type name and you would get /post/<slug>. Because hasArchive is false, posts appears in single-post URLs only, and /posts on its own resolves to whatever else claims it.
versioning restates the framework defaults rather than changing them. Twenty-five retained snapshots per post and a sixty-second autosave cadence are what any type with supports: ["revisions", "autosave"] gets when it omits the option. Writing them out pins the numbers to the plugin.
capabilityType: "post" names the capability family, and here it matches the type name, so it changes nothing on its own. It exists so a second entry type can opt into the same family. Register a news type with capabilityType: "post" and it is gated by entry:post:* rather than minting entry:news:*, which is how two types share one set of permissions.
The full label table carries thirteen strings, from singular (“Post”) through moveToTrash (“Move post to trash?”). That table is what keeps the admin from saying “Add Entry” over a post form. Each string is a translatable descriptor rather than a bare string, which is why the plugin also declares an i18n slot and ships catalogs.
keywords are synonyms the admin command palette matches alongside the sidebar label, so typing “articles” or “news” finds Posts. They are translatable descriptors too.
The URLs it compiles
Section titled “The URLs it compiles”With only the blog installed, five public route shapes exist that would not exist without it.
/posts/<slug>is one post. The pattern captures a single segment, becausepostis not hierarchical./category/<path>is a category archive. The path can nest, so/category/food/bakingis the Baking term under Food./category/<path>/page/2is the second page of that archive, twenty entries to a page./tag/<term>is a tag archive, one segment because tags do not nest./tag/<term>/page/2is its pagination.
The site root is not in that list because it is always there. / lists published entries from every public non-hierarchical type, newest publish time first, twenty per page, with /page/2 onward for the rest. Install the blog beside a recipe type and both appear in that one feed, interleaved by date. A hierarchical type is excluded, which is why pages never show up there.
Categories and tags
Section titled “Categories and tags”The two taxonomies differ in one option and inherit the rest.
import { definePlugin } from "plumix/plugin";
export const example = definePlugin("example", { setup: (ctx) => { ctx.registerTermTaxonomy("category", { label: "Categories", labels: { singular: "Category", plural: "Categories" }, isHierarchical: true, entryTypes: ["post"], isPublic: true, hasAdminColumn: true, rewrite: { slug: "category", isHierarchical: true }, keywords: ["taxonomy", "categories"], });
ctx.registerTermTaxonomy("tag", { label: "Tags", labels: { singular: "Tag", plural: "Tags" }, isHierarchical: false, entryTypes: ["post"], isPublic: true, hasAdminColumn: true, rewrite: { slug: "tag" }, keywords: ["taxonomy", "tags"], }); },});isHierarchical decides both the data and the URL. A category can name a parent and its archive URL nests to match, so a term whose parent is Food lives at /category/food/baking and nowhere else. Ask for /category/baking and you get a 404, because the leaf’s real ancestor chain does not match the one the URL claims. A tag has no parent and one segment.
entryTypes: ["post"] is what a term archive queries. /category/food lists published entries whose type appears in that list and nothing else, and a taxonomy registered with an empty list serves an empty archive. The same list is what the cache purge reads, so editing a term purges the stored pages of the types named here. The other half, termTaxonomies: ["category", "tag"] on the entry type, is what puts the two pickers on the post editor. Both registrations name each other, and each name does a different job. Taxonomies and Terms covers the pairing in full, including why hasAdminColumn: true records intent without adding a column today.
Terms live in one shared table keyed by taxonomy, so a category term and a tag term can both be slugged pasta without colliding.
Reshaping what it registers
Section titled “Reshaping what it registers”blog() takes an override per registration — post, category, tag, and relatedPosts. Each one is a partial of the options the plugin passes to registerEntryType / registerTermTaxonomy, so anything those accept can be overridden, and anything you leave out keeps the plugin’s default.
A site that serves its blog at /insights with a paginated archive:
import type { PlumixConfigInput } from "plumix";
import { blog } from "@plumix/plugin-blog";
export const plugins: PlumixConfigInput["plugins"] = [ blog({ post: { rewrite: { slug: "insights" }, hasArchive: true, archivePerPage: 4, }, }),];Three rules govern the merge.
- Object-valued options merge key by key. Overriding
labels.singularleaves the other twelve labels alone, rather than replacing the table. - Arrays and plain values replace.
supports: ["title", "editor"]is the whole list, not an addition to it. - An array can compose instead by passing a function:
supports: (prev) => prev.filter((s) => s !== "revisions")drops one entry and keeps the rest, so you do not restate a list to remove one item from it.
Passing false in place of an override skips that registration entirely. blog({ tag: false }) registers posts and categories and no tag taxonomy — and drops "tag" from the post type’s own termTaxonomies, so the type never advertises a taxonomy that is not there.
The registered names — post, category, tag — are not overridable. They are the values stored in the type column on every entry and the taxonomy column on every term, and the keys a theme’s forEntryType("post") matches on, so renaming one would orphan existing rows and silently stop templates matching. Change labels to change what the admin displays.
Capabilities it mints
Section titled “Capabilities it mints”Registering post derives eight capabilities, each mapped to a minimum role on the ladder from subscriber up to admin.
| Capability | Minimum role |
|---|---|
entry:post:read |
subscriber |
entry:post:create |
contributor |
entry:post:edit_own |
contributor |
entry:post:publish |
author |
entry:post:edit_any |
editor |
entry:post:delete |
editor |
entry:post:read_revisions |
editor |
entry:post:restore_revision |
editor |
Each taxonomy derives five more, so category and tag each mint read at subscriber, assign at contributor, and edit, delete and manage at editor. A contributor can therefore tag a draft but cannot rename the tag.
Nothing in your code grants any of these. They exist because the registration ran. Access & Identity covers how a principal’s role is checked against them.
Related posts in a theme
Section titled “Related posts in a theme”Beyond the three content registrations, the plugin registers a per-request data dependency named relatedPosts. A template declares it and receives the result alongside its data.
import type { EntryData } from "plumix";import { defineTemplate, forEntryType } from "plumix";import { Link } from "plumix/blocks/renderer";
import type { RelatedPosts } from "@plumix/plugin-blog";
const post = defineTemplate<EntryData>({ relatedPosts: ["related"], render: ({ data, relatedPosts }) => ( <article> <h1>{data.entry.title}</h1> <ul> {(relatedPosts?.related ?? []).map((other) => ( <li key={other.id}> <Link entry={other}>{other.title}</Link> </li> ))} </ul> </article> ),});
export const rules = [forEntryType("post").template(post)];The rules name is arbitrary. The array is what you hand defineTheme as its templates slot, and Themes shows the descriptor it sits in.
The string "related" is a slug you choose, and it is the key the result comes back under. The loader ignores it otherwise, so one name is as good as another. other.url is nullable. The builder behind it is the synchronous permalink builder, which returns null for an entry whose type is unregistered, for a type registered with isPublic: false, and for an entry of a hierarchical type that has a parent, because a nested URL needs an ancestor walk the synchronous path will not run. <Link entry={...}> reads that field and renders the children without an anchor when it is null, so the strip never emits a dead link.
What the loader returns is narrow on purpose. At most three entries, published and carrying a publish time, of the same entry type as the one being rendered, sharing at least one term with it, newest first. The current entry is excluded. On a route that is not a single entry, or on a post with no terms, or on a post whose terms nobody else uses, the loader returns nothing and the strip renders empty.
The RelatedPosts import is there even though the sample never names the type in a signature. It pulls in the declaration that adds relatedPosts to the template registry. A module augmentation applies to the whole program rather than one file, so plumix.config.ts importing the plugin is already enough to make the key valid; the import in the theme file keeps the dependency visible where it is used.
What the plugin does not do
Section titled “What the plugin does not do”It registers no meta boxes, so a post stores title, slug, content and excerpt and nothing else until you declare fields for it from a plugin of your own. It registers no theme and no templates, so a site with the blog and no theme renders the built-in welcome screen rather than a post. It adds no comment form; discussion is @plumix/plugin-comments, installed separately. And it creates no tables, so there is no plumix migrate generate step after installing it.
Related
Section titled “Related”Every option on this page is documented in full in Entry Types and Taxonomies and Terms, which cover the registration surface the plugin is using. Overview covers installing plugins in general and the version track this one is on. The URL shapes above are the router’s default output, and Permalinks and Slugs covers reshaping them. Which posts are visible at those URLs depends on Statuses and Publishing. The capability table is enforced by the model in Access & Identity, and the template that renders a post comes from Themes.
Next steps
Section titled “Next steps”Read Pages next if the site needs standing content such as About or Contact. It registers the other built-in entry type, and the two are usually installed together.
If you want posts to carry structured data beyond the four built-in columns, Meta Boxes is where you attach a card of fields to an installed type from your own plugin, and Field Types lists what those fields can be.
To put posts on screen, Template Hierarchy covers how a request for /posts/<slug> finds a template, and Templates covers the builders that bind one to the post type.