From 88407881db9837804ddc7b8b4e719b200ecc17ee Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 1 Oct 2026 12:40:24 +0400 Subject: [PATCH] vapor_split: report events and v-model as bindings vapor_split wrote LiveView's conventions into its output: events became phx-* attributes and v-model added phx-change="_changed". That is PhoenixVapor's policy, not Vize's, and it left callers no way to choose otherwise (PhoenixVapor's hybrid mode needs no event attributes at all). The split now leaves events and v-models unrendered and returns them as bindings: the IR node, as vapor_ir encodes it, and the {static_index, offset} where the element's start tag ends, so a caller can insert its own attributes there. Binding markers go through the same marker pass as slots, so positions stay exact across slots, and every nested v-if and v-for split carries its own bindings. This changes vapor_split's output and is meant for 0.16.0. --- CHANGELOG.md | 2 +- codegen/vize/codegen/native_types.ex | 12 ++- guides/features/vapor.md | 15 ++- lib/vize.ex | 20 ++-- native/vize_ex_nif/src/generated_types.rs | 8 ++ native/vize_ex_nif/src/lib.rs | 3 +- native/vize_ex_nif/src/vapor_split.rs | 122 +++++++++++++++------- test/vize/vapor_split_test.exs | 50 ++++++++- 8 files changed, 178 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4bfc9..fc8f432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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="_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 diff --git a/codegen/vize/codegen/native_types.ex b/codegen/vize/codegen/native_types.ex index b5a4c59..9eaf3ce 100644 --- a/codegen/vize/codegen/native_types.ex +++ b/codegen/vize/codegen/native_types.ex @@ -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()}] } @@ -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 :: %{ diff --git a/guides/features/vapor.md b/guides/features/vapor.md index 76f446d..6a9415d 100644 --- a/guides/features/vapor.md +++ b/guides/features/vapor.md @@ -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(

{{ msg }}

)) @@ -66,4 +66,13 @@ split.statics # ["

", "

"] 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()) + +split.statics # [""] +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. diff --git a/lib/vize.ex b/lib/vize.ex index 53b5a98..8b6a375 100644 --- a/lib/vize.ex +++ b/lib/vize.ex @@ -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 diff --git a/native/vize_ex_nif/src/generated_types.rs b/native/vize_ex_nif/src/generated_types.rs index 1ad2bc0..653ec42 100644 --- a/native/vize_ex_nif/src/generated_types.rs +++ b/native/vize_ex_nif/src/generated_types.rs @@ -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, pub slots: Vec>, + pub bindings: Vec>, } #[derive(Clone, Debug, rustler::NifMap)] pub struct EncodedTemplateAsset { @@ -209,6 +216,7 @@ pub struct EncodedVaporOutput { pub struct EncodedVaporSplit<'a> { pub statics: Vec, pub slots: Vec>, + pub bindings: Vec>, pub templates: Vec, pub element_template_map: Vec<(usize, usize)>, } diff --git a/native/vize_ex_nif/src/lib.rs b/native/vize_ex_nif/src/lib.rs index 64b6a06..fef74a8 100644 --- a/native/vize_ex_nif/src/lib.rs +++ b/native/vize_ex_nif/src/lib.rs @@ -1013,11 +1013,12 @@ fn vapor_split_nif_impl<'a>(env: Env<'a>, source: &str) -> NifResult> { 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 diff --git a/native/vize_ex_nif/src/vapor_split.rs b/native/vize_ex_nif/src/vapor_split.rs index ddfe244..fc58708 100644 --- a/native/vize_ex_nif/src/vapor_split.rs +++ b/native/vize_ex_nif/src/vapor_split.rs @@ -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> { @@ -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>( @@ -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> { @@ -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>, slot: Term<'a>, @@ -292,34 +309,71 @@ 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>, -) -> (Vec, Vec>) { + binding_nodes: Vec<(Atom, Term<'a>)>, +) -> (Vec, Vec>, Vec>) { + 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::() 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>( @@ -327,7 +381,7 @@ pub(crate) fn process_block<'a, 'b>( block: &'b BlockIRNode<'b>, ir: &'b RootIRNode<'b>, source: &str, -) -> (Vec, Vec>) { +) -> (Vec, Vec>, Vec>) { let template_html: String = block .returns .iter() @@ -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> = 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); } } } @@ -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); } } _ => {} @@ -551,5 +601,5 @@ pub(crate) fn process_block<'a, 'b>( } } - split_on_slot_markers(&html, slots) + split_on_markers(&html, slots, binding_nodes) } diff --git a/test/vize/vapor_split_test.exs b/test/vize/vapor_split_test.exs index 91d9992..46420cb 100644 --- a/test/vize/vapor_split_test.exs +++ b/test/vize/vapor_split_test.exs @@ -6,10 +6,8 @@ defmodule Vize.VaporSplitTest do statics = Enum.join(split.statics) - assert statics =~ "phx-change=\"name_changed\"" assert statics =~ "x|) + describe "bindings" do + test "events are reported with where their element's start tag ends" do + {:ok, split} = + Vize.vapor_split(~S|
|) - assert split.statics == [~s||] + assert split.statics == ["
"] + + assert [%{kind: :set_event, node: node, at: {0, 12}}] = split.bindings + assert node.key == {:static_, "click"} + assert node.value == ~s|say("hi")| + assert node.modifiers.non_keys == ["prevent"] + + assert binary_part(hd(split.statics), 0, 12) == "
|) + + assert split.statics == [~s()] + + assert [%{kind: :directive, node: %{name: "model", value: "name"}, at: {1, 1}}] = + split.bindings + + assert [%{kind: :v_model}] = split.slots + end + + test "positions count from the static they fall in" do + {:ok, split} = + Vize.vapor_split(~S|

{{ a }}

|) + + assert [%{at: {index, offset}}] = split.bindings + assert binary_part(Enum.at(split.statics, index), 0, offset) =~ ~r/
  • {{ item }}
  • x| + ) + + assert split.bindings == [] + + [for_slot, if_slot] = split.slots + assert [%{kind: :set_event, node: %{value: "pick(item)"}}] = for_slot.render.bindings + assert [%{kind: :set_event, node: %{value: "off"}}] = if_slot.positive.bindings + end end end