Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

## Unreleased

### Breaking changes

- `Vize.vapor_split/1` is replaced by `Vize.split_template/2`, which works from Vize's L2 semantic IR, the representation Vize's SSR compiler builds on, instead of scanning Vapor's template HTML. The split no longer has to locate elements in generated HTML, so whole classes of bugs are gone: slots dropped after text nodes, components placed before their siblings, a root `v-if` rendered twice, and implicit default slot content lost beside a named slot. The slots have a new shape, documented in [Splitting templates](guides/features/templates-and-ssr.md#splitting-templates):
- Expressions are source strings, and every slot has a `:position`.
- An `:attr` slot is a whole attribute, so a renderer can leave it out, as Vue does for `null` and false boolean attributes; a static attribute of the same name comes with it.
- Components carry their props, events, and the content of each slot; `<slot>` outlets are slots too.
- `v-show`, `v-model` on `<input>` and `<textarea>`, `v-html`, `v-text`, `v-bind` objects and dynamic attribute names are slots.
- Events and `v-model`s are `:on` and `:model` bindings.
- Errors from `split_template/2` are `Vize.Error`s with a `Vize.Diagnostic` and position for each problem, such as an expression that doesn't parse; it used to return a list of strings.

### Added

- `split_template/2` takes `root_attrs: true` for a component's template: the attributes of a single root element come back as one `:root_attrs` slot, so a caller can merge the [fallthrough attributes](https://vuejs.org/guide/components/attrs.html) a parent passes.
- Warnings, such as a custom directive that doesn't run on the server, come back in the split's `:diagnostics`.
- `Vize.Diagnostic` has a `:severity`, and `Vize.Diagnostic.to_code_diagnostic/2` converts one to Elixir's `t:Code.diagnostic/1` shape, positioned in the file a template came from.
- `Vize.Error` messages list their diagnostics with positions.

## 0.16.1 - 2026-10-03

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

176 changes: 136 additions & 40 deletions codegen/vize/codegen/native_types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -122,71 +122,167 @@ defmodule Vize.Codegen.NativeTypes do
required(:element_template_map) => [{R.usize(), R.usize()}]
}

@type encoded_vapor_split :: %{
## split_template
#
# A template split into static HTML and the dynamic slots between them, the
# shape of `%Phoenix.LiveView.Rendered{}`. Expressions are JavaScript source
# strings. Every slot has a `position`, `{line, column}` in the template.

@type split_template_opts :: %{required(:root_attrs) => boolean()}

@type encoded_template_split :: %{
required(:statics) => [String.t()],
required(:slots) => [R.term()],
required(:bindings) => [encoded_split_binding()],
required(:templates) => [String.t()],
required(:element_template_map) => [{R.usize(), R.usize()}]
required(:diagnostics) => [encoded_split_diagnostic()]
}

## vapor_split slots
#
# Each slot fills the hole between two statics. Expressions (`R.term()`) are
# a string, or `{:static, string}` for static values.

@type encoded_split_block :: %{
required(:statics) => [String.t()],
required(:slots) => [R.term()],
required(:bindings) => [encoded_split_binding()]
}

# An event or v-model on an element, left for the caller to render. `at` is
# `{static_index, byte_offset}`: where that element's start tag ends.
# A problem with the template. Positions are 1-based; `end` is exclusive.
@type encoded_split_diagnostic :: %{
required(:severity) => :error | :warning,
required(:message) => String.t(),
required(:start) => encoded_position(),
required(:end) => encoded_position()
}

# An event or `v-model` on an element, left for the caller to render. `at` is
# `{static_index, byte_offset}`: where the element's start tag ends.
@type encoded_split_binding :: %{
required(:kind) => :set_event | :directive,
required(:node) => R.term(),
required(:at) => {R.usize(), R.usize()}
required(:kind) => :on | :model,
required(:name) => String.t() | nil,
required(:modifiers) => [String.t()],
required(:value) => String.t() | nil,
required(:at) => {R.usize(), R.usize()},
required(:position) => {R.usize(), R.usize()}
}

@type encoded_values_slot :: %{
required(:kind) => :set_prop | :set_text,
required(:values) => [R.term()]
# Escaped text from an interpolation.
@type encoded_text_slot :: %{
required(:kind) => :text,
required(:value) => String.t(),
required(:position) => {R.usize(), R.usize()}
}

@type encoded_value_slot :: %{
required(:kind) => :v_show | :v_model | :set_html,
required(:value) => R.term()
# Unescaped HTML, from `v-html`.
@type encoded_html_slot :: %{
required(:kind) => :html,
required(:value) => String.t(),
required(:position) => {R.usize(), R.usize()}
}

# One attribute, rendered whole with its leading space so a caller can leave
# it out. `name_value` is the expression of a dynamic `:[name]`. `static` is
# a static attribute of the same name, such as `class="a"` beside
# `:class="b"`, and `show` the `v-show` expression a `style` combines with.
@type encoded_attr_slot :: %{
required(:kind) => :attr,
required(:name) => String.t() | nil,
required(:name_value) => String.t() | nil,
required(:static) => String.t() | nil,
required(:value) => String.t() | nil,
required(:show) => String.t() | nil,
required(:position) => {R.usize(), R.usize()}
}

# The attributes of an object, from `v-bind="attrs"`.
@type encoded_spread_slot :: %{
required(:kind) => :spread,
required(:value) => String.t(),
required(:position) => {R.usize(), R.usize()}
}

# A `v-model` on a form element: the `value`, `checked` state, or text it
# renders. `type` and `static_value` are the element's static `type` and
# `value` attributes, which decide that for checkboxes and radios.
@type encoded_model_slot :: %{
required(:kind) => :model,
required(:tag) => String.t(),
required(:type) => String.t() | nil,
required(:static_value) => String.t() | nil,
required(:value) => String.t(),
required(:position) => {R.usize(), R.usize()}
}

# `negative` is a split block, a nested if slot, or nil.
@type encoded_if_slot :: %{
required(:kind) => :if_node,
required(:condition) => R.term(),
required(:positive) => encoded_split_block(),
required(:negative) => R.term() | nil
required(:kind) => :if,
required(:branches) => [encoded_if_branch()],
required(:position) => {R.usize(), R.usize()}
}

# `condition` is nil for `v-else`.
@type encoded_if_branch :: %{
required(:condition) => String.t() | nil,
required(:block) => encoded_split_block()
}

@type encoded_for_slot :: %{
required(:kind) => :for_node,
required(:source) => R.term(),
required(:value) => R.term() | nil,
required(:key_prop) => R.term() | nil,
required(:render) => encoded_split_block()
required(:kind) => :for,
required(:source) => String.t(),
required(:value) => String.t(),
required(:key) => String.t() | nil,
required(:index) => String.t() | nil,
required(:key_prop) => String.t() | nil,
required(:block) => encoded_split_block(),
required(:position) => {R.usize(), R.usize()}
}

# A prop, attribute or `v-bind` object passed to a component or outlet, or
# one of a root element's attributes. A spread has neither name.
@type encoded_prop :: %{
required(:name) => String.t() | nil,
required(:name_value) => String.t() | nil,
required(:static) => String.t() | nil,
required(:value) => String.t() | nil
}

@type encoded_event :: %{
required(:name) => String.t() | nil,
required(:modifiers) => [String.t()],
required(:value) => String.t() | nil
}

@type encoded_component_slot :: %{
required(:kind) => :create_component,
required(:tag) => String.t(),
required(:props) => [R.term()],
required(:value) =>
:regular
| :teleport
| :keep_alive
| :suspense
| :transition
| :transition_group
| :dynamic
required(:kind) => :component,
required(:name) => String.t(),
required(:props) => [encoded_prop()],
required(:events) => [encoded_event()],
required(:slots) => [encoded_slot_content()],
required(:position) => {R.usize(), R.usize()}
}

# Content passed to one of a component's slots. `params` is the slot props
# pattern, such as `{ item }`.
@type encoded_slot_content :: %{
required(:name) => String.t() | nil,
required(:name_value) => String.t() | nil,
required(:params) => String.t() | nil,
required(:block) => encoded_split_block()
}

# A `<slot>` outlet, where the parent's slot content renders.
@type encoded_slot_outlet :: %{
required(:kind) => :slot,
required(:name) => String.t() | nil,
required(:name_value) => String.t() | nil,
required(:props) => [encoded_prop()],
required(:fallback) => encoded_split_block() | nil,
required(:position) => {R.usize(), R.usize()}
}

# With `root_attrs: true`, every attribute of the template's single root
# element, in authored order, so a caller can merge a component's
# fallthrough attributes in.
@type encoded_root_attrs_slot :: %{
required(:kind) => :root_attrs,
required(:props) => [encoded_prop()],
required(:show) => String.t() | nil,
required(:position) => {R.usize(), R.usize()}
}

## SFC
Expand Down
8 changes: 7 additions & 1 deletion guides/cheatsheets/api.cheatmd
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ Vize.compile_vapor(source,

```elixir
Vize.vapor_ir(source)
Vize.vapor_split(source)
```

### Split for server rendering

```elixir
{:ok, %{statics: statics, slots: slots}} = Vize.split_template(source)
Vize.split_template(source, root_attrs: true)
```

## CSS
Expand Down
46 changes: 46 additions & 0 deletions guides/features/templates-and-ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,49 @@ result.code
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](https://github.com/elixir-volt/phoenix_vapor) 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.

```elixir
{: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](https://vuejs.org/guide/essentials/template-syntax.html#boolean-attributes). 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](https://vuejs.org/guide/components/slots.html); a `<slot>` outlet is a slot of its own:

```elixir
{: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](https://vuejs.org/guide/components/attrs.html) the parent passed:

```elixir
{: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-model`s 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:

```elixir
{: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 `t:Code.diagnostic/1` shape for a file, offset to where the template starts in it.
22 changes: 1 addition & 21 deletions guides/features/vapor.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,4 @@ Every node has a `:kind`:

Expressions are strings; static values are `{:static_, "value"}` tuples.

## Splitting templates

`Vize.vapor_split/1` turns a template into static HTML strings and the dynamic slots between them, the shape of `%Phoenix.LiveView.Rendered{}`. `v-if` and `v-for` blocks are split recursively.

```elixir
{:ok, split} = Vize.vapor_split(~s(<div :class="cls"><p>{{ msg }}</p></div>))

split.statics # ["<div class=\"", "\"><p>", "</p></div>"]
split.slots # [%{kind: :set_prop, values: ["cls"]}, %{kind: :set_text, values: ["msg"]}]
```

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

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

split.statics # ["<button>", "</button>"]
split.bindings # [%{kind: :set_event, node: %{key: {:static_, "click"}, value: "save", ...}, at: {0, 7}}]
```

`at: {0, 7}` is byte 7 of the first static, just before the `>`. [PhoenixVapor](https://github.com/elixir-volt/phoenix_vapor) uses it to add `phx-click="save"` there.
To render a template on a server without JavaScript, see [Splitting templates](templates-and-ssr.md#splitting-templates).
Loading
Loading