A site's sitemap, built ahead of the request with Sitemapper and kept in
MishkaCmsCore.Runtime.States.AssetsState under {site.name, "sitemap"} as
%{index: index, files: files}. MishkaCmsCoreWeb.SeoController serves it and never builds it.
What goes in it
Its addresses, each once whoever lists it, the first to name a path keeping it, and none ending in
a slash — /blog/ and /blog are the one /blog, and the home page is the site's address alone,
https://example.com:
- the site's pages a search engine may be sent to
(
MishkaCmsCore.Runtime.PageEditions.indexable/1), each at its public addresses (PageEditions.urls/2): a literal page at its one, and a page whose only capture is the language at one per language it answers (PageEditions.served_languages/2); - the site's
seo.sitemap.pathssetting; - what a plugin on
seo:sitemap:urlsadds, below.
Every address is the one a visitor is served at (MishkaCmsCore.Runtime.Languages.url/3): on a
site that leaves its primary language unprefixed, /en/pricing, whoever lists it, is listed as
/pricing, so no address in the sitemap redirects.
None is listed at the path of a literal page indexable/1 leaves out — a login-only or a noindex
one — whichever of them names it: the resolver answers that page there before any pattern. Nor at
an address the page that serves it names permissions for, a pattern page included
(MishkaCmsCore.Runtime.PageEditions.locked_ids/1, read when the sitemap is built): a plugin's
/blog/hello is left out while /blog/:slug is locked, since a crawler is answered the 404
there. Nor at
a path a route in front of the pages answers — every :public GET or live route of the
configured extensions (MishkaCmsCore.ExtensionRouter), /robots.txt, /sitemap.xml and this
sitemap's own files among them — since a page stored there is never served. A path that does
not start with / is left out too.
Every address is absolute, on the site's host (MishkaCmsCore.Helpers.site_url/2), and so is
every image: one at a path on the site is made absolute there, one with no scheme
(//cdn.example.com/a.jpg) takes the site's, an http(s):// one is kept as it is, and anything
else, or one with no host, is left out. What a URL may not hold as it is — a space, a control
character, anything past ASCII — is percent-encoded in both, and an escape already in them is
kept, so no address breaks the file it is in. A lastmod is written to the second.
What is stored
files—"sitemap-00001.xml.gz","sitemap-00002.xml.gz"… by name, each a gzipped<urlset>of up to 50,000 addresses and 50 MB, served at/<name>as it is stored. At the root, because a sitemap may list only the addresses under the folder it is served from;index— the plain<sitemapindex>of them, served at/sitemap.xml, each file listed athttps://<host>/<name>with the time this node first stored those files as itslastmod.
A build that makes the files already stored keeps what is stored, index and all, so a file's
lastmod moves only when its bytes do, and a rebuild that changed nothing — a layout's, a plugin
chain's — tells a crawler nothing changed. Gzip writes no time into a file, so the same addresses
make the same bytes. A node that starts again dates its files from its first build.
A site with nothing to list has no file, and its index is a <sitemapindex> with no <sitemap>
in it. The sitemaps.org schemas want at least one entry in an index and in a <urlset> alike, so
no document for an empty site validates against them, and Google reports either as an "Empty
sitemap" rather than failing to fetch it; the index keeps /sitemap.xml the same kind of document
at every size, and a <urlset> listed in it would be a second empty one.
seo:sitemap:urls
A plugin is handed %{site: site, pages: pages, urls: urls}:
site— the site asMishkaCmsCore.Runtime.States.SiteStateholds it;pages— the list of the site's indexable pattern pages holding a capture only their owner can fill,/:lang/blog/:slug;urls— the addresses so far, anEnumerableof%{path: String.t(), lastmod: DateTime.t() | nil, images: [String.t()] | nil},imagesoptional.
It returns the map with its own addresses after them, as a stream read a batch at a time, so a
site of any size is built without holding its rows — stream_rows/2 reads a query that way:
def call(%{site: site, urls: urls} = data),
do: {:reply, %{data | urls: Stream.concat(urls, posts(site))}}
defp posts(site) do
MyExt.Post
|> Ash.Query.for_read(:public_list, %{}, tenant: site.id, authorize?: false)
|> Ash.Query.select([:slug, :updated_at, :cover])
|> SitemapCompiler.stream_rows(tenant: site.id, authorize?: false)
|> Stream.map(&%{path: "/posts/" <> &1.slug, lastmod: &1.updated_at, images: [&1.cover]})
endA path listed twice — by core and a plugin, by two plugins, or by one plugin twice, with or without a slash at its end — is listed once, the first time it comes: a build remembers every path it wrote while it reads the stream.
Nothing is read while the chain runs: the build reads the stream once, after it, so a stream that
raises then fails the build — the job is retried as Oban retries any, and the sitemap stored
before is served meanwhile. A chain that ends in a urls that is not an Enumerable is ignored,
and core's own addresses are built alone.
When it is built
At boot, by every node for every open site, two at a time
(MishkaCmsCore.Runtime.Handlers.BootHandler.build_sitemaps/0). After that, by the site's
"sitemap" job, on a :sitemaps queue of its own, that schedule/1 enqueues when what it lists
changes:
- a page or a layout —
MishkaCmsCore.Runtime.Workers.RuntimeCompilerWorker, after its own; - the site switched on, renamed or installed —
BootHandler.compile_site/2; its host —MishkaCmsCore.Runtime.Resources.Changes.ScheduleSiteRuntime; - its languages, how its addresses name them, or its
seo.sitemap.paths, and a row an extension lists —MishkaCmsCore.Runtime.Resources.Notifiers.SitemapRefresh; - a
seo:sitemap:urlsplugin started or stopped — every open site's (attach/0).
A site has one such job pending at a time — a build that failed and waits to retry counts — and each enqueue moves it later, so a burst of changes is one build. A build is given ten minutes and three tries. A request that finds no sitemap enqueues it too, but leaves a pending or running one where it is, so requests coming faster than it is due cannot keep putting it off or start a second.
An extension whose seo:sitemap:urls plugin lists rows of its own resources adds
MishkaCmsCore.Runtime.Resources.Notifiers.SitemapRefresh to those resources' notifiers:, or
calls schedule/1 with the row's site where no notifier sees the change.
Each node keeps its own copy. A job rebuilds it only on the node that runs it, so another node
serves its previous one until its own next build (ROADMAP_DISTRIBUTED_CMS.md, row 17).
Hooks
runtime:compile:before_sitemap—%{site: site_name}, before a build;seo:sitemap:urls— the addresses a plugin adds, above;runtime:compile:after_sitemap—%{site: site_name, urls: count, files: count}, once it is stored or the stored one is kept.
Storing a changed one fires runtime:state:assets:changed (AssetsState.save/1).
Summary
Functions
Attach the rebuild to mishka_installer's compile telemetry. Idempotent.
Builds the sitemap of the site named site_name and stores it in this node's AssetsState.
Enqueues the "sitemap" job of the site site_id names, or of every open site for nil — a
global row.
Every row query reads, as a stream that reads 100 at a time: each read starts after the
last id the one before it returned, one indexed step however many rows come before it, where an
offset would walk them all again on every page.
Types
Functions
@spec attach() :: :ok
Attach the rebuild to mishka_installer's compile telemetry. Idempotent.
Builds the sitemap of the site named site_name and stores it in this node's AssetsState.
{:error, :no_site} when SiteState holds no open site by that name, before the build or once it
is done: a site switched off, removed or renamed while it was built has nothing stored.
@spec schedule(String.t() | nil, keyword()) :: :ok | {:ok, Oban.Job.t()} | {:error, term()}
Enqueues the "sitemap" job of the site site_id names, or of every open site for nil — a
global row.
A switched-off or unknown site has nothing to build, and is :ok. So is every call under the
:env_test? flag, as for every other runtime job.
Options
:replace— what an enqueue changes on the site's job already pending, as Oban's:replace. Unset, it moves the job later;[]leaves it where it is.:states— the states of a job already there that this enqueue leaves as it is instead of adding one, as Oban'sunique: [states: ...]. Unset, a pending one or one waiting to retry;:incompletea running one too.
@spec stream_rows(Ash.Query.t(), keyword()) :: Enumerable.t()
Every row query reads, as a stream that reads 100 at a time: each read starts after the
last id the one before it returned, one indexed step however many rows come before it, where an
offset would walk them all again on every page.
query is an Ash.Query.for_read/4 of an action that paginates, on a resource whose primary key
is id. It is read in id order whatever order it asks for, with id selected beside what it
selects. opts are Ash.read!/2's — the tenant, authorize?.