chore: prepare vite integration

This commit is contained in:
Denis Donici 2026-03-03 20:23:21 +02:00
parent 8780b81b87
commit b7a7558107
19 changed files with 508 additions and 143 deletions

View file

@ -414,3 +414,13 @@ export declare function useEventReply<T = unknown, P extends object | void = obj
options?: UseEventReplyOptions<T>
): UseEventReplyReturn<T, P>;
// ---------------------------------------------------------------------------
// Virtual module type declaration
// ---------------------------------------------------------------------------
declare module "virtual:live-svelte-components" {
import type { Component } from "svelte";
const components: Record<string, Component>;
export default components;
}

View file

@ -1,9 +1,106 @@
/// <reference types="vite/client" />
import { resolve, relative } from "node:path"
import { readdirSync, existsSync } from "node:fs"
const VIRTUAL_MODULE_ID = "virtual:live-svelte-components"
const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID
/**
* Returns the base directory from a glob pattern (everything before the first `*`),
* with any trailing slash stripped.
* @param {string} pattern - Glob pattern like `'./svelte/**\/*.svelte'`
* @returns {string}
*/
export function getBaseDir(pattern) {
const idx = pattern.indexOf("*")
return idx === -1 ? pattern : pattern.slice(0, idx).replace(/\/$/, "")
}
/**
* Derives the component name from an absolute file path relative to its base directory.
* Strips the `.svelte` extension and normalizes backslashes to forward slashes.
* @param {string} filePath - Absolute path to the `.svelte` file
* @param {string} baseDir - Absolute base directory (resolved from pattern)
* @returns {string} Component name, e.g. `'Counter'` or `'forms/ContactForm'`
*/
export function getComponentName(filePath, baseDir) {
return relative(baseDir, filePath)
.replace(/\.svelte$/, "")
.replace(/\\/g, "/")
}
/**
* Recursively walks a directory and collects `.svelte` file paths.
* @param {string} dir - Absolute path to the directory
* @param {string[]} [results=[]] - Accumulator array
* @returns {string[]}
*/
function walkDir(dir, results = []) {
if (!existsSync(dir)) return results
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = resolve(dir, entry.name)
if (entry.isDirectory()) walkDir(full, results)
else if (entry.name.endsWith(".svelte")) results.push(full)
}
} catch {
/* ignore permission errors */
}
return results
}
/**
* Generates ESM virtual module source from a pre-built list of discovered files.
* Pure function no filesystem access. Exported for testability.
* Uses JSON.stringify for all embedded string literals to safely handle paths
* or component names that contain single quotes or other special characters.
* @param {{ file: string, baseDir: string }[]} files
* @returns {string} ESM source code string
*/
export function buildModuleCode(files) {
if (files.length === 0) {
return `export default {}\n`
}
const imports = files
.map(({ file }, i) => `import __c${i} from ${JSON.stringify(file.replace(/\\/g, "/"))}`)
.join("\n")
const entries = files
.map(({ file, baseDir }, i) => ` ${JSON.stringify(getComponentName(file, baseDir))}: __c${i}`)
.join(",\n")
return `${imports}\nexport default {\n${entries}\n}\n`
}
/**
* Discovers all `.svelte` files for the given component path patterns and
* delegates code generation to `buildModuleCode`.
* @param {string[]} componentPaths - Glob patterns relative to Vite project root
* @param {string} root - Absolute Vite project root directory
* @returns {string} ESM source code string
*/
function generateVirtualModuleCode(componentPaths, root) {
const allFiles = []
for (const pattern of componentPaths) {
const baseDir = resolve(root, getBaseDir(pattern))
const files = walkDir(baseDir)
for (const file of files) {
allFiles.push({ file, baseDir })
}
}
return buildModuleCode(allFiles)
}
/**
* @typedef {Object} PluginOptions
* @property {string} [path] - SSR render endpoint path (default: "/ssr_render")
* @property {string} [entrypoint] - SSR entrypoint file (default: "./js/server.js")
* @property {string | string[]} [components] - Glob pattern(s) for Svelte component
* auto-discovery via `virtual:live-svelte-components`.
* Patterns are relative to the Vite project root (where vite.config.js lives).
* Default: `['./svelte/**\/*.svelte']`
*/
/**
@ -56,18 +153,60 @@ const jsonMiddleware = (req, res, next) => {
}
/**
* LiveSvelte Vite plugin for SSR and hot reload support.
* LiveSvelte Vite plugin for SSR, hot reload support, and component auto-discovery.
*
* NOTE: Unlike LiveVue, LiveSvelte's `render()` function returns a `{head, html, css}`
* object (not a plain HTML string). This plugin serialises that result as JSON so the
* Elixir `LiveSvelte.SSR.ViteJS` module can decode it with `Jason.decode!/1`.
*
* **Component auto-discovery**: Import `virtual:live-svelte-components` to get a
* `Record<name, Component>` map of all discovered Svelte components. Pass the map
* directly to `getHooks()` and `getRender()`.
*
* @param {PluginOptions} [opts]
* @returns {import("vite").Plugin}
*/
function liveSveltePlugin(opts = {}) {
const componentPaths = opts.components
? Array.isArray(opts.components)
? opts.components
: [opts.components]
: ["./svelte/**/*.svelte"]
let root = process.cwd()
/** @type {import("vite").ViteDevServer | null} */
let viteServer = null
function invalidateVirtualModule() {
if (viteServer) {
const mod = viteServer.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID)
if (mod) {
viteServer.moduleGraph.invalidateModule(mod)
viteServer.ws.send({ type: "full-reload" })
}
}
}
return {
name: "live-svelte",
configResolved(config) {
root = config.root
},
resolveId(id) {
if (id === VIRTUAL_MODULE_ID) {
return RESOLVED_VIRTUAL_MODULE_ID
}
},
load(id) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
return generateVirtualModuleCode(componentPaths, root)
}
},
handleHotUpdate({ file, modules, server, timestamp }) {
if (file.match(/\.(heex|ex)$/)) {
const invalidatedModules = new Set()
@ -100,14 +239,35 @@ function liveSveltePlugin(opts = {}) {
return []
}
},
configureServer(server) {
viteServer = server
process.stdin.on("close", () => process.exit(0))
process.stdin.resume()
const path = opts.path || "/ssr_render"
// Watch component directories for new/deleted .svelte files so the
// virtual:live-svelte-components module stays up to date.
const baseDirs = componentPaths.map(p => resolve(root, getBaseDir(p)))
for (const dir of baseDirs) {
if (existsSync(dir)) server.watcher.add(dir)
}
server.watcher.on("add", filePath => {
if (filePath.endsWith(".svelte") && baseDirs.some(dir => filePath.startsWith(dir))) {
invalidateVirtualModule()
}
})
server.watcher.on("unlink", filePath => {
if (filePath.endsWith(".svelte") && baseDirs.some(dir => filePath.startsWith(dir))) {
invalidateVirtualModule()
}
})
const ssrPath = opts.path || "/ssr_render"
const entrypoint = opts.entrypoint || "./js/server.js"
server.middlewares.use(function liveSvelteMiddleware(req, res, next) {
if (req.method == "POST" && req.url?.split("?", 1)[0] === path) {
if (req.method == "POST" && req.url?.split("?", 1)[0] === ssrPath) {
jsonMiddleware(req, res, async () => {
try {
const render = (await server.ssrLoadModule(entrypoint)).render

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
// @ts-expect-error - vite_plugin.js is plain JS without type declarations
import { getBaseDir, getComponentName, buildModuleCode } from "./vite_plugin.js";
import path from "node:path";
describe("getBaseDir", () => {
it("strips wildcard and trailing slash from glob pattern", () => {
expect(getBaseDir("./svelte/**/*.svelte")).toBe("./svelte");
});
it("returns pattern unchanged when no wildcard present", () => {
expect(getBaseDir("./svelte")).toBe("./svelte");
});
it("handles parent-directory relative patterns", () => {
expect(getBaseDir("../svelte/**/*.svelte")).toBe("../svelte");
});
it("handles single-level glob pattern", () => {
expect(getBaseDir("./svelte/*.svelte")).toBe("./svelte");
});
it("handles pattern with trailing slash before wildcard", () => {
expect(getBaseDir("./components/**/*.svelte")).toBe("./components");
});
});
describe("getComponentName", () => {
const base = path.resolve("/project/assets/svelte");
it("returns filename without extension for top-level components", () => {
const file = path.resolve("/project/assets/svelte/Counter.svelte");
expect(getComponentName(file, base)).toBe("Counter");
});
it("preserves subdirectory prefix for nested components", () => {
const file = path.resolve("/project/assets/svelte/forms/ContactForm.svelte");
expect(getComponentName(file, base)).toBe("forms/ContactForm");
});
it("handles deeply nested components", () => {
const file = path.resolve("/project/assets/svelte/ui/forms/Input.svelte");
expect(getComponentName(file, base)).toBe("ui/forms/Input");
});
it("normalizes backslashes to forward slashes", () => {
const file = path.resolve("/project/assets/svelte/forms/Form.svelte");
const result = getComponentName(file, base);
expect(result).not.toContain("\\");
expect(result).toBe("forms/Form");
});
it("strips .svelte extension correctly", () => {
const file = path.resolve("/project/assets/svelte/MyComponent.svelte");
const result = getComponentName(file, base);
expect(result).toBe("MyComponent");
expect(result).not.toContain(".svelte");
});
});
describe("buildModuleCode", () => {
it("returns an empty default export for an empty file list", () => {
expect(buildModuleCode([])).toBe("export default {}\n");
});
it("generates a single import and export entry for one component", () => {
const base = path.resolve("/project/assets/svelte");
const file = path.resolve("/project/assets/svelte/Counter.svelte");
const code = buildModuleCode([{ file, baseDir: base }]);
expect(code).toMatch(/^import __c0 from /m);
expect(code).toContain("Counter.svelte");
expect(code).toContain('"Counter": __c0');
expect(code).toContain("export default {");
});
it("generates sequential __c0, __c1 imports for multiple components", () => {
const base = path.resolve("/project/assets/svelte");
const files = [
{ file: path.resolve("/project/assets/svelte/Counter.svelte"), baseDir: base },
{ file: path.resolve("/project/assets/svelte/forms/Form.svelte"), baseDir: base },
];
const code = buildModuleCode(files);
expect(code).toMatch(/^import __c0 from /m);
expect(code).toMatch(/^import __c1 from /m);
expect(code).toContain('"Counter": __c0');
expect(code).toContain('"forms/Form": __c1');
});
it("uses double-quoted string literals (JSON.stringify) for safe path embedding", () => {
const base = path.resolve("/project/assets/svelte");
const file = path.resolve("/project/assets/svelte/Counter.svelte");
const code = buildModuleCode([{ file, baseDir: base }]);
// JSON.stringify wraps in double quotes — safe for paths containing single quotes or backslashes
expect(code).toMatch(/import __c0 from ".*Counter\.svelte"/);
expect(code).toMatch(/"Counter": __c0/);
});
});

View file

@ -3,7 +3,8 @@ import Config
config :live_svelte,
ssr_module: LiveSvelte.SSR.NodeJS,
ssr: true
# json_library defaults to LiveSvelte.JSON (native Erlang :json module)
# json_library defaults to LiveSvelte.JSON (native Erlang :json module)
# if Mix.env() == :dev do
# esbuild = fn args ->

View file

@ -9,17 +9,7 @@ import topbar from "../vendor/topbar"
// TODO(Epic 9): remove createLiveJsonHooks once live_json dependency is removed
import {createLiveJsonHooks} from "live_json"
import {getHooks} from "live_svelte"
// import.meta.glob returns Record<path, module> (e.g. {"../svelte/Counter.svelte": {default: ...}}).
// getHooks expects Record<name, Component> (e.g. {"Counter": Component}).
// Transform: strip path prefix and .svelte extension to get the component name.
const rawComponents = import.meta.glob('../svelte/**/*.svelte', { eager: true })
const Components = Object.fromEntries(
Object.entries(rawComponents).map(([path, mod]) => [
path.replace('../svelte/', '').replace('.svelte', ''),
mod.default,
])
)
import Components from "virtual:live-svelte-components"
function formatPayload(str) {
if (!str || str === "—") return "—"

View file

@ -3,16 +3,6 @@
// syntax used by server.js. Configured via vite.config.js:
// liveSveltePlugin({ entrypoint: './js/server.vite.js' })
import { getRender } from "live_svelte"
// import.meta.glob returns Record<path, module> (e.g. {"../svelte/Counter.svelte": {default: ...}}).
// getRender/getHooks expect Record<name, Component> (e.g. {"Counter": Component}).
// Transform: strip path prefix and .svelte extension to get the component name.
const rawComponents = import.meta.glob("../svelte/**/*.svelte", { eager: true })
const Components = Object.fromEntries(
Object.entries(rawComponents).map(([path, mod]) => [
path.replace("../svelte/", "").replace(".svelte", ""),
mod.default,
])
)
import Components from "virtual:live-svelte-components"
export const render = getRender(Components)

View file

@ -1,6 +1,6 @@
<script>
let { color, index } = $props();
console.log(color);
let {color, index} = $props()
console.log(color)
</script>
<div
@ -9,7 +9,7 @@
class:border-red-500={color === "red"}
class:bg-red-500={color === "red"}
class:text-white={color === "red"}
class="flex flex-col justify-center items-center p-4 w-100 border-1 rounded-md shadow-lg"
class="flex flex-col justify-center items-center p-4 w-100 border rounded-md shadow-lg"
>
<div class="font-bold p-1 text-lg">{index}</div>
<div class="font-bold p-1 text-lg">Svelte component</div>

View file

@ -157,6 +157,12 @@
</a>
<span class="text-base-content/50 text-sm">- Named slots with dynamic content</span>
</li>
<%!-- <li>
<a href={~p"/live-slots-nested"} class="link link-primary">
Nested Slots
</a>
<span class="text-base-content/50 text-sm">- Named slots with dynamic content</span>
</li> --%>
</ul>
</div>
</div>

View file

@ -2,51 +2,52 @@ defmodule ExampleWeb.Router do
use ExampleWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {ExampleWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_live_flash)
plug(:put_root_layout, {ExampleWeb.Layouts, :root})
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
end
pipeline :api do
plug :accepts, ["json"]
plug(:accepts, ["json"])
end
scope "/", ExampleWeb do
pipe_through :browser
pipe_through(:browser)
get "/", PageController, :home
get("/", PageController, :home)
# same order as in app.html.heex:
get "/hello-world", PageController, :hello_world
get "/lodash", PageController, :lodash
live "/live-struct", LiveStruct
live "/live-simple-counter", LiveSimpleCounter
live "/live-lights", LiveLights
live "/live-sigil", LiveSigil
get "/plus-minus-svelte", PageController, :plus_minus_svelte
live "/live-plus-minus", LivePlusMinus
live "/live-plus-minus-hybrid", LivePlusMinusHybrid
live "/live-static-color", LiveStaticColor
live "/live-log-list", LiveLogList
live "/live-breaking-news", LiveBreakingNews
live "/live-chat", LiveChat
live "/live-json", LiveJson
live "/live-props-diff", LivePropsDiff
live "/streams", Streams
live "/live-id-list-diff", LiveIdListDiff
live "/live-slots-simple", LiveSlotsSimple
live "/live-slots-dynamic", LiveSlotsDynamic
live "/live-client-side-loading", LiveClientSideLoading
get("/hello-world", PageController, :hello_world)
get("/lodash", PageController, :lodash)
live("/live-struct", LiveStruct)
live("/live-simple-counter", LiveSimpleCounter)
live("/live-lights", LiveLights)
live("/live-sigil", LiveSigil)
get("/plus-minus-svelte", PageController, :plus_minus_svelte)
live("/live-plus-minus", LivePlusMinus)
live("/live-plus-minus-hybrid", LivePlusMinusHybrid)
live("/live-static-color", LiveStaticColor)
live("/live-log-list", LiveLogList)
live("/live-breaking-news", LiveBreakingNews)
live("/live-chat", LiveChat)
live("/live-json", LiveJson)
live("/live-props-diff", LivePropsDiff)
live("/streams", Streams)
live("/live-id-list-diff", LiveIdListDiff)
live("/live-slots-simple", LiveSlotsSimple)
live("/live-slots-dynamic", LiveSlotsDynamic)
live("/live-slots-nested", LiveSlotsNested)
live("/live-client-side-loading", LiveClientSideLoading)
# Ecto Examples
live "/live-notes-otp", LiveNotesOtp
live "/live-form", LiveForm
live "/live-upload", LiveUpload
live "/live-event-reply", LiveEventReply
live "/live-navigation", LiveNavigation
live "/live-navigation/:page", LiveNavigation
live "/live-composition", LiveComposition
live("/live-notes-otp", LiveNotesOtp)
live("/live-form", LiveForm)
live("/live-upload", LiveUpload)
live("/live-event-reply", LiveEventReply)
live("/live-navigation", LiveNavigation)
live("/live-navigation/:page", LiveNavigation)
live("/live-composition", LiveComposition)
end
# Other scopes may use custom stacks.
@ -64,10 +65,10 @@ defmodule ExampleWeb.Router do
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through :browser
pipe_through(:browser)
live_dashboard "/dashboard", metrics: ExampleWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
live_dashboard("/dashboard", metrics: ExampleWeb.Telemetry)
forward("/mailbox", Plug.Swoosh.MailboxPreview)
end
end
end

View file

@ -62,7 +62,8 @@ defmodule LiveSvelte do
attr :diff, :boolean,
default: true,
doc: "When true (and config enable_props_diff is true), only changed props are sent on update. Set to false to always send full props."
doc:
"When true (and config enable_props_diff is true), only changed props are sent on update. Set to false to always send full props."
slot :inner_block, doc: "Inner block of the Svelte component"
@ -79,7 +80,8 @@ defmodule LiveSvelte do
ssr_active = Application.get_env(:live_svelte, :ssr, true)
use_diff = diff_enabled?(assigns)
svelte_id = assigns.id || key_based_id(assigns.name, assigns.key, assigns.props, assigns.__changed__)
svelte_id =
assigns.id || key_based_id(assigns.name, assigns.key, assigns.props, assigns.__changed__)
# Snapshot previous props BEFORE props_for_payload/5 updates the process dict.
# Used for JSON Patch diff computation (Tier 2 + 3).
@ -148,31 +150,31 @@ defmodule LiveSvelte do
<script>
<%= raw(@ssr_render["head"]) %>
</script>
<div
id={@svelte_id}
data-name={@name}
data-props={json(@props_to_send)}
data-props-diff={json(@props_diff)}
data-streams-diff={json(@streams_diff)}
data-use-diff={to_string(@use_diff)}
data-ssr={@ssr_render != nil}
data-live-json={
if @init, do: json(@live_json_props), else: @live_json_props |> Map.keys() |> json()
}
data-slots={@slots |> Slots.base_encode_64() |> json}
phx-hook="SvelteHook"
phx-update="ignore"
class={@class}
>
<div id={"#{@svelte_id}-target"} data-svelte-target>
<%= raw(@ssr_render["head"]) %>
<style>
<%= raw(@ssr_render["css"]["code"]) %>
</style>
<%= raw(@ssr_render["html"]) %>
<%= render_slot(@loading) %>
<div
id={@svelte_id}
data-name={@name}
data-props={json(@props_to_send)}
data-props-diff={json(@props_diff)}
data-streams-diff={json(@streams_diff)}
data-use-diff={to_string(@use_diff)}
data-ssr={@ssr_render != nil}
data-live-json={
if @init, do: json(@live_json_props), else: @live_json_props |> Map.keys() |> json()
}
data-slots={@slots |> Slots.base_encode_64() |> json}
phx-hook="SvelteHook"
phx-update="ignore"
class={@class}
>
<div id={"#{@svelte_id}-target"} data-svelte-target>
<%= raw(@ssr_render["head"]) %>
<style>
<%= raw(@ssr_render["css"]["code"]) %>
</style>
<%= raw(@ssr_render["html"]) %>
<%= render_slot(@loading) %>
</div>
</div>
</div>
</.live_json>
"""
end
@ -196,9 +198,14 @@ defmodule LiveSvelte do
props = Map.get(assigns, :props, %{})
cond do
init or dead or not use_diff -> props
is_map(assigns.__changed__[:props]) -> props_changed_only(props, assigns.__changed__[:props])
true -> props
init or dead or not use_diff ->
props
is_map(assigns.__changed__[:props]) ->
props_changed_only(props, assigns.__changed__[:props])
true ->
props
end
end
@ -445,6 +452,7 @@ defmodule LiveSvelte do
nil ->
maybe_reset_id_counters_for_update(changed)
counter_id(name)
identity ->
"#{name}-#{identity}"
end
@ -490,7 +498,11 @@ defmodule LiveSvelte do
# Simple counter for standalone (non-loop) components that lack identity props.
defp counter_id(name) do
Process.put(:live_svelte_counter_names, Enum.uniq([name | Process.get(:live_svelte_counter_names, [])]))
Process.put(
:live_svelte_counter_names,
Enum.uniq([name | Process.get(:live_svelte_counter_names, [])])
)
Process.put(:live_svelte_total_counter, Process.get(:live_svelte_total_counter, 0) + 1)
key = {:live_svelte_counter, name}
count = Process.get(key, 0)

View file

@ -91,7 +91,10 @@ defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form 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."
addition =
"\n\nEncode form with LiveSvelte.Encoder.encode(form, nilify_not_loaded: true) to avoid."
Enum.join([first | [addition | rest]], "\n\n")
end)
end
@ -105,37 +108,56 @@ defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form 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)
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
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)
{: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
%{__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
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
[%{__meta__: _} | _] = value ->
Enum.map(value, &Map.delete(&1, :__meta__))
value ->
value
end
end
end
@ -164,6 +186,7 @@ defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form do
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 ->
@ -178,9 +201,11 @@ defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form do
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
_ ->
acc
end
end)
end
@ -240,6 +265,7 @@ if Code.ensure_loaded?(Ecto.Changeset) do
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) ->
@ -254,6 +280,7 @@ if Code.ensure_loaded?(Ecto.Changeset) do
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)
_ ->
@ -270,7 +297,11 @@ if Code.ensure_loaded?(Ecto.Changeset) do
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))
String.replace(
acc,
"%{#{key}}",
value |> List.wrap() |> Enum.map_join(", ", &to_string/1)
)
end)
end
end
@ -344,6 +375,7 @@ defimpl LiveSvelte.Encoder, for: Any do
# (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)
@ -357,18 +389,22 @@ defimpl LiveSvelte.Encoder, for: Any do
cond do
only = Keyword.get(opts, :only) ->
case only -- fields do
[] -> only
[] ->
only
error_keys ->
raise ArgumentError,
":only specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}"
":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]
[] ->
fields -- [:__struct__ | except]
error_keys ->
raise ArgumentError,
":except specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}"
":except specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}"
end
true ->

View file

@ -81,10 +81,12 @@ defmodule LiveSvelte.Test do
defp decode_handlers(nil), do: %{}
defp decode_handlers(""), do: %{}
defp decode_handlers(_str), do: %{} # LiveSvelte does not emit data-handlers yet
# LiveSvelte does not emit data-handlers yet
defp decode_handlers(_str), do: %{}
defp decode_slots(nil), do: %{}
defp decode_slots(""), do: %{}
defp decode_slots(str) do
str
|> Jason.decode!()
@ -108,16 +110,20 @@ defmodule LiveSvelte.Test do
Enum.reduce(opts, components_tree, fn
{:id, id}, result ->
filtered = Enum.filter(result, &(attr_from_tree(&1, "id") == id))
if filtered == [] do
raise "No Svelte component found with id=\"#{id}\". Available: #{available}"
end
filtered
{:name, name}, result ->
filtered = Enum.filter(result, &(attr_from_tree(&1, "data-name") == name))
if filtered == [] do
raise "No Svelte component found with name=\"#{name}\". Available: #{available}"
end
filtered
{key, _}, _ ->

View file

@ -31,14 +31,22 @@ defmodule LiveSvelte.Reload do
assigns =
assigns
|> assign(:vite_host, vite_host)
|> assign(:stylesheets, for(path <- assigns.assets, String.ends_with?(path, ".css"), do: path))
|> assign(:javascripts, for(path <- assigns.assets, String.ends_with?(path, ".js"), do: path))
|> assign(
:stylesheets,
for(path <- assigns.assets, String.ends_with?(path, ".css"), do: path)
)
|> assign(
:javascripts,
for(path <- assigns.assets, String.ends_with?(path, ".js"), do: path)
)
~H"""
<%= if @vite_host do %>
<script type="module" src={"#{@vite_host}/@vite/client"}></script>
<script type="module" src={"#{@vite_host}/@vite/client"}>
</script>
<link :for={path <- @stylesheets} rel="stylesheet" href={"#{@vite_host}#{path}"} />
<script :for={path <- @javascripts} type="module" src={"#{@vite_host}#{path}"}></script>
<script :for={path <- @javascripts} type="module" src={"#{@vite_host}#{path}"}>
</script>
<% else %>
<%= render_slot(@inner_block) %>
<% end %>

View file

@ -54,6 +54,7 @@ defmodule LiveSvelte.AutoIdTest do
{:live_svelte_prev_props, _} = k -> Process.delete(k)
_ -> :ok
end)
Process.delete(:live_svelte_counter_names)
Process.delete(:live_svelte_total_counter)
Process.delete(:live_svelte_expected_total)

View file

@ -121,8 +121,8 @@ defmodule LiveSvelte.EncoderTest do
use Ecto.Schema
schema "users" do
field :name, :string
field :email, :string
field(:name, :string)
field(:email, :string)
end
end
@ -134,7 +134,9 @@ defmodule LiveSvelte.EncoderTest do
|> Ecto.Changeset.apply_action(:validate)
case cs do
{:ok, _} -> flunk("expected invalid changeset")
{:ok, _} ->
flunk("expected invalid changeset")
{:error, changeset} ->
encoded = Encoder.encode(changeset, [])
assert encoded.valid? == false

View file

@ -12,11 +12,14 @@ defmodule LiveSvelte.TestTest do
"data-ssr": "false",
"data-slots": ~s({"default":"PHA+c2xvdDwvcD4="})
]
merged = Keyword.merge(default, attrs)
attr_str =
merged
|> Enum.map(fn {k, v} -> ~s(#{k}='#{v}') end)
|> Enum.join(" ")
"""
<div #{attr_str} phx-hook="SvelteHook" phx-update="ignore">
<div id="Counter-1-target"></div>
@ -57,6 +60,7 @@ defmodule LiveSvelte.TestTest do
<div id="A-1" data-name="A" data-props='{}' data-ssr="false" data-slots='{}' phx-hook="SvelteHook"></div>
<div id="B-1" data-name="B" data-props='{"x":1}' data-ssr="false" data-slots='{}' phx-hook="SvelteHook"></div>
"""
svelte = Test.get_svelte(html, name: "B")
assert svelte.name == "B"
assert svelte.props == %{"x" => 1}
@ -64,6 +68,7 @@ defmodule LiveSvelte.TestTest do
test "raises when name does not match" do
html = svelte_root_html()
assert_raise RuntimeError, ~r/No Svelte component found with name="NotFound"/, fn ->
Test.get_svelte(html, name: "NotFound")
end
@ -77,6 +82,7 @@ defmodule LiveSvelte.TestTest do
<div id="First" data-name="X" data-props='{}' data-ssr="false" data-slots='{}' phx-hook="SvelteHook"></div>
<div id="Second" data-name="Y" data-props='{"y":2}' data-ssr="false" data-slots='{}' phx-hook="SvelteHook"></div>
"""
svelte = Test.get_svelte(html, id: "Second")
assert svelte.id == "Second"
assert svelte.props == %{"y" => 2}
@ -84,6 +90,7 @@ defmodule LiveSvelte.TestTest do
test "raises when id does not match" do
html = svelte_root_html()
assert_raise RuntimeError, ~r/No Svelte component found with id="no-such"/, fn ->
Test.get_svelte(html, id: "no-such")
end
@ -93,6 +100,7 @@ defmodule LiveSvelte.TestTest do
describe "get_svelte from HTML with no Svelte components" do
test "raises when no Svelte root present" do
html = "<div>plain</div>"
assert_raise RuntimeError, ~r/No Svelte components found/, fn ->
Test.get_svelte(html)
end
@ -102,12 +110,17 @@ defmodule LiveSvelte.TestTest do
describe "get_svelte from LiveView" do
test "extracts Svelte component from rendered LiveView component" do
html =
Phoenix.LiveViewTest.__render_component__(nil, &LiveSvelte.svelte/1, %{
name: "TestComponent",
props: %{value: 42},
ssr: false,
socket: nil
}, [])
Phoenix.LiveViewTest.__render_component__(
nil,
&LiveSvelte.svelte/1,
%{
name: "TestComponent",
props: %{value: 42},
ssr: false,
socket: nil
},
[]
)
svelte = Test.get_svelte(html)
assert svelte.name == "TestComponent"
@ -118,7 +131,12 @@ defmodule LiveSvelte.TestTest do
test "get_svelte(html, name: ...) when HTML from render_component" do
html =
Phoenix.LiveViewTest.__render_component__(nil, &LiveSvelte.svelte/1, %{name: "Demo", props: %{}, ssr: false, socket: nil}, [])
Phoenix.LiveViewTest.__render_component__(
nil,
&LiveSvelte.svelte/1,
%{name: "Demo", props: %{}, ssr: false, socket: nil},
[]
)
svelte = Test.get_svelte(html, name: "Demo")
assert svelte.name == "Demo"
@ -128,6 +146,7 @@ defmodule LiveSvelte.TestTest do
describe "invalid option" do
test "raises on unknown option" do
html = svelte_root_html()
assert_raise ArgumentError, ~r/invalid keyword option/, fn ->
Test.get_svelte(html, foo: "bar")
end

View file

@ -40,8 +40,10 @@ defmodule LiveSvelte.PropsDiffTest do
encoded
|> String.replace("&quot;", "\"")
|> String.replace("&#39;", "'")
# Use Erlang :json (same family as default LiveSvelte.JSON encoder)
:json.decode(unescaped)
_ ->
nil
end
@ -86,11 +88,14 @@ defmodule LiveSvelte.PropsDiffTest do
test "when enable_props_diff is false, always returns full props" do
Application.put_env(:live_svelte, :enable_props_diff, false)
try do
assigns = base_assigns(
props: %{"x" => 10, "y" => 20},
__changed__: %{props: %{"x" => 10, "y" => 0}}
)
assigns =
base_assigns(
props: %{"x" => 10, "y" => 20},
__changed__: %{props: %{"x" => 10, "y" => 0}}
)
# In real component rendering, `diff` is present (default true). Ensure global config still wins.
assigns = Map.put(assigns, :diff, true)
result = LiveSvelte.props_for_payload(assigns)
@ -109,7 +114,9 @@ defmodule LiveSvelte.PropsDiffTest do
|> String.replace("&quot;", "\"")
|> String.replace("&#39;", "'")
|> String.replace("&#x2B;", "+")
:json.decode(unescaped)
_ ->
nil
end
@ -179,7 +186,13 @@ defmodule LiveSvelte.PropsDiffTest do
describe "Tier 3 - ID-based list diffing via object_hash" do
test "inserting new item at front with id-list produces fewer ops than N replaces" do
items_old = [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}, %{id: 3, name: "Carol"}]
items_new = [%{id: 4, name: "Dave"}, %{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}, %{id: 3, name: "Carol"}]
items_new = [
%{id: 4, name: "Dave"},
%{id: 1, name: "Alice"},
%{id: 2, name: "Bob"},
%{id: 3, name: "Carol"}
]
diff = LiveSvelte.calculate_props_diff(%{items: items_new}, %{items: items_old})
content_ops = Enum.reject(diff, &(&1.op == "test"))

View file

@ -11,6 +11,7 @@ defmodule LiveSvelte.SlotsTest do
test "filters out slot entries from assigns" do
slot_entry = %{__slot__: :button, inner_block: fn -> "content" end}
assigns = %{
name: "Test",
button: [slot_entry],

View file

@ -104,7 +104,9 @@ defmodule LiveSvelte.StreamsTest do
diff = decode_streams_diff(html)
# Initial render produces replace (empty) + upsert
replace_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "replace" && Enum.at(op, 1) == "/items" end)
replace_op =
Enum.find(diff, fn op -> Enum.at(op, 0) == "replace" && Enum.at(op, 1) == "/items" end)
assert replace_op != nil
assert Enum.at(replace_op, 2) == []
@ -113,9 +115,15 @@ defmodule LiveSvelte.StreamsTest do
assert Enum.at(upsert_op, 1) == "/items/-"
# Order matters: replace MUST come before upsert so the array exists before items are inserted
replace_idx = Enum.find_index(diff, fn op -> Enum.at(op, 0) == "replace" && Enum.at(op, 1) == "/items" end)
replace_idx =
Enum.find_index(diff, fn op ->
Enum.at(op, 0) == "replace" && Enum.at(op, 1) == "/items"
end)
upsert_idx = Enum.find_index(diff, fn op -> Enum.at(op, 0) == "upsert" end)
assert replace_idx < upsert_idx, "replace [] must precede upsert ops (got replace at #{replace_idx}, upsert at #{upsert_idx})"
assert replace_idx < upsert_idx,
"replace [] must precede upsert ops (got replace at #{replace_idx}, upsert at #{upsert_idx})"
end
test "insert at 0 produces upsert with path /items/0" do
@ -258,9 +266,11 @@ defmodule LiveSvelte.StreamsTest do
html = render_html(assigns)
diff = decode_streams_diff(html)
replace_op = Enum.find(diff, fn op ->
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-1")
end)
replace_op =
Enum.find(diff, fn op ->
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-1")
end)
assert replace_op != nil, "expected replace op at $$dom_id path for update_only: true"
assert Enum.at(replace_op, 1) == "/items/$$items-1"
@ -320,9 +330,11 @@ defmodule LiveSvelte.StreamsTest do
html = render_html(assigns)
diff = decode_streams_diff(html)
replace_op = Enum.find(diff, fn op ->
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-5")
end)
replace_op =
Enum.find(diff, fn op ->
Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-5")
end)
value = Enum.at(replace_op, 2)
assert value["__dom_id"] == "items-5"
assert value["id"] == 5