MishkaCmsCore.Runtime.SiteLayout (Mishka CMS Core v0.0.1)

Copy Markdown View Source

The one layout every page of a site is drawn in, site-main, and the contract through which a page, or the extension that owns it, hands that layout what only the page knows: the languages it is written in, and the bar under the header.

MishkaCmsCore.Install.StarterContent writes the layout on every site, the installer's first one, a site created later in the admin and every dev site alike. It draws, top to bottom:

  1. the site-header component: the logo, the links, the language switcher, the dark toggle and the signed-in visitor's menu;
  2. the section bar, section_bar/1, when the page has one;
  3. the flash messages;
  4. <main id="site-main">, the content column, holding the page;
  5. the site-footer component.

The sign-in pages are the exception: they hang on the auth layout of their own.

The width

The layout owns the content column. <main> is mx-auto w-full max-w-[1160px] flex-1 px-8 pb-24: the home design's 1160 px, its 32 px gutters, and the 96 px under the last section. The header, the bar, the messages and the footer use the same 1160 px and 32 px, so every edge lines up, on a phone as on a desktop.

So a page draws its sections straight into the column and NO outer container of its own: no mx-auto, no max-w-*, no side padding around the whole page. A container of the page's own doubles the gutters. What the page does own is its vertical rhythm, the room above its first section in particular: pt-16 on the blog's list pages, pt-[72px] on the home page.

<section class="flex flex-col gap-6 pt-16">
  <h1 class="font-newsreader text-[56px]">Notes, guides and long reads.</h1>
  …
</section>

A section that runs edge to edge, such as a band with a coloured or dotted background, adds site-bleed, a utility of the site's theme (MishkaCmsCore.Install.StarterContent). The section then reaches both edges of the window while what is inside it stays in the column, so it needs no container either:

<section class="site-bleed bg-wash pt-[72px] pb-24 dark:bg-wash-dark">
  <div class="mx-auto max-w-[820px] text-center">…</div>
</section>

A section with site-bleed is a direct child of the page, not one nested inside a narrower box: it measures the column it breaks out of.

What a page hands the layout

MishkaCmsWeb.RuntimeLive calls assign_frame/1 in every handle_params/3, after the page's language and @query_params are known and before its head is resolved. It fires event/0 with the map given/2 builds, reads the answer through accept/2 and assigns:

  • @page_languages: the languages this page is in, each with its own address. The header's language switcher lists them (it is hidden for fewer than two), and the head's hreflang links are built from the same list (MishkaCmsCore.Runtime.Seo).
  • @page_section_bar: the bar under the header, or nil for none.

A plugin is a MishkaInstaller.Event.Hook on event/0, registered by its application like any other. Its call/1 is handed this map and returns it, changed or not:

  • page: the :page section of the page module's __page_information__/0, with language the language of THIS response. Read only.
  • section: the page's section, page.section: what an extension files its pages under ("blog", "document"), as MishkaCmsCore.Runtime.ExtensionProvision writes it.
  • record: the kind of record the page shows, "blog:post", from its extra (MishkaCmsCore.Runtime.Seo.record_of/1), or nil for none.
  • extra: the page's extra.
  • params: @query_params, the path's captures ("slug", "lang") and the query.
  • language: the language of this response, page.language.
  • site_id: the site's id.
  • current_user: the signed-in visitor, or nil for a guest. A plugin reads the other editions as this visitor, so an edition only some readers may open is offered to them.
  • source: the rows the page's plugin on MishkaCmsCore.Runtime.PageSource.event/0 read for this request, as the visitor, or nil. A plugin that finds the record there reads it from there rather than asking again; one that finds nil reads for itself.
  • translations: what the layout shows when no plugin says otherwise, already normalised (translations/3).
  • section_bar: nil.

A plugin answers for the pages it owns: matched by record, by section, or by page.path. Every other page goes back untouched, and before anything is read. Nothing is cached across addresses: the event runs on every one, so a plugin reads what source does not hold with one query, and only for its own pages.

translations

The languages THIS record is written in, each at that edition's own address:

%{language: "ar", path: "/ar/blog/category/akhbar", name: "العربية", title: "الأخبار"}
  • language (required): the language code;
  • path (required): the public address of that edition: a / path on the site, built with MishkaCmsCore.Runtime.Languages.url/3 so it follows the site's URL mode, or an http(s):// address;
  • name: what the switcher calls the language; Languages.name/1 of language when left out;
  • title: the record's own title in that language, shown as the link's tooltip.

Include the edition being read: the switcher marks it as the current one. Leave out a language the record has no edition in: an English post with no Persian version answers en (and whatever else it has) and no fa, and the switcher offers no Persian. A language the site does not publish in is dropped, and so is an entry without a language or an address. Fewer than two languages left is [], and the switcher is not drawn.

What a plugin leaves untouched is today's behaviour: a page of the CMS's own is offered in each language it answers in, at the same path (MishkaCmsCore.Runtime.PageEditions.alternates/3). That is right for /:lang/blog, and wrong for /:lang/blog/:slug, where a post's translation has a slug of its own. So a page that shows a record answers for it here.

section_bar

nil, or a map of what the bar under the header shows. Everything in it is optional; a bar with nothing to show is not drawn:

  • title, title_path: the section's name, set in the home design's italic serif, and the address it links to;
  • label: what the links' <nav> is called to a screen reader; title when left out;
  • links: the section's places, drawn as the design's pill bar, [%{label: "Posts", path: "/blog", current: true}, …]; current marks the place the reader is in;
  • crumbs: a breadcrumb trail, [%{label: "Docs", path: "/docs"}, %{label: "Install"}], the last one being the page the reader is on;
  • component: something the bar draws on its trailing side, which may be interactive. A {module, function} function component, called with assigns; or the name of one of the site's own components (MishkaCmsCore.Runtime.LiveViewHelpers.component/1), called with assigns as its attributes. A stateful one is drawn inside the page's LiveView and handles its own events, and needs an id among them;
  • assigns: what component is handed.

Every address is a public one, as in translations.

The bar's own markup is the core's, and its classes are in every site's stylesheet (classes/0). The classes a component draws with have to reach the stylesheet too. A component of the site that a page or a layout names is already in it; one that only the bar draws, and an Elixir function component, is not. List it on the runtime:compile:content_components event: the component's name in components, or the function component's classes in classes (MishkaCmsCore.Runtime.Compilers.TailwindCompiler.content_components/1).

An example

The blog's categories, which are one row per language joined by language_group_id:

defmodule MishkaBlog.Plugins.PageFrame do
  use MishkaInstaller.Event.Hook, event: "runtime:page:frame", initial: %{priority: 50}

  alias MishkaCmsCore.Runtime.Languages

  @impl true
  def call(%{section: "blog"} = frame) do
    {:reply, :halt, %{frame | translations: translations(frame), section_bar: bar(frame)}}
  end

  def call(frame), do: {:reply, frame}

  # Every published edition of the category the slug names, the one being read included.
  defp translations(%{record: "blog:category", params: %{"slug" => slug}} = frame) do
    for category <- category_editions(frame.site_id, slug, frame.language) do
      %{
        language: category.language,
        path: Languages.url("/:lang/blog/category/:slug",
          %{"lang" => category.language, "slug" => category.slug}, frame.site_id),
        title: category.name
      }
    end
  end

  # A list page, `/:lang/blog`: the same path in every language is right.
  defp translations(frame), do: frame.translations

  # One read: the row the slug names in this language, then its `language_group_id`'s rows.
  defp category_editions(site_id, slug, language), do: …

  defp bar(%{language: language, site_id: site_id, page: page}) do
    url = &Languages.url(&1, %{"lang" => language}, site_id)

    %{
      title: "Blog",
      title_path: url.("/:lang/blog"),
      links: [
        %{label: "Posts", path: url.("/:lang/blog"), current: page.path == "/:lang/blog"},
        %{label: "Categories", path: url.("/:lang/blog/categories")},
        %{label: "Tags", path: url.("/:lang/blog/tags")},
        %{label: "People", path: url.("/:lang/blog/people")}
      ]
    }
  end
end

And the docs' bar, a breadcrumb with an interactive version picker beside it: the site's own doc-switcher component, named, handed the page's rows and its URL keys:

%{
  title: "Docs",
  title_path: "/docs",
  crumbs: [%{label: workspace.name, path: "/docs/guide"}, %{label: page.title}],
  component: "doc-switcher",
  assigns: %{id: "doc-version", page_source: source, params: params, lang: language}
}

{:reply, :halt, frame} keeps later plugins from answering for a page this one owns; {:reply, frame} lets them. A plugin that raises, or an event whose plugins cannot run, leaves the page with what given/2 holds.

Changing language without a reload

The header's switcher links an edition whose path is a path on the site with navigate, so a language change is a live navigation: the page, the header and the title swap in the open socket. An http(s):// address stays a plain href, a full load.

The root layout draws <html lang dir> once, with the HTTP response, so the layout itself keeps both right after any live navigation (the switcher, Back and Forward, another navigate link, a patch): a hidden element whose phx-mounted runs Phoenix.LiveView.JS.set_attribute/2 on html. Its id carries the language, so a patch from one language to another mounts it again. No hook and no script. A layout of a site's own that draws a switcher this way needs the same element; the starter's is the one to copy (MishkaCmsCore.Install.StarterContent). The page's <title> follows through live_title, and its hreflang, canonical and share tags through the mishka:head event that MishkaCmsWeb.RuntimeLive pushes.

Putting an extension's pages on the layout

layout_id/1 is the row to write into a page an extension creates itself, so the page is drawn between the header and the footer every page of the site shares.

Summary

Types

The bar under the header, as section_bar/1 draws it.

What a page hands its layout: @page_languages and @page_section_bar.

One language of a page, as the header's switcher and the head's hreflang read it.

Functions

The map a plugin on event/0 returned, read against the one it was given.

The page's frame, assigned to socket as @page_languages and @page_section_bar.

Every class section_bar/1 draws with, for whatever builds a site's stylesheet (MishkaCmsCore.Runtime.Compilers.TailwindCompiler).

The event a page's frame is resolved through, once per handle_params/3.

What page hands its layout: given/2, then the plugins on event/0, then accept/2. Takes the options given/2 takes.

The map event/0 is called with for page, before any plugin has answered. See the moduledoc for each key.

The id of site_id's site-main layout, or nil for a site that has none. An archived one is none: nothing is drawn in it.

The bar under the header, for the site-main layout

bar as section_bar/1 draws it: a bar/0 with every key present, or nil when it is not a map or holds nothing to draw.

entries as the switcher reads them: each a translation/0, one per language, in the order the site lists its languages (any other after them, by code), and [] for fewer than two.

Types

bar()

@type bar() :: %{
  title: String.t() | nil,
  title_path: String.t() | nil,
  label: String.t() | nil,
  links: [%{label: String.t(), path: String.t(), current: boolean()}],
  crumbs: [%{label: String.t(), path: String.t() | nil}],
  component: {module(), atom()} | String.t() | nil,
  assigns: map()
}

The bar under the header, as section_bar/1 draws it.

frame()

@type frame() :: %{translations: [translation()], section_bar: bar() | nil}

What a page hands its layout: @page_languages and @page_section_bar.

translation()

@type translation() :: %{
  language: String.t(),
  path: String.t(),
  name: String.t(),
  title: String.t() | nil
}

One language of a page, as the header's switcher and the head's hreflang read it.

Functions

accept(answer, given)

@spec accept(term(), map()) :: frame()

The map a plugin on event/0 returned, read against the one it was given.

  • translations: the given list when the plugin left it as it was; otherwise the plugin's, through translations/3 with only the site's languages kept. Anything but a list is the given one.
  • section_bar: the plugin's, through section_bar_of/1.

Anything but a map is the given frame.

assign_frame(socket)

The page's frame, assigned to socket as @page_languages and @page_section_bar.

For MishkaCmsWeb.RuntimeLive, which calls it in handle_params/3 once the page, its language, @query_params and @page_source are assigned and before the head is resolved: the head's hreflang links read @page_languages too. @page_languages holds the page's own editions until this replaces it with the frame's.

classes()

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

Every class section_bar/1 draws with, for whatever builds a site's stylesheet (MishkaCmsCore.Runtime.Compilers.TailwindCompiler).

event()

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

The event a page's frame is resolved through, once per handle_params/3.

frame(page, opts)

@spec frame(map(), keyword()) :: frame()

What page hands its layout: given/2, then the plugins on event/0, then accept/2. Takes the options given/2 takes.

given(page, opts)

@spec given(map(), keyword()) :: map()

The map event/0 is called with for page, before any plugin has answered. See the moduledoc for each key.

page is the :page section of a page module's __page_information__/0, with the language of this response. Options: :site_id, :current_user, :extra (the page's extra), :params (@query_params), :source (@page_source) and :translations, the page's own editions (MishkaCmsCore.Runtime.PageEditions.alternates/3), which translations/3 normalises.

layout_id(site_id)

@spec layout_id(String.t() | nil) :: String.t() | nil

The id of site_id's site-main layout, or nil for a site that has none. An archived one is none: nothing is drawn in it.

For an extension writing a page of its own: layout_id: SiteLayout.layout_id(site.id).

section_bar(assigns)

The bar under the header, for the site-main layout:

<MishkaCmsCore.Runtime.SiteLayout.section_bar bar={assigns[:page_section_bar]} site="acme" />

Draws nothing for a page with no bar. site is the site's name, which a component given by name is looked up in.

Attributes

  • bar (:any) - @page_section_bar: a bar/0, or nil. Defaults to nil.
  • site (:string) - The site's name, for a component given by name. Defaults to nil.
  • id (:string) - Defaults to "site-section-bar".

section_bar_of(bar)

@spec section_bar_of(term()) :: bar() | nil

bar as section_bar/1 draws it: a bar/0 with every key present, or nil when it is not a map or holds nothing to draw.

A link needs a label and an address; a crumb a label, its path optional. A component is a {module, function} whose module exports that function with one argument, or a component's name; anything else is none. assigns is a map, %{} when left out. Keys may be atoms or strings.

translations(entries, site_id, opts \\ [])

@spec translations([map()] | term(), String.t() | nil, keyword()) :: [translation()]

entries as the switcher reads them: each a translation/0, one per language, in the order the site lists its languages (any other after them, by code), and [] for fewer than two.

An entry needs a language that is a language code and a path that is an address, a / path or an http(s):// one; anything else is dropped. A missing name is Languages.name/1 of the language. With only_site_languages: true, a language site_id does not publish in is dropped too.

translations(
  [%{language: "fa", path: "/fa/blog/akhbar"}, %{language: "en", path: "/blog/news"}],
  site_id
)
#=> [%{language: "en", path: "/blog/news", name: "English", title: nil},
#    %{language: "fa", path: "/fa/blog/akhbar", name: "Persian", title: nil}]