Page frame: languages and the section bar
v0.0.1
In Mishka CMS, a page can tell the site's layout what only the page knows: the languages it is written in and the bar under the header. The core asks for both through the runtime:page:frame event, and the extension that owns the page answers it with a plugin. This page lists the payload a plugin receives, what the layout reads back, the shape of a language entry and of the bar, and the classes the bar draws with. For the rows a page reads once and hands to this event, see Page source: read a page's rows once. For every other event, see Plugin events.
When the event fires
The host app serves database pages through MishkaCmsWeb.RuntimeLive. Its handle_params/3 calls MishkaCmsCore.Runtime.SiteLayout.assign_frame/1 for every address: the first load, each live navigation and each patch. The call comes after the page's language, @query_params and @page_source are known, and before the CMS resolves the page's head.
The CMS builds the payload with SiteLayout.given/2, fires the event, and reads the answer with SiteLayout.accept/2. It assigns two values to the page.
| Assign | Holds | Read by |
|---|---|---|
@page_languages | The page's languages as a translations list, or [] for fewer than two. Until the plugins answer, it holds the page's own editions. | The header's language switcher, which is hidden for fewer than two languages. The head's hreflang links and og:locale:alternate tags come from the same list. See Set a page's SEO and share card. |
@page_section_bar | The bar under the header as a map, or nil for none. | The site-main layout, which draws it with MishkaCmsCore.Runtime.SiteLayout.section_bar/1. |
Write a plugin for the event
A plugin is a module that uses MishkaInstaller.Event.Hook with event: "runtime:page:frame". The application that ships it starts it under its supervisor, as Mishka Blog and Mishka Document do. Its call/1 receives the payload and returns it, changed or not.
| Return value | Effect |
|---|---|
{:reply, frame} | Passes the payload to the next plugin. Return it for every page the plugin does not own. |
{:reply, :halt, frame} | Ends the chain, so later plugins do not answer for a page this plugin owns. |
- Answer only for your own pages. Match them by
record, bysectionor bypage.path, and return every other page untouched before you read anything. - The event runs for every address, and the CMS caches nothing across addresses. Read what
sourcedoes not hold with one query, and only for your own pages. - A plugin that raises, or an event whose plugins cannot run, leaves the page with the payload the CMS built.
The payload
The layout reads back only translations and section_bar. A change to any other key does not reach the layout, so treat those keys as read only.
| Key | Holds | In your answer |
|---|---|---|
page | The :page section of the page module's __page_information__/0: title, path, section, language, language_group_id, timestamp, content and seo. Its language is the language of this response. | Read only |
section | page.section: the name an extension files its pages under, such as "blog" or "document". The extension's provision writes it on every page it ships. | Read only |
record | The kind of record the page shows, such as "blog:post", from the "record" key of extra. nil for a page that shows none. | Read only |
extra | The page's extra map. | Read only |
params | @query_params: the captures of the page's path, such as "slug" and "lang", and the query string. | Read only |
language | The language of this response, the same as page.language. | Read only |
site_id | The site's id. | Read only |
current_user | The signed-in visitor, or nil for a guest. Read the other editions as this visitor, and offer an edition that only some readers can open only to them. | Read only |
source | The rows that the page's plugin on runtime:page:source read for this request, as the visitor, or nil. A plugin that finds its record here takes it from here. One that finds nil reads for itself. See Page source. | Read only |
translations | What the layout shows when no plugin answers: the page's own editions, already in the shape the rules below describe. | Read back |
section_bar | nil. | Read back |
What the layout reads from the answer
| Answer | Result |
|---|---|
| Anything but a map | The page keeps the translations it was given and has no bar. |
translations, the same list that was given | The page keeps it. |
translations, a changed list | Read through the rules below, and only the site's languages are kept. |
translations, anything but a list | The given list. |
section_bar | Read through the bar rules below. A bar with nothing to draw becomes nil. |
The translations entry
A translations list holds one map for each language the record is written in, at that edition's own address. Keys can be atoms or strings.
| Key | Required | Holds |
|---|---|---|
language | Yes | The language code, such as "fr" or "pt-br". Write it in lowercase, as the site lists it. The CMS drops an entry without a code. |
path | Yes | The public address of that edition. Either a path on the site that starts with /, built with MishkaCmsCore.Runtime.Languages.url/3 so it follows the site's URL mode, or an address that starts with http:// or https://. The CMS drops an entry with any other text there, including an address that starts with //. |
name | No | What the switcher calls the language. Languages.name/1 of language when you leave it out or blank. |
title | No | The record's own title in that language. The starter's switcher shows it as the link's tooltip. nil when you leave it out or blank. |
The rules, in the order the CMS applies them:
- Only the site's languages. The CMS drops a language that the site does not publish in from a plugin's answer.
- One entry for each language. When two entries share a language, the first stays.
- In the site's order. The entries follow the order of the site's language list.
- Fewer than two gives
[]. With one language left, or none, the header draws no switcher.
The rules for what you put in the list:
- Include the edition being read. The starter's switcher marks the entry for the page's own language as the current one.
- Leave out a language the record has no edition in. An English post with no Persian edition answers
enand whatever else it has, and nofa. - Build each edition's address from that edition's own slug. A translation of a record often has a slug of its own, so the slug in the current address does not belong in another language's address.
A path on the site is a live link, so picking the language swaps the page without a reload. An http:// or https:// address is a plain link and loads in full. See Changing language without a reload.
What the page offers without a plugin
When a plugin leaves translations alone, the page offers its own editions.
- A page with translations in its language group: each edition at its own stored path.
- A page whose path captures only
lang, such as/:lang/blog: one entry for each language it answers in, at the same path. A page markedserves_all_languagesanswers every language of its site. - A page whose path captures anything else, such as
/:lang/blog/:slug: only itself, so the list is[]. A swapped path would carry the slug of the edition the visitor is leaving. The page that shows a record answers for it here instead.
The section bar
section_bar is nil or a map. Every key is optional, and keys can be atoms or strings. A map with no title, no links, no crumbs and no component draws no bar.
| Key | Holds |
|---|---|
title | The section's name, set in italic type. It is a link when title_path is set and plain text when it is not. |
title_path | The address the title links to. |
label | What a screen reader calls the links' navigation. title when you leave it out, and the word Section when there is no title either. |
links | The section's places, drawn as a bar of pills: [%{label: "Posts", path: "/blog", current: true}]. A link needs a label and an address, or the CMS drops it. current: true marks the place the reader is in. Any other value counts as false. |
crumbs | A breadcrumb trail: [%{label: "Docs", path: "/docs"}, %{label: "Install"}]. A crumb needs a label. Its path is optional, and the CMS treats an invalid one as none. The last crumb is the page the reader is on, and the bar draws it as text even when it has a path. |
component | Something the bar draws on its trailing side. It can be interactive. Give a {module, function} tuple, or the name of one of the site's own components. See the table below. |
assigns | The map that component receives. %{} when you leave it out or give anything but a map. |
Every address in the bar follows the same rule as a translations path: a path that starts with /, or an http:// or https:// address. A path on the site is a live link, and an address off the site is a plain link. Build each address in the page's language, as the example does.
Form of component | What the bar does |
|---|---|
{MyApp.Bar, :cart} | Calls the function component with assigns. The module must export the function with one argument, or the CMS logs a warning and drops the component. |
"shop-cart" | Draws the site's component of that name, found through MishkaCmsCore.Runtime.LiveViewHelpers.component/1, with assigns as its attributes. A component that keeps state runs inside the page's LiveView, handles its own events and needs an id in assigns. Without one, the bar draws a box that says the component needs an id. |
The bar draws its parts in this order: the title, the links, the breadcrumb trail and the component. A layout that you write yourself calls the bar with the site's name. The bar looks up a component given by name on that site:
<MishkaCmsCore.Runtime.SiteLayout.section_bar bar={assigns[:page_section_bar]} site="acme" />The bar's classes and the stylesheet
A site's stylesheet is built from the site's own templates, and no template names the bar's classes. The CMS adds every class of the bar's own markup to each site's stylesheet (MishkaCmsCore.Runtime.SiteLayout.classes/0), so you list none of those.
The classes of your component need to reach the stylesheet too. A component of the site that a page or a layout names is already in it. A component that only the bar draws is not, and neither is an Elixir function component. Tell the stylesheet about them on the runtime:compile:content_components event. The CMS fires it while it builds a site's stylesheet, never during a request, with meta %{site: site_name}.
| Key | Holds |
|---|---|
site | The site's name. |
site_id | The site's id. |
components | Starts as []. Add the names of the site's components that stored content draws. The stylesheet includes the template of each name, and of the components that template reaches, when a template the site reaches draws content with <.rich_content ... />. Without such a template, a name here adds nothing. |
classes | Starts as []. Add the classes a component draws with, one string for each class. The stylesheet includes every class listed here. Use it for a function component, and for a component that the bar draws by name when no template of the site draws content. |
icons | Starts as []. Add icon class names that your stored content uses, such as hero-inbox. |
- Append to each list and keep what is there, because other plugins add theirs.
- The CMS drops a value that is not a string, and it drops a repeat.
- A class is one string of at most 512 bytes. It must contain a letter, and its
[and(must close. The CMS drops a class that has a space, a", a\, a{, a}, a<, a;, a backtick or a character outside printable ASCII. - The CMS keeps at most 20,000 classes for a site.
- A site that the CMS does not know, and an event whose plugins cannot run, give three empty lists.
Example: a shop section
A shop extension files its pages under the section shop and marks a product page with the record shop:product. The plugin below gives a product page one entry for each edition and gives every shop page a bar with a cart. The example keeps its product rows in the plugin so that it runs as written. Read yours from your database as the visitor, or take them from frame.source.
The cart is a function component. Its classes reach the stylesheet through the second plugin.
defmodule MyShop.Bar do
use Phoenix.Component
attr :id, :string, required: true
attr :count, :integer, default: 0
def cart(assigns) do
~H"""
<a id={@id} href="/cart" class="rounded-full border px-3 py-1 text-sm">Cart ({@count})</a>
"""
end
enddefmodule MyShop.Plugins.PageFrame do
use MishkaInstaller.Event.Hook, event: "runtime:page:frame", initial: %{priority: 50}
alias MishkaCmsCore.Runtime.Languages
# The rows live here so the example runs as written. Read yours from your database as the
# visitor, or take them from `frame.source`.
@products [
[
%{language: "en", slug: "mug", name: "Mug"},
%{language: "fr", slug: "tasse", name: "Tasse"},
%{language: "de", slug: "becher", name: "Becher"}
]
]
@impl true
def call(%{section: "shop"} = frame) do
{:reply, :halt, %{frame | translations: translations(frame), section_bar: bar(frame)}}
end
def call(frame), do: {:reply, frame}
# Every edition of the product the slug names, the one being read included.
defp translations(%{record: "shop:product", params: %{"slug" => slug}} = frame) do
for row <- editions(slug) do
captures = %{"lang" => row.language, "slug" => row.slug}
%{
language: row.language,
path: Languages.url(frame.page.path, captures, frame.site_id),
title: row.name
}
end
end
# A list page keeps what the layout would offer: the same path in each language.
defp translations(frame), do: frame.translations
defp editions(slug),
do: Enum.find(@products, [], &Enum.any?(&1, fn row -> row.slug == slug end))
defp bar(%{language: language, site_id: site_id, page: page}) do
url = &Languages.url(&1, %{"lang" => language}, site_id)
%{
title: "Shop",
title_path: url.("/:lang/shop"),
links: [
%{label: "Products", path: url.("/:lang/shop"), current: page.path == "/:lang/shop"},
%{label: "Orders", path: url.("/:lang/shop/orders")}
],
component: {MyShop.Bar, :cart},
assigns: %{id: "shop-cart", count: 2}
}
end
enddefmodule MyShop.Plugins.ContentComponents do
use MishkaInstaller.Event.Hook,
event: "runtime:compile:content_components",
initial: %{priority: 50}
@bar_classes "rounded-full border px-3 py-1 text-sm"
@impl true
def call(%{classes: classes} = data) do
{:reply, %{data | classes: classes ++ String.split(@bar_classes)}}
end
endThe application starts both plugins under its supervisor:
defmodule MyShop.Application do
use Application
@impl true
def start(_type, _args) do
plugins = [MyShop.Plugins.PageFrame, MyShop.Plugins.ContentComponents]
Supervisor.start_link(plugins, strategy: :one_for_one, name: MyShop.Supervisor)
end
endTake a site whose languages are en and fr, and a visitor who opens /fr/shop/tasse, a product page stored at /:lang/shop/:slug. The layout receives this:
frame.translations
#=> [
#=> %{language: "en", path: "/shop/mug", name: "English", title: "Mug"},
#=> %{language: "fr", path: "/fr/shop/tasse", name: "French", title: "Tasse"}
#=> ]
frame.section_bar
#=> %{
#=> title: "Shop",
#=> title_path: "/fr/shop",
#=> label: nil,
#=> links: [
#=> %{label: "Products", path: "/fr/shop", current: false},
#=> %{label: "Orders", path: "/fr/shop/orders", current: false}
#=> ],
#=> crumbs: [],
#=> component: {MyShop.Bar, :cart},
#=> assigns: %{id: "shop-cart", count: 2}
#=> }The list holds the edition being read and the other edition the site publishes. The German edition is left out, because the site does not publish in German. The bar's component is the tuple you gave, and the bar draws Cart (2) on its trailing side. A list page such as /shop gets the same bar with Products marked as the current place, and the translations stay as the layout offers them.
To draw a component of the site instead, set component: "shop-cart" in the bar. Add the name to the stylesheet event with %{data | components: components ++ ["shop-cart"]} when a template of the site draws content, as the document extension does for its doc-switcher. Otherwise list the classes of shop-cart in classes.