Skip to content

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 no robots.txt, sitemap or /llms.txt.

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.

The same seo carries the plugin’s filters, so another plugin subscribes with ctx.filters.add(seo.ogImage, …) and needs no declare module. Each is async, and a handler receives (value, input, ctx):

Filter Value Input
seo.ogImage OgImage | null { data, featured }
seo.robotsTxt the /robots.txt body none
seo.llmsTxt the /llms.txt body none
seo.sitemap.urls one scope’s page of URLs { scope, page }
seo.schema.needs whether a piece is kept { name, facts }
seo.schema.piece one structured-data piece { name, facts }
seo.schema.graph the whole graph { facts }

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 late on the render.document chain whatever order the plugins array is in, after every handler that doesn’t ask for late itself, so nothing a site writes is overridden or duplicated. Installing this plugin adds what a page was missing and nothing else.

Install the package with whichever package manager the project uses:

Terminal window
pnpm add @plumix/plugin-seo

Then add the descriptor to the plugins array:

plumix.config.ts
import { plumix } from "plumix";
import { seo } from "@plumix/plugin-seo";
export default plumix({
plugins: [seo()],
});

Every option is optional. Three of them switch off a default, for a site whose head or root documents already say what the plugin would add. Each one defaults to true, so a site that sets none of them gets everything below.

Option false removes
llmsTxt The /llms.txt route. The path falls through to the content router and 404s, or a site plugin can register its own /llms.txt without a conflict. seo.llmsTxt is never applied.
articleTags article:published_time, article:modified_time and article:author on an article page. Its og:type still says article.
structuredData The structured-data graph. It is never built, no application/ld+json script is appended, and none of the seo.schema filters is applied. Breadcrumbs and breadcrumbTrail still work, because a theme calls them itself.
plumix.config.ts
seo({ llmsTxt: false, articleTags: false, structuredData: false });

metaBox is the fourth; Scope is derived, not configured covers it. sitemaps is the fifth; Sitemap policy covers it. indexViews is the sixth; Views covers it.

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_image when a social image resolved, summary when none did.
  • One <script type="application/ld+json"> holding the page’s structured-data graph, on every page that is indexable, unless the site set structuredData: false.

A single entry that is an article additionally gets article:published_time, article:modified_time and article:author, unless the site set articleTags: false. article:author is the absolute URL of the author’s archive (/authors/<slug>, under the site’s base path), because Open Graph reads it as a profile; a site that turned the author route off gets no article:author. Every other page is og:type: website and carries none of the three.

Each page is one of two kinds, and that one answer decides its og:type, whether it carries the article:* tags, and whether its graph has an article piece, so the three never disagree. It is resolved in this order:

  1. The template’s own og:type. A document that declares og:type keeps it, and the page is an article only when that value is article.
  2. The editor’s Content type. Web page makes the entry a website; an Article subtype makes it an article.
  3. The entry type’s seoType, "article" or "website".
  4. The entry type’s shape. A flat type, such as blog posts, is an article; a hierarchical one, such as pages, is a website.

A page that is not an entry (an archive, a term page, an author page, a date page, the front page) is always a website. To make a hierarchical type an article, or a flat one a website, set seoType where the type is registered:

ctx.registerEntryType("guide", {
label: "Guides",
isHierarchical: true,
seoType: "article",
});

A page that was not found is the exception: it carries the robots directive and nothing else from this plugin — no description, no og:*, no twitter:*, no verification tags and no structured data. The URL resolved to nothing, so there is no page to describe or share. The directive still follows Index pages that were not found.

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.

A page whose document sets canonical: false gets neither core’s canonical nor this plugin’s: no <link rel="canonical">, no og:url, and a WebPage in the graph with no url. The graph’s identifiers still hang off the request’s own address, so its pieces keep referencing each other. A canonical the page declares itself in link, or an editor’s canonical override, still renders, and og:url and the WebPage url follow it.

When a social image resolves, og:image is emitted with og:image:width, og:image:height, twitter:image and — where the image says what it shows — og:image:alt and twitter:image:alt beside it. They travel as a group: a theme that declared its own og:image keeps the whole group, because a size or a description appended next to it would describe some other picture.

The alt text is the media row’s own: an image chosen through the featured or ogImage role carries it, so filling in a picture’s alt text in the media library is what puts it in the head. A generated card, a URL typed into the SEO box and the site-wide default have none, and those pages carry neither alt tag rather than an empty one.

Five links resolve one og:image, in this order:

  1. The entry’s explicit choice — the entry’s ogImage image role.
  2. The social image URL typed into the SEO box below.
  3. Whatever a seo.ogImage handler returns — where @plumix/plugin-og puts a generated card.
  4. The entry’s featured photo — its featured role.
  5. 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 handler receives the featured photo in its input 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.

An image a subscriber returns may carry an alt of its own, which is then the one the head emits — a crop of the author’s photo still shows what the photo showed, so passing its alt along keeps the description with the picture.

ctx.filters.add(seo.ogImage, (image, { data, 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" };
});

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 Web page, or 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 can be an article, 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.

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.

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:

plumix.config.ts
seo({ metaBox: { exclude: ["landing_page"] } });

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 view The page is a view the site did not name in indexViews.
7 paginated Page 2 or beyond of an archive.
8 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. search_results, paginated and not_found 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.

A view is an app page such as /login or /account, usually different for every visitor, so the plugin marks every view noindex and lists none in the sitemap or llms.txt. A site that wants one offered to search engines names it in indexViews:

plumix.config.ts
seo({ indexViews: ["compareShare"] });

A name that no plugin registered with registerView fails the boot, naming it. An indexed view still carries no automatic canonical link; the view’s template declares one in document() when the page has one address.

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.

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 · Demo

Unless the site set structuredData: false, 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 article (a website entry, an archive, a term, an author, a date) carries the site-level pieces and no article or author. The author piece carries the byline’s name and, where the site serves author archives, the archive’s url. 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.

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.filters.add(seo.schema.needs, (needed, { name, facts }) =>
name === "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.filters.add(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.filters.add(seo.schema.graph, (graph, { facts }, appCtx) => [
...graph,
{ "@type": "Product", "@id": `${appCtx.origin}/#product`, name: "A thing" },
]);

An article entry’s article piece is an Article unless the editor says otherwise. The Content type field on the SEO box offers Web page, which makes the entry a website with no article piece, then Article, BlogPosting, NewsArticle and TechArticle, which make it an article of that type. Left unset, the entry follows its type. A stored value outside that roster is not an answer and the type’s own answer stands. An Article subtype retypes the piece and nothing else: the @id and every reference to it stay where they were.

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.

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:

theme/post.tsx
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.

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 (unless the site set llmsTxt: false).

/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 the seo.robotsTxt filter, so a plugin can append Sitemap: lines, a crawl-delay or per-agent rules without owning the route:

ctx.filters.add(
seo.robotsTxt,
(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 sitemap a plugin contributed with registerSitemap — paged at 1,000 URLs each. A scope with nothing published is left out.

Each <sitemap> entry carries a <lastmod> where the plugin knows one. An entry type’s page carries the newest updatedAt among the entries on that page. Its pages and their dates come from one pass over the type’s entries in id order, one query per entry type however many pages it has. A contributed page carries what its source’s lastmod answers. A taxonomy’s page carries none, since a term stores no modification time.

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>.

A contributed scope answers to the site’s sitemaps policy. A source listing permalinks of a noindex type therefore advertises pages that say noindex until the site sets its scope to false there.

Each scope’s sub-sitemap answers at a URL named by its kind, so an entry type and a taxonomy sharing a name each get one:

Scope URL
An entry type /sitemap-entries-<type>-<after>.xml
A taxonomy /sitemap-terms-<taxonomy>-<page>.xml
A plugin’s sitemap /sitemap-<name>-<page>.xml

An entry type’s page is named by the id it starts after, not by its number: the first is /sitemap-entries-post-0.xml, and the next starts after the last id the first lists, such as /sitemap-entries-post-1042.xml. A page reads the 1,000 entries after that id, so a late page costs what the first does. Take the URLs from the index rather than building them. A seo.sitemap.urls subscriber gets that id as after for an entry type’s scope, and the 1-based page number as page for any other, so it checks which with "after" in at.

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 index’s pages and the pages it names agree. The plugin keeps the ids of entries marked Hide from search engines in a table of its own, seo_noindexed_entries, updated when an editor saves the field, so the sitemap looks them up instead of reading every entry’s meta. plumix migrate creates the table and fills it from the entries already marked. An entry whose meta is written straight to the database, around Plumix, is not recorded until it is next saved or published. entries and terms belong to seo, so a registerSitemap call naming entries or terms, or a name starting entries- or terms-, fails the boot naming its plugin. Two plugins contributing one name fail the boot naming both.

An entry type with an access policy gets its scope like any other, and each entry is listed when its policy lets anonymous visitors list it. The sitemap asks that for anonymous visitors whoever requests it, so a signed-in member fetching /sitemap.xml sees what a crawler sees. A members-only entry is never listed. A soft-challenged entry is, since its page answers 200 with a teaser. The check is part of the same WHERE, so a page of 1,000 URLs is still full.

Scopes are enumerated when the theme is ready, so the routes the plugin claims are exactly the scopes the site registered — /sitemap-entries-nope-0.xml is a 404, not an empty document, and a page segment that is not a page number, or for an entry type an id, 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.

The site decides which scopes its sitemap lists and what their URLs’ <changefreq> and <priority> default to — its own scopes and every plugin’s — in the sitemaps option. Entry types sit under entries, taxonomies under terms, and a plugin’s contributed sitemap under its own name:

seo({
sitemaps: {
entries: { post: { changefreq: "weekly", priority: 0.8 }, page: false },
terms: { category: { changefreq: "monthly" } },
location: { changefreq: "daily" },
},
});

false leaves a scope out: it is absent from the index and its sub-sitemap serves an empty <urlset>. That is about the sitemap only — a page of a dropped type still says index in its head, which the Hide from search engines settings decide. A policy object is the default for every URL in the scope. changefreq takes the sitemaps.org vocabulary (always, hourly, daily, weekly, monthly, yearly, never), and priority is 0.0–1.0, which the plugin does not clamp. A key that names no scope fails the boot, naming the key — a misspelt name, and also a registered type the sitemap never lists because it is not public, since its policy would never apply.

For one URL, a value its scope’s provider set — a contributed source’s urls — beats the site’s default, and the seo.sitemap.urls filter below sees the merged values and has the last word. A URL that ends up with neither field writes neither element. Google ignores both fields; they are there for parity with an existing sitemap and for crawlers that read them.

The site-wide indexing toggle still empties every scope, a plugin’s included.

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 entry’s image roles, one image per role its type declares a field in — the same roles 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 roles 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 a role 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 nothing here sniffs a payload to decide: the adapter that produced a reference answers whether it is an image, so a role field holding a PDF resolves to nothing. A role holds one image, so an entry lists at most one picture per role it declares. Relative URLs — the worker-proxied path a private bucket resolves to — are made absolute, since <image:loc> has to be.

The seo.sitemap.urls filter adjusts one scope’s page before it is serialized. Its input holds the scope as { kind, name } — kind is "entries", "terms" or "contributed" — and the 1-based page, and the request context follows, so a handler can query for rows to inject rather than only reshape what is there:

ctx.filters.add(seo.sitemap.urls, (urls, { scope, page }, appCtx) => {
if (scope.kind !== "entries" || scope.name !== "post" || page !== 1) {
return urls;
}
return [...urls, { loc: `${appCtx.origin}/handbook` }];
});

A URL can carry changefreq and priority too, so a subscriber can set them per URL, after the site’s policy has been applied:

ctx.filters.add(seo.sitemap.urls, (urls, { scope }) =>
scope.kind === "entries" && scope.name === "insight"
? urls.map((url) => ({ ...url, changefreq: "weekly", priority: 0.8 }))
: urls,
);

A plugin adds a URL space of its own to the index with ctx.registerSitemap(name, source), called during setup. The URLs need no archive behind them, so a plugin that serves pages from routes of its own lists them here. count drives the index’s pagination without a URL scan, urls answers one 1-based page, and lastmod, if given, answers the ISO-8601 time of the newest URL on a page for that page’s index entry:

ctx.registerSitemap("locations", {
count: (appCtx) => countLocations(appCtx),
urls: (appCtx, page) => locationUrls(appCtx, page),
lastmod: (appCtx, page) => newestLocationUpdate(appCtx, page),
});

Its sub-sitemap answers at /sitemap-locations-<page>.xml. registerSitemap exists on the setup context only while seo is installed, so a plugin that calls it declares @plumix/plugin-seo as an optional peer. An archive registered with registerArchiveType that wants a sitemap calls it the same way, under the archive’s name. Seo does not read the archive, so a source whose URLs answer to an access policy names it as access, such as the one the archive was registered with:

ctx.registerSitemap("courses", {
count: (appCtx) => countCourses(appCtx),
urls: (appCtx, page) => courseUrls(appCtx, page),
access: membersPolicy,
});

Seo asks that policy for an anonymous visitor, whoever requests the sitemap. When it refuses, the scope is left out as a false policy leaves it: it is absent from the index, its sub-sitemap serves an empty <urlset>, and count, lastmod and urls are never called. A policy that grants or soft-challenges keeps the scope listed. A source with no access lists whatever its urls answer, so it lists only URLs the public may see.

Sitemap responses declare public, max-age=0, s-maxage=86400, a day at the edge, and go through the CDN when one is configured. Each scope is stored under what it read — an entry type’s entries through compileEntryQuery, a taxonomy’s terms through taxonomyTermRows — so publishing a post retires the post scope, and creating or deleting a category retires the category scope, and each 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. /llms.txt declares an hour instead (s-maxage=3600) and reads each entry type it lists through compileEntryQuery too, so an entry write, a change of access choice included, retires it with the sitemap page that lists the entry. A seo.sitemap.urls subscriber that adds rows from its own data reads them with ctx.cache.reads(key), so its ctx.cache.changed(key) retires the sitemap pages those rows joined.

A contributed source is stored under whatever its count, urls and lastmod read: entries through compileEntryQuery, a taxonomy’s terms through taxonomyTermRows, and your plugin’s own data through ctx.cache.reads, which names your plugin’s namespace, so your ctx.cache.changed retires the sub-sitemap. A source that reads neither rides the one-day window instead of a purge.

With no CDN configured, every sitemap is generated per request.

/llms.txt is the llmstxt.org convention: a small Markdown file naming the site, its tagline and what is worth reading. Unless the site set llmsTxt: false, the plugin serves the site title and tagline from the Site identity group — falling back to the host when no title is set — then one section per public entry type, and links the sitemap index last.

Each section is headed by the type’s plural label and lists its 50 newest entries, newest first, one line each:

## Posts
- [Hello world](https://example.com/post/hello-world): The entry's excerpt.
- [An entry with no excerpt](https://example.com/post/bare)

An entry is listed by the sitemap’s rule, asked for anonymous visitors: published, not marked Hide from search engines, and listable under its access policy. A type held out of the index by default gets no section, and neither does a type with nothing listed. The sitemaps option does not apply here, and views and terms are not listed. A blank title reads Untitled, and the line ends at the link when the excerpt is empty. No entry ever contributes its body, so a soft-challenged entry gives its excerpt like any other. The 50-per-type cap is fixed; reshape the file through the filter below.

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 the entry sections and the map. 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.filters.add(
seo.llmsTxt,
(body) => `${body}\n## Docs\n\n- [Handbook](https://example.com/handbook)\n`,
);

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 — an author 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,nofollow on every page, disallows all crawling in robots.txt, empties the sitemap and withholds the llms.txt map.
  • Default social image URL — the last link of the chain above.
  • This site represents — Organization or Person, which is what the graph’s publisher piece 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.txt and withholds the llms.txt map.
  • 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 setup gets its own. A type registered later — from the afterSetup of 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 except one that was not found, under the name that engine reads:

Field Meta name
Google google-site-verification
Bing msvalidate.01
Yandex yandex-verification
Baidu baidu-site-verification
Pinterest 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.

A theme’s document declaration is merged before this plugin’s filter runs, so anything it sets survives:

theme.ts
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.

Overview covers installing a plugin and the version track this one is on. OG Cards contributes the generated card that sits at link 3 of the chain above, and needs this plugin installed to reach a page’s head.

The featured and ogImage roles the chain reads are filled by meta-box fields — Meta Boxes covers declaring them and Image Roles what a role is and how to add one of your own. 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.ogImage handler receives.

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.