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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@

### Changed

- **Breaking:** `vapor_split/1` no longer writes LiveView attributes. Events and `v-model`s are returned in a new `:bindings` list instead of becoming `phx-*` attributes and a `phx-change="<name>_changed"` convention. Each binding carries the Vapor IR node and the `{static_index, offset}` where its element's start tag ends, so callers such as PhoenixVapor add their own attributes. Every nested split has its own `:bindings`.
- NIFs take one typed options map instead of positional arguments; `compile_sfc_nif` had eight. RustQ derives the Rust struct and decoder for each map from a typespec, and the public functions keep their keyword options and defaults. CSS browser targets are optional versions instead of `-1` sentinels.

### Fixed

- Document that CSS `:targets` take effect only with `minify: true`, because Vize applies them while minifying.
- Escape event handlers in the `phx-*` attributes `vapor_split` writes. `@click='say("hi")'` produced `phx-click="say("hi")"`, which ends the attribute early.

## 0.15.0 - 2026-09-30

Expand Down
12 changes: 11 additions & 1 deletion codegen/vize/codegen/native_types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ defmodule Vize.Codegen.NativeTypes do
@type encoded_vapor_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()}]
}
Expand All @@ -136,7 +137,16 @@ defmodule Vize.Codegen.NativeTypes do

@type encoded_split_block :: %{
required(:statics) => [String.t()],
required(:slots) => [R.term()]
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.
@type encoded_split_binding :: %{
required(:kind) => :set_event | :directive,
required(:node) => R.term(),
required(:at) => {R.usize(), R.usize()}
}

@type encoded_values_slot :: %{
Expand Down
15 changes: 12 additions & 3 deletions guides/features/vapor.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ Every node has a `:kind`:

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

## Splitting templates for LiveView
## Splitting templates

`Vize.vapor_split/1` turns a template into static HTML strings and the dynamic slots between them, shaped for `%Phoenix.LiveView.Rendered{}`. `v-if` and `v-for` blocks are split recursively.
`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>))
Expand All @@ -66,4 +66,13 @@ split.statics # ["<div class=\"", "\"><p>", "</p></div>"]
split.slots # [%{kind: :set_prop, values: ["cls"]}, %{kind: :set_text, values: ["msg"]}]
```

The current split also applies LiveView conventions: event handlers become `phx-*` attributes, and `v-model` adds `phx-change`. Those conventions are moving to [PhoenixVapor](https://github.com/elixir-volt/phoenix_vapor), which renders Vue templates as LiveView. A later release will report events as data on the split instead.
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.
20 changes: 13 additions & 7 deletions lib/vize.ex
Original file line number Diff line number Diff line change
Expand Up @@ -371,16 +371,22 @@ defmodule Vize do
end

@doc """
Compile a Vue template into a statics/slots split ready for LiveView `%Rendered{}`.
Split a Vue template into static HTML and the dynamic slots between them.

Returns `{:ok, split}` where `split` has:
- `"statics"` — list of static HTML strings (interleaved between dynamic slots)
- `"slots"` — ordered list of slot descriptors, each with `:kind` and values/sub-IR
- `:templates` — raw template strings (for sub-block rendering)
- `:element_template_map` — element ID → template index mapping

The statics + slots can be directly assembled into a `%Phoenix.LiveView.Rendered{}`
struct by evaluating each slot against assigns.
* `:statics` — static HTML strings, one more than there are slots
* `:slots` — slot descriptors in document order, each with a `:kind`.
`v-if` and `v-for` slots carry their own split.
* `:bindings` — events and `v-model`s, which the split leaves for the caller
to render. Each has a `:kind` (`:set_event` or `:directive`), the IR
`:node` as `vapor_ir/1` encodes it, and `:at`, a `{static_index, offset}`
pair: the byte offset in that static where the element's start tag ends,
so attributes can be inserted there.
* `:templates` — raw template strings (for sub-block rendering)
* `:element_template_map` — element ID → template index mapping

The statics and slots map directly onto a `%Phoenix.LiveView.Rendered{}`.
"""
@spec vapor_split(String.t()) :: {:ok, map()} | {:error, [String.t()]}
def vapor_split(source) do
Expand Down
8 changes: 8 additions & 0 deletions native/vize_ex_nif/src/generated_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,16 @@ pub struct EncodedSourceLocation {
pub source: String,
}
#[derive(Clone, Debug, rustler::NifMap)]
pub struct EncodedSplitBinding<'a> {
pub kind: Atom,
pub node: Term<'a>,
pub at: (usize, usize),
}
#[derive(Clone, Debug, rustler::NifMap)]
pub struct EncodedSplitBlock<'a> {
pub statics: Vec<String>,
pub slots: Vec<Term<'a>>,
pub bindings: Vec<EncodedSplitBinding<'a>>,
}
#[derive(Clone, Debug, rustler::NifMap)]
pub struct EncodedTemplateAsset {
Expand Down Expand Up @@ -209,6 +216,7 @@ pub struct EncodedVaporOutput {
pub struct EncodedVaporSplit<'a> {
pub statics: Vec<String>,
pub slots: Vec<Term<'a>>,
pub bindings: Vec<EncodedSplitBinding<'a>>,
pub templates: Vec<String>,
pub element_template_map: Vec<(usize, usize)>,
}
Expand Down
3 changes: 2 additions & 1 deletion native/vize_ex_nif/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1013,11 +1013,12 @@ fn vapor_split_nif_impl<'a>(env: Env<'a>, source: &str) -> NifResult<Term<'a>> {

let ir = transform_to_ir(&allocator, &root, source);

let (statics, slots) = process_block(env, &ir.block, &ir, source);
let (statics, slots, bindings) = process_block(env, &ir.block, &ir, source);

let split = EncodedVaporSplit {
statics,
slots,
bindings,
templates: ir.templates.iter().map(ToString::to_string).collect(),
element_template_map: ir
.element_template_map
Expand Down
122 changes: 86 additions & 36 deletions native/vize_ex_nif/src/vapor_split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ use crate::html_inject::{
build_elem_to_tag, inject_attr, inject_before_close, parse_tag_tree,
replace_first_space_in_content, replace_range, replace_text_node, TagEntry,
};
use crate::ir_encoders::encode_ir_prop;
use crate::ir_encoders::{encode_directive_ir_node, encode_ir_prop, encode_set_event_ir_node};
use crate::ir_encoding::encode_simple_expr;
use crate::term_encoding::{
EncodedComponentSlot, EncodedForSlot, EncodedIfSlot, EncodedSplitBlock, EncodedValueSlot,
EncodedValuesSlot,
EncodedComponentSlot, EncodedForSlot, EncodedIfSlot, EncodedSplitBinding, EncodedSplitBlock,
EncodedValueSlot, EncodedValuesSlot,
};

fn encode_slot_values<'a>(env: Env<'a>, kind: Atom, values: Vec<Term<'a>>) -> Term<'a> {
Expand All @@ -35,8 +35,12 @@ fn split_block<'a, 'b>(
ir: &'b RootIRNode<'b>,
source: &str,
) -> EncodedSplitBlock<'a> {
let (statics, slots) = process_block(env, block, ir, source);
EncodedSplitBlock { statics, slots }
let (statics, slots, bindings) = process_block(env, block, ir, source);
EncodedSplitBlock {
statics,
slots,
bindings,
}
}

fn encode_slot_if_split<'a, 'b>(
Expand Down Expand Up @@ -107,6 +111,7 @@ fn encode_slot_component<'a>(env: Env<'a>, node: &CreateComponentIRNode) -> Term
}

const SLOT_MARKER_PREFIX: &str = "\0VIZE_SLOT_";
const BINDING_MARKER_PREFIX: &str = "\0VIZE_BINDING_";
const SLOT_MARKER_SUFFIX: char = '\0';

struct SlotMarker<'a> {
Expand Down Expand Up @@ -137,6 +142,18 @@ fn slot_marker(index: usize) -> String {
format!("{SLOT_MARKER_PREFIX}{index}{SLOT_MARKER_SUFFIX}")
}

// Marks where an element's start tag ends, for an event or v-model the split
// reports as a binding instead of rendering.
fn push_binding_marker<'a>(
bindings: &mut Vec<(Atom, Term<'a>)>,
kind: Atom,
node: Term<'a>,
) -> String {
let index = bindings.len();
bindings.push((kind, node));
format!("{BINDING_MARKER_PREFIX}{index}{SLOT_MARKER_SUFFIX}")
}

fn push_slot_marker<'a>(
slots: &mut Vec<SlotMarker<'a>>,
slot: Term<'a>,
Expand Down Expand Up @@ -292,42 +309,79 @@ fn inject_structural_marker(
}
}

fn split_on_slot_markers<'a>(
// Splits the HTML at slot markers into statics, and turns binding markers into
// `{static_index, byte_offset}` positions in those statics.
fn split_on_markers<'a>(
html: &str,
mut slots: Vec<SlotMarker<'a>>,
) -> (Vec<String>, Vec<Term<'a>>) {
binding_nodes: Vec<(Atom, Term<'a>)>,
) -> (Vec<String>, Vec<Term<'a>>, Vec<EncodedSplitBinding<'a>>) {
let unsplit = || (vec![html.to_string()], Vec::new(), Vec::new());
let mut statics = Vec::new();
let mut ordered_slots = Vec::new();
let mut bindings: Vec<(usize, (usize, usize))> = Vec::new();
let mut current = String::new();
let mut rest = html;

while let Some(position) = rest.find(SLOT_MARKER_PREFIX) {
statics.push(rest[..position].to_string());
loop {
let next_slot = rest
.find(SLOT_MARKER_PREFIX)
.map(|p| (p, SLOT_MARKER_PREFIX));
let next_binding = rest
.find(BINDING_MARKER_PREFIX)
.map(|p| (p, BINDING_MARKER_PREFIX));

let Some((position, prefix)) = [next_slot, next_binding]
.into_iter()
.flatten()
.min_by_key(|(position, _)| *position)
else {
break;
};

current.push_str(&rest[..position]);

let marker_body = &rest[position + SLOT_MARKER_PREFIX.len()..];
let marker_body = &rest[position + prefix.len()..];
let Some(suffix_position) = marker_body.find(SLOT_MARKER_SUFFIX) else {
return (vec![html.to_string()], Vec::new());
return unsplit();
};
let Ok(index) = marker_body[..suffix_position].parse::<usize>() else {
return (vec![html.to_string()], Vec::new());
};
let Some(slot) = slots.get_mut(index).and_then(|slot| slot.term.take()) else {
return (vec![html.to_string()], Vec::new());
return unsplit();
};

ordered_slots.push(slot);
if prefix == SLOT_MARKER_PREFIX {
let Some(slot) = slots.get_mut(index).and_then(|slot| slot.term.take()) else {
return unsplit();
};
ordered_slots.push(slot);
statics.push(std::mem::take(&mut current));
} else {
bindings.push((index, (statics.len(), current.len())));
}

rest = &marker_body[suffix_position + SLOT_MARKER_SUFFIX.len_utf8()..];
}

statics.push(rest.to_string());
(statics, ordered_slots)
current.push_str(rest);
statics.push(current);

let bindings = bindings
.into_iter()
.map(|(index, at)| {
let (kind, node) = binding_nodes[index];
EncodedSplitBinding { kind, node, at }
})
.collect();

(statics, ordered_slots, bindings)
}

pub(crate) fn process_block<'a, 'b>(
env: Env<'a>,
block: &'b BlockIRNode<'b>,
ir: &'b RootIRNode<'b>,
source: &str,
) -> (Vec<String>, Vec<Term<'a>>) {
) -> (Vec<String>, Vec<Term<'a>>, Vec<EncodedSplitBinding<'a>>) {
let template_html: String = block
.returns
.iter()
Expand All @@ -347,21 +401,17 @@ pub(crate) fn process_block<'a, 'b>(
let mut elem_to_tag = build_elem_to_tag(&block.returns, &block.operation, &tags);
let mut slots: Vec<SlotMarker<'a>> = Vec::new();

let mut binding_nodes: Vec<(Atom, Term<'a>)> = Vec::new();

for op in &block.operation {
if let OperationNode::SetEvent(event) = op {
if let Some(&tag_pos) = elem_to_tag.get(&event.element) {
let event_name = event.key.content;
let handler = event
.value
.as_ref()
.map(|value| value.content)
.unwrap_or(event_name);
let attr = format!(
" phx-{}=\"{}\"",
event_name,
html_escape::encode_double_quoted_attribute(handler)
let marker = push_binding_marker(
&mut binding_nodes,
atoms::set_event(),
encode_set_event_ir_node(env, event),
);
inject_attr(&mut html, &mut tags, tag_pos, &attr);
inject_attr(&mut html, &mut tags, tag_pos, &marker);
}
}
}
Expand Down Expand Up @@ -451,12 +501,12 @@ pub(crate) fn process_block<'a, 'b>(
let marker = push_slot_marker(&mut slots, slot, dir.dir.loc.span.start);
let attr = format!(" value=\"{marker}\"");
inject_attr(&mut html, &mut tags, tag_pos, &attr);
let handler_name = format!("{}_changed", simple.content);
let change_attr = format!(
" phx-change=\"{}\"",
html_escape::encode_double_quoted_attribute(&handler_name)
let marker = push_binding_marker(
&mut binding_nodes,
atoms::directive(),
encode_directive_ir_node(env, dir),
);
inject_attr(&mut html, &mut tags, tag_pos, &change_attr);
inject_attr(&mut html, &mut tags, tag_pos, &marker);
}
}
_ => {}
Expand Down Expand Up @@ -551,5 +601,5 @@ pub(crate) fn process_block<'a, 'b>(
}
}

split_on_slot_markers(&html, slots)
split_on_markers(&html, slots, binding_nodes)
}
Loading
Loading