chore: added svelte encoder

This commit is contained in:
Denis Donici 2026-02-22 15:19:14 +02:00 committed by Wout De Puysseleir
parent 5d526021dc
commit 2de7f6d733
8 changed files with 590 additions and 6 deletions

View file

@ -561,6 +561,23 @@ you don't need `@derive Jason.Encoder` when using the default native JSON encode
If you're using Jason and need custom struct encoding behavior, see the
[Structs and Ecto](#structs-and-ecto) section for details on `@derive Jason.Encoder`.
#### LiveSvelte.Encoder protocol
All props pass through the `LiveSvelte.Encoder` protocol before JSON encoding. You can control which struct fields are sent to Svelte with `@derive`:
```elixir
defmodule User do
@derive {LiveSvelte.Encoder, except: [:password]}
defstruct [:name, :email, :password]
end
```
- `@derive LiveSvelte.Encoder` — encode all fields except `__struct__`
- `@derive {LiveSvelte.Encoder, only: [:name, :email]}` — encode only listed keys
- `@derive {LiveSvelte.Encoder, except: [:password]}` — encode all except listed keys
Phoenix.HTML.Form, Ecto.Changeset, and Phoenix LiveView upload structs have built-in encoders. Date/Time types are encoded as ISO8601 strings.
### live_json
LiveSvelte has support for [live_json](https://github.com/Miserlou/live_json).

View file

@ -13,8 +13,10 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
end
defp wait_for_headline_with_text(session, text, attempts \\ 50) do
items = headline_items(session)
found = Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ text end)
# Use container text to avoid stale refs when DOM updates (e.g. after Add/Remove).
container = session |> find(Query.css("[data-testid='breaking-news-headlines']"))
container_text = Wallaby.Element.text(container)
found = String.contains?(container_text, text)
cond do
found -> session
attempts == 0 -> raise "timeout waiting for headline containing #{inspect(text)}"
@ -85,8 +87,10 @@ defmodule ExampleWeb.LiveBreakingNewsTest do
end
defp wait_for_headline_removed(session, text, attempts) do
items = headline_items(session)
found = Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ text end)
# Use container text to avoid stale refs: after Remove, li elements are detached from DOM.
container = session |> find(Query.css("[data-testid='breaking-news-headlines']"))
container_text = Wallaby.Element.text(container)
found = String.contains?(container_text, text)
cond do
not found -> session
attempts == 0 -> raise "timeout waiting for headline #{inspect(text)} to be removed"

View file

@ -151,7 +151,16 @@ defmodule LiveSvelte do
defp json(props) do
json_library = Application.get_env(:live_svelte, :json_library, LiveSvelte.JSON)
json_library.encode!(props)
# Ensure props pass through LiveSvelte.Encoder for all JSON libraries.
# LiveSvelte.JSON already runs the encoder internally, so avoid double work.
if json_library == LiveSvelte.JSON do
json_library.encode!(props)
else
props
|> LiveSvelte.Encoder.encode([])
|> json_library.encode!()
end
end
# --- Deterministic ID generation ------------------------------------------------

378
lib/live_svelte/encoder.ex Normal file
View file

@ -0,0 +1,378 @@
defprotocol LiveSvelte.Encoder do
@moduledoc """
Protocol for encoding values for LiveSvelte JSON serialization.
Transforms structs and other terms into JSON-compatible data (maps, lists,
primitives) before the configured JSON library encodes to a string. Supports
`@derive` with `:only` and `:except` to control which struct fields are
encoded. By default all keys except `:__struct__` are encoded.
## Deriving
* `@derive LiveSvelte.Encoder` encode all struct fields except `:__struct__`
* `@derive {LiveSvelte.Encoder, only: [:a, :b]}` encode only listed keys
* `@derive {LiveSvelte.Encoder, except: [:secret]}` encode all except listed keys
## Example
defmodule User do
@derive {LiveSvelte.Encoder, except: [:password]}
defstruct [:name, :email, :password]
end
For structs you don't own, use `Protocol.derive/3` outside the module.
"""
@type t :: term()
@type opts :: Keyword.t()
@fallback_to_any true
@doc "Encodes a value to a JSON-compatible term (map, list, or primitive)."
@spec encode(t(), opts()) :: any()
def encode(value, opts \\ [])
end
defimpl LiveSvelte.Encoder, for: Integer do
def encode(value, _opts), do: value
end
defimpl LiveSvelte.Encoder, for: Float do
def encode(value, _opts), do: value
end
defimpl LiveSvelte.Encoder, for: BitString do
def encode(value, _opts), do: value
end
defimpl LiveSvelte.Encoder, for: Atom do
def encode(atom, _opts), do: atom
end
defimpl LiveSvelte.Encoder, for: List do
def encode(list, opts) do
Enum.map(list, &LiveSvelte.Encoder.encode(&1, opts))
end
end
defimpl LiveSvelte.Encoder, for: Map do
def encode(map, opts) do
Map.new(map, fn {key, value} ->
{key, LiveSvelte.Encoder.encode(value, opts)}
end)
end
end
defimpl LiveSvelte.Encoder, for: [Date, Time, NaiveDateTime, DateTime] do
def encode(value, _opts) do
@for.to_iso8601(value)
end
end
defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form do
def encode(%Phoenix.HTML.Form{} = form, opts) do
LiveSvelte.Encoder.encode(
%{
name: form.name,
values: encode_form_values(form, opts),
errors: encode_form_errors(form) || %{},
valid: get_form_validity(form)
},
opts
)
rescue
error in [Protocol.UndefinedError] ->
reraise maybe_enhance_error(error), __STACKTRACE__
end
defp get_form_validity(%{source: %{valid?: valid}}), do: valid
defp get_form_validity(_), do: true
if Code.ensure_loaded?(Ecto.Association.NotLoaded) do
defp maybe_enhance_error(%{value: %Ecto.Association.NotLoaded{}} = error) do
Map.update!(error, :description, fn description ->
[first | rest] = String.split(description, "\n\n")
addition = "\n\nEncode form with LiveSvelte.Encoder.encode(form, nilify_not_loaded: true) to avoid."
Enum.join([first | [addition | rest]], "\n\n")
end)
end
defp maybe_enhance_error(error), do: error
else
defp maybe_enhance_error(error), do: error
end
if Code.ensure_loaded?(Ecto.Changeset) do
@relations [:embed, :assoc]
defp collect_changeset_values(%Ecto.Changeset{} = source, opts) do
data = Map.new(source.types, fn {field, type} -> {field, get_field_value(source, field, type, opts)} end)
result = if is_struct(source.data), do: Map.merge(source.data, data), else: data
Map.delete(result, :__meta__)
end
defp get_field_value(source, field, {tag, %{cardinality: :one}}, opts) when tag in @relations do
case Map.fetch(source.changes, field) do
{:ok, nil} -> nil
{:ok, %Ecto.Changeset{} = changeset} -> collect_changeset_values(changeset, opts)
:error ->
case Map.fetch!(source.data, field) do
%Ecto.Association.NotLoaded{} = not_loaded ->
if opts[:nilify_not_loaded], do: nil, else: not_loaded
%{__meta__: _} = value -> Map.delete(value, :__meta__)
value -> value
end
end
end
defp get_field_value(source, field, {tag, %{cardinality: :many}}, opts) when tag in @relations do
case Map.fetch(source.changes, field) do
{:ok, changesets} ->
changesets
|> Enum.filter(&(&1.params != nil))
|> Enum.map(&collect_changeset_values(&1, opts))
:error ->
case Map.fetch!(source.data, field) do
%Ecto.Association.NotLoaded{} = not_loaded ->
if opts[:nilify_not_loaded], do: nil, else: not_loaded
[%{__meta__: _} | _] = value -> Enum.map(value, &Map.delete(&1, :__meta__))
value -> value
end
end
end
defp get_field_value(source, field, _type, _opts) do
Phoenix.HTML.FormData.Ecto.Changeset.input_value(source, %{params: source.params}, field)
end
if Code.ensure_loaded?(Phoenix.HTML.FormData.Ecto.Changeset) do
def encode_form_values(%{impl: Phoenix.HTML.FormData.Ecto.Changeset, source: source}, opts) do
source |> collect_changeset_values(opts) |> LiveSvelte.Encoder.encode(opts)
end
end
end
def encode_form_values(form, opts) do
base_values =
form.hidden
|> Map.new()
|> Map.merge(form.data)
|> Map.merge(Map.new(form.params))
LiveSvelte.Encoder.encode(base_values, opts)
end
if Code.ensure_loaded?(Ecto.Changeset) do
defp collect_changeset_errors(%Ecto.Changeset{} = changeset) do
errors = translate_errors(changeset.errors)
Enum.reduce(changeset.changes, errors, fn {field, value}, acc ->
case Map.get(changeset.types, field) do
{tag, %{cardinality: :one}} when tag in @relations ->
embed_errors = collect_changeset_errors(value)
if embed_errors == %{}, do: acc, else: Map.put(acc, field, embed_errors)
{tag, %{cardinality: :many}} when tag in @relations ->
list_errors =
value
|> Enum.filter(&(&1.params != nil))
|> Enum.map(fn embed_changeset ->
embed_errors = collect_changeset_errors(embed_changeset)
if embed_errors == %{}, do: nil, else: embed_errors
end)
if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors)
_ -> acc
end
end)
end
if Code.ensure_loaded?(Phoenix.HTML.FormData.Ecto.Changeset) do
def encode_form_errors(%{impl: Phoenix.HTML.FormData.Ecto.Changeset} = form) do
collect_changeset_errors(form.source)
end
end
end
def encode_form_errors(form) do
translate_errors(form.errors)
end
defp translate_errors(errors) do
Map.new(errors, fn {field, error} ->
{field, error |> List.wrap() |> Enum.map(&translate_error/1)}
end)
end
defp translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", value |> List.wrap() |> Enum.map_join(", ", &to_string/1))
end)
end
end
if Code.ensure_loaded?(Ecto.Changeset) do
defimpl LiveSvelte.Encoder, for: Ecto.Changeset do
def encode(%Ecto.Changeset{} = cs, opts) do
LiveSvelte.Encoder.encode(
%{
params: cs.params,
changes: cs.changes,
errors: changeset_errors_to_map(cs),
valid?: cs.valid?
},
opts
)
end
defp changeset_errors_to_map(%Ecto.Changeset{} = changeset) do
errors = changeset_errors_to_list(changeset.errors)
Enum.reduce(changeset.changes, errors, fn {field, value}, acc ->
case Map.get(changeset.types, field) do
{:embed, %{cardinality: :one}} when is_struct(value, Ecto.Changeset) ->
embed_errors = changeset_errors_to_map(value)
if embed_errors == %{}, do: acc, else: Map.put(acc, field, embed_errors)
{:embed, %{cardinality: :many}} when is_list(value) ->
list_errors =
value
|> Enum.filter(&match?(%Ecto.Changeset{}, &1))
|> Enum.map(fn embed_cs ->
embed_errors = changeset_errors_to_map(embed_cs)
if embed_errors == %{}, do: nil, else: embed_errors
end)
if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors)
{:assoc, %{cardinality: :one}} when is_struct(value, Ecto.Changeset) ->
embed_errors = changeset_errors_to_map(value)
if embed_errors == %{}, do: acc, else: Map.put(acc, field, embed_errors)
{:assoc, %{cardinality: :many}} when is_list(value) ->
list_errors =
value
|> Enum.filter(&match?(%Ecto.Changeset{}, &1))
|> Enum.map(fn assoc_cs ->
assoc_errors = changeset_errors_to_map(assoc_cs)
if assoc_errors == %{}, do: nil, else: assoc_errors
end)
if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors)
_ ->
acc
end
end)
end
defp changeset_errors_to_list(errors) do
Map.new(errors, fn {field, error} ->
{field, error |> List.wrap() |> Enum.map(&error_tuple_to_message/1)}
end)
end
defp error_tuple_to_message({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", value |> List.wrap() |> Enum.map_join(", ", &to_string/1))
end)
end
end
end
if Code.ensure_loaded?(Phoenix.LiveView) do
defimpl LiveSvelte.Encoder, for: Phoenix.LiveView.UploadConfig do
def encode(%Phoenix.LiveView.UploadConfig{} = struct, opts) do
errors =
Enum.map(struct.errors, fn {key, value} ->
%{ref: key, error: LiveSvelte.Encoder.encode(value, opts)}
end)
entries =
Enum.map(struct.entries, fn entry ->
encoded = LiveSvelte.Encoder.encode(entry, opts)
entry_errors = errors |> Enum.filter(&(&1.ref == entry.ref)) |> Enum.map(& &1.error)
Map.put(encoded, :errors, entry_errors)
end)
LiveSvelte.Encoder.encode(
%{
ref: struct.ref,
name: struct.name,
accept: struct.accept,
max_entries: struct.max_entries,
auto_upload: struct.auto_upload?,
entries: entries,
errors: errors
},
opts
)
end
end
defimpl LiveSvelte.Encoder, for: Phoenix.LiveView.UploadEntry do
def encode(%Phoenix.LiveView.UploadEntry{} = struct, opts) do
LiveSvelte.Encoder.encode(
%{
ref: struct.ref,
client_name: struct.client_name,
client_size: struct.client_size,
client_type: struct.client_type,
progress: struct.progress,
done: struct.done?,
valid: struct.valid?,
preflighted: struct.preflighted?
},
opts
)
end
end
end
defimpl LiveSvelte.Encoder, for: Any do
defmacro __deriving__(module, struct, opts) do
fields = fields_to_encode(struct, opts)
quote do
defimpl LiveSvelte.Encoder, for: unquote(module) do
def encode(struct, opts) do
struct
|> Map.take(unquote(fields))
|> LiveSvelte.Encoder.encode(opts)
end
end
end
end
# Default for structs without explicit impl: encode all keys except __struct__ and __meta__
# (matches @derive LiveSvelte.Encoder default; __meta__ stripped for Ecto schemas).
def encode(%{__struct__: _module} = struct, opts) do
keys = Map.keys(struct) -- [:__struct__, :__meta__]
struct
|> Map.take(keys)
|> LiveSvelte.Encoder.encode(opts)
end
def encode(value, _opts), do: value
defp fields_to_encode(struct, opts) do
fields = Map.keys(struct)
cond do
only = Keyword.get(opts, :only) ->
case only -- fields do
[] -> only
error_keys ->
raise ArgumentError,
":only specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}"
end
except = Keyword.get(opts, :except) ->
case except -- fields do
[] -> fields -- [:__struct__ | except]
error_keys ->
raise ArgumentError,
":except specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}"
end
true ->
fields -- [:__struct__]
end
end
end

View file

@ -48,6 +48,7 @@ defmodule LiveSvelte.JSON do
@spec encode!(term()) :: binary()
def encode!(term) do
term
|> LiveSvelte.Encoder.encode([])
|> prepare_term()
|> :json.encode()
|> IO.iodata_to_binary()
@ -76,7 +77,11 @@ defmodule LiveSvelte.JSON do
"""
@spec prepare(term()) :: term()
def prepare(term), do: prepare_term(term)
def prepare(term) do
term
|> LiveSvelte.Encoder.encode([])
|> prepare_term()
end
# Recursively prepare terms for JSON encoding.
# Converts structs to maps, nil to null, and handles nested structures.

View file

@ -11,6 +11,7 @@ defmodule LiveSvelte.MixProject do
elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
consolidate_protocols: Mix.env() != :test,
aliases: aliases(),
deps: deps(),
@ -60,6 +61,8 @@ defmodule LiveSvelte.MixProject do
defp deps do
[
{:ex_doc, "~> 0.37.3", only: :dev, runtime: false},
{:ecto, ">= 3.0.0", optional: true},
{:phoenix_ecto, ">= 4.0.0", optional: true},
{:jason, "~> 1.2", optional: true},
{:lazy_html, ">= 0.1.0", only: :test},
{:nodejs, "~> 3.1"},

View file

@ -1,7 +1,9 @@
%{
"castore": {:hex, :castore, "1.0.0", "c25cd0794c054ebe6908a86820c8b92b5695814479ec95eeff35192720b71eec", [:mix], [], "hexpm", "577d0e855983a97ca1dfa33cbb8a3b6ece6767397ffb4861514343b078fc284b"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.6.1", "a774bfa7b4512a1211bf15880b462be12a4c48ed753a170c68c63b2c95888150", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "569f7409fb5a932211573fc20e2a930a0d5cf3377c5b4f6506c651b1783a1678"},
"ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"},
@ -15,6 +17,7 @@
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nodejs": {:hex, :nodejs, "3.1.0", "904c07b81a7b6077af35784df32ab36c62bd2b96edb91bfd04c157c21956cfa5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1", [hex: :poolboy, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.7", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "a6b4480f3f266abb5927be8afacfc7809feefd7d1337fa3ce957d0b98eeeae52"},
"phoenix": {:hex, :phoenix, "1.7.0", "cbed113bdc203e2ced75859011fe7e71eeebb6259cefa54de810d9c7048b5e22", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "8526139d4bd79ec97c5c3c8e69f6cd663597f782756cec874ba7da5429c93e34"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.15", "58137e648fca9da56d6e931c9c3001f895ff090291052035f395bc958b82f1a5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "888dd8ea986bebbda741acc65aef788c384d13db91fea416461b2e96aa06a193"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},

View file

@ -0,0 +1,165 @@
defmodule LiveSvelte.EncoderTest do
use ExUnit.Case, async: true
require Protocol
alias LiveSvelte.Encoder
describe "encode/2 primitives" do
test "Integer passes through" do
assert Encoder.encode(42, []) == 42
end
test "Float passes through" do
assert Encoder.encode(3.14, []) == 3.14
end
test "BitString passes through" do
assert Encoder.encode("hello", []) == "hello"
end
test "Atom passes through" do
assert Encoder.encode(:ok, []) == :ok
end
test "List recurses" do
assert Encoder.encode([1, "a", :x], []) == [1, "a", :x]
assert Encoder.encode([%{a: 1}], []) == [%{a: 1}]
end
test "Map recurses" do
assert Encoder.encode(%{a: 1, b: "two"}, []) == %{a: 1, b: "two"}
end
end
describe "encode/2 Date/Time ISO8601" do
test "Date" do
d = ~D[2026-01-31]
assert Encoder.encode(d, []) == "2026-01-31"
end
test "Time" do
t = ~T[14:30:00]
assert Encoder.encode(t, []) == "14:30:00"
end
test "NaiveDateTime" do
ndt = ~N[2026-01-31 14:30:00]
assert Encoder.encode(ndt, []) == "2026-01-31T14:30:00"
end
test "DateTime" do
dt = DateTime.from_naive!(~N[2026-01-31 14:30:00], "Etc/UTC")
assert Encoder.encode(dt, []) == "2026-01-31T14:30:00Z"
end
end
describe "@derive LiveSvelte.Encoder (default: all except __struct__)" do
defmodule DeriveDefault do
defstruct [:a, :b, :c]
end
Protocol.derive(Encoder, DeriveDefault)
test "encodes all struct keys except __struct__" do
s = %DeriveDefault{a: 1, b: "two", c: :three}
assert Encoder.encode(s, []) == %{a: 1, b: "two", c: :three}
end
end
describe "@derive {LiveSvelte.Encoder, only: keys}" do
defmodule DeriveOnly do
defstruct [:a, :b, :c]
end
Protocol.derive(Encoder, DeriveOnly, only: [:a, :c])
test "encodes only specified keys" do
s = %DeriveOnly{a: 1, b: "secret", c: 3}
assert Encoder.encode(s, []) == %{a: 1, c: 3}
end
end
describe "@derive {LiveSvelte.Encoder, except: keys}" do
defmodule DeriveExcept do
defstruct [:a, :b, :c]
end
Protocol.derive(Encoder, DeriveExcept, except: [:b])
test "encodes all except specified keys" do
s = %DeriveExcept{a: 1, b: "secret", c: 3}
assert Encoder.encode(s, []) == %{a: 1, c: 3}
end
end
describe "Phoenix.HTML.Form" do
test "encodes name, values, errors, valid" do
form = %Phoenix.HTML.Form{
name: "user",
source: %{},
params: %{"name" => "alice", "email" => "a@b.com"},
data: %{},
hidden: %{},
impl: Phoenix.HTML.FormData.Map,
id: "user-form",
errors: [name: {"can't be blank", []}],
options: []
}
encoded = Encoder.encode(form, [])
assert encoded.name == "user"
assert encoded.valid == true
assert encoded.values["name"] == "alice"
assert encoded.values["email"] == "a@b.com"
assert encoded.errors[:name] == ["can't be blank"]
end
end
describe "Ecto.Changeset (when Ecto loaded)" do
if Code.ensure_loaded?(Ecto) do
defmodule EncoderTestUser do
use Ecto.Schema
schema "users" do
field :name, :string
field :email, :string
end
end
test "encodes params, changes, errors, valid?" do
cs =
%EncoderTestUser{}
|> Ecto.Changeset.cast(%{name: "alice", email: "a@b.com"}, [:name, :email])
|> Ecto.Changeset.add_error(:name, "can't be blank")
|> Ecto.Changeset.apply_action(:validate)
case cs do
{:ok, _} -> flunk("expected invalid changeset")
{:error, changeset} ->
encoded = Encoder.encode(changeset, [])
assert encoded.valid? == false
assert encoded.params["name"] == "alice"
assert encoded.errors[:name] == ["can't be blank"]
end
end
end
end
describe "nested structs" do
defmodule Inner do
defstruct [:x]
end
defmodule Outer do
defstruct [:inner, :tag]
end
Protocol.derive(Encoder, Inner)
Protocol.derive(Encoder, Outer)
test "nested structs are encoded recursively" do
s = %Outer{inner: %Inner{x: 42}, tag: "outer"}
assert Encoder.encode(s, []) == %{inner: %{x: 42}, tag: "outer"}
end
end
end