What a site needs before anybody can read its documentation: this extension's live components, three sample pages built out of them, and the "latest documents" block saved as a snippet an author can drop on a home page.
Why a production site needs this at all
MishkaDocument.Kit.install!/1 writes the components, and until now it had no caller at all. A
real install goes through MishkaCmsCore.Install.Bootstrap, which offers Chelekom and knows
nothing about this extension, and a site created later in the admin gets nothing until somebody asks — so the
builder's palette on those sites had no doc- components in it and there was no page to put them
on. Workspaces, versions, sections and notes existed in the admin with no address a reader could
reach them at. ensure!/1 is that missing step, and it runs in production two ways:
MishkaCmsCore.Install.Extensions fires installer:extension:install, MishkaDocument.Plugins.Installer
answers to its own key, and this is what it runs. Two screens fire it: the install wizard, for the
extensions an operator ticked, and the "Set up documentation pages" button on this extension's dashboard,
for a site that already exists.
Idempotent, and that is the whole design
Running this twice must be indistinguishable from running it once, because both callers can run it again: an admin can press the button whenever they like, and the wizard and the button can both reach a site again.
- Components. Only the ones the site has no row for at all — active or archived — are
installed, through
selection: %{components: missing}.MishkaCmsCore.Runtime.Handlers.UiKitHandler.install_kit!/2UPSERTS, so handing it a name the site already has would overwrite whatever the admin edited into it, silently. A component the admin archived stays archived for the same reason: it is there, and the admin put it where it is. - Pages. Only where every identity the page row has is free, which
MishkaCmsCore.Runtime.PageProvisionreads off the resource rather than this module naming them. A held ADDRESS is a page left to whoever holds it while the rest of the run happens; a held NAME is not — a site holding a page called "Documentation" at an address of its own gets/:lang/docsunder the next free name and is told which. An archived page counts as holding both, which is stricter than the indexes are — they are partial on(archived_at IS NULL)— because a page an admin archived is an answer, not an absence. - The snippet. Only when nothing of that name is saved for this site's page editor.
- The bar's words. Registered in the site's translations
(
MishkaDocument.Plugins.PageFrame.words/0), where a row that is there is left as it is.
Nothing is ever updated, so nothing an author touched can be lost. A component or a sample page
that has moved on since the site was set up is the "Documentation components" card's, on the
document dashboard: MishkaCmsCore.Runtime.Handlers.UiKitUpgrade says which component rows are
behind the bundle and which of them carry an edit, and writes only the ones the admin chooses,
and MishkaCmsCore.Runtime.PageSamples offers each outdated page as a DRAFT of itself, which the
admin previews in the builder and publishes when they want it. sample_pages/1 is what the run
and the card read the pages from.
The pages below are written against the bundle in this checkout. An attribute a page hands a
component whose row does not declare it is swept by
MishkaCmsCore.Runtime.Compilers.ComponentCompiler into the component's global attr: it is
rendered onto the element as an HTML attribute, read by nothing, and the behaviour the page asked
for is silently the old one. A site whose components are behind the bundle takes the newer ones
from that card. What neither the run nor the card does is what MishkaDocument.Kit.install!/1
does, upsert every component the bundle ships over whatever is there.
MishkaDocument.ProvisionTest holds the templates and the bundle together.
The pages, and why they look the way they do
Three pattern pages in /:lang PATH mode, which is what seeded and starter content uses here:
/:lang/docs every document the site publishes
/:lang/docs/:workspace one document's front page and its contents
/:lang/docs/:workspace/*section one page of it, with its notes — headless/switch for the
page switch filed under headless (`MishkaDocument.Address`)A document given an address of its own is read there instead, on two pages of the same design
written when the address is set — see ensure_address!/3.
No static route in the host application's router answers any of them — the hand-written marketing
the user keeps now sits under mish-, /mish-chelekom/docs and
/mish-cms/extensions/documentation — and the /:lang prefix is what guarantees it stays that
way. MishkaCmsWeb.DocumentProvisionRoutesTest derives the router's own list rather than
repeating it here.
The bare /docs is NOT provisioned, and the prefix is not what made it free: no static route
ever answered /docs, which is why MishkaCmsCore.Dev.Seeds could already publish a page there.
A row of ours could not fill the :lang segment the bases below are built from, so every link
off it would name a language nobody asked for; and the address is the site's own to publish at.
ONE ROW PER PAGE, not one per language. :lang is a capture, and MishkaCmsCore.Runtime.Languages
admits only the site's own languages in it — so /fa/docs answers on a site that publishes Persian
and falls through to the 404 on one that does not.
One document, one URL
Every block is given lang={@page.language} and a params map the language has been taken out
of — the snippet included, on a page belonging to somebody else — which is one rule rather than
two: a page's language is read off the page, and no block is left to find it in a query string.
MishkaCmsCore.Runtime.PageEditions.language/3 is what makes that one rule enough for a page this
module has never seen: it is the :lang capture where the path has one and the page row's own
language where it has none, so there is no page whose language a block has to guess at. A
workspace slug belongs to ONE edition, so /fa/docs/<an-english-slug> draws "That page is not
published." rather than the English document inside a Persian, right-to-left shell.
Handing @query_params through whole would work today — MishkaCmsWeb.RuntimeLive merges the
captured lang over the query string precisely so /fa/docs?lang=xyz cannot undo the check that
let the page answer — but it would work by that precedence rather than by anything these pages
say, and a block would then be taking its language from a key a visitor also writes. lang_param
is left at its default, because on a base that names the language as a segment it decides nothing:
doc_href/5 carries the code in the path and writes no query key at all.
The bases the blocks build links from keep the capture IN them — doc_base="/:lang/docs", not
"/" <> @page.language <> "/docs". A component fills the :lang segment with the language of the
ROW it is linking to, which is the language the reader will be reading in when they arrive, and
builds the address the site's URL mode gives it (site_path/2,
MishkaCmsCore.Runtime.LiveViewHelpers): /docs/<slug> in a first language the site leaves
unprefixed, /fa/docs/<slug> in Persian. A base with the page's own language baked in would offer
every translation under the language the reader is leaving.
base, which only the index's pager has, stays a real URL: nothing appends a slug to it, and its
?page=2 has to be somewhere a reader could have typed.
The header, the footer, the language nav and the bar are the layout's
Every page below hangs on the site's site-main layout when the site has one, and draws only what
the design shows between its header and its footer, straight into the layout's column
(MishkaCmsCore.Runtime.SiteLayout): no container, no max-w-* and no side padding of its own,
since the column is 1160 pixels with its gutters, and a second one would double them. The logo,
the Docs and GitHub links, the sign-in menu, the dark-mode switch and the language switcher are
site-header's, and the footer is site-footer's.
The documentation has no switcher of its own. The header lists the languages a page is in
(@page_languages), and on the library, /:lang/docs, that is this same page's URL with the
lang capture swapped, which is exactly right. On the two document paths it is not: the
:workspace and :section captures are slugs of one edition, and the Persian edition of a
document has Persian slugs, so the core's default would offer /fa/docs/<english-slug>, a URL
its own page answers as not published. MishkaDocument.Plugins.PageFrame answers for them on
runtime:page:frame: the languages the document or the page really exists in, each at its own
address, and the bar under the header — Docs, the document and the page as a trail, and the
version in a menu (doc-switcher). A page of a document at an address of its own
(ensure_address!/3) is answered the same way.
Every template stays inside what BOTH renderers assign: @page, @query_params and
@page_languages. The admin's Preview tab assigns no visitor and no captures (see
MishkaCmsCore.Runtime.RuntimePreview), which is why no URL here is built from a capture — only
from @page.language, which a preview does assign.
Summary
Functions
The names actor is missing to give a document at address its pages on site_id — [] when
the site already has a page at both paths, so nothing would be written.
The bundle version these pages are written against, stamped onto every page this writes.
The component names this extension's bundle ships, read off the bundle itself.
The base the documents with no address of their own are read under, by their slug:
/:lang/docs. A document's own address may not be under it (MishkaDocument.Address.check/1).
Gives site everything the documentation ships and it is missing, and returns what happened as
%{permissions: names, components: names, pages: paths, snippets: names, renamed_pages: renamed, skipped_pages: held} — permissions empty once any site has installed it, the next three empty
on a site that already has it all, renamed_pages naming every sample page written under a name
of its own because the one it asked for was taken, and skipped_pages naming every one something
already there holds, so the caller can say which rather than leave it to the logs. The bar's
words are registered in the site's translations too (see "Idempotent" above).
Gives a document at address the two pages it is read at — its front page, and every page of it
(MishkaDocument.Address.page_paths/1) — on site, where the site has no page at that path yet.
A page the site already has at either path, its own design among them, is left as it is.
The paths of the two sample pages that read one document: :workspace its front page, :section
one page of it. Each is filled with a document's language and slugs by
MishkaCmsCore.Runtime.Languages.url/3 — what MishkaDocument.Plugins.SitemapUrls lists.
What this extension declares about itself, for
MishkaCmsCore.Runtime.ExtensionProvision — the bundle, the section its pages belong to, the
pages themselves and the snippet.
The paths of the sample pages, in the order they are created.
The sample pages as this checkout ships them, as the attribute maps a page create takes.
What the snippet is called in the Studio's list.
Types
@type report() :: MishkaCmsCore.Runtime.ExtensionProvision.report()
Functions
The names actor is missing to give a document at address its pages on site_id — [] when
the site already has a page at both paths, so nothing would be written.
@spec bundle_version() :: String.t() | nil
The bundle version these pages are written against, stamped onto every page this writes.
It is the KIT's version rather than the extension's: the pages and the components are one release
together — a page drawing a component the site has not got renders an error where the block should
be — and the kit is what MishkaCmsCore.Runtime.Handlers.UiKitUpgrade already versions.
@spec component_names() :: [String.t()]
The component names this extension's bundle ships, read off the bundle itself.
@spec docs_base() :: String.t()
The base the documents with no address of their own are read under, by their slug:
/:lang/docs. A document's own address may not be under it (MishkaDocument.Address.check/1).
Gives site everything the documentation ships and it is missing, and returns what happened as
%{permissions: names, components: names, pages: paths, snippets: names, renamed_pages: renamed, skipped_pages: held} — permissions empty once any site has installed it, the next three empty
on a site that already has it all, renamed_pages naming every sample page written under a name
of its own because the one it asked for was taken, and skipped_pages naming every one something
already there holds, so the caller can say which rather than leave it to the logs. The bar's
words are registered in the site's translations too (see "Idempotent" above).
Takes the site record the hook is handed, or its id. Raises when the site is not there, or when a write is refused for any reason but a page being taken.
opts takes :actor — whoever asked for this. Every row of the site is then written as them,
through the action their own role may use, and the resource's policy answers for it; the
permission names, shared by every site, are written by the system. Leave it out and the whole run
writes as the system, which is what the installer wizard and the dev seeds do: they have no
session behind them. See MishkaCmsCore.Runtime.ExtensionProvision.ensure!/4.
Gives a document at address the two pages it is read at — its front page, and every page of it
(MishkaDocument.Address.page_paths/1) — on site, where the site has no page at that path yet.
A page the site already has at either path, its own design among them, is left as it is.
Written as opts[:actor], who needs what MishkaCmsCore.Runtime.ExtensionProvision.required/1
names; leave it out and the system writes. Returns the report ensure!/3 gives.
The paths of the two sample pages that read one document: :workspace its front page, :section
one page of it. Each is filled with a document's language and slugs by
MishkaCmsCore.Runtime.Languages.url/3 — what MishkaDocument.Plugins.SitemapUrls lists.
@spec manifest() :: MishkaCmsCore.Runtime.ExtensionProvision.manifest()
What this extension declares about itself, for
MishkaCmsCore.Runtime.ExtensionProvision — the bundle, the section its pages belong to, the
pages themselves and the snippet.
pages and snippet.source take the SITE because a stored <.component> call names the site
whose component it draws, so the markup cannot be written until the site is known.
@spec page_paths() :: [String.t()]
The paths of the sample pages, in the order they are created.
The sample pages as this checkout ships them, as the attribute maps a page create takes.
ensure!/1 writes the ones the site has no row for; MishkaCmsCore.Runtime.PageSamples compares
the same maps against the rows a site already has, which is how an admin is offered a newer
version of a page they are already serving. Both read them from here, so the two can never be
describing different pages.
Takes the site record or its id, and the actor to read it as — nil for a caller with no session
behind it.
@spec snippet_name() :: String.t()
What the snippet is called in the Studio's list.