diff --git a/assets/js/live_svelte/vite_plugin.js b/assets/js/live_svelte/vite_plugin.js new file mode 100644 index 0000000..1df4f92 --- /dev/null +++ b/assets/js/live_svelte/vite_plugin.js @@ -0,0 +1,131 @@ +/// + +/** + * @typedef {Object} PluginOptions + * @property {string} [path] - SSR render endpoint path (default: "/ssr_render") + * @property {string} [entrypoint] - SSR entrypoint file (default: "./js/server.js") + */ + +/** + * @param {string} path + * @returns {"css-update" | "js-update" | null} + */ +function hotUpdateType(path) { + if (path.endsWith("css")) return "css-update" + if (path.endsWith("js")) return "js-update" + return null +} + +/** + * @param {import("http").ServerResponse} res + * @param {number} statusCode + * @param {unknown} data + */ +const jsonResponse = (res, statusCode, data) => { + res.statusCode = statusCode + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify(data)) +} + +/** + * Custom JSON parsing middleware + * @param {import("http").IncomingMessage & { body?: Record }} req + * @param {import("http").ServerResponse} res + * @param {() => Promise} next + */ +const jsonMiddleware = (req, res, next) => { + let data = "" + + req.on("data", chunk => { + data += chunk + }) + + req.on("end", () => { + try { + req.body = JSON.parse(data) + next() + } catch (error) { + jsonResponse(res, 400, { error: "Invalid JSON" }) + } + }) + + req.on("error", err => { + console.error(err) + jsonResponse(res, 500, { error: "Internal Server Error" }) + }) +} + +/** + * LiveSvelte Vite plugin for SSR and hot reload support. + * + * 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`. + * + * @param {PluginOptions} [opts] + * @returns {import("vite").Plugin} + */ +function liveSveltePlugin(opts = {}) { + return { + name: "live-svelte", + handleHotUpdate({ file, modules, server, timestamp }) { + if (file.match(/\.(heex|ex)$/)) { + const invalidatedModules = new Set() + for (const mod of modules) { + server.moduleGraph.invalidateModule(mod, invalidatedModules, timestamp, true) + } + + const updates = Array.from(invalidatedModules).flatMap(m => { + const { file } = m + + if (!file) return [] + + const updateType = hotUpdateType(file) + + if (!updateType) return [] + + return { + type: updateType, + path: m.url, + acceptedPath: m.url, + timestamp: timestamp, + } + }) + + server.ws.send({ + type: "update", + updates, + }) + + return [] + } + }, + configureServer(server) { + process.stdin.on("close", () => process.exit(0)) + process.stdin.resume() + + const path = 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) { + jsonMiddleware(req, res, async () => { + try { + const render = (await server.ssrLoadModule(entrypoint)).render + const result = await render(req.body?.name, req.body?.props, req.body?.slots) + // LiveSvelte render returns {head, html, css} — must JSON-encode for Elixir decoder + res.setHeader("Content-Type", "application/json") + res.end(JSON.stringify(result)) + } catch (e) { + e instanceof Error && server.ssrFixStacktrace(e) + jsonResponse(res, 500, { error: e }) + } + }) + } else { + next() + } + }) + }, + } +} + +export default liveSveltePlugin diff --git a/example_project/assets/js/app.vite.js b/example_project/assets/js/app.vite.js new file mode 100644 index 0000000..90c41df --- /dev/null +++ b/example_project/assets/js/app.vite.js @@ -0,0 +1,67 @@ +// Vite dev server entry point for HMR development. +// Replaces the esbuild-plugin-import-glob syntax in app.js with Vite's native +// import.meta.glob. The esbuild build (app.js) remains unchanged for production. + +import "phoenix_html" +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +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" + +// Vite-native glob (replaces `import * as Components from "../svelte/**/*.svelte"`) +const Components = import.meta.glob('../svelte/**/*.svelte', { eager: true }) + +function formatPayload(str) { + if (!str || str === "—") return "—" + try { + return JSON.stringify(JSON.parse(str), null, 2) + } catch { + return str + } +} + +const PropsDiffPayloadDisplay = { + mounted() { + this.updateDisplays() + const root = this.el + const diffOnEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='true']") + const diffOffEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='false']") + const observer = new MutationObserver(() => this.updateDisplays()) + if (diffOnEl) observer.observe(diffOnEl, {attributes: true, attributeFilter: ["data-props"]}) + if (diffOffEl) observer.observe(diffOffEl, {attributes: true, attributeFilter: ["data-props"]}) + this._observer = observer + }, + updated() { + this.updateDisplays() + }, + destroyed() { + this._observer?.disconnect() + }, + updateDisplays() { + const root = this.el + const diffOnEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='true']") + const diffOffEl = root.querySelector("[data-name='PropsDiffDemo'][data-use-diff='false']") + const preOn = root.querySelector("#payload-display-diff-on") + const preOff = root.querySelector("#payload-display-diff-off") + if (preOn) preOn.textContent = formatPayload(diffOnEl?.getAttribute("data-props") ?? "—") + if (preOff) preOff.textContent = formatPayload(diffOffEl?.getAttribute("data-props") ?? "—") + }, +} + +const Hooks = { + ...createLiveJsonHooks(), + ...getHooks(Components), + PropsDiffPayloadDisplay, +} + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}}) + +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +liveSocket.connect() +window.liveSocket = liveSocket diff --git a/example_project/assets/js/server.vite.js b/example_project/assets/js/server.vite.js new file mode 100644 index 0000000..834b81a --- /dev/null +++ b/example_project/assets/js/server.vite.js @@ -0,0 +1,9 @@ +// Vite SSR entry point for liveSveltePlugin's /ssr_render endpoint. +// Uses Vite-native import.meta.glob instead of the esbuild-plugin-import-glob +// syntax used by server.js. Configured via vite.config.js: +// liveSveltePlugin({ entrypoint: './js/server.vite.js' }) +import { getRender } from "live_svelte" + +const Components = import.meta.glob("../svelte/**/*.svelte", { eager: true }) + +export const render = getRender(Components) diff --git a/example_project/assets/package.json b/example_project/assets/package.json index 737e5ff..0c106da 100644 --- a/example_project/assets/package.json +++ b/example_project/assets/package.json @@ -3,7 +3,9 @@ "@types/lodash": "^4.14.192", "daisyui": "^5.0.0", "dynamic-marquee": "^2.6.2", - "esbuild": "^0.24.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "esbuild": "^0.24.0", + "vite": "^6.0.0", "esbuild-plugin-import-glob": "^0.1.1", "esbuild-svelte": "^0.9.0", "lodash": "^4.17.21", diff --git a/example_project/assets/vite.config.js b/example_project/assets/vite.config.js new file mode 100644 index 0000000..bb057a5 --- /dev/null +++ b/example_project/assets/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vite" +import { svelte } from "@sveltejs/vite-plugin-svelte" +import liveSveltePlugin from "live_svelte/vitePlugin" + +export default defineConfig({ + plugins: [svelte(), liveSveltePlugin({ entrypoint: "./js/server.vite.js" })], + server: { + host: "localhost", + port: 5173, + }, +}) diff --git a/example_project/config/dev.exs b/example_project/config/dev.exs index 2730079..d95c400 100644 --- a/example_project/config/dev.exs +++ b/example_project/config/dev.exs @@ -62,6 +62,21 @@ config :example, ExampleWeb.Endpoint, # Enable dev routes for dashboard and mailbox config :example, dev_routes: true +# To use Vite dev server for HMR + SSR during development: +# +# 1. Install Vite deps (one-time): +# cd assets && npm install +# +# 2. Start Vite dev server (in a separate terminal): +# cd assets && npx vite +# +# 3. Enable Vite in config (uncomment below): +# config :live_svelte, ssr_module: LiveSvelte.SSR.ViteJS +# config :live_svelte, vite_host: "http://localhost:5173" +# +# With :vite_host set, LiveSvelte.Reload.vite_assets/1 in root.html.heex +# automatically serves assets from Vite (with HMR) instead of compiled files. + # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n" diff --git a/example_project/lib/example_web/components/core_components.ex b/example_project/lib/example_web/components/core_components.ex index fe0d21b..4ee214c 100644 --- a/example_project/lib/example_web/components/core_components.ex +++ b/example_project/lib/example_web/components/core_components.ex @@ -84,17 +84,17 @@ defmodule ExampleWeb.CoreComponents do

- <%= render_slot(@title) %> + {render_slot(@title)}

- <%= render_slot(@subtitle) %> + {render_slot(@subtitle)}

- <%= render_slot(@inner_block) %> + {render_slot(@inner_block)}
<.button :for={confirm <- @confirm} @@ -103,14 +103,14 @@ defmodule ExampleWeb.CoreComponents do phx-disable-with class="py-2 px-3" > - <%= render_slot(confirm) %> + {render_slot(confirm)} <.link :for={cancel <- @cancel} phx-click={hide_modal(@on_cancel, @id)} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" > - <%= render_slot(cancel) %> + {render_slot(cancel)}
@@ -158,9 +158,9 @@ defmodule ExampleWeb.CoreComponents do

<.icon :if={@kind == :info} name="hero-information-circle-mini" class="w-4 h-4" /> <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="w-4 h-4" /> - <%= @title %> + {@title}

-

<%= msg %>

+

{msg}

""" end @@ -325,9 +325,9 @@ defmodule ExampleWeb.CoreComponents do class="checkbox checkbox-sm" {@rest} /> - <%= @label %> + {@label} - <.error :for={msg <- @errors}><%= msg %> + <.error :for={msg <- @errors}>{msg} """ end @@ -335,7 +335,7 @@ defmodule ExampleWeb.CoreComponents do def input(%{type: "select"} = assigns) do ~H"""
- <.label for={@id}><%= @label %> + <.label for={@id}>{@label} - <.error :for={msg <- @errors}><%= msg %> + <.error :for={msg <- @errors}>{msg}
""" end @@ -354,7 +354,7 @@ defmodule ExampleWeb.CoreComponents do def input(%{type: "textarea"} = assigns) do ~H"""
- <.label for={@id}><%= @label %> + <.label for={@id}>{@label} - <.error :for={msg <- @errors}><%= msg %> + <.error :for={msg <- @errors}>{msg}
""" end @@ -372,7 +372,7 @@ defmodule ExampleWeb.CoreComponents do def input(assigns) do ~H"""
- <.label for={@id}><%= @label %> + <.label for={@id}>{@label} - <.error :for={msg <- @errors}><%= msg %> + <.error :for={msg <- @errors}>{msg}
""" end @@ -398,7 +398,7 @@ defmodule ExampleWeb.CoreComponents do def label(assigns) do ~H""" """ end @@ -412,7 +412,7 @@ defmodule ExampleWeb.CoreComponents do ~H"""

<.icon name="hero-exclamation-circle-mini" class="mt-0.5 w-5 h-5 flex-none" /> - <%= render_slot(@inner_block) %> + {render_slot(@inner_block)}

""" end @@ -431,13 +431,13 @@ defmodule ExampleWeb.CoreComponents do

- <%= render_slot(@inner_block) %> + {render_slot(@inner_block)}

- <%= render_slot(@subtitle) %> + {render_slot(@subtitle)}

-
<%= render_slot(@actions) %>
+
{render_slot(@actions)}
""" end @@ -478,8 +478,8 @@ defmodule ExampleWeb.CoreComponents do - - + + - <%= render_slot(col, @row_item.(row)) %> + {render_slot(col, @row_item.(row))} @@ -507,7 +507,7 @@ defmodule ExampleWeb.CoreComponents do :for={action <- @action} class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700" > - <%= render_slot(action, @row_item.(row)) %> + {render_slot(action, @row_item.(row))} @@ -537,8 +537,8 @@ defmodule ExampleWeb.CoreComponents do
-
<%= item.title %>
-
<%= render_slot(item) %>
+
{item.title}
+
{render_slot(item)}
@@ -563,7 +563,7 @@ defmodule ExampleWeb.CoreComponents do class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" > <.icon name="hero-arrow-left-solid" class="w-3 h-3" /> - <%= render_slot(@inner_block) %> + {render_slot(@inner_block)} """ @@ -679,5 +679,4 @@ defmodule ExampleWeb.CoreComponents do def translate_errors(errors, field) when is_list(errors) do for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) end - end diff --git a/example_project/lib/example_web/components/layouts.ex b/example_project/lib/example_web/components/layouts.ex index c930085..114c000 100644 --- a/example_project/lib/example_web/components/layouts.ex +++ b/example_project/lib/example_web/components/layouts.ex @@ -77,12 +77,15 @@ defmodule ExampleWeb.Layouts do
  • - <%= group.label %> + {group.label}
    @@ -98,13 +101,20 @@ defmodule ExampleWeb.Layouts do ~H"""
<%= col[:label] %><%= gettext("Actions") %>{col[:label]}{gettext("Actions")}