From b7a7558107a1fc1091e5097f1b60cae86bd1c443 Mon Sep 17 00:00:00 2001 From: Denis Donici Date: Tue, 3 Mar 2026 20:23:21 +0200 Subject: [PATCH] chore: prepare vite integration --- assets/js/live_svelte/types.d.ts | 10 ++ assets/js/live_svelte/vite_plugin.js | 166 +++++++++++++++++- assets/js/live_svelte/vite_plugin.test.ts | 97 ++++++++++ config/config.exs | 3 +- example_project/assets/js/app.vite.js | 12 +- example_project/assets/js/server.vite.js | 12 +- .../assets/svelte/StaticTest.svelte | 6 +- .../controllers/page_html/home.html.heex | 6 + example_project/lib/example_web/router.ex | 79 +++++---- lib/live_svelte.ex | 72 ++++---- lib/live_svelte/encoder.ex | 68 +++++-- lib/live_svelte/test.ex | 8 +- lib/reload.ex | 16 +- test/auto_id_test.exs | 1 + test/live_svelte/encoder_test.exs | 8 +- test/live_svelte/test_test.exs | 33 +++- test/props_diff_test.exs | 23 ++- test/slots_test.exs | 1 + test/streams_test.exs | 30 +++- 19 files changed, 508 insertions(+), 143 deletions(-) create mode 100644 assets/js/live_svelte/vite_plugin.test.ts diff --git a/assets/js/live_svelte/types.d.ts b/assets/js/live_svelte/types.d.ts index 9b81eda..21eb32c 100644 --- a/assets/js/live_svelte/types.d.ts +++ b/assets/js/live_svelte/types.d.ts @@ -414,3 +414,13 @@ export declare function useEventReply ): UseEventReplyReturn; +// --------------------------------------------------------------------------- +// Virtual module type declaration +// --------------------------------------------------------------------------- + +declare module "virtual:live-svelte-components" { + import type { Component } from "svelte"; + const components: Record; + export default components; +} + diff --git a/assets/js/live_svelte/vite_plugin.js b/assets/js/live_svelte/vite_plugin.js index 1df4f92..9262065 100644 --- a/assets/js/live_svelte/vite_plugin.js +++ b/assets/js/live_svelte/vite_plugin.js @@ -1,9 +1,106 @@ /// +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` 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 diff --git a/assets/js/live_svelte/vite_plugin.test.ts b/assets/js/live_svelte/vite_plugin.test.ts new file mode 100644 index 0000000..df3ec79 --- /dev/null +++ b/assets/js/live_svelte/vite_plugin.test.ts @@ -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/); + }); +}); diff --git a/config/config.exs b/config/config.exs index 7a1435a..4ddc847 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 -> diff --git a/example_project/assets/js/app.vite.js b/example_project/assets/js/app.vite.js index 77ab994..11f422a 100644 --- a/example_project/assets/js/app.vite.js +++ b/example_project/assets/js/app.vite.js @@ -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 (e.g. {"../svelte/Counter.svelte": {default: ...}}). -// getHooks expects Record (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 "—" diff --git a/example_project/assets/js/server.vite.js b/example_project/assets/js/server.vite.js index d0aaadc..0acf4d7 100644 --- a/example_project/assets/js/server.vite.js +++ b/example_project/assets/js/server.vite.js @@ -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 (e.g. {"../svelte/Counter.svelte": {default: ...}}). -// getRender/getHooks expect Record (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) diff --git a/example_project/assets/svelte/StaticTest.svelte b/example_project/assets/svelte/StaticTest.svelte index 59aac38..6f10472 100644 --- a/example_project/assets/svelte/StaticTest.svelte +++ b/example_project/assets/svelte/StaticTest.svelte @@ -1,6 +1,6 @@
{index}
Svelte component
diff --git a/example_project/lib/example_web/controllers/page_html/home.html.heex b/example_project/lib/example_web/controllers/page_html/home.html.heex index 11a50f8..c222c46 100644 --- a/example_project/lib/example_web/controllers/page_html/home.html.heex +++ b/example_project/lib/example_web/controllers/page_html/home.html.heex @@ -157,6 +157,12 @@ - Named slots with dynamic content + <%!--
  • + + Nested Slots + + - Named slots with dynamic content +
  • --%>
    diff --git a/example_project/lib/example_web/router.ex b/example_project/lib/example_web/router.ex index 6856278..6d40ad1 100644 --- a/example_project/lib/example_web/router.ex +++ b/example_project/lib/example_web/router.ex @@ -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 diff --git a/lib/live_svelte.ex b/lib/live_svelte.ex index 832ce15..8e863ce 100644 --- a/lib/live_svelte.ex +++ b/lib/live_svelte.ex @@ -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 -
    Map.keys() |> json() - } - data-slots={@slots |> Slots.base_encode_64() |> json} - phx-hook="SvelteHook" - phx-update="ignore" - class={@class} - > -
    - <%= raw(@ssr_render["head"]) %> - - <%= raw(@ssr_render["html"]) %> - <%= render_slot(@loading) %> +
    Map.keys() |> json() + } + data-slots={@slots |> Slots.base_encode_64() |> json} + phx-hook="SvelteHook" + phx-update="ignore" + class={@class} + > +
    + <%= raw(@ssr_render["head"]) %> + + <%= raw(@ssr_render["html"]) %> + <%= render_slot(@loading) %> +
    -
    """ 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) diff --git a/lib/live_svelte/encoder.ex b/lib/live_svelte/encoder.ex index f5c0a27..9121e1d 100644 --- a/lib/live_svelte/encoder.ex +++ b/lib/live_svelte/encoder.ex @@ -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 -> diff --git a/lib/live_svelte/test.ex b/lib/live_svelte/test.ex index 2e2b23b..2794f8f 100644 --- a/lib/live_svelte/test.ex +++ b/lib/live_svelte/test.ex @@ -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, _}, _ -> diff --git a/lib/reload.ex b/lib/reload.ex index 14c7e4f..cc0dca6 100644 --- a/lib/reload.ex +++ b/lib/reload.ex @@ -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 %> - + - + <% else %> <%= render_slot(@inner_block) %> <% end %> diff --git a/test/auto_id_test.exs b/test/auto_id_test.exs index 686e2a5..695fef8 100644 --- a/test/auto_id_test.exs +++ b/test/auto_id_test.exs @@ -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) diff --git a/test/live_svelte/encoder_test.exs b/test/live_svelte/encoder_test.exs index cbb6796..48effcb 100644 --- a/test/live_svelte/encoder_test.exs +++ b/test/live_svelte/encoder_test.exs @@ -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 diff --git a/test/live_svelte/test_test.exs b/test/live_svelte/test_test.exs index b069cb6..2b2d7b4 100644 --- a/test/live_svelte/test_test.exs +++ b/test/live_svelte/test_test.exs @@ -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(" ") + """
    @@ -57,6 +60,7 @@ defmodule LiveSvelte.TestTest do
    """ + 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
    """ + 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 = "
    plain
    " + 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 diff --git a/test/props_diff_test.exs b/test/props_diff_test.exs index cf2f948..04a1346 100644 --- a/test/props_diff_test.exs +++ b/test/props_diff_test.exs @@ -40,8 +40,10 @@ defmodule LiveSvelte.PropsDiffTest do encoded |> String.replace(""", "\"") |> String.replace("'", "'") + # 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(""", "\"") |> String.replace("'", "'") |> String.replace("+", "+") + :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")) diff --git a/test/slots_test.exs b/test/slots_test.exs index f8de912..3ee0407 100644 --- a/test/slots_test.exs +++ b/test/slots_test.exs @@ -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], diff --git a/test/streams_test.exs b/test/streams_test.exs index efd9807..dd0f7d1 100644 --- a/test/streams_test.exs +++ b/test/streams_test.exs @@ -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