Draw an extension's pages in the site layout

v0.0.1

A site draws its pages in one layout, site-main, apart from the sign-in pages. The layout holds the site's header and footer, with the page between them. An extension's pages join it when the extension names it on each page it ships. This guide hangs an extension's sample pages on site-main, draws their sections into the layout's column, and keeps the page's language right in a layout of your own.

Before you begin

  • Have an extension with a provision, the code that writes its sample pages onto a site when the site sets the extension up. MishkaBlog.Provision and MishkaDocument.Provision are two. See Extensions and plugins and Install an extension.
  • Use a site that has the site-main layout. Every site has it: the install wizard writes it with the first site, and a site you add later in the admin gets it too. To change it, see Design the site layout.
  • The header's language switcher and the bar under the header read each page's frame, which a plugin on the runtime:page:frame event fills in. See Page frame: languages and the section bar.

Hang a page on the layout

Call MishkaCmsCore.Runtime.SiteLayout.layout_id/1 with the site's id, and put the answer in each page's layout_id:

elixir
alias MishkaCmsCore.Runtime.SiteLayout

SiteLayout.layout_id(site.id)
#=> "b9c2239d-16c1-4eb0-9448-70a95b1ab3fe"

layout_id/1 returns the id of the site's layout named site-main, as a string. It returns nil when the site has no such layout, when the layout is archived, and when the argument is not a site id. A page whose layout_id is nil has no layout of its own. It is drawn in the application's default layout, without the site's header and footer.

The id belongs to one site, so your provision builds its pages from a function of the site. Mishka Blog and Mishka Document both read the id once and write it on every page they ship. The example at the end of this page has the same shape.

A provision writes only the pages a site lacks and never updates a row. A page that already exists keeps the layout it has, and running the provision again does not move it.

A new site's site-main draws, from top to bottom:

  1. the site-header component;
  2. the section bar, when the page has one;
  3. the flash messages;
  4. <main id="site-main">, the content column that holds the page;
  5. the site-footer component.

The site's owner can change all of it, so your page draws only what lies between the header and the footer.

The sign-in pages are the exception. The pages at /:lang/auth and under it hang on the auth layout, which every site also has. Leave them there.

Draw sections into the column

The layout owns the content column. Its <main> element has the classes mx-auto w-full max-w-[1160px] flex-1 px-8 pb-24: a column up to 1160 px wide, 32 px gutters at the sides, and 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 and on a desktop.

Draw the page's sections straight into the column, with no container of its own: no mx-auto, no max-w-* and no side padding around the whole page. A container of the page's own doubles the gutters.

The page does own its vertical rhythm, and the room above its first section in particular. Mishka Blog's list pages use pt-16.

heex
<section class="flex flex-col gap-6 pt-16">
  <h1 class="text-[44px] font-medium">Notes, guides and long reads.</h1>
  <p class="text-body dark:text-body-dark">Short posts from the team.</p>
</section>

Run a section edge to edge

A band with a background of its own, such as a colored or dotted one, adds site-bleed. The section then reaches both edges of the window, and what is inside it stays in the column. It needs no container either.

heex
<section class="site-bleed bg-tint-1 pt-[72px] pb-24 dark:bg-tint-1-dark">
  <div class="mx-auto max-w-[820px] text-center">
    <h2 class="text-[32px] font-medium">Start here</h2>
  </div>
</section>

site-bleed is a utility in the site's site-theme stylesheet:

css
@utility site-bleed {
  margin-inline: calc(50% - 50vw);
  padding-inline: calc(50vw - 50%);
}

The negative margin reaches the window's edges, and the padding brings the content back to the column's edges. Both are measured against the column the section sits in. Make a site-bleed section a direct child of the page. A section nested in a narrower box measures that box.

The outer element of site-main has overflow-x-clip. It clips what 100vw adds where the window has a scrollbar, so the page does not scroll sideways. It uses clip and not hidden, so a sticky element inside a page still sticks. Keep that class on the outer element of a layout of your own.

Keep the page's language right in a layout of your own

The root layout draws <html lang dir> once, with the HTTP response. In the host application, MishkaCmsWeb.RuntimeLive.html_lang/1 and html_dir/1 give it the page's language and that language's direction. A language change from the header's switcher is a live navigation: a link to an edition at a path on the site uses navigate, so the page, the header and the title swap inside the open connection and the document is not drawn again. A visit that moves from an English page to a Persian one would keep lang="en" dir="ltr" on a right-to-left page.

site-main fixes this with one hidden element. A layout of your own that lets visitors change language without a reload needs the same element. Put it first inside the layout's outer element:

heex
<span
  id={"site-lang-" <> lang(assigns)}
  hidden
  phx-mounted={
    Phoenix.LiveView.JS.set_attribute({"lang", lang(assigns)}, to: "html")
    |> Phoenix.LiveView.JS.set_attribute({"dir", dir(assigns)}, to: "html")
  }
>
</span>
  • The element is empty and hidden. It draws nothing.
  • Its id is site-lang- followed by the page's language, such as site-lang-fa. A patch from one language to another replaces the element, and a new element mounts again.
  • Its phx-mounted runs Phoenix.LiveView.JS.set_attribute/2 twice on html, once for lang and once for dir. It needs no hook and no script. On the first load it writes what the response already says.

The template calls two helper functions. Add them to the layout under Helper Functions, each with the argument assigns. This is the body of lang:

elixir
(assigns[:page] || %{})[:language] ||
  MishkaCmsCore.Runtime.Languages.primary(MishkaCmsCore.Runtime.Translations.site())

This is the body of dir:

elixir
MishkaCmsCore.Runtime.Languages.direction(lang(assigns))

lang returns the language of this response, assigns[:page].language. On a pattern page such as /:lang/notes that is the captured language. With no page, it returns the site's first language. dir returns "rtl" for a right-to-left language such as Arabic, Persian, Hebrew or Urdu, and "ltr" for the rest.

Use the site's shared colors

The Mishka Blog and Mishka Document kits are written in a set of color names that each site's site-theme stylesheet defines. Your extension's pages can use the same names. The site's owner can change the colors on the Stylesheets screen, where site-theme is a stylesheet of the kind Theme. See Add custom CSS, JS hooks and components.

Each name is a Tailwind color, so it works in bg-, text- and border- classes. A name with a -dark twin is paired with dark:, which applies when the document has data-theme="dark".

heex
<span class="bg-tint-1 text-tint-1-ink dark:bg-tint-1-dark dark:text-tint-1-ink-dark">New</span>
NameDark twinWhat it is for
tint-1 to tint-6tint-N-darkSix surfaces: teal, violet, amber, rose, blue and stone.
tint-1-ink to tint-6-inktint-N-ink-darkThe ink for the surface of the same number.
chip-washchip-wash-darkThe wash behind a chosen chip or link.
brand-darkNoneThe brand on a dark page.
bodybody-darkThe text of a long read.
edge-hoverNoneThe edge a hovered or focused card takes. It is the same in both modes.
warn-inkwarn-ink-darkThe ink of a warning or a featured mark.
warn-washwarn-wash-darkThe wash behind a warning or a featured mark.

MishkaCmsCore.Install.StarterContent.theme_css/0 returns the stylesheet text a new site's site-theme holds. A test can use it to check that a color your markup names is defined:

elixir
css = MishkaCmsCore.Install.StarterContent.theme_css()

String.contains?(css, "--color-tint-1-ink-dark:")
#=> true

Example: a notes page

A Notes extension ships one page. Its provision reads the layout id, then writes a tinted band that runs edge to edge, followed by a section in the column. Notes.Kit.bundle_path/0 stands for the path of the extension's UI kit bundle, as MishkaBlog.Kit.bundle_path/0 does for Mishka Blog.

elixir
defmodule Notes.Provision do
  alias MishkaCmsCore.Runtime.{ExtensionProvision, SiteLayout}

  def manifest do
    %{
      app: :notes,
      bundle_path: Notes.Kit.bundle_path(),
      section: "notes",
      pages: &page_attrs_for/1
    }
  end

  def ensure!(site), do: ExtensionProvision.ensure!(manifest(), site)

  defp page_attrs_for(%{id: site_id}) do
    layout_id = SiteLayout.layout_id(site_id)

    [
      %{
        name: "Notes",
        title: "Notes",
        path: "/:lang/notes",
        format: :heex,
        template: """
        <section class="site-bleed bg-tint-1 pt-[72px] pb-16 dark:bg-tint-1-dark">
          <div class="mx-auto max-w-[820px] text-center">
            <h1 class="text-[44px] font-medium text-tint-1-ink dark:text-tint-1-ink-dark">Notes</h1>
            <p class="text-body dark:text-body-dark">Short posts from the team.</p>
          </div>
        </section>
        <section class="flex flex-col gap-6 pt-16">
          <h2 class="text-[28px] font-medium">Latest</h2>
          <p class="text-body dark:text-body-dark">No notes yet.</p>
        </section>
        """,
        layout_id: layout_id,
        active: true
      }
    ]
  end
end

Run it for a site that has site-main:

elixir
report = Notes.Provision.ensure!(site)
report.pages
#=> ["/:lang/notes"]

The site now has a page at /:lang/notes, in the section notes, whose layout_id is the id of its site-main layout.

Next steps