SEO
@plumix/plugin-seo writes what a site tells a search engine and a social scraper: a page’s head meta, its structured data, /robots.txt, and the sitemap, plus a per-entry and per-term box where an editor overrides any of it. Core emits a canonical URL and nothing else, so without this plugin a page carries no description, no robots directive, no Open Graph tags and no JSON-LD, and the site serves neither robots.txt nor a sitemap.
Overview
Section titled “Overview”The plugin exports a factory named seo. It adds no database tables and takes one optional argument, so installing it is a package install and one array entry.
Every tag is written as a gap-filler: it is appended only when nothing has already set that name or property. A theme that declares its own og:site_name keeps it, and so does another plugin’s — the contribution runs last on the render:document chain whatever order the plugins array is in, so nothing a site writes is overridden or duplicated. Installing this plugin adds what a page was missing and nothing else.
Quickstart
Section titled “Quickstart”Install the package with whichever package manager the project uses:
pnpm add @plumix/plugin-seonpm install @plumix/plugin-seoyarn add @plumix/plugin-seoThen add the descriptor to the plugins array:
import { plumix } from "plumix";
import { seo } from "@plumix/plugin-seo";
export default plumix({ plugins: [seo()],});What lands in the head
Section titled “What lands in the head”Every public page gets:
description— the entry’s excerpt, falling back to the site tagline.robots— decided by the assertion chain below.og:title,og:type,og:url,og:site_name,og:description,og:locale.twitter:card—summary_large_imagewhen a social image resolved,summarywhen none did.- One
<script type="application/ld+json">holding the page’s structured-data graph, on every page that is indexable.
A single entry additionally gets article:published_time, article:modified_time and article:author. An archive, a term page, an author page, a date page and the front page are og:type: website and carry none of the three.
og:title is the title core resolved for the page — an entry’s title with its shortcodes expanded, an archive’s plural label, Search: <query>, or the title a registerArchiveType resolver returned. og:url is the same canonical URL core’s <link rel="canonical"> and its redirect normalizer resolve, so the page cannot contradict where core sends traffic.
When a social image resolves, og:image is emitted with og:image:width, og:image:height and twitter:image beside it. The four travel as a group: a theme that declared its own og:image keeps the whole group, because a size appended next to it would describe some other picture.
The social image chain
Section titled “The social image chain”Five links resolve one og:image, in this order:
- The entry’s explicit choice — a media field the entry type tagged
.ogImage(). - The social image URL typed into the SEO box below.
- Whatever a
seo:og_imagesubscriber returns — where@plumix/plugin-ogputs a generated card. - The entry’s featured photo — a media field tagged
.featured(). - The site-wide default, set on the SEO settings screen.
The order is fixed here, not by subscription order, so a generated card never outranks an author’s deliberate choice however the plugins array happens to be written.
A subscriber receives the featured photo alongside the value rather than as the value, so it can improve on it — crop it to a card’s shape, say — instead of only replacing it. Returning the value it was handed leaves the chain alone, so a subscriber that only handles some pages passes through on the rest.
ctx.addFilter("seo:og_image", (image, data, appCtx, featured) => { if (image !== null) return image; // someone earlier already answered if (data.kind !== "entry") return null; // decline; the chain carries on return featured ?? { url: "https://example.com/fallback.png" };});Per-entry and per-term overrides
Section titled “Per-entry and per-term overrides”Every publicly-visible entry type and taxonomy gets a Search & social box:
| Field | Meta key | What it does |
|---|---|---|
| Search title | seo_title |
Replaces <title> and og:title for this page. |
| Search description | seo_description |
Replaces the excerpt-then-tagline description. |
| Canonical URL | seo_canonical |
Replaces the canonical URL core derived from the request path, in the tag and in og:url. |
| Social image URL | seo_og_image |
Link 2 of the chain above. |
| Hide from search engines | seo_noindex |
noindex in the head and out of the sitemap. |
| Do not follow links on this page | seo_nofollow |
nofollow in the head. |
| Content type | seo_schema_type |
The schema.org type of the entry’s article piece. Entry box only. |
Content type is on the entry box and not the term box: only an entry has an article piece in the structured-data graph for the choice to retype, so a term box carrying the control would offer an answer nothing reads. The other six fields are on both.
A search title is the page’s title, so a theme’s titleTemplate still composes it — How to knead dough · Demo under "%s · Demo". A page with no override sets no title at all and keeps whatever core resolved.
The fields ride the entity’s own Save — there is no second save action and no new table. Storage is the meta column entries and terms already have, under keys prefixed seo_ because meta keys are one flat namespace shared by every box on an entity.
A term’s box is the same box: a category or tag archive is not a second-class page.
The search result preview
Section titled “The search result preview”Above the fields, on an entry, sits a live preview of the search result the entry will produce — the URL, the resolved title and the resolved description, through the same functions the head runs. It updates as the author types: the search title, the search description and the Hide from search engines toggle are read off the form, not off the saved row, so a change shows before a save. The entry’s own title and excerpt are not — they sit outside the meta bag the control can see — so a preview falling back to either shows the last saved one until Refresh.
Two length indicators sit under it, one per line, counting the resolved text against 60 characters for the title and 155 for the description, and saying so when a line will be cut short.
When the page is not offered to search engines the preview says so in words, naming the assertion that fired: the whole site, this entry, the content type, the taxonomy, a search-results page, page two of an archive, or a page that was not found. That is what the reason string on the predicate is for — an author can tell a deliberate choice from an inherited rule without reverse-engineering a toggle.
A term’s box has no preview: it is written from an entry’s own permalink and excerpt, and a term archive has neither.
Scope is derived, not configured
Section titled “Scope is derived, not configured”Nothing lists which types get the box. A type or taxonomy registered with isPublic: false — a menu item, a menu group — never had a public page to write search copy for, so it is excluded for free. Everything else is in.
For the exception — a type that is public but that nobody writes search copy for — name it. Names are not checked against the registry, and entry types and taxonomies share one list, so a typo silently leaves the box in place and a name both a type and a taxonomy use excludes both:
seo({ metaBox: { exclude: ["landing_page"] } });One predicate, several consumers
Section titled “One predicate, several consumers”Whether a page is offered to a search engine is decided once, by an ordered set of named assertions that short-circuits on the first that fires:
| # | Reason | Fires when |
|---|---|---|
| 1 | site_private |
Indexing is turned off site-wide. |
| 2 | entry_override |
The entry or term is marked Hide from search engines. |
| 3 | type_default |
Its entry type is held out — on the type’s entries and on its archive. |
| 4 | taxonomy_default |
Its taxonomy is held out — on that taxonomy’s term archives. |
| 5 | search_results |
The page is a search-results listing. |
| 6 | paginated |
Page 2 or beyond of an archive. |
| 7 | not_found |
The URL resolved to nothing. |
| — | default |
Nothing asserted; the page is indexable. |
The order is the design. A site held out of the index cannot be put back in by one entry, and an editor’s answer for one entry outranks the default set for its whole type. The last three are off by default and each is a toggle on the settings screen.
The directive it produces pairs that answer with the follow one: index,follow,max-image-preview:large when the page is offered, noindex,follow when it is not, and nofollow in place of follow when the site is private or the editor asked for it.
A plugin archive registered with registerArchiveType states the facts these arms read. Core cannot derive them from a payload it does not define, so the archive puts them on it: page is the 1-based index the paginated arm asks for, and query is what the visitor typed, which is what puts the archive under search_results beside core’s own /search. @plumix/plugin-search states both, so replacing the built-in search page does not change what the site offers a crawler. An archive that states neither is an ordinary page, and nothing here fires on it.
The robots directive and sitemap membership both read that one answer, so a page cannot claim noindex in its head while still appearing in the sitemap. The reason travels with the decision rather than being thrown away, which is what lets an editor be told why a page is excluded instead of being shown a bare toggle.
Title patterns
Section titled “Title patterns”A pattern is a line with %%variables%% in it, resolved per page. Set one per entry type so every entry of that type is titled consistently, and a site-wide one for everything else:
| Field | What it covers |
|---|---|
| Default title pattern | Every page no per-type pattern covers — term, author, date and search archives included. |
| <Type> under the per-type fields | That type’s entries and its archive. |
An entry’s own Search title outranks both. A pattern that resolves to nothing at all — every variable empty on this page — leaves the title exactly as the theme resolved it.
The variables
Section titled “The variables”| Variable | Resolves to |
|---|---|
%%title%% |
The title the page already had. |
%%sitename%% |
The site title, from Site identity. |
%%sep%% |
The Title separator setting; · unless changed. |
%%term%% |
The term’s name, on a term archive. |
%%author%% |
The author’s display name (or slug), on an entry or an author archive. |
%%date%% |
An entry’s publication date, or the period a date archive covers, at that archive’s own precision. |
%%searchphrase%% |
The query, on a search-results page — core’s own, or a plugin archive that states one. |
%%count%% |
How many entries the listing matched, on an archive core paginates. |
A variable the page has nothing for resolves to the empty string, and a separator left holding nothing together is trimmed away — so %%term%% %%sep%% %%sitename%% ships as Demo, not · Demo, on a page with no term. A name that is not a variable is dropped rather than emitted: shipping %%titel%% into a search result is worse than shipping a shorter title.
%%count%% is the one a plugin archive never fills. Core counts what it paginates, and a plugin archive’s listing is its own: @plumix/plugin-search probes for a next page rather than totalling its matches, so it has no number to state and pays no query to find one.
%%title%% %%sep%% %%sitename%% → Kneading, explained · Demo%%searchphrase%% (%%count%%) → dough (12)%%term%% %%sep%% %%sitename%% → News · DemoStructured data
Section titled “Structured data”Every indexable page carries one <script type="application/ld+json"> in its head, holding a graph: a set of pieces that reference each other by URL fragment rather than repeating themselves. That referencing convention is what makes it a graph instead of a pile of disconnected objects, and it is what a crawler follows to learn that the article on this page was written by the person named two pieces over.
An entry page carries all seven pieces:
| Piece | @type |
@id |
|---|---|---|
website |
WebSite |
<site>/#website |
publisher |
Organization or Person |
<site>/#organization / <site>/#person |
webpage |
WebPage |
<canonical>#webpage |
article |
Article or a subtype |
<canonical>#article |
breadcrumb |
BreadcrumbList |
<canonical>#breadcrumb |
image |
ImageObject |
<canonical>#primaryimage |
author |
Person |
<site>/#/schema/person/<slug> |
Every identifier is derived from the site root and the page’s canonical URL, so two renders of one URL produce the same graph. Under a base path the site root is the prefix itself, so <site> reads https://example.com/blog and the identifiers follow it. A page that is not an entry — an archive, a term, an author, a date — carries the site-level pieces and no article or author. A piece with nothing to say is absent rather than empty: a page with no social image has no ImageObject and no primaryImageOfPage pointing at one, and a site that never set a title publishes no name rather than its own URL.
The ImageObject is the page’s own image — an entry’s explicit choice, a generated card, or its featured photo. The site-wide Default social image URL is deliberately not used: it is a sharing fallback, so every article would otherwise claim the same bytes as its own #primaryimage, and Article.image is read as representative of the article it hangs off. The page is still shared with it.
A page marked noindex emits no graph at all. Structured data exists to make a page eligible for a rich result, and a page asking not to be indexed is not — advertising one anyway would have the page’s graph and its robots directive say different things about it.
Reshaping the graph
Section titled “Reshaping the graph”Three filters, from the coarsest to the finest. Each is a place a plugin can describe its own content without forking this one.
Drop a piece with seo:schema:needs. It is asked once per piece, and returning false leaves it out:
ctx.addFilter("seo:schema:needs", (needed, piece, facts) => piece === "breadcrumb" && facts.query !== null ? false : needed,);Reshape one piece with seo:schema:piece. Keep the @id — it is what the rest of the graph points at:
ctx.addFilter("seo:schema:piece", (piece, name) => name === "publisher" ? { ...piece, sameAs: ["https://example.social/@demo"] } : piece,);Transform the whole graph with seo:schema:graph, for a plugin adding nodes of its own. Returning an empty array emits no script:
ctx.addFilter("seo:schema:graph", (graph, facts, appCtx) => [ ...graph, { "@type": "Product", "@id": `${appCtx.origin}/#product`, name: "A thing" },]);Picking the type
Section titled “Picking the type”An entry’s article piece is an Article unless the editor says otherwise. The Content type field on the SEO box offers Article, BlogPosting, NewsArticle and TechArticle; a stored value outside that roster is not an answer and the inferred type stands. The choice retypes the piece and nothing else — the @id and every reference to it stay where they were.
Serialization
Section titled “Serialization”This plugin serializes the script itself. <, >, & and the U+2028 / U+2029 line separators become \uXXXX escapes — the same string to a JSON reader, inert to an HTML tokenizer — so an entry titled </script><script>… cannot close the element it is sitting in. serializeJsonLd is exported for a plugin emitting a script of its own.
A theme that declared its own application/ld+json keeps it and this plugin emits none: a page making two claims about what it is helps nobody.
Breadcrumbs
Section titled “Breadcrumbs”The trail is Home → the entry type’s archive, where it has one → the page itself, with the last step unlinked as Google asks. A type that is not public gets no archive step even when it declares hasArchive, because the router skips it before it emits an archive route. A term archive, an author archive and a date archive get a two-step trail; the front page gets none, because it is already Home.
The same trail feeds the BreadcrumbList in the graph and the Breadcrumbs component a theme renders, so what the page shows and what search results claim cannot disagree:
import { Breadcrumbs } from "@plumix/plugin-seo";
export default function Post({ data }: { data: EntryData }) { return ( <article> <Breadcrumbs data={data} /> <h1>{data.entry.title}</h1> </article> );}It renders a <nav data-plumix-breadcrumbs> wrapping an <ol>, and nothing at all on a page with no trail. Style it through that attribute — there is no class-name API to keep in step with. For a trail of your own shape, breadcrumbTrail(ctx, data) returns the same items the graph is built from.
Ancestors are not walked: a hierarchical entry’s parents and a nested term’s parents would each cost a per-render round-trip, and both already carry their own resolved URL. A plugin archive gets no trail either — core does not define that payload, so its hierarchy is the owning plugin’s to describe through seo:schema:piece.
robots.txt and the sitemap
Section titled “robots.txt and the sitemap”Installing the plugin puts four kinds of machine-readable document at the site root: /robots.txt, the sitemap, the stylesheet that renders it, and /llms.txt.
/robots.txt allows all crawling while the indexing toggle is on, and disallows everything when it is off. An indexable site’s file ends with a Sitemap: line pointing at the index below, and the settings screen can replace the rules above it. The body goes through a seo:robots-txt filter, so a plugin can append Sitemap: lines, a crawl-delay or per-agent rules without owning the route:
ctx.addFilter( "seo:robots-txt", (body) => `${body}Sitemap: https://example.com/sitemap.xml\n`,);Turning Block AI crawlers on adds one more group to the file, naming the crawlers that feed model training and assistant answers — GPTBot, ClaudeBot, Google-Extended, PerplexityBot, Bytespider and a couple of dozen others — and disallowing them everything. Ordinary search crawlers are untouched: holding those out is what the indexing toggle does. A site already held out of the index says nothing extra, since its allow-none rule covers every agent. To block a name the list does not carry, or unblock one it does, append to the body through the same filter.
/sitemap.xml is an index, not a URL list. It names one sub-sitemap per scope — every public entry type, every public taxonomy, and every archive a plugin registered a sitemap for — paged at 1,000 URLs each. A scope with nothing published is left out.
A scope whose entry type or taxonomy is held out of the index leaves the set entirely — it is absent from the index and its sub-sitemap serves an empty <urlset>.
Two seams are worth knowing about. An archive registered with registerArchiveType answers to neither per-type nor per-taxonomy default: it is a plugin’s own URL space, and declaring a sitemap is what opts it in — so an archive listing permalinks of a noindex type would advertise pages that say noindex. And a taxonomy sharing a name with an entry type gets no sub-sitemap at all, because the entry type claims the scope name first; its term archives still say index.
/sitemap-<scope>-<page>.xml is the URL list for one scope and page. Entry scopes list published entries with a lastmod; taxonomy scopes list term archives. Drafts, scheduled entries, non-public types and anything marked Hide from search engines never appear — the exclusion is a WHERE, so the count driving the index’s pagination and the page it pages agree.
Scopes are enumerated when the theme is ready, so the routes the plugin claims are exactly the scopes the site registered — /sitemap-nope-1.xml is a 404, not an empty document, and a page segment that is not a page number is a 404 too.
A site with indexing turned off publishes an empty index and empty URL lists, so a crawler that already knows the URL learns nothing from it.
Both documents name /sitemap.xsl, an XSLT stylesheet the plugin serves. A crawler ignores the instruction and parses the same XML; a browser renders a table of the URLs, their last-modified stamps and how many pictures each carries. The stylesheet is inline and static — nothing it renders depends on a second request. Browsers are phasing XSLT support out; where it has gone, the document falls back to the plain XML tree the browser would have shown anyway.
Images
Section titled “Images”An entry’s pictures ride its <url> as sitemap image entries, so image search finds them without crawling the page for <img> tags. Those pictures are the media fields the entry type tagged .featured() or .ogImage() — the same declarations link 1 and link 4 of the chain above read — plus the social image URL an editor typed into the SEO box, which leads because it is the one they chose. One photo named by two fields is listed once.
A whole page of entries resolves through one batched pass rather than a query each — chunked at 100 ids per query, as core’s own read path chunks — and only for types that declare such a field. An entry with no picture serializes exactly as it did before, and a page with none declares no image namespace at all.
Only pictures are listed, and at most ten per URL. A role-tagged field can be a .multiple() gallery, so the cap is what keeps a page of 1,000 entries from becoming tens of thousands of image entries; and because one batched call carries ids from fields that each declare their own accept, a non-image upload is filtered on its own MIME type rather than by the field’s scope. Relative URLs — the worker-proxied path a private bucket resolves to — are made absolute, since <image:loc> has to be.
Adding URLs
Section titled “Adding URLs”The seo:sitemap:urls filter adjusts one scope’s page before it is serialized. It receives the scope name, the 1-based page and the request context, so a subscriber can query for rows to inject rather than only reshape what is there:
ctx.addFilter("seo:sitemap:urls", (urls, scope, page, appCtx) => { if (scope !== "post" || page !== 1) return urls; return [...urls, { loc: `${appCtx.origin}/handbook` }];});An archive registered with registerArchiveType folds into the index by declaring a sitemap. count drives the index’s pagination without a URL scan, urls answers one page, and tags names the cache tags its pages are stored under:
ctx.registerArchiveType("event-series", { routes: ["/events/:series"], resolve: (appCtx, params) => resolveSeries(appCtx, params), sitemap: { count: (appCtx) => countSeries(appCtx), urls: (appCtx, page) => seriesUrls(appCtx, page), tags: ["t:event"], },});Caching
Section titled “Caching”Sitemap responses declare public, max-age=0, s-maxage=3600 and go through the CDN when one is configured. Each scope stores under its own tags — t:<type> for an entry type, the t:<type> tags of a taxonomy’s entry types for a taxonomy — so publishing a post retires the post scope and leaves the rest of the set alone. Saving the settings group purges all of them, since the indexing toggle decides whether any of them have URLs at all.
A scope with no tags to contribute rides the one-hour window instead of a purge: a taxonomy registered with no entryTypes has none to name, and so does an archive whose sitemap omits tags. Declare tags on an archive whose content changes more often than that.
With no CDN configured, every sitemap is generated per request.
llms.txt
Section titled “llms.txt”/llms.txt is the llmstxt.org convention: a small Markdown file naming the site, its tagline and what is worth reading. The plugin serves the site title and tagline from the Site identity group — falling back to the host when no title is set — and links the sitemap index.
The map is offered only to a site that wants to be read this way. One held out of the index has nothing to offer, and one that blocks AI crawlers has already said the opposite of what a map here would say, so both get the heading and a sentence instead of a list. The file is still served either way: a crawler that fetches it gets an answer rather than a 404 it would read as “not implemented yet”.
To list the pages that actually matter — a handbook, an API reference — rewrite the body:
ctx.addFilter( "seo:llms-txt", (body) => `${body}\n## Docs\n\n- [Handbook](https://example.com/handbook)\n`,);Instant indexing
Section titled “Instant indexing”Setting an IndexNow key turns on notification. Publishing or updating an entry submits its URL to the shared IndexNow endpoint, which fans out to every participating engine, so a change is picked up in minutes rather than at the next crawl. The key is served at /indexnow-key.txt, which the submission names as its keyLocation — the ownership proof an engine fetches back.
Every gate the head and the sitemap apply is applied here too. A draft, an entry an editor marked Hide from search engines, an entry of a non-public type and a site held out of the index are never submitted — a page nobody may index is a page nobody is told about.
One publish is one submission: entry.update fires entry:updated and entry:published for a publish transition, and both reach this, so the submission is memoized per entry for the request — a duplicate is what an endpoint’s abuse handling watches for.
The submission is deferred, so it never joins the request the editor is waiting on, and every failure — an unreachable endpoint, a timeout, a refused key — becomes a log line. A missed notification is not a failed publish.
Removals are not notified. Trashing or unpublishing an entry drops it from the sitemap and leaves the URL to the engine’s own recrawl, which is what tells it the page is gone.
Settings
Section titled “Settings”The plugin owns one settings page, SEO, composed of three groups. Each is its own card with its own Save, and each is gated by settings:manage — a contributor never sees the page.
Search & social holds everything the site answers about its own content:
- Allow search engines to index this site — off puts
noindex,nofollowon every page, disallows all crawling inrobots.txt, empties the sitemap and withholds thellms.txtmap. - Default social image URL — the last link of the chain above.
- This site represents —
OrganizationorPerson, which is what the graph’spublisherpiece is typed as. - Title separator and Default title pattern — the two title-pattern settings above.
- Index search-results pages, Index page 2 and beyond of an archive, Index pages that were not found — the three arms of the chain that are off by default.
- Block AI crawlers — adds the AI-crawler group to
robots.txtand withholds thellms.txtmap. - IndexNow key — turns on publish-time notification. Empty means none.
- One title pattern and one indexing toggle per public entry type, and one indexing toggle per public taxonomy. Those fields are enumerated from what the site registered, so a type a plugin registers during its own
setupgets its own. A type registered later — from theafterSetupof a plugin listed after this one — is too late to be enumerated, and a name that is not[a-zA-Z0-9_-]+cannot be a settings key, so it gets no per-type fields either.
Site verification holds the token each engine hands you to prove you own the site. Every one set reaches the head of every page, under the name that engine reads:
| Field | Meta name |
|---|---|
google-site-verification |
|
| Bing | msvalidate.01 |
| Yandex | yandex-verification |
| Baidu | baidu-site-verification |
p:domain_verify |
robots.txt holds hand-written content replacing the generated rules — the User-agent: * group, not the two site-wide answers composed around them. A site with indexing turned off still disallows everything, whatever the box holds; the AI-crawler group is still added while that toggle is on; and the Sitemap: line is still appended unless the author wrote one of their own, so an edit cannot drop the reference by forgetting it. It can still point the line elsewhere or disallow the path — what is preserved is the declaration, not the crawl.
Saving any of the three purges the cached sitemap set and the cached content pages of every registered entry type, since between them they rewrite a page’s robots directive, title and verification tags.
The first two fields lived on core’s Site identity group before this plugin existed. A site upgrading from that keeps its answers with no migration step: the plugin reads its own group first and falls back to the old site.public and site.default_og_image rows, and the settings form is seeded from the same fallback, so the next save writes the values through under the new keys.
Themes keep the last word
Section titled “Themes keep the last word”A theme’s document declaration is merged before this plugin’s filter runs, so anything it sets survives:
export default defineTheme({ document: { meta: [{ property: "og:site_name", content: "The Recipe Site" }], }, templates: [...],});The page carries exactly one og:site_name, and it is the theme’s.
Related
Section titled “Related”Overview covers installing a plugin and the version track this one is on. OG Cards contributes the generated card that sits at link 2 of the chain above, and needs this plugin installed to reach a page’s head.
The .featured() and .ogImage() roles the chain reads are meta-box fields — Meta Boxes covers declaring them and Field Types the media types they hold. The head this plugin writes into is the theme’s: Themes covers the document declaration whose tags win, and Template Data the data a seo:og_image subscriber receives.
Next steps
Section titled “Next steps”Read Blog if the site has no entry type yet: an entry is what carries an excerpt, a byline and the timestamps this plugin emits.
To generate a social card per page rather than point every share at one default image, OG Cards takes the card pipeline apart.
To change what a page says beyond what the settings group covers, the render:document filter is the same seam this plugin uses — Overview covers the registration context a filter is added from.