MishkaCmsCore.Runtime.PageBuilder.CodeCheck (Mishka CMS Core v0.0.1)

Copy Markdown View Source

Compiles a page's code without loading it, so an author is told about a mistake instead of a visitor being shown one.

Why this has to exist

A page's template, events, infos and helpers are all Elixir by the time MishkaCmsCore.Runtime.Compilers.LiveViewCompiler is done with them, and nothing between the form and the BEAM reads them first. The failure that follows is quiet in three different ways:

  • a page whose precompile is false — the default — is not compiled when it is saved, so the save reports success and MishkaCmsCore.Runtime.RuntimeStatus reports Ready;
  • a page whose module IS loaded and then fails to recompile keeps serving the previous version, because the old code is only purged on the success branch;
  • the reason is recorded in MishkaCmsCore.Runtime.States.CompileFailureState and read by nothing that a human can see.

So the author saves, is told nothing, and the page either does not change or 500s for the first visitor. This module is the missing answer to "did that work?".

Three classes of wrong, and which of them a check can catch

Measured against the real compiler:

{:noreply, socket}     parses, compiles, works
{:noreply, socket      raises TokenMissingError — caught here, with a line
def def def            parses fine, fails when the module is compiled — caught here
""                     parses to nothing, compiles to nil, crashes on the first click —
                       caught here by name, because the message a reader needs is not the
                       one the runtime would eventually give them

The budget

Compiling a page means expanding use MishkaCmsWeb, :live_view, which is about a second of work, so the budget is generous: it is here to catch a macro that never returns, not to police a busy machine. Checks do not wait for each other — each compiles under a name of its own — so the deadline only ever covers one compile.

Fidelity

The stages are LiveViewCompiler's own — build_assigns/1, build_page_forms/1, build_event_handlers/1, build_info_handlers/1, build_renders/1, build_helpers/1, create_module_ast/1 — called directly rather than through build_module/1, which differs only in firing the runtime:compile:before_page hook and a telemetry span. A check must not fire either, and a check that reimplemented the stages would eventually pass code the real compile rejects.

Running them one at a time is also what makes the error attributable: each stage owns one group of fragments, so a raise identifies the tab without parsing the message.

Summary

Types

One thing wrong. tab is the group it belongs to, index and name locate it within that group, and line is a line in the author's own box — each fragment is authored standalone, so there is no offset to apply.

Functions

Checks one page's code.

One line of prose for a fault, the way it should read above the box it belongs to.

Whether the page's code compiles. For a caller that does not want to render the reasons.

Whether a TEMPLATE is HEEx the compiler will take.

Types

fault()

@type fault() :: %{
  tab: :template | :events | :infos | :helpers | :page,
  index: non_neg_integer() | nil,
  name: String.t() | nil,
  line: pos_integer() | nil,
  message: String.t()
}

One thing wrong. tab is the group it belongs to, index and name locate it within that group, and line is a line in the author's own box — each fragment is authored standalone, so there is no offset to apply.

Functions

check(page)

@spec check(map()) :: :ok | {:error, [fault()]}

Checks one page's code.

Takes the same shape LiveViewCompiler takes — :template, :events, :infos, :state, :helpers — and fills in the rest, because none of it affects whether the code compiles.

Returns :ok, or every fault it can attribute. Fragment faults are reported together so an author fixing three typos is told about three typos; a module-level fault is reported alone, because everything after the first one is noise.

message(map)

@spec message(fault()) :: String.t()

One line of prose for a fault, the way it should read above the box it belongs to.

ok?(page)

@spec ok?(map()) :: boolean()

Whether the page's code compiles. For a caller that does not want to render the reasons.

template(heex)

@spec template(String.t() | nil) :: :ok | {:error, fault()}

Whether a TEMPLATE is HEEx the compiler will take.

THE TOKENIZER IS NOT THE COMPILER, and until this existed the tokenizer was the only thing the editor asked. MishkaCmsCore.Runtime.PageBuilder.Tree.parse/1 accepts {@a + } — the braces delimit an expression and the expression is a token to it — and HEEx refuses it. So hand-written markup went into the document, into a draft and into the live record, and the failure surfaced later as a site that would not compile, in a log nobody was looking at.

This is check/1's syntax half on its own: Phoenix.LiveView.TagEngine.compile/2, which is the call every one of this runtime's compilers makes, with no module built around it. It costs milliseconds rather than the second a full page compile does, which is what lets it sit in front of every Apply in the code drawer rather than only in front of a publish.

What it CANNOT catch is anything semantic — an undefined function, a component called with the wrong attrs — because none of that is decided until the module is compiled. check/1 is where those are caught, for a page; the class this catches is the class the tokenizer lets through.