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 `|) + + assert result.statics == [""] + assert [%{kind: :model, tag: "textarea"}] = result.slots + end + end + + describe "structure" do + test "v-if branches are blocks" do + result = + split(~S|

A {{ x }}

B

C

|) + + assert [%{kind: :if, branches: [first, second, third]}] = result.slots + assert %{condition: "a", block: %{statics: ["

A ", "

"]}} = first + assert %{condition: "b", block: %{statics: ["

B

"]}} = second + assert %{condition: nil, block: %{statics: ["

C

"]}} = third + end + + test "v-for has its source, aliases, key, and block" do + result = + split(~S||) + + assert [ + %{ + kind: :for, + source: "items", + value: "item", + key: "i", + key_prop: "item.id", + block: %{statics: ["
  • ", "
  • "]} + } + ] = result.slots + end + + test "template wrappers render only their content" do + result = split(~S||) + + assert [%{branches: [%{block: %{statics: ["12"]}}]}] = result.slots + end + + test "slots keep their place among static siblings" do + result = split(~S|
    x{{ y }}
    |) + + assert result.statics == ["
    x", "", "", "
    "] + assert kinds(result) == [:component, :text, :component] + end + end + + describe "components" do + test "have props, events, and slot content" do + result = + split( + ~S|

    {{ x }}

    | + ) + + assert [%{kind: :component, name: "Card", props: props, events: events, slots: slots}] = + result.slots + + assert [%{name: "title", static: "T"}, %{name: "count", value: "n"}] = props + assert [%{name: "close", value: "shut"}] = events + + assert [ + %{name: "footer", params: "{ ok }", block: %{statics: ["F ", ""]}}, + %{name: "default", params: nil, block: %{statics: ["

    ", "

    "]}} + ] = slots + end + + test "v-slot on the component makes its children the default slot" do + assert [%{slots: [%{name: "default", params: "{ item }"}]}] = + split(~S|{{ item }}|).slots + end + + test "v-model is a prop and an update event" do + assert [ + %{ + props: [%{name: "modelValue", value: "q"}], + events: [%{name: "update:modelValue", value: "q"}] + } + ] = + split(~S||).slots + end + + test "a outlet has its name, props, and fallback" do + result = split(~S|
    none
    |) + + assert [ + %{ + kind: :slot, + name: "end", + props: [%{name: "count", value: "n"}], + fallback: %{statics: ["none"]} + } + ] = result.slots + end + end + + describe "root_attrs" do + test "gathers the root element's attributes into one slot" do + result = + split(~S||, + root_attrs: true + ) + + assert result.statics == ["", ""] + assert [%{kind: :root_attrs, props: props}, %{kind: :slot}] = result.slots + + assert Enum.map(props, &{&1.name, &1.static, &1.value}) == [ + {"type", "button", nil}, + {"class", "btn", "c"}, + {"disabled", "", nil} + ] + end + + test "leaves templates with several roots alone" do + assert split(~S|

    x

    y

    |, root_attrs: true).statics == + [~S|

    x

    y

    |] + end + end + + describe "diagnostics" do + test "an expression that doesn't parse is an error with its position" do + assert {:error, %Vize.Error{diagnostics: [diagnostic]} = error} = + Vize.split_template(~S|

    {{ a + }}

    |) + + assert %Vize.Diagnostic{severity: :error, location: %{start: %{line: 1, column: 7}}} = + diagnostic + + assert Exception.message(error) =~ "1:7: can't parse the expression `a +`" + end + + test "a custom directive is a warning" do + assert %{diagnostics: [%Vize.Diagnostic{severity: :warning, message: message}]} = + split(~S||) + + assert message =~ "v-focus" + end + + test "convert to Elixir's diagnostic shape relative to a file" do + {:error, %Vize.Error{diagnostics: [diagnostic]}} = Vize.split_template("

    \n{{ a + }}

    ") + + assert %{file: "Page.vue", severity: :error, position: {11, 4}} = + Vize.Diagnostic.to_code_diagnostic(diagnostic, file: "Page.vue", origin: {10, 5}) + end + end +end diff --git a/test/vize/vapor_split_test.exs b/test/vize/vapor_split_test.exs deleted file mode 100644 index 6190468..0000000 --- a/test/vize/vapor_split_test.exs +++ /dev/null @@ -1,239 +0,0 @@ -defmodule Vize.VaporSplitTest do - use ExUnit.Case, async: true - - test "preserves self-closing tag syntax when injecting attrs" do - {:ok, split} = Vize.vapor_split("
    ") - - statics = Enum.join(split.statics) - - assert statics =~ "{{ one }}{{ two }}") - - assert length(split.statics) >= 3 - assert Enum.count(split.slots, &(&1.kind == :set_text)) == 2 - end - - test "keeps slot ordering aligned with static markers" do - {:ok, split} = - Vize.vapor_split("
    {{ msg }}
    ok
    ") - - assert Enum.map(split.slots, & &1.kind) == [:set_prop, :set_text, :if_node] - assert length(split.statics) == length(split.slots) + 1 - end - - test "orders nested property slots by document position" do - {:ok, split} = - Vize.vapor_split("
    ") - - assert [ - %{kind: :set_prop, values: ["outer"]}, - %{kind: :set_prop, values: ["inner"]} - ] = split.slots - - assert split.statics == ["
    "] - end - - test "orders structural and text slots by document position" do - {:ok, leading_if} = - Vize.vapor_split("

    visible

    {{ message }}
    ") - - assert Enum.map(leading_if.slots, & &1.kind) == [:if_node, :set_text] - assert length(leading_if.statics) == length(leading_if.slots) + 1 - - {:ok, trailing_if} = - Vize.vapor_split("
    {{ message }}

    visible

    ") - - assert Enum.map(trailing_if.slots, & &1.kind) == [:set_text, :if_node] - assert length(trailing_if.statics) == length(trailing_if.slots) + 1 - end - - test "positions structural slots between static siblings" do - {:ok, split} = - Vize.vapor_split("
    before

    visible

    after
    ") - - assert Enum.map(split.slots, & &1.kind) == [:if_node] - assert split.statics == ["
    before", "after
    "] - end - - test "distinguishes removed and retained tags with the same name" do - {:ok, split} = - Vize.vapor_split("

    conditional

    static

    ") - - assert Enum.map(split.slots, & &1.kind) == [:if_node] - assert split.statics == ["
    ", "

    static

    "] - end - - test "preserves static content between structural and text slots" do - {:ok, split} = - Vize.vapor_split("

    visible

    static{{ message }}
    ") - - assert Enum.map(split.slots, & &1.kind) == [:if_node, :set_text] - assert split.statics == ["
    ", "static", "
    "] - end - - test "positions v-for slots between static siblings" do - {:ok, split} = - Vize.vapor_split( - "
    • before
    • {{ item }}
    • after
    " - ) - - assert Enum.map(split.slots, & &1.kind) == [:for_node] - assert split.statics == ["
    • before
    • ", "
    • after
    "] - end - - test "replaces the whole text node for mixed static and dynamic text" do - {:ok, split} = Vize.vapor_split("Hello {{ a }}, you have {{ n }} items") - - assert split.statics == ["", ""] - - assert [ - %{ - kind: :set_text, - values: [ - {:static_, "Hello "}, - "a", - {:static_, ", you have "}, - "n", - {:static_, " items"} - ] - } - ] = - split.slots - end - - test "matches HTML-escaped static text" do - {:ok, split} = Vize.vapor_split("

    \"Tom\" & {{ x }} <3

    ") - - assert split.statics == ["

    ", "

    "] - end - - test "places text slots after sibling elements" do - {:ok, split} = Vize.vapor_split("
    bold hi {{ x }}
    ") - - assert split.statics == ["
    bold", "
    "] - end - - test "replaces Vapor anchor comments with structural slots" do - {:ok, split} = - Vize.vapor_split( - "

    A

    B

    {{ x }}
    " - ) - - assert Enum.map(split.slots, & &1.kind) == [:if_node, :if_node, :set_prop, :set_text] - assert split.statics == ["
    ", "", "", "
    "] - end - - test "matches text nodes by decoded content, not by a substring" do - {:ok, split} = Vize.vapor_split("
    static x hi {{ a }}
    ") - assert split.statics == ["
    static x", "
    "] - - {:ok, split} = Vize.vapor_split("
    x {{ a }}x x {{ c }}
    ") - assert split.statics == ["
    ", "x ", "
    "] - end - - # https://github.com/elixir-volt/vize_ex/issues/3 - test "orders slots by document position across nesting and structural nodes" do - {:ok, split} = Vize.vapor_split(~s()) - assert Enum.map(split.slots, & &1.values) == [["l1"], ["l2"], ["l3"]] - - {:ok, split} = Vize.vapor_split(~s(
    Y{{ label }}
    )) - assert Enum.map(split.slots, & &1.kind) == [:if_node, :set_text] - assert split.statics == ["
    ", "", "
    "] - - {:ok, split} = - Vize.vapor_split( - ~s(
    Y
    {{ label }}
    ) - ) - - assert Enum.map(split.slots, & &1.kind) == [:if_node, :set_text] - assert split.statics == ["
    ", "
    ", "
    "] - end - - test "places v-html slots inside their element" do - {:ok, split} = Vize.vapor_split(~s(
    )) - assert split.statics == ["
    ", "
    "] - assert [%{kind: :set_html, value: "raw"}] = split.slots - - {:ok, split} = Vize.vapor_split(~s(

    {{ x }}
    )) - assert split.statics == ["

    ", "

    ", "
    "] - assert Enum.map(split.slots, & &1.kind) == [:set_html, :set_text] - end - - 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 == ["
    "] - - 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 - - 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|
    • a
      • {{ b }}
    • c
    |) - - assert split.statics == ["
    • a
      • ", "
    • c
    "] - assert [%{kind: :set_text, values: ["b"]}] = split.slots - end - - test "slots after mixed text and elements" do - {:ok, split} = Vize.vapor_split(~S|

    a{{ x }}c{{ y }}

    |) - - assert split.statics == ["

    a", "c", "

    "] - 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|
    hi
    |) - - assert [%{at: {0, offset}}] = split.bindings - assert binary_part(hd(split.statics), 0, offset) == "
    hi