MishkaBlog.Provision (Mishka Blog v0.0.1)

Copy Markdown View Source

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!/2 UPSERTS, 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.PageProvision reads 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 what MishkaCmsCore.Dev.Seeds writes at /blog, gets /:lang/blog under 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 none

What 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

Functions

bundle_version()

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

component_names()

@spec component_names() :: [String.t()]

The component names this extension's bundle ships, read off the bundle itself.

ensure!(site, answers \\ %{}, opts \\ [])

@spec ensure!(map() | String.t(), map(), keyword()) :: report()

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.

item_paths()

@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"

list_page()

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

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.

page_paths()

@spec page_paths() :: [String.t()]

The paths of the sample pages, in the order they are created.

places()

@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"]

sample_pages(site, actor \\ nil)

@spec sample_pages(map() | String.t(), map() | nil) :: [map()]

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.

snippet_name()

@spec snippet_name() :: String.t()

What the sidebar snippet is called in the Studio's list.