diff --git a/CHANGELOG.md b/CHANGELOG.md
index f484d37..ea439b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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; `` outlets are slots too.
+ - `v-show`, `v-model` on `` and `
> {
- let source_tags = parse_tag_tree(source);
- let excluded = structural_source_tags(block, &source_tags);
- let mut cursor = 0usize;
-
- rendered_tags
- .iter()
- .map(|rendered| {
- if rendered.is_anchor() {
- return None;
- }
- let match_pos = source_tags[cursor..]
- .iter()
- .position(|source_tag| {
- !excluded.contains(&source_tag.pos)
- && !source_tag.is_anchor()
- && source_tag.tag.eq_ignore_ascii_case(&rendered.tag)
- })
- .map(|relative| cursor + relative);
-
- match_pos.map(|pos| {
- cursor = pos + 1;
- source_tags[pos].open_start as u32
- })
- })
- .collect()
-}
-
-fn inject_structural_marker(
- html: &mut String,
- tags: &mut [TagEntry],
- elem_to_tag: &std::collections::HashMap,
- insertion: (Option, Option),
- marker: &str,
- source_position: (&[Option], u32),
- slots: &[SlotMarker<'_>],
-) {
- let (parent, anchor) = insertion;
- let (tag_source_offsets, source_offset) = source_position;
- if let Some(anchor_pos) = anchor.and_then(|id| elem_to_tag.get(&id)).copied() {
- if let Some(entry) = tags.get(anchor_pos) {
- // The marker takes the place of Vapor's own anchor comment.
- let anchor_len = if entry.is_anchor() {
- entry.open_end - entry.open_start
- } else {
- 0
- };
- replace_range(html, tags, entry.open_start, anchor_len, marker);
- return;
- }
- }
-
- let bounds = parent
- .and_then(|id| elem_to_tag.get(&id))
- .and_then(|&tag_pos| tags.get(tag_pos))
- .map(|entry| (entry.open_end, entry.close_start.unwrap_or(entry.open_end)));
-
- let next_tag_position = tags
- .iter()
- .zip(tag_source_offsets)
- .filter(|(tag, offset)| {
- offset.is_some_and(|offset| offset > source_offset)
- && bounds
- .map(|(start, end)| tag.open_start >= start && tag.open_start <= end)
- .unwrap_or(true)
- })
- .map(|(tag, _)| tag.open_start)
- .min();
-
- let next_marker_position = slots
- .iter()
- .enumerate()
- .filter(|(_, slot)| slot.source_offset > source_offset)
- .filter_map(|(index, _)| html.find(&slot_marker(index)))
- .filter(|position| {
- bounds
- .map(|(start, end)| *position >= start && *position <= end)
- .unwrap_or(true)
- })
- .min();
-
- if let Some(position) = [next_tag_position, next_marker_position]
- .into_iter()
- .flatten()
- .min()
- {
- replace_range(html, tags, position, 0, marker);
- } else if let Some(parent_id) = parent {
- if let Some(&tag_pos) = elem_to_tag.get(&parent_id) {
- inject_before_close(html, tags, tag_pos, marker);
- }
- } else {
- html.push_str(marker);
- }
-}
-
-// 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>,
- 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;
-
- 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 + prefix.len()..];
- let Some(suffix_position) = marker_body.find(SLOT_MARKER_SUFFIX) else {
- return unsplit();
- };
- let Ok(index) = marker_body[..suffix_position].parse::() else {
- return unsplit();
- };
-
- 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()..];
- }
-
- 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)
-}
-
-type SplitResult<'a> = Result<(Vec, Vec>, Vec>), String>;
-
-pub(crate) fn process_block<'a, 'b>(
- env: Env<'a>,
- block: &'b BlockIRNode<'b>,
- ir: &'b RootIRNode<'b>,
- source: &str,
-) -> SplitResult<'a> {
- let template_html: String = block
- .returns
- .iter()
- .map(|&elem_id| {
- let template_idx = ir
- .element_template_map
- .get(&elem_id)
- .copied()
- .unwrap_or(elem_id);
- ir.templates.get(template_idx).copied().unwrap_or("")
- })
- .collect();
-
- let mut html = template_html;
- let mut tags = parse_tag_tree(&html);
- let tag_source_offsets = align_tag_source_offsets(&tags, source, block);
- 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 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, &marker);
- }
- }
- }
-
- let all_effects: Vec<_> = block
- .effect
- .iter()
- .flat_map(|effect| effect.operations.iter())
- .collect();
-
- let mut prop_effects = Vec::new();
- let mut text_effects = Vec::new();
- let mut html_effects = Vec::new();
-
- for operation in &all_effects {
- match operation {
- OperationNode::SetProp(prop) => prop_effects.push(prop),
- OperationNode::SetText(text) => text_effects.push(text),
- OperationNode::SetHtml(html_node) => html_effects.push(html_node),
- _ => {}
- }
- }
-
- for element in text_effects
- .iter()
- .map(|text| text.element)
- .chain(html_effects.iter().map(|html| html.element))
- {
- if elem_to_tag.contains_key(&element) {
- continue;
- }
- if let Some(parent_tag) = block
- .operation
- .iter()
- .find_map(|operation| match operation {
- OperationNode::ChildRef(child) if child.child_id == element => {
- elem_to_tag.get(&child.parent_id).copied()
- }
- _ => None,
- })
- {
- elem_to_tag.insert(element, parent_tag);
- }
- }
-
- // 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> = prop
- .prop
- .values
- .iter()
- .map(|value| encode_simple_expr(env, value))
- .collect();
- let slot = encode_slot_values(env, atoms::set_prop(), values);
- let source_offset = prop
- .prop
- .values
- .first()
- .map(|value| value.loc.span.start)
- .unwrap_or(prop.prop.key.loc.span.start);
- let marker = push_slot_marker(&mut slots, slot, source_offset);
- let attr_name = prop.prop.key.content;
- let attr = format!(" {attr_name}=\"{marker}\"");
- inject_attr(&mut html, &mut tags, tag_pos, &attr);
- }
- }
-
- for op in &block.operation {
- if let OperationNode::Directive(dir) = op {
- if let Some(&tag_pos) = elem_to_tag.get(&dir.element) {
- match dir.name {
- "vShow" => {
- if let Some(vize_atelier_core::ExpressionNode::Simple(simple)) =
- &dir.dir.exp
- {
- let slot = encode_slot_value(env, atoms::v_show(), simple);
- let marker = push_slot_marker(&mut slots, slot, dir.dir.loc.span.start);
- let attr = format!(" style=\"{marker}\"");
- inject_attr(&mut html, &mut tags, tag_pos, &attr);
- }
- }
- "model" => {
- if let Some(vize_atelier_core::ExpressionNode::Simple(simple)) =
- &dir.dir.exp
- {
- let slot = encode_slot_value(env, atoms::v_model(), simple);
- 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 marker = push_binding_marker(
- &mut binding_nodes,
- atoms::directive(),
- encode_directive_ir_node(env, dir),
- );
- inject_attr(&mut html, &mut tags, tag_pos, &marker);
- }
- }
- _ => {}
- }
- }
- }
- }
-
- for text in &text_effects {
- if let Some(&tag_pos) = elem_to_tag.get(&text.element) {
- let values: Vec> = text
- .values
- .iter()
- .map(|value| encode_simple_expr(env, value))
- .collect();
- let slot = encode_slot_values(env, atoms::set_text(), values);
- let source_offset = text
- .values
- .first()
- .map(|value| value.loc.span.start)
- .unwrap_or(u32::MAX);
- let marker = push_slot_marker(&mut slots, slot, source_offset);
- let template_text = text_node_template(text.values.iter().map(|value| &**value));
- if !replace_text_node(&mut html, &mut tags, tag_pos, &template_text, &marker) {
- replace_first_space_in_content(&mut html, &mut tags, tag_pos, &marker);
- }
- }
- }
-
- for html_effect in &html_effects {
- if let Some(&tag_pos) = elem_to_tag.get(&html_effect.element) {
- let slot = encode_slot_value(env, atoms::set_html(), &html_effect.value);
- let marker = push_slot_marker(&mut slots, slot, html_effect.value.loc.span.start);
- // `v-html` owns the element's content, which the template leaves empty.
- if !replace_first_space_in_content(&mut html, &mut tags, tag_pos, &marker) {
- inject_before_close(&mut html, &mut tags, tag_pos, &marker);
- }
- }
- }
-
- for operation in &block.operation {
- 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 marker = push_slot_marker(&mut slots, slot, source_offset);
- inject_structural_marker(
- &mut html,
- &mut tags,
- &elem_to_tag,
- (if_node.parent, placeholder_anchor(if_node.anchor)),
- &marker,
- (&tag_source_offsets, source_offset),
- &slots,
- );
- }
- 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 marker = push_slot_marker(&mut slots, slot, source_offset);
- inject_structural_marker(
- &mut html,
- &mut tags,
- &elem_to_tag,
- (for_node.parent, placeholder_anchor(for_node.anchor)),
- &marker,
- (&tag_source_offsets, source_offset),
- &slots,
- );
- }
- OperationNode::CreateComponent(component) => {
- let source_offset = component
- .props
- .iter()
- .flat_map(|prop| prop.values.iter())
- .map(|value| value.loc.span.start)
- .min()
- .unwrap_or(u32::MAX);
- let slot = encode_slot_component(env, component);
- let marker = push_slot_marker(&mut slots, slot, source_offset);
- inject_structural_marker(
- &mut html,
- &mut tags,
- &elem_to_tag,
- (component.parent, placeholder_anchor(component.anchor)),
- &marker,
- (&tag_source_offsets, source_offset),
- &slots,
- );
- }
- _ => {}
- }
- }
-
- Ok(split_on_markers(&html, slots, binding_nodes))
-}
diff --git a/rustq.exs b/rustq.exs
index 80aaffa..ddb81ad 100644
--- a/rustq.exs
+++ b/rustq.exs
@@ -144,7 +144,7 @@ nifs = [
compile_sass_nif: [],
compile_css_nif: [attrs: [A.attr(:allow, [A.path([:clippy, :too_many_arguments])])]],
bundle_css_nif: [],
- vapor_split_nif: [],
+ split_template_nif: [],
generate_dts_nif: []
]
@@ -161,7 +161,16 @@ source_atoms =
atoms =
(source_atoms ++ encoder_atoms ++ Vize.Codegen.Sfc.atoms() ++ Vize.Codegen.VaporIR.atoms())
- |> Enum.uniq()
+ # A raw identifier such as `atoms::r#for()` names the atom `for`, which
+ # other sources declare as `{:for, "for"}`.
+ |> Enum.map(fn
+ "r#" <> name -> {"r#" <> name, name}
+ atom -> atom
+ end)
+ |> Enum.uniq_by(fn
+ {_ident, name} -> to_string(name)
+ name -> name
+ end)
|> Enum.sort()
|> Enum.map(fn
"end_" -> {"end_", "end"}
diff --git a/test/vize/split_template_test.exs b/test/vize/split_template_test.exs
new file mode 100644
index 0000000..389cbeb
--- /dev/null
+++ b/test/vize/split_template_test.exs
@@ -0,0 +1,247 @@
+defmodule Vize.SplitTemplateTest do
+ use ExUnit.Case, async: true
+
+ defp split(template, opts \\ []), do: Vize.split_template!(template, opts)
+ defp kinds(%{slots: slots}), do: Enum.map(slots, & &1.kind)
+
+ describe "static content" do
+ test "is one static" do
+ assert split(~S|
Hello
|).statics ==
+ [~S|
Hello
|]
+ end
+
+ test "decodes entities once and escapes them again" do
+ assert split(~S|
x < y && z
|).statics ==
+ [~S|
x < y && z
|]
+ end
+
+ test "closes self-closing elements that aren't void" do
+ assert split(~S|
|).statics == [
+ "
"
+ ]
+ end
+ end
+
+ describe "text" do
+ test "an interpolation is a text slot" do
+ result = split(~S|
{{ name }}
|)
+
+ assert result.statics == ["
", "
"]
+ assert [%{kind: :text, value: "name", position: {1, 7}}] = result.slots
+ end
+
+ test "static text around interpolations stays in the statics" do
+ result = split(~S|
Hi {{ first }} {{ last }}!
|)
+
+ assert result.statics == ["
Hi ", " ", "!
"]
+ assert Enum.map(result.slots, & &1.value) == ["first", "last"]
+ end
+
+ test "v-html and v-text replace the element's content" do
+ result = split(~S||)
+
+ assert result.statics == ["
", "
", "
"]
+ assert kinds(result) == [:html, :text]
+ end
+ end
+
+ describe "attributes" do
+ test "a binding is an attribute slot, rendered whole" do
+ result = split(~S|x|)
+
+ assert result.statics == [~S|x"]
+ assert [%{kind: :attr, name: "href", value: "url", static: nil}] = result.slots
+ end
+
+ test "takes in a static attribute of the same name" do
+ result = split(~S|
x
|)
+
+ assert result.statics == ["
x
"]
+ assert [%{name: "class", static: "a", value: "{ on: active }"}] = result.slots
+ end
+
+ test "v-show combines with the style" do
+ assert [%{name: "style", static: "color: red", value: nil, show: "visible"}] =
+ split(~S|
x
|).slots
+ end
+
+ test "dynamic names and v-bind objects" do
+ result = split(~S|