Standalone templates

Vize.compile_template/2 compiles a template, without an SFC around it, to a render function:

{:ok, result} = Vize.compile_template(~s(<div v-if="show">{{ msg }}</div>))

result.code      # render function
result.preamble  # imports
result.helpers   # ["openBlock", "createBlock", "Fragment", "createCommentVNode", ...]

Options:

  • :mode: "function" (default) for a plain function, or "module" for an ES module.
  • :ssr: compile for server-side rendering.

Server-side rendering

Vize.compile_ssr/1 compiles a template for Vue's server-side rendering, to code that writes HTML with _push(). Run it in a JavaScript runtime such as QuickBEAM.

{:ok, result} = Vize.compile_ssr("<div>{{ msg }}</div>")

result.code
# function ssrRender(_ctx, _push, _parent, _attrs) {
#   _push(`<div${_ssrRenderAttrs(_attrs)}>${_ssrInterpolate(_ctx.msg)}</div>`)
# }

result.preamble
# import { ssrInterpolate as _ssrInterpolate, ssrRenderAttrs as _ssrRenderAttrs } from "@vue/server-renderer"

Splitting templates

Vize.split_template/2 turns a template into static HTML and the dynamic slots between them, the shape of %Phoenix.LiveView.Rendered{}, so a server can render it without JavaScript. PhoenixVapor uses it to render Vue templates in Phoenix LiveView.

It works from Vize's L2 semantic IR, which Vize's SSR compiler also builds on: elements, static attributes and text become HTML, and everything that depends on data becomes a slot. Expressions are JavaScript source strings, and every slot has a :position, {line, column} in the template.

{:ok, split} = Vize.split_template(~s(<p :class="kind">Hi {{ name }}</p>))

split.statics  # ["<p", ">Hi ", "</p>"]
split.slots    # [%{kind: :attr, name: "class", value: "kind", static: nil, ...}, %{kind: :text, value: "name", ...}]

An :attr slot stands for the whole attribute with its leading space, so a renderer can leave it out, as Vue does for null and a false boolean attribute. A static attribute of the same name, such as class="a" beside :class="b", arrives as its :static.

v-if and v-for slots carry a block each: its own :statics, :slots and :bindings. A component slot has its :props, :events, and the content passed to each of its slots; a <slot> outlet is a slot of its own:

{:ok, split} = Vize.split_template(~s(<Card title="Hi"><p>Body</p><template #footer="{ ok }">{{ ok }}</template></Card>))

[%{kind: :component, name: "Card", props: [%{name: "title", static: "Hi"}], slots: slots}] = split.slots
slots  # [%{name: "footer", params: "{ ok }", block: %{statics: ["", ""], ...}}, %{name: "default", block: ...}]

A component's own template can be split with root_attrs: true. When it has a single root element, all of that element's attributes, static ones included, come back as one :root_attrs slot, so the caller can merge in the fallthrough attributes the parent passed:

{:ok, split} = Vize.split_template(~s(<button class="btn" type="button"><slot /></button>), root_attrs: true)

split.statics  # ["<button", ">", "</button>"]
split.slots    # [%{kind: :root_attrs, props: [%{name: "class", static: "btn"}, %{name: "type", static: "button"}]}, %{kind: :slot, ...}]

Events and v-models are not rendered. They are reported as bindings, with the position where their element's start tag ends, so a caller can add whatever attributes its runtime needs:

{:ok, split} = Vize.split_template(~s(<button @click="save">{{ label }}</button>))

split.statics   # ["<button>", "</button>"]
split.bindings  # [%{kind: :on, name: "click", value: "save", modifiers: [], at: {0, 7}, ...}]

at: {0, 7} is byte 7 of the first static, just before the >. PhoenixVapor adds phx-click="save" there.

A template with errors, such as an expression that doesn't parse, returns {:error, %Vize.Error{}} with a Vize.Diagnostic for each, and warnings come back in the split's :diagnostics. Vize.Diagnostic.to_code_diagnostic/2 converts one to Elixir's Code.diagnostic/1 shape for a file, offset to where the template starts in it.