Skip to content

Overview

The plugins array in plumix.config.ts is how you add an entry type, a route or an admin page to your site. It is not the whole registration surface. Core registers its own blocks, a theme’s descriptor declares templates, blocks, shortcodes and redirects statically, and the redirects key in plumix.config.ts contributes redirect rules with no plugin involved. Installing a published plugin is a package install and one entry in that array.

A published plugin and the local plugin your site keeps for its own model are the same object. Both come from definePlugin, both carry an id and a setup function, and the site calls each one’s setup once at boot with the same registration context. Content Modelling covers writing one. This page covers installing one you did not write.

The registration context a plugin receives is the same one your own plugin receives, and it is wide. Entry types and taxonomies, meta boxes on entries, terms and users, blocks, marks, shortcodes and patterns, field types, settings groups and pages, RPC routers, REST resources, raw routes, rewrite rules, redirects, archive types, MCP tools, scheduled tasks, capabilities, admin pages, dashboard widgets, login links, lookup adapters, image roles, template deps, event and filter handlers all arrive through the same ctx. A package that registers one shortcode installs exactly like a package that registers six entry types.

Installing one changes two things in your repository and can change two more at run time. The package lands in package.json and the descriptor lands in the plugins array. Beyond that, a plugin that owns database tables needs a migration generated and applied before its first request, and a plugin that registers an entry type mints that type’s capabilities, so roles gain permissions nobody wrote by hand.

These are all of them — every package published under the @plumix/plugin-* scope, ordered by what a new site reaches for first. A scaffolded project installs only the ones you ticked, and SEO and Feeds are the two that start ticked; a project scaffolded with none has none.

Registers the post entry type with the category and tag taxonomies.

Registers the hierarchical page entry type rooted at /.

Registers a media entry type, a two-phase upload, and a media() field builder. The upload sends bytes straight to storage when the storage adapter implements presignPut, and routes them through the worker when it does not.

Registers navigation menus and the named locations a theme renders them into.

Adds threaded, moderated discussion to the entry types you nominate.

Renders a form you declared in plumix.config.ts as a block, and stores what visitors send. The form is a value in your repository rather than a row, so it deploys with your code and reverts with git revert.

Records an activity feed and adds an audit helper to the request context.

Writes the head meta a public page needs: a description, a robots directive, the Open Graph set with an entry’s timestamps and byline, the Twitter card, and the resolved social image. Every tag is gap-filled, so a theme that sets one keeps it. It also serves /robots.txt and the paged sitemap, and owns the og:image resolution chain that @plumix/plugin-og contributes a card to.

Renders a social card per page — an entry, a term or an entry type’s archive, an author, a date, the front page — serves it from a route of its own, and carries it into the page’s og:image. The URL carries the card’s digest and is served immutable, so an edit publishes a link the scrapers have to fetch again. Its ogCards slot is a rule kind of the theme descriptor’s, resolved by the same walk templates uses — Custom Rule Kinds takes it apart as the worked example.

Serves RSS 2.0 and Atom for every archive — the front page, each entry type’s archive, each taxonomy term, each author and each date period — carrying the entries the archive’s page lists, and advertises the matching one in every page’s head. Nothing serves /feed without it.

Keeps a full-text index of everything the site publishes. Installing it materializes a plain-text projection of every searchable entry and an SQLite FTS5 index over it. Triggers in the database close both boundaries where the index could drift from the content, so a seed, a migration or a bulk import cannot leave a site searching stale text. An entry saved through the editor is indexed after the response, so nobody waits for it.

Adding comments to the recipe site. comments owns a database table, which makes this the longest install path any of them takes.

Install the package with whichever package manager the project uses:

Terminal window
pnpm add @plumix/plugin-comments

Then:

  1. Put it in the plugins array. comments is a factory, so you call it. The site’s own plugin stays in the array beside it.

    import { plumix } from "plumix";
    import { auth } from "plumix/auth";
    import { comments } from "@plumix/plugin-comments";
    import {
    cloudflare,
    cloudflareDeployOrigin,
    d1,
    } from "@plumix/runtime-cloudflare";
    import { recipes } from "./plugins/recipes";
    import { theme } from "./theme";
    export default plumix({
    runtime: cloudflare(),
    database: d1({ binding: "DB", session: "auto" }),
    auth: auth({
    passkey: {
    rpName: "Recipes",
    ...cloudflareDeployOrigin({
    workerName: "recipes",
    accountSubdomain: "your-account",
    localOrigin: "http://localhost:5173",
    }),
    },
    }),
    plugins: [recipes, comments({ entryTypes: ["recipe"] })],
    theme,
    });
  2. Apply the migrations. The plugin ships its own migration history, and plumix migrate applies it, after core’s, once the descriptor is in the array. plumix dev does the same on start, and plumix deploy and the built Node and Bun servers apply it in production.

    Terminal window
    pnpm plumix migrate
  3. Restart the dev server.

    Terminal window
    pnpm dev

    Recipes now accept comments, and the moderation queue is in the admin.

A plugin package exports either a descriptor or a function that returns one, and which of the two decides whether you write recipes or recipes(). A plugin you write with definePlugin is a descriptor, so it goes in the array as it is.

Every first-party plugin exports a factory instead, so you call it. Options are read at boot, either in the factory call itself or when the plugin’s setup runs, and nothing re-reads them after that. Changing an option means editing the call and restarting.

import type { PlumixConfigInput } from "plumix";
import { auditLog } from "@plumix/plugin-audit-log";
import { blog } from "@plumix/plugin-blog";
import { comments } from "@plumix/plugin-comments";
import { feeds } from "@plumix/plugin-feeds";
import { media } from "@plumix/plugin-media";
import { menu } from "@plumix/plugin-menu";
import { og } from "@plumix/plugin-og";
import { pages } from "@plumix/plugin-pages";
export const plugins: PlumixConfigInput["plugins"] = [
blog(),
pages(),
media(),
menu({ locations: { primary: { label: "Primary navigation" } } }),
comments({ entryTypes: ["recipe"], mode: "all", maxDepth: 2 }),
auditLog(),
og(),
feeds(),
];

feeds() takes no options at all, and every option the others take has a default, so the empty parentheses on blog(), pages(), media(), auditLog(), og() and feeds() are a complete call. comments() on its own holds an address’s first comment for review and auto-approves once that address has an approved comment behind it. Its other defaults are three levels of reply nesting, twenty root comments to a page, and five submissions per source per ten minutes.

The array is the order setup runs in. setup is not handed the registry, because there it would hold only what the plugins ahead of it had registered.

A descriptor’s optional afterSetup runs once every plugin’s setup has, with ctx.plugins holding everything they registered. It is where a plugin registers what it derives from other plugins’ types, such as a feed per entry type or an SEO box on every public taxonomy, and it sees the whole set wherever the plugin sits in the array. afterSetup functions run in array order too, so what one registers there is visible only to the afterSetup of a plugin listed after it.

One step runs earlier than that and does not care about order. A descriptor’s optional provides function runs for every plugin in the array before any plugin’s setup runs, so a helper published with extendPluginContext is on every plugin’s setup context whatever the order.

provides has a second call, extendAppContext, which puts a helper on the per-request context instead of the setup context. That is the one @plumix/plugin-audit-log uses for audit, so ctx.audit is there for any plugin’s routes, event and filter handlers and RPC procedures at request time, and no plugin’s setup can reach it.

Public URLs do not depend on array order either. The router compiles taxonomy rules ahead of entry-type rules and sorts every rule by a priority number, so a plugin whose entry type sits at the URL root cannot shadow another plugin’s routes by being listed first. Routing covers how a URL reaches the rule that serves it.

A plugin that builds something in provides and reads it again in setup or afterSetup declares it with state. Core calls state once per install, before any plugin’s provides, and passes what it returns as the second argument to all three steps. Here other plugins name badges during their setup, and the badges plugin turns each into a capability once they all have:

// A `declare module` block needs the module itself in scope.
import type { PluginContextExtensions } from "plumix";
import { definePlugin } from "plumix/plugin";
declare module "plumix" {
interface PluginContextExtensions {
registerBadge(name: string): void;
}
}
export const badges = definePlugin("acme-badges", {
state: () => ({ names: new Set<string>() }),
provides: (ctx, { names }) => {
ctx.extendPluginContext("registerBadge", (name) => {
names.add(name);
});
},
setup: () => undefined,
afterSetup: (ctx, { names }) => {
for (const name of names) {
ctx.registerCapability(`badge:${name}`, { grantTo: ["editor"] });
}
},
});

A function helper’s this is the setup context of the plugin calling it, so this.id names that plugin. To call back later into code that plugin handed you, such as a callback a request runs, keep this.runInFrame and call through it: ctx.cache inside then names the plugin that registered the callback, not yours.

State goes in state, never in the plugin factory’s closure. A descriptor can be installed more than once (a build installs it alongside the running app), and each install needs its own. The state is private to the plugin’s own lifecycle steps; share anything through extendPluginContext or extendAppContext.

A dependency that only a rarely hit route needs, such as a PDF renderer or a wasm engine, should not load with the plugin, or every isolate pays for it on its first request. Load it with loadOnce from plumix/support, declared at module scope:

import { definePlugin } from "plumix/plugin";
import { loadOnce } from "plumix/support";
const loadInvoicePdf = loadOnce(() => import("./invoice-pdf.js"));
export const invoices = definePlugin("acme-invoices", {
setup: (ctx) => {
ctx.registerRoute({
method: "GET",
path: "/invoice.pdf",
auth: "authenticated",
handler: async (request) => {
const { renderInvoicePdf } = await loadInvoicePdf();
return renderInvoicePdf(request);
},
});
},
});

The first call runs the factory, and every later call in the isolate, a concurrent one included, awaits that same promise. Every caller then gets one module that has finished evaluating. An import() written in the handler runs again on each request instead, and under plumix dev Vite’s module runner has handed a concurrent request a module whose body was still running. If the load rejects, loadOnce forgets it and the next call runs the factory again, so one failed load does not break the route until a restart.

The factory can do more than import, and what it resolves to is what is kept: loadOnce(() => import("./engine.js").then((m) => m.createEngine())) builds the engine once per isolate.

Some of what a plugin registers is a name other code then asks for, and an image role is the clearest small example. Core ships featured and ogImage; a listing thumbnail distinct from the share image is a role you add. A theme has no setup step, so a site declares its own as a one-file plugin like this.

Two steps, and both are needed: registerImageRole makes the name exist at run time, and a declare module augmentation makes it exist to the compiler. Augment through the root plumix specifier — that is the one every registry merges on.

// A `declare module` block needs the module itself in scope, and a type
// import is enough — without it TypeScript reports `plumix` as not found.
import type { ImageRoles } from "plumix";
import { definePlugin } from "plumix/plugin";
declare module "plumix" {
interface ImageRoles {
hero: true;
}
}
export const heroRole = definePlugin("acme-hero-role", {
setup: (ctx) => {
ctx.registerImageRole("hero", { single: true });
},
});

single: true caps each entry type, taxonomy and the users at one field in the role. A name is registered once, so a second plugin claiming hero fails at boot naming both owners.

A field joins the role with .role("hero") on a media chain — media("cover").role("hero") — in any meta box on any scope. Registration order does not matter: the field may be declared by a plugin listed before the one that registers the role.

The payoff is the read. images is keyed by ImageRoles, so hero autocompletes and nothing has to know which field holds it:

import type { EntryData } from "plumix/theme";
import type { ReactNode } from "react";
export function Hero({ data }: { data: EntryData }): ReactNode {
const hero = data.entry.images.hero;
return hero ? <img src={hero.url} alt={hero.alt ?? ""} /> : null;
}

Skip the augmentation and both .role("hero") and data.entry.images.hero are compile errors, because ImageRoles is the only place either name comes from.

@plumix/plugin-comments, @plumix/plugin-audit-log and @plumix/plugin-search each store rows in a table of their own, and each declares a schemaModule naming where that table’s definition lives. For @plumix/plugin-audit-log that is the default storage’s table; a swapped storage names its own, or none. The package that schemaModule resolves into ships the migration history for that table, and plumix migrate applies it after core’s, recording it in a tracking table of its own. Until it is applied the database holds core’s tables only, so the plugin’s first query fails on a missing table.

The rest add no tables of their own. @plumix/plugin-feeds reads the shared entries table and writes nothing at all. @plumix/plugin-blog and @plumix/plugin-pages register entry types, and every entry of every type is a row in the shared entries table. @plumix/plugin-menu goes further and reuses the same tables for its own model: a menu is a row in terms, a menu item is a row in entries, and membership is a row in entry_term. @plumix/plugin-media stores an asset as an entry and the bytes in object storage, and @plumix/plugin-og stores a rendered card in the same bucket, keyed by its content.

A plugin writes its own tables with ctx.db however it likes. It writes core’s tables, the ones plumix/schema exports, through ctx.entries, ctx.terms and ctx.settings.write. Those run core’s save path: the meta pipeline, the lifecycle actions, the edit-token and lease checks, and the purge of every cached page that read the row. A direct insert, update or delete on a core table skips all of it, so cached pages keep the old row. Reading core’s tables directly is fine. The first-party plugins are linted for this: a direct write to a core table fails their lint.

Cloudflare Workers covers applying a migration to a deployed D1 database rather than the local one.

A plugin that owns tables ships its own history, so a site never generates migrations for a package’s tables. As the author of a plugin published as a package, you keep that history in four steps:

  1. Add a drizzle.config.ts at the package root, pointing drizzle-kit at the plugin’s schema module:

    import { defineConfig } from "drizzle-kit";
    export default defineConfig({
    dialect: "sqlite",
    casing: "snake_case",
    schema: "./src/db/schema.ts",
    out: "./migrations",
    });
  2. Run drizzle-kit generate after every change to the schema, and commit what it writes to migrations/. A table may reference core’s tables, such as entries or users, by foreign key. Its history holds only the plugin’s own tables.

  3. Add migrations to the files of package.json, so the history reaches npm with the package.

  4. Point schemaModule at a subpath of the package that exports the schema, such as "@acme/plugin-bookmarks/schema", so plumix migrate can find the package.

A plugin that lives in the site’s own folder, rather than in a package, declares the site’s own tables. Its schemaModule is a path relative to the folder holding plumix.config.ts, such as "./src/plugins/bookmarks/schema.ts". The path may leave off .ts, .tsx or .js, or name a folder with an index.ts or index.js. A path that names no file fails plumix migrate and plumix dev with migrate_schema_module_unresolved, naming the file it looked for. plumix migrate generate writes their history to the site’s migrations/.

Either way, the schema module builds its tables from plumix/db. Its table, column, index and constraint builders come from the same drizzle instance core’s own tables are built with, so the module needs no drizzle-orm import of its own:

src/db/schema.ts
import { index, integer, sqliteTable, text } from "plumix/db";
import { users } from "plumix/schema";
export const bookmarks = sqliteTable(
"bookmarks",
{
id: integer().primaryKey({ autoIncrement: true }),
userId: integer()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
url: text().notNull(),
},
(table) => [index("bookmarks_user_id_idx").on(table.userId)],
);

drizzle-kit models tables, columns and indexes. DDL outside that, such as a full-text virtual table or a trigger, goes in a hand-written migration in the plugin’s own history:

Terminal window
pnpm exec drizzle-kit generate --custom --name fts_index

drizzle-kit adds an empty migration to migrations/ and records it in the history’s journal. Write the statements into it, with --> statement-breakpoint on its own line between two statements:

CREATE VIRTUAL TABLE `entry_fts` USING fts5(body, content='entry_search', content_rowid='id');
--> statement-breakpoint
CREATE TRIGGER `entry_search_ai` AFTER INSERT ON `entry_search` BEGIN
INSERT INTO `entry_fts`(rowid, body) VALUES (new.id, new.body);
END;

It applies after the migrations before it, so the DDL lands behind the tables it references. Like any migration that has reached a database, it is never edited afterwards. A change is a new migration.

A release that changes the shape of data the plugin already stored, such as a renamed settings key, reshaped meta or a value every row now needs, ships a data migration. It is a TypeScript step with a name and an up that receives a drizzle database as db. Build it with defineDataMigration from plumix/db, and import tables from plumix/schema or the plugin’s own schema module. A plugin needs no tables and no schemaModule to ship one.

The plugin names a module whose default export is its data migrations in the order they run, and points dataMigrations at it the way schemaModule points at a schema:

src/index.ts
import { definePlugin } from "plumix/plugin";
export const flags = definePlugin("acme_flags", {
setup() {},
dataMigrations: "@acme/plugin-flags/data-migrations",
});

Export that subpath from the package’s exports, as you would ./schema. A plugin in the site’s own folder points dataMigrations at a path relative to the folder holding plumix.config.ts, as its schemaModule does; one that names no file fails with migrate_data_module_unresolved. A site lists its own in plumix.config.ts, as dataMigrations: "./data-migrations.ts", a path relative to the config file.

Rename a settings key:

src/data-migrations.ts
import { and, defineDataMigration, eq } from "plumix/db";
import { settings } from "plumix/schema";
export const renameBetaEnabled = defineDataMigration({
name: "rename-beta-enabled",
async up({ db }) {
await db
.update(settings)
.set({ key: "beta" })
.where(
and(eq(settings.group, "acme_flags"), eq(settings.key, "beta_enabled")),
);
},
});
export default [renameBetaEnabled];

Rename a meta key. Meta is a JSON column, so the rename is json_set and json_remove in one statement. The -> operator keeps an object or array as JSON rather than turning it into a string:

import { defineDataMigration, sql } from "plumix/db";
export const renameSubtitle = defineDataMigration({
name: "rename-subtitle-meta",
async up({ db }) {
await db.run(sql`
UPDATE entries
SET meta = json_remove(json_set(meta, '$.tagline', meta -> '$.subtitle'), '$.subtitle')
WHERE json_type(meta, '$.subtitle') IS NOT NULL
`);
},
});

Backfill a value. D1 caps the parameters one statement binds, so update by id in chunkForD1 batches:

import {
and,
chunkForD1,
defineDataMigration,
eq,
inArray,
sql,
} from "plumix/db";
import { entries } from "plumix/schema";
export const backfillFeatured = defineDataMigration({
name: "backfill-featured",
async up({ db }) {
const rows = await db
.select({ id: entries.id })
.from(entries)
.where(
and(
eq(entries.type, "post"),
sql`json_type(${entries.meta}, '$.featured') IS NULL`,
),
);
for (const ids of chunkForD1(rows.map((row) => row.id))) {
await db
.update(entries)
.set({
meta: sql`json_set(${entries.meta}, '$.featured', json('false'))`,
})
.where(inArray(entries.id, ids));
}
},
});

A test calls up directly against the test harness’s database, then reads what it left:

src/data-migrations.test.ts
import { eq } from "plumix/db";
import { settings } from "plumix/schema";
import { createTestDb } from "plumix/test";
import { expect, test } from "vitest";
import { renameBetaEnabled } from "./data-migrations.js";
test("renames the beta flag", async () => {
const db = await createTestDb();
await db
.insert(settings)
.values({ group: "acme_flags", key: "beta_enabled", value: true });
await renameBetaEnabled.up({ db });
expect(
await db.select().from(settings).where(eq(settings.group, "acme_flags")),
).toEqual([{ group: "acme_flags", key: "beta", value: true }]);
});

Data migrations run wherever schema migrations do, and always after every owner’s schema migrations, so an up can read a column a schema migration of the same release added. They run in owner order: core’s, then each plugin’s in the order of plugins, then the site’s, each list in its own order. plumix dev runs them on start, and plumix migrate runs them by hand, against the local database or, on Cloudflare, the one --production, --preview or --env names. On Node and Bun the built server runs them when it starts, before it listens. On Cloudflare, plumix deploy runs them against D1 before wrangler deploy, so the previous Worker serves the reshaped data until the new one is live.

Each one is recorded in __plumix_data_migrations under its owner and name once its up resolves, and never runs again. plumix migrate prints applied 1 data migration or data migrations up to date for each owner that has any, and plumix migrate status lists each one as applied or pending.

  • A released data migration never changes. Sites that already ran it will not run it again.
  • Renaming one runs it again, because the name is what is recorded.
  • Fix forward. A mistake is corrected by a new data migration, never by editing the old one. There is no down().
  • Write up so it can run again. When an up throws, the run stops, nothing after it runs, and it is not recorded, so the next run retries it. It runs outside a transaction, because D1 cannot hold one open across TypeScript, so a retry may find some of its work done. Filter on the old shape, as the recipes above do.
  • Downgrades aren’t supported. Rolling back is restoring the database: D1 Time Travel on Cloudflare, or a copy of the SQLite file taken before the upgrade on Node and Bun.

Five add a page of their own: media has the library at /media, menu has the menu builder at /menus, comments has the moderation queue at /comments, audit-log has the activity feed at /audit-log, and forms has the submissions inbox at /form-submissions. Two add a surface without adding a page — seo contributes a page under Settings and a preview of how an entry will read in search results to the editor sidebar, and og contributes a preview of its social card to the same sidebar, but only when og({ preview }) asks for the box, since rendering it is the only reason that chunk exists. Blog, pages, feeds and search ship no admin UI at all.

Each of the seven declares a path to an admin entry module, and the Vite plugin compiles every declared entry into one bundle for your site during plumix build and plumix dev.

The consequence is that a newly installed admin-facing plugin appears after a build, not after a config reload. If the admin looks unchanged once the package is in the array, stop the dev server and start it again.

Moving between admin screens runs a view transition, and a plugin page gets it with no code. Each transition carries one type naming its direction: nav-forward for a new screen, nav-back for a step back through history, and nav-replace for a navigation that replaces the current history entry. A change to only the search params or the hash, such as filtering a list, starts no transition, and neither does any navigation for a user who prefers reduced motion.

The admin’s defaults are a short cross-fade with a slide in the direction of travel. The sidebar and header hold still because they carry their own view-transition-name, plumix-admin-sidebar and plumix-admin-header. Give an element of your page a view-transition-name to hold it still the same way, and keep the name unique on the page, since a name used twice stops the transition. Restyle a direction from your plugin’s CSS with :active-view-transition-type():

:root:active-view-transition-type(nav-forward)::view-transition-new(root) {
animation-duration: 300ms;
}

The admin’s rules sit in its base cascade layer, below the layer your plugin’s stylesheet lands in, so a rule like this one wins without raising its specificity. To keep a single link from animating, pass viewTransition={false} to Link, or to navigate(), from @tanstack/react-router.

The platform releases in lockstep. plumix and the four packages behind it, @plumix/core, @plumix/admin, @plumix/admin-editor and @plumix/admin-ui, share one version number and go out together. You depend on plumix and get the rest transitively, so there is one platform number to pin. Your runtime adapter, @plumix/runtime-cloudflare, sits outside that group and carries its own number.

The eleven plugins each release on their own number, and those numbers have already diverged from each other and from the platform. One plugin sitting several minor versions ahead of another says nothing about either one’s compatibility with the platform or with each other.

Compatibility is carried by a peer dependency instead. Each plugin declares plumix as a peer from the first platform release its code works with up to <1.0.0, so any later pre-1.0 platform release satisfies it, and upgrading plumix does not oblige you to upgrade the plugins beside it. A plugin republishes when its own code changes, not because the platform moved.

create-plumix-app can do the install and the array entry for you. Each plugin package describes itself to the scaffolder, which splices the import, the registration and any config slot the plugin needs into the generated plumix.config.ts and wrangler.jsonc.

Terminal window
pnpm create plumix-app my-site --plugins blog,pages

Picking media this way is what puts storage: r2({ binding: "MEDIA" }) in the config and an r2_buckets entry in the Workers configuration. Installation walks the wizard, and Project Structure covers what it wrote.

The plugins array is one slot among twenty-two in Configuration, which covers the file these snippets edit. Writing a descriptor rather than installing one is Content Modelling, and the registration options these plugins pass are documented in Entry Types. A plugin that contributes a field type, as the media plugin does, extends the roster in Field Types. A plugin can also contribute a per-request data dependency a template declares and receives, which the blog and comments plugins both do, and Themes covers the render layer that reads it. A plugin that sends email declares each mail in its descriptor’s mails field, which Mail covers.

Read Blog and Pages next. Between them they cover the two entry types most sites install before anything else, and each is a worked example of the registration options this page only names. OG Cards renders a share image per published entry, and Feeds syndicates those entries as RSS and Atom — neither needs configuration beyond the array entry. Search replaces that page with ranked, full-text results, and unlike those two it needs a migration generated before its first request.

Translation Catalogs covers the descriptor’s i18n slot — the one that decides whether a plugin’s translations reach a site at all.

Menus documents navigation menus and the locations a theme renders them into. Comments documents threading, the moderation queue and the trust policy the quickstart above configured. Media documents the library, the upload flow and the media field types. Forms documents declaring a form, placing it and reading what comes in. SEO documents head meta, the sitemap and robots.txt, and Audit Log documents the activity feed.

Five pages in this section are not written yet. Publishing a Plugin covers taking a local plugin to npm, The Plugin Descriptor documents every slot on the descriptor object, Config Schema covers validating the options a factory takes, Admin Entry covers shipping admin screens, and Versioning and Peer Ranges goes further into the two tracks described above.