# `MishkaCmsCore.Runtime.SiteLayout`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/site_layout.ex#L5)

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.

# `bar`

```elixir
@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`

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

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

# `translation`

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

# `accept`

```elixir
@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`

```elixir
@spec assign_frame(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
```

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`

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

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

# `event`

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

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

# `frame`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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 `t: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`

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

`bar` as `section_bar/1` draws it: a `t: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`

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

`entries` as the switcher reads them: each a `t: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}]

---

*Consult [api-reference.md](api-reference.md) for complete listing*
