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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Fixed

- `vapor_split/1` no longer drops slots when a text node comes before an element. Vapor counts text nodes when it navigates the template, so in `<li>a<ul><li>{{ b }}</li></ul></li>` the split looked for the wrong element and silently left `{{ b }}` out. Elements are now located by their DOM node position.
- `vapor_split/1` returns an error if it can't locate an element a slot or binding belongs to, instead of leaving the slot out.

## 0.16.0 - 2026-10-01

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Hex.pm](https://img.shields.io/hexpm/v/vize.svg)](https://hex.pm/packages/vize) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/vize)

Elixir bindings for [Vize](https://vizejs.dev), a Vue.js toolchain written in Rust. Parse, analyze, lint, and compile Vue single-file components from Elixir, including Vapor mode and its intermediate representation, plus a LightningCSS-based CSS pipeline.
Elixir bindings for [Vize](https://vizejs.dev), a [Vue.js](https://vuejs.org) toolchain written in Rust. Parse, analyze, lint, and compile Vue [single-file components](https://vuejs.org/guide/scaling-up/sfc.html) from Elixir, including [Vapor mode](https://github.com/vuejs/core/releases/tag/v3.6.0-beta.1#about-vapor-mode) and its intermediate representation, plus a [LightningCSS](https://lightningcss.dev)-based CSS pipeline.

```elixir
{:ok, result} =
Expand Down
2 changes: 2 additions & 0 deletions guides/features/sfc.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Single-File Components

A [single-file component](https://vuejs.org/guide/scaling-up/sfc.html) is a `.vue` file with a template, scripts, and styles. Vue's [SFC syntax specification](https://vuejs.org/api/sfc-spec.html) describes the blocks.

## Parsing

`Vize.parse_sfc/1` returns the descriptor: the template, `<script>`, `<script setup>`, styles, and custom blocks, each with its content, `lang`, attributes, and location.
Expand Down
2 changes: 1 addition & 1 deletion guides/features/templates-and-ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Options:

## Server-side rendering

`Vize.compile_ssr/1` compiles a template to code that writes HTML with `_push()`. Run it in a JavaScript runtime such as [QuickBEAM](https://github.com/elixir-volt/quickbeam).
`Vize.compile_ssr/1` compiles a template for Vue's [server-side rendering](https://vuejs.org/guide/scaling-up/ssr.html), to code that writes HTML with `_push()`. Run it in a JavaScript runtime such as [QuickBEAM](https://github.com/elixir-volt/quickbeam).

```elixir
{:ok, result} = Vize.compile_ssr("<div>{{ msg }}</div>")
Expand Down
2 changes: 1 addition & 1 deletion guides/features/vapor.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Vapor Mode

Vapor is Vue's compilation mode without a virtual DOM: a template compiles to code that creates and updates DOM nodes directly.
[Vapor mode](https://github.com/vuejs/core/releases/tag/v3.6.0-beta.1#about-vapor-mode) is Vue's compilation mode without a virtual DOM: a template compiles to code that creates and updates DOM nodes directly. It is new in Vue 3.6.

## Compiling

Expand Down
70 changes: 44 additions & 26 deletions native/vize_ex_nif/src/html_inject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ pub(crate) struct TagEntry {
pub(crate) tag: String,
pub(crate) pos: usize,
pub(crate) parent: Option<usize>,
pub(crate) child_index: usize,
/// Position among the parent's child nodes, counting text runs and anchors
/// as the DOM does. Vapor's `child`/`next` offsets count the same way.
pub(crate) node_index: usize,
pub(crate) open_start: usize,
pub(crate) open_end: usize,
pub(crate) close_start: Option<usize>,
Expand Down Expand Up @@ -61,36 +63,47 @@ fn skip_declaration(bytes: &[u8], i: &mut usize) {
}
}

fn sibling_count(entries: &[TagEntry], parent: Option<usize>) -> usize {
entries
.iter()
.filter(|entry| entry.parent == parent)
.count()
fn next_node_index(
node_counts: &mut std::collections::HashMap<Option<usize>, usize>,
parent: Option<usize>,
) -> usize {
let count = node_counts.entry(parent).or_default();
let index = *count;
*count += 1;
index
}

pub(crate) fn parse_tag_tree(html: &str) -> Vec<TagEntry> {
let bytes = html.as_bytes();
let len = bytes.len();
let mut entries: Vec<TagEntry> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
let mut node_counts: std::collections::HashMap<Option<usize>, usize> =
std::collections::HashMap::new();
let mut in_text = false;
let mut i = 0;

while i < len {
if bytes[i] != b'<' {
if !in_text {
*node_counts.entry(stack.last().copied()).or_default() += 1;
in_text = true;
}
i += 1;
continue;
}
in_text = false;

if html[i..].starts_with(VAPOR_ANCHOR) {
// Anchors are DOM nodes at runtime, so child/next offsets count them.
let parent = stack.last().copied();
let child_index = sibling_count(&entries, parent);
let node_index = next_node_index(&mut node_counts, parent);
let pos = entries.len();
entries.push(TagEntry {
tag: VAPOR_ANCHOR_TAG.to_string(),
pos,
parent,
child_index,
node_index,
open_start: i,
open_end: i + VAPOR_ANCHOR.len(),
close_start: None,
Expand Down Expand Up @@ -159,14 +172,14 @@ pub(crate) fn parse_tag_tree(html: &str) -> Vec<TagEntry> {
let open_end = i;

let parent = stack.last().copied();
let child_index = sibling_count(&entries, parent);
let node_index = next_node_index(&mut node_counts, parent);

let pos = entries.len();
entries.push(TagEntry {
tag: tag_name.clone(),
pos,
parent,
child_index,
node_index,
open_start,
open_end,
close_start: None,
Expand Down Expand Up @@ -200,24 +213,20 @@ pub(crate) fn build_elem_to_tag(
match op {
vize_atelier_vapor::ir::OperationNode::ChildRef(node) => {
if let Some(&parent_tag_pos) = map.get(&node.parent_id) {
let children: Vec<_> = tags
.iter()
.filter(|tag| tag.parent == Some(parent_tag_pos))
.collect();
if let Some(child) = children.get(node.offset) {
if let Some(child) = tags.iter().find(|tag| {
tag.parent == Some(parent_tag_pos) && tag.node_index == node.offset
}) {
map.insert(node.child_id, child.pos);
}
}
}
vize_atelier_vapor::ir::OperationNode::NextRef(node) => {
if let Some(&prev_tag_pos) = map.get(&node.prev_id) {
if let Some(prev_entry) = tags.get(prev_tag_pos) {
let siblings: Vec<_> = tags
.iter()
.filter(|tag| tag.parent == prev_entry.parent)
.collect();
let target_idx = prev_entry.child_index + node.offset;
if let Some(sibling) = siblings.get(target_idx) {
let target = prev_entry.node_index + node.offset;
if let Some(sibling) = tags.iter().find(|tag| {
tag.parent == prev_entry.parent && tag.node_index == target
}) {
map.insert(node.child_id, sibling.pos);
}
}
Expand Down Expand Up @@ -379,9 +388,18 @@ mod tests {
assert_eq!(tags[1].close_start, None);
assert_eq!(tags[2].close_start, None);
assert_eq!(tags[3].close_start, Some(html.find("</span>").unwrap()));
assert_eq!(tags[1].child_index, 0);
assert_eq!(tags[2].child_index, 1);
assert_eq!(tags[3].child_index, 2);
assert_eq!(tags[1].node_index, 0);
assert_eq!(tags[2].node_index, 1);
assert_eq!(tags[3].node_index, 2);
}

#[test]
fn parse_tag_tree_counts_text_runs_in_node_indices() {
let tags = parse_tag_tree("<p>a<b></b>c<!----><i></i></p>");

assert_eq!(tags[1].node_index, 1);
assert_eq!(tags[2].node_index, 3);
assert_eq!(tags[3].node_index, 4);
}

#[test]
Expand All @@ -403,8 +421,8 @@ mod tests {
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].parent, None);
assert_eq!(tags[1].parent, None);
assert_eq!(tags[0].child_index, 0);
assert_eq!(tags[1].child_index, 1);
assert_eq!(tags[0].node_index, 0);
assert_eq!(tags[1].node_index, 1);
}

#[test]
Expand Down
5 changes: 4 additions & 1 deletion native/vize_ex_nif/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1013,7 +1013,10 @@ 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, bindings) = process_block(env, &ir.block, &ir, source);
let (statics, slots, bindings) = match process_block(env, &ir.block, &ir, source) {
Ok(split) => split,
Err(message) => return Ok(error_term(env, vec![message])),
};

let split = EncodedVaporSplit {
statics,
Expand Down
65 changes: 47 additions & 18 deletions native/vize_ex_nif/src/vapor_split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,43 +34,45 @@ fn split_block<'a, 'b>(
block: &'b BlockIRNode<'b>,
ir: &'b RootIRNode<'b>,
source: &str,
) -> EncodedSplitBlock<'a> {
let (statics, slots, bindings) = process_block(env, block, ir, source);
EncodedSplitBlock {
) -> Result<EncodedSplitBlock<'a>, String> {
let (statics, slots, bindings) = process_block(env, block, ir, source)?;
Ok(EncodedSplitBlock {
statics,
slots,
bindings,
}
})
}

fn encode_slot_if_split<'a, 'b>(
env: Env<'a>,
if_node: &'b IfIRNode<'b>,
ir: &'b RootIRNode<'b>,
source: &str,
) -> Term<'a> {
) -> Result<Term<'a>, String> {
let negative = match &if_node.negative {
Some(NegativeBranch::Block(block)) => Some(split_block(env, block, ir, source).encode(env)),
Some(NegativeBranch::If(nested)) => Some(encode_slot_if_split(env, nested, ir, source)),
Some(NegativeBranch::Block(block)) => {
Some(split_block(env, block, ir, source)?.encode(env))
}
Some(NegativeBranch::If(nested)) => Some(encode_slot_if_split(env, nested, ir, source)?),
None => None,
};

EncodedIfSlot {
Ok(EncodedIfSlot {
kind: atoms::if_node(),
condition: encode_simple_expr(env, &if_node.condition),
positive: split_block(env, &if_node.positive, ir, source),
positive: split_block(env, &if_node.positive, ir, source)?,
negative,
}
.encode(env)
.encode(env))
}

fn encode_slot_for_split<'a, 'b>(
env: Env<'a>,
for_node: &'b ForIRNode<'b>,
ir: &'b RootIRNode<'b>,
source: &str,
) -> Term<'a> {
EncodedForSlot {
) -> Result<Term<'a>, String> {
Ok(EncodedForSlot {
kind: atoms::for_node(),
source: encode_simple_expr(env, &for_node.source),
value: for_node
Expand All @@ -81,9 +83,9 @@ fn encode_slot_for_split<'a, 'b>(
.key_prop
.as_ref()
.map(|key_prop| encode_simple_expr(env, key_prop)),
render: split_block(env, &for_node.render, ir, source),
render: split_block(env, &for_node.render, ir, source)?,
}
.encode(env)
.encode(env))
}

fn encode_slot_component<'a>(env: Env<'a>, node: &CreateComponentIRNode) -> Term<'a> {
Expand Down Expand Up @@ -376,12 +378,14 @@ fn split_on_markers<'a>(
(statics, ordered_slots, bindings)
}

type SplitResult<'a> = Result<(Vec<String>, Vec<Term<'a>>, Vec<EncodedSplitBinding<'a>>), String>;

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<EncodedSplitBinding<'a>>) {
) -> SplitResult<'a> {
let template_html: String = block
.returns
.iter()
Expand Down Expand Up @@ -457,6 +461,31 @@ pub(crate) fn process_block<'a, 'b>(
}
}

// A slot whose element can't be located would silently disappear from the
// output, so fail instead.
let bound_elements = block
.operation
.iter()
.filter_map(|operation| match operation {
OperationNode::SetEvent(event) => Some(event.element),
OperationNode::Directive(dir) if matches!(dir.name, "model" | "show") => {
Some(dir.element)
}
_ => None,
});
if let Some(element) = text_effects
.iter()
.map(|text| text.element)
.chain(html_effects.iter().map(|html| html.element))
.chain(prop_effects.iter().map(|prop| prop.element))
.chain(bound_elements)
.find(|element| !elem_to_tag.contains_key(element))
{
return Err(format!(
"vapor_split could not locate element {element} in the template"
));
}

for prop in &prop_effects {
if let Some(&tag_pos) = elem_to_tag.get(&prop.element) {
let values: Vec<Term<'a>> = prop
Expand Down Expand Up @@ -551,7 +580,7 @@ pub(crate) fn process_block<'a, 'b>(
match operation {
OperationNode::If(if_node) => {
let source_offset = if_node.condition.loc.span.start;
let slot = encode_slot_if_split(env, if_node, ir, source);
let slot = encode_slot_if_split(env, if_node, ir, source)?;
let marker = push_slot_marker(&mut slots, slot, source_offset);
inject_structural_marker(
&mut html,
Expand All @@ -565,7 +594,7 @@ pub(crate) fn process_block<'a, 'b>(
}
OperationNode::For(for_node) => {
let source_offset = for_node.source.loc.span.start;
let slot = encode_slot_for_split(env, for_node, ir, source);
let slot = encode_slot_for_split(env, for_node, ir, source)?;
let marker = push_slot_marker(&mut slots, slot, source_offset);
inject_structural_marker(
&mut html,
Expand Down Expand Up @@ -601,5 +630,5 @@ pub(crate) fn process_block<'a, 'b>(
}
}

split_on_markers(&html, slots, binding_nodes)
Ok(split_on_markers(&html, slots, binding_nodes))
}
24 changes: 24 additions & 0 deletions test/vize/vapor_split_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,28 @@ defmodule Vize.VaporSplitTest do
assert [%{kind: :set_event, node: %{value: "off"}}] = if_slot.positive.bindings
end
end

describe "text nodes before elements" do
test "an element after a text node in a nested list keeps its slot" do
{:ok, split} =
Vize.vapor_split(~S|<ul><li>a<ul><li>{{ b }}</li></ul></li><li>c</li></ul>|)

assert split.statics == ["<ul><li>a<ul><li>", "</li></ul></li><li>c</li></ul>"]
assert [%{kind: :set_text, values: ["b"]}] = split.slots
end

test "slots after mixed text and elements" do
{:ok, split} = Vize.vapor_split(~S|<p>a<b>{{ x }}</b>c<i :title="t">{{ y }}</i></p>|)

assert split.statics == ["<p>a<b>", "</b>c<i title=\"", "\">", "</i></p>"]
assert Enum.map(split.slots, & &1.kind) == [:set_text, :set_prop, :set_text]
end

test "an event after a text node is bound to its element" do
{:ok, split} = Vize.vapor_split(~S|<div>hi<button @click="go">x</button></div>|)

assert [%{at: {0, offset}}] = split.bindings
assert binary_part(hd(split.statics), 0, offset) == "<div>hi<button"
end
end
end
Loading