What a site needs before anybody can look at its blog: this extension's live components, nine sample pages built out of them, and the sidebar saved as a snippet an author can drop anywhere.
Why a production site needs this at all
MishkaBlog.Kit.install!/1 writes the components, and until now its only caller was the dev seed.
A real install went 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 blog components in it and there was no page to put them
on. ensure!/1 is that missing step, and it runs in production two ways:
MishkaCmsCore.Install.Extensions fires installer:extension:install, MishkaBlog.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 blog 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 "Blog" at an address of its own, which is exactly whatMishkaCmsCore.Dev.Seedswrites at/blog, gets/:lang/blogunder 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.
Nothing is ever updated, so nothing an author touched can be lost. What a site that was set up
months ago does about the sample pages having moved on since is
MishkaCmsCore.Runtime.PageSamples, behind the same card as the component update: it 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 both read the pages from.
WHICH MEANS A SITE PROVISIONED TODAY CAN GET PAGES NEWER THAN ITS COMPONENTS. The pages below are
written against the bundle in this checkout, and a site that already has the components keeps the
ones it has — so an attribute added to a component since that site was seeded reaches a row that
does not declare it, and MishkaCmsCore.Runtime.Compilers.ComponentCompiler sweeps every
undeclared key 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. Such a
site takes the newer components from the "Blog components" card on the blog dashboard, which is
MishkaCmsCore.Runtime.Handlers.UiKitUpgrade: it says which rows are behind the bundle and which
of them carry an edit, and writes only the ones the admin chooses. What it does NOT do is what
MishkaBlog.Kit.install!/1 does — upsert every component the bundle ships over whatever is there,
the dev seed's own call — and neither does the button here, because an admin asking for pages has
not asked to lose an edit. MishkaBlog.ProvisionTest holds the templates and the bundle together;
no test can see a row installed months ago.
The pages, and why they look the way they do
Nine pattern pages in /:lang PATH mode, which is what seeded and starter content uses here:
/:lang/blog the search, the featured strip and the paged list
/:lang/blog/:slug one post, its conversation and what to read next
/:lang/blog/category/:slug the same screen, narrowed to that category's posts
/:lang/blog/tag/:slug the same screen, narrowed to that tag's posts
/:lang/blog/categories every category, as cards
/:lang/blog/tags the whole tag cloud
/:lang/blog/author/:slug one blogger, their teams and their posts
/:lang/blog/team/:slug one team, its members and their posts
/:lang/blog/people every team with its members, and the bloggers in noneWhat a page is drawn in
Every page is hung on the site's site-main layout (MishkaCmsCore.Runtime.SiteLayout), whose
header, bar and footer are the site's own: the logo, the language switcher, the sign-in menu and
the dark-mode switch; the blog's name with its four places (Posts, Categories, Tags and People),
which MishkaBlog.Plugins.PageFrame hands the layout; and the footer. A page therefore draws only
what lies between them, straight into the layout's column, with no container, width or side
padding of its own. The layout's header answers for the page's languages, which the same plugin
reads off the record a page shows.
The colours, type and radii of everything are the tokens of the site's theme, with a dark: twin
for each, and the screens are the design's: the search box with its count, the featured strip, the
row of categories above the cards, the pager that says which posts it shows, the article with its
conversation and what to read next, and the people with their posts. A site that skipped the
starter content has neither the layout nor the theme tokens the markup is written in
(bg-surface, text-dim, font-newsreader and the rest), and these pages are plain on it.
No static route in the host application's router answers any of them, which is the point of the
/:lang prefix: /mish-blog/* is hand-written marketing the user keeps, and a runtime page
underneath one of those routes would never be reached.
The bare /blog that prefix left free is NOT provisioned. A row there 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, which is why it was freed.
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/blog answers on a site that publishes Persian
and falls through to the 404 on one that does not, and the template reads the language it was asked
for from @page.language.
One article, one URL, and the links that follow from it
Every component on these pages is given language={@page.language}, blog-post-detail and
blog-post-comments included: a slug belongs to one edition, so /fa/blog/<english-slug> finds
nothing and says so rather than serving the English article inside a Persian, right-to-left page —
or publishing that article's conversation underneath the message saying it is not here.
That is also why the bases the components build links from are written with the capture still in
them — post_base="/:lang/blog", not "/" <> @page.language <> "/blog". The 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): /blog/<slug> in a first language the
site leaves unprefixed, /fa/blog/<slug> in Persian. A base with the page's own language baked in
would offer every other language's row under the language the reader is leaving. The languages of
a post itself are not a link of the page's: the layout's header lists them, from
MishkaBlog.Plugins.PageFrame, which gives each edition its own slug.
base, where a page has one, is the pager's and stays a real URL — nothing appends a slug to it,
and its ?page=2 has to be somewhere a reader could have typed.
What :slug means depends on the page, and only the page knows
One capture name carries a category on /:lang/blog/category/:slug, a tag on
/:lang/blog/tag/:slug, a blogger on /:lang/blog/author/:slug, a team on /:lang/blog/team/:slug
and a post on /:lang/blog/:slug — while a category list and a tag cloud both read params under
the same default selected_param to mark the row the reader is on. So a capture is handed only to
the component whose kind of row it holds, and the sidebar snippet, which an author drops on a page
this module knows nothing about, is handed none and marks none.
Every template stays inside what BOTH renderers assign: @page, @query_params,
assigns[:current_user] and assigns[:page_source]. The admin's Preview tab assigns no visitor, no
rows and no captures (see MishkaCmsCore.Runtime.RuntimePreview), so @current_user would raise
there and a nil slug reached by <> would too — hence assigns[:current_user],
assigns[:page_source] and || "" on everything captured.
Summary
Functions
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.
Gives site everything the blog ships and it is missing, and returns what happened as
%{permissions: names, components: names, pages: paths, snippets: names, renamed_pages: renamed, skipped_pages: held}, MishkaCmsCore.Runtime.ExtensionProvision.ensure!/4's report.
permissions is empty once any site has installed it, the next three are empty on a site that
already has it all, renamed_pages names every sample page written under a name of its own
because the one it asked for was taken, and skipped_pages names every one something already
there holds, so the caller can say which rather than leave it to the logs.
How many posts the featured strip on the blog's front page draws, and the list under it leaves
out: what MishkaBlog.PageSource reads for the two of them, once.
The paths of the sample pages that read one row each, by the kind of row their :slug capture
names — where a post, a category, a tag, a blogger and a team is read.
How many posts one page of the list holds on the blog's front page, a category's page and a
tag's: what MishkaBlog.PageSource reads of that list for its first paint, once.
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 places of the blog, in the order its bar lists them, each with the path of the page that is its front. A post, a category and a tag are in Posts, and a blogger and a team are in People.
The sample pages as this checkout ships them, as the attribute maps a page create takes.
What the sidebar snippet is called in the Studio's list.
Types
@type report() :: MishkaCmsCore.Runtime.ExtensionProvision.report()
Functions
@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.
Gives site everything the blog ships and it is missing, and returns what happened as
%{permissions: names, components: names, pages: paths, snippets: names, renamed_pages: renamed, skipped_pages: held}, MishkaCmsCore.Runtime.ExtensionProvision.ensure!/4's report.
permissions is empty once any site has installed it, the next three are empty on a site that
already has it all, renamed_pages names every sample page written under a name of its own
because the one it asked for was taken, and skipped_pages names every one something already
there holds, so the caller can say which rather than leave it to the logs.
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.
@spec featured_strip() :: pos_integer()
How many posts the featured strip on the blog's front page draws, and the list under it leaves
out: what MishkaBlog.PageSource reads for the two of them, once.
@spec item_paths() :: %{ post: String.t(), category: String.t(), tag: String.t(), author: String.t(), team: String.t() }
The paths of the sample pages that read one row each, by the kind of row their :slug capture
names — where a post, a category, a tag, a blogger and a team is read.
Provision.item_paths().post
#=> "/:lang/blog/:slug"
@spec list_page() :: pos_integer()
How many posts one page of the list holds on the blog's front page, a category's page and a
tag's: what MishkaBlog.PageSource reads of that list for its first paint, once.
@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.
@spec places() :: [{:posts | :categories | :tags | :people, String.t()}]
The places of the blog, in the order its bar lists them, each with the path of the page that is its front. A post, a category and a tag are in Posts, and a blogger and a team are in People.
Provision.places()
#=> [posts: "/:lang/blog", categories: "/:lang/blog/categories", tags: "/:lang/blog/tags",
# people: "/:lang/blog/people"]
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 sidebar snippet is called in the Studio's list.