MishkaCmsCore.Builder.Command (Mishka CMS Core v0.0.1)

Copy Markdown View Source

A Phoenix.LiveView.JS command as a term, printed to source and read back from it.

Nothing is spelled by hand

An action says what it wants as a triple — the command's name, its positional arguments, and its options — and every step after that belongs to somebody else's code:

  • Phoenix.LiveView.JS decides whether the options are legal. The triple is applied to it and the %Phoenix.LiveView.JS{} it answers with is thrown away; what is kept is that it answered at all. So transition:, time:, display: and blocking: are supported by this module knowing nothing about them, an option a later LiveView adds arrives supported, and the message an author sees for a wrong one is LiveView's own.
  • Macro.escape/1 turns the terms into quoted Elixir, which is what gets a class name containing a double quote, a backslash or #{ right — the third being the one that is not a broken string but interpolation running in somebody's page.
  • The code formatter prints it, at infinite width so that one command is one line.
  • Code.string_to_quoted/1 parses it back, and MishkaCmsCore.Builder.Literal unwraps the literals into the terms an action matches on.

This is what replaced a private table of ~s|JS.#{verb}("#{class}", to: "#{target}")|. A string built that way has to be re-checked after the fact, because building it is where the mistake goes; a term printed by the formatter cannot have the mistake in it.

Where the application happens

In the editor, once, when an author presses Apply — MishkaCmsCore.Runtime.PageBuilder.Interactions calls this from the admin LiveView. What reaches a document is the printed source and only that, so nothing here is in a visitor's page, in the compiled module, or on the render path.

What a page is allowed to contain

A command names JS and carries data. That is the whole rule, and it is kept by construction rather than by inspection: an action hands over terms, data?/1 refuses anything that is not data before it is escaped, and the source is built here. There is no path by which a package's text reaches a document.

Summary

Types

t()

One command: the Phoenix.LiveView.JS function name, its positional arguments, and its options.

Functions

The commands a source string holds, in the order they run, or :no.

The source for one command, or for a pipe of them.

Types

t()

@type t() :: {atom(), [term()], keyword()}

One command: the Phoenix.LiveView.JS function name, its positional arguments, and its options.

{:add_class, ["is-open"], [to: ".pb-7f3a91b2"]}
{:push, ["pb:say"], [value: %{"kind" => "info", "message" => "Saved"}]}
{:show, [], [to: ".pb-7f3a91b2", time: 300]}

Functions

read(source)

@spec read(String.t()) :: {:ok, [t()]} | :no

The commands a source string holds, in the order they run, or :no.

A pipe reads as a list; anything that is not JS calls carrying literal data reads as :no and belongs to whoever hand-wrote it.

iex> MishkaCmsCore.Builder.Command.read(~s|JS.toggle_class("on", to: ".pb-1")|)
{:ok, [{:toggle_class, ["on"], [to: ".pb-1"]}]}

iex> MishkaCmsCore.Builder.Command.read(~s|JS.show(to: some_variable)|)
:no

write(commands)

@spec write(t() | [t()]) :: {:ok, String.t()} | {:error, String.t()}

The source for one command, or for a pipe of them.

iex> MishkaCmsCore.Builder.Command.write({:add_class, ["is-open"], [to: ".pb-1"]})
{:ok, ~s|JS.add_class("is-open", to: ".pb-1")|}

iex> MishkaCmsCore.Builder.Command.write({:show, [], [to: ".pb-1", speed: "fast"]})
{:error, "invalid option for show\nExpected keys to be one of [:to, :transition, :display, :time, :blocking], got: :speed\n"}