MishkaCmsCore.Runtime.Authoring.CodeCheck (Mishka CMS Core v0.0.5-alpha.4)

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. column comes with it when the fault is an element of the template.

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 nests only what the placement rules allow, asked of the markup an author typed or pasted before it replaces the document.

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,
  optional(:column) => 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. column comes with it when the fault is an element of the template.

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.

placement(heex, opts \\ [])

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

Whether a template nests only what the placement rules allow, asked of the markup an author typed or pasted before it replaces the document.

<p>words <.form>…</.form></p> tokenizes and compiles, and a browser closes the <p> in front of the form, so the canvas draws the form beside the paragraph while the Layers and Code views keep it inside. Containment.misplaced/2 asks the placement rules of a whole tree; this reads the markup into one and returns the first element they refuse, at the line and column it opens at, with the words a refused drop gives.

opts takes :components, the kit catalog the editor places with. A kit component is the element its own markup opens with, so Chelekom's Form Wrapper is a <form> only when its row is handed over; without it the CMS's own <.form> and <.input> are still read.

This is separate from check/1, which a page's save asks of markup that is already stored: a stored page that nests wrongly must still save.

Markup the tokenizer cannot read is :ok here, because Tree.parse/1 reports it.

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.Authoring.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.