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

Copy Markdown View Source

The render-time bridge between a compiled runtime template and a compiled runtime component.

Every module built by MishkaCmsCore.Runtime.Compilers.ComponentCompiler imports component/1 and component/2 from here, so a template written as <.component component_name="card" .../> resolves against an already-loaded module rather than through an Code.eval_quoted/3 interpretation. Which resolution it takes is the module's own answer: a function component is apply(CardModule, :card, [assigns]), and one compiled stateful goes through Phoenix.Component.live_component/1 instead. The template is identical either way — see the fork in applied/3 for why the call site must not change.

MishkaCmsCore.Runtime.StaticHelpers is this module for the surfaces that render a template to a string, where a live component has no diff to be turned into HTML by.

Slot items arriving from the database (:runtime_slot) are grouped by their for_slot name and resolved against the target module's declared slots, so a name the component never declared is reported as a slot error instead of silently minting an atom.

Which site's components a render reaches

A global component (site="Global", or no site) and a component of the site this process renders for — the one MishkaCmsCore.Runtime.Translations.put_site/1 named — and nothing else. A component reads as its own site, so one named from another site's page would draw that site's template and that site's rows to every visitor, and run its handle_event. It is refused before it is built and nothing is drawn in its place. A process that names no site reaches the global ones only. The module that answers is asked as well, since two names can hash to one module: it is drawn only when it was built from a row of the site asked for, or from a global row.

The site is named where a request becomes a page — MishkaCmsWeb.RuntimeLive, MishkaCmsWeb.DraftPreviewLive, MishkaCmsWeb.ErrorHTML — and by the builder's canvas; a preview built for one site among others renders inside rendering_for/2. A global layout draws on the page's site, so it reaches that site's components and the global ones.

In a task

The site a template renders for and the response's CSP nonce are kept in the process rendering it, as Gettext's locale is. A task the template starts through MishkaGervaz.Messages.start_async/4 — or assign_async/4, or in_caller_locale/1 — is handed all three (carry_into_tasks/0), so t/1,2, site_path/2, component/1 and csp_nonce/0 answer inside it what they answer outside it:

def load(socket) do
  MishkaGervaz.Messages.start_async(socket, :links, fn ->
    %{about: site_path("/:lang/about", "fa"), label: t("About")}
  end)
end

A task started any other way, Phoenix.LiveView.start_async/4 among them, renders for no site and in the default locale: site_path/2 is nil there, and t/1 answers from the global words.

Events

  • runtime:render:component_unavailable — what is drawn in place of a refused component, an empty %Phoenix.LiveView.Rendered{}, with %{component_name: name, site: site, site_id: site_id} as its meta: the name and site the template asked for, and the site being rendered. A plugin may answer with markup of its own.
  • runtime:render:before_content_component — before each component a rich_content/1 marker names is drawn; see MishkaCmsCore.Runtime.ContentComponents.

See also MishkaCmsCore.Runtime.Compilers.ComponentCompiler for how the target modules and their __component_information__/0 metadata surface are generated.

Summary

Functions

The captcha a form asks — see MishkaCmsCore.Captcha.Widget.captcha/1.

Registers what the imports above read from the process rendering a template — the site MishkaCmsCore.Runtime.Translations.put_site/1 names and the nonce MishkaCmsCore.Runtime.CspNonce.put/1 records — with MishkaGervaz.Messages.carry_process_keys/1, so a task started through MishkaGervaz.Messages.start_async/4, assign_async/4 or in_caller_locale/1 renders as its starter does. Gervaz carries the locale itself. See "In a task" above.

Renders a runtime component by name.

Renders a runtime component from a name/site pair, merging any extra assigns.

This response's Content-Security-Policy nonce, for the rare <style> a template cannot avoid — a page, a layout and a component alike, since a component never sees the page's assigns.

Builds the runtime module for name/site if it is not already loaded, and says whether it is.

What a runtime template imports from here — component/1,2, captcha/1, sign_in_providers/1, rich_content/1, t/1,2, csp_nonce/0 and site_path/2 — less any name its own helpers already define.

Runs fun rendering for the site named site, then puts back the site this process rendered for.

Whether a template rendered by this process may reach a component of the site named site.

The module component/1 draws for name of the site named site, built on first use — or :error where it draws nothing: a name nothing answers to, an inactive component, another site's.

Stored HTML with the components its markers name drawn in place — a post's body, say.

The buttons of the services the site signs in with — see MishkaCmsCore.Accounts.SignInProvider.Widget.sign_in_providers/1.

The public address of a stored path on the site this page is rendered for, in a language or with captures — MishkaCmsCore.Runtime.Languages.url/3, so a link is written once and follows the site's URL mode

The same component, rendered as MARKUP rather than as something LiveView will run — what the builder's canvas draws.

key in the language this page is being answered in — the runtime counterpart of gettext/1, for templates that live in the database and so can never reach a .po file.

Functions

captcha(assigns)

The captcha a form asks — see MishkaCmsCore.Captcha.Widget.captcha/1.

<.captcha form={f} for="sign_in" tenant={assigns[:current_tenant]} />

carry_into_tasks()

@spec carry_into_tasks() :: :ok

Registers what the imports above read from the process rendering a template — the site MishkaCmsCore.Runtime.Translations.put_site/1 names and the nonce MishkaCmsCore.Runtime.CspNonce.put/1 records — with MishkaGervaz.Messages.carry_process_keys/1, so a task started through MishkaGervaz.Messages.start_async/4, assign_async/4 or in_caller_locale/1 renders as its starter does. Gervaz carries the locale itself. See "In a task" above.

Called once, when MishkaCmsCore.Application starts.

component(assigns)

Renders a runtime component by name.

Requires :component_name; :site selects the tenant-scoped module and may be nil for a global component. Any other assign is forwarded to the component untouched.

A :site that is neither global nor the site this process renders for draws nothing and builds nothing — see "Which site's components a render reaches" above.

A component whose precompile flag is unset is not built at boot, so it is built when a render first needs it. The page or layout drawing it builds the components it reaches several at once before it draws (MishkaCmsCore.Runtime.ComponentPrefetch), and one it could not tell it reaches is built here — by the first render that names it. MishkaCmsCore.Runtime.Handlers.ModuleCompilerHandler coordinates through its registry, so concurrent first renders of the same component wait on one compile rather than racing, and a component whose template does not compile is remembered in MishkaCmsCore.Runtime.States.CompileFailureState so the next render short-circuits to the placeholder instead of retrying.

The component's CSS does not depend on this: MishkaCmsCore.Runtime.Handlers.BootHandler writes a template file for every active component whether or not it compiles a module, and that file is what the closure walk and Tailwind read. So a lazily-built component renders styled the first time it is seen.

component(name, site, assigns \\ %{})

Renders a runtime component from a name/site pair, merging any extra assigns.

csp_nonce()

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

This response's Content-Security-Policy nonce, for the rare <style> a template cannot avoid — a page, a layout and a component alike, since a component never sees the page's assigns.

<style nonce={csp_nonce()}>…</style>

nil outside a response, which HEEx renders as no attribute. See MishkaCmsCore.Runtime.CspNonce for where the value comes from.

ensure_component_loaded(name, site)

@spec ensure_component_loaded(String.t(), String.t() | nil) :: boolean()

Builds the runtime module for name/site if it is not already loaded, and says whether it is.

component/1 calls this itself. It is public for the callers that read a component's compiled metadata before rendering it — the page builder canvas asks __component_information__/1 which attributes a component declares, and a component that has never been built declares nothing, so every attribute an author set would be silently dropped from the preview.

imports(helpers)

@spec imports([map()] | nil) :: keyword(non_neg_integer())

What a runtime template imports from here — component/1,2, captcha/1, sign_in_providers/1, rich_content/1, t/1,2, csp_nonce/0 and site_path/2 — less any name its own helpers already define.

Every compiler — component, page and layout — asks this rather than writing the list itself, so the three cannot drift. The exception is not hypothetical: importing a t/1 into a module that defines one is a compile error, and a template that saved yesterday would stop saving today. Its own function wins, as a local function would anyway.

rendering_for(site, fun)

@spec rendering_for(String.t() | nil, (-> result)) :: result when result: term()

Runs fun rendering for the site named site, then puts back the site this process rendered for.

site is a name, as a <.component site="…"> attribute carries one. "Global", nil and a name SiteState does not know render for no site, which reaches the global components alone.

For a process that renders one site's template and then another's — a preview built in the admin, a palette card — where naming the site for the whole process would leave it pointing at whichever was drawn last. t/1 inside fun answers from the same site's words.

renders_here?(site)

@spec renders_here?(String.t() | nil) :: boolean()

Whether a template rendered by this process may reach a component of the site named site.

"Global" and nil always may; a site name only when it is the site MishkaCmsCore.Runtime.Translations.put_site/1 named for this process. A name SiteState does not know never may. Two ETS reads for a site name, none for a global one.

component/1 and static_component/1 ask it themselves. It is public for a caller that builds a component before rendering it — the builder's canvas reads a component's declared attributes first — so that a component the render would refuse is never built, and its row never read, on the way there.

resolve_component(name, site)

@spec resolve_component(String.t(), String.t() | nil) :: {:ok, module()} | :error

The module component/1 draws for name of the site named site, built on first use — or :error where it draws nothing: a name nothing answers to, an inactive component, another site's.

For a caller that must know before it draws, as rich_content/1 does to cast a marker's attributes to what the component declares.

rich_content(assigns)

@spec rich_content(map()) :: Phoenix.LiveView.Rendered.t()

Stored HTML with the components its markers name drawn in place — a post's body, say.

<.rich_content
  id={"#{@id}-body"}
  html={@body}
  site={site_name()}
  current_user={@current_user}
/>
  • html — sanitized HTML; it is drawn as it is given, markers aside.
  • site — the site's name, as <.component site="…"> takes it. A marker names the site's own component, else the global one of that name. nil reaches the global ones alone.
  • id — what a stateful component's id starts with, "content" when not given; unique on the page when two of these draw content.
  • current_user — handed to a component that declares a current_user attribute.

A marker that names nothing this site may draw is drawn as nothing. See MishkaCmsCore.Runtime.ContentComponents for the marker and its rules.

sign_in_providers(assigns)

The buttons of the services the site signs in with — see MishkaCmsCore.Accounts.SignInProvider.Widget.sign_in_providers/1.

<.sign_in_providers site={assigns[:current_site]} />

site_path(path, captures)

@spec site_path(String.t(), String.t() | map() | nil) :: String.t() | nil

The public address of a stored path on the site this page is rendered for, in a language or with captures — MishkaCmsCore.Runtime.Languages.url/3, so a link is written once and follows the site's URL mode:

<.link navigate={site_path("/:lang/about", @page.language)}>About</.link>
<a href={site_path("/:lang/blog/:slug", %{"lang" => @page.language, "slug" => @post.slug})}>

A language of nil is the site's primary one. nil when the path makes no address — a capture it needs is missing, or the language is not one the site publishes in — which HEEx draws as no href at all. The site is the one MishkaCmsCore.Runtime.Translations.put_site/1 named for this process, as for component/1.

static_component(assigns)

The same component, rendered as MARKUP rather than as something LiveView will run — what the builder's canvas draws.

Why the canvas cannot call component/1

MishkaCmsCoreWeb.Live.Admin.Builder.CanvasLive.contain/3 forces every node's render to iodata on purpose, so that a component's raise, throw or exit lands inside a rescue the editor owns rather than taking an author's unsaved session with it. Phoenix.LiveView.Engine implements Phoenix.HTML.Safe for %Phoenix.LiveView.Component{} only to raise — the two facts are irreconcilable, and contain/3 is the one that must win.

So a stateful component is rendered here the way LiveView would, minus the process: its mount/1 runs if it has one, the compiler's update_many/1 carries the author's assigns across, and render/1 is called as the ordinary arity-1 function it is. Real markup, real size, real styling.

What the author sees, and what they do not

@myself is nil, so phx-target renders away rather than pointing at a cid that does not exist — which means nothing in the frame responds to a click. That is the honest picture: the canvas is showing the component's COLD state, and the editor says so beside the selection rather than leaving the reader to wonder why the dropdown will not open.

The socket built here carries private[:runtime_cold], which is how a component that loads in the background knows to read inline instead: there is no process to send a task's answer to. That is the ONLY surface where it is set — a page's first HTTP render is not connected either, and a component must not confuse the two.

A function component takes the same path it always did, and another site's component is refused here exactly as component/1 refuses it.

t(key, bindings \\ [])

@spec t(term(), keyword() | map()) :: String.t()

key in the language this page is being answered in — the runtime counterpart of gettext/1, for templates that live in the database and so can never reach a .po file.

<h2>{t("Categories")}</h2>
<p>{t("Read %{count} posts", count: @count)}</p>

A key nobody has translated renders as itself. The value is a plain string, escaped like any other. See MishkaCmsCore.Runtime.Translations.translate/2.