aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHayleigh Thompson <me@hayleigh.dev>2024-01-23 00:09:45 +0000
committerGitHub <noreply@github.com>2024-01-23 00:09:45 +0000
commit24f6962aa457d32319756f6217aafde7b0a9c752 (patch)
tree42119d9b073f56eabe9dda4ae2065ef4b2086e6a
parent45e671ac32de95ae1a0a9f9e98da8645d01af3cf (diff)
downloadlustre-24f6962aa457d32319756f6217aafde7b0a9c752.tar.gz
lustre-24f6962aa457d32319756f6217aafde7b0a9c752.zip
✨ Add universal components that can run on the server (#39)
* :heavy_plus_sign: Add gleam_erlang gleam_otp and gleam_json dependencies. * :sparkles: Add json encoders for elememnts and attributes. * :sparkles: Add the ability to perform an effect with a custom dispatch function. * :construction: Experiment with a server-side component runtime. * :construction: Expose special server click events. * :construction: Experiment with a server-side component runtime. * :construction: Experiment with a server-side component runtime. * :construction: Experiment with a server-side component runtime. * :construction: Create a basic server component client bundle. * :construction: Create a basic server component demo. * :bug: Fixed a bug where the runtime stopped performing patches. * :refactor: Roll back introduction of shadow dom. * :recycle: Refactor to Custom Element-based approach to encapsulating server components. * :truck: Move some things around. * :sparkles: Add a minified version of the server component runtime. * :wrench: Add lustre/server/* to internal modules. * :recycle: on_attribute_change and on_client_event handlers are now functions not dicts. * :recycle: Refactor server component event handling to no longer need explicit tags. * :fire: Remove unnecessary attempt to stringify events. * :memo: Start documeint lustre/server functions. * :construction: Experiment with a js implementation of the server component backend runtime. * :recycle: Experiment with an API that makes heavier use of conditional complilation. * :recycle: Big refactor to unify server components, client components, and client apps. * :bug: Fixed some bugs with client runtimes. * :recycle: Update examples to new lustre api/ * :truck: Move server demo into examples/ folder/ * :wrench: Add lustre/runtime to internal modules. * :construction: Experiment with a diffing implementation. * :wrench: Hide internal modules from docs. * :heavy_plus_sign: Update deps to latest versions. * :recycle: Move diffing and vdom code into separate internal modules. * :sparkles: Bring server components to feature parity with client components. * :recycle: Update server component demo. * :bug: Fix bug where attribute changes weren't properly broadcast. * :fire: Remove unused 'Patch' type. * :recycle: Stub out empty js implementations so we can build for js. * :memo: Docs for the docs gods. * :recycle: Rename lustre.server_component to lustre.component.
-rw-r--r--examples/components/gleam.toml2
-rw-r--r--examples/components/manifest.toml6
-rw-r--r--examples/components/src/components.gleam15
-rw-r--r--examples/counter/gleam.toml2
-rw-r--r--examples/counter/manifest.toml6
-rw-r--r--examples/events/manifest.toml6
-rw-r--r--examples/input/manifest.toml6
-rw-r--r--examples/nested/manifest.toml6
-rw-r--r--examples/nested/src/nested.gleam2
-rw-r--r--examples/nested/src/nested/counter.gleam53
-rw-r--r--examples/server_demo/README.md22
-rw-r--r--examples/server_demo/gleam.toml13
-rw-r--r--examples/server_demo/manifest.toml32
-rw-r--r--examples/server_demo/src/demo.gleam22
-rw-r--r--examples/server_demo/src/demo/app.gleam88
-rw-r--r--examples/server_demo/src/demo/socket.gleam78
-rw-r--r--examples/server_demo/src/demo/web.gleam58
-rw-r--r--examples/svg/manifest.toml6
-rw-r--r--gleam.toml6
-rw-r--r--manifest.toml7
-rw-r--r--priv/lustre-server-component.min.mjs1
-rw-r--r--priv/lustre-server-component.mjs558
-rw-r--r--src/client-component.ffi.mjs74
-rw-r--r--src/client-runtime.ffi.mjs133
-rw-r--r--src/lustre.ffi.mjs224
-rw-r--r--src/lustre.gleam340
-rw-r--r--src/lustre/attribute.gleam85
-rw-r--r--src/lustre/effect.gleam36
-rw-r--r--src/lustre/element.gleam154
-rw-r--r--src/lustre/event.gleam7
-rw-r--r--src/lustre/internals/constants.gleam26
-rw-r--r--src/lustre/internals/patch.gleam374
-rw-r--r--src/lustre/internals/vdom.gleam353
-rw-r--r--src/lustre/runtime.gleam244
-rw-r--r--src/lustre/server.gleam183
-rw-r--r--src/runtime.ffi.mjs61
-rw-r--r--src/server-component.mjs155
-rw-r--r--src/server-runtime.ffi.mjs143
-rw-r--r--src/vdom.ffi.mjs407
-rw-r--r--test/lustre_test.gleam3
40 files changed, 3467 insertions, 530 deletions
diff --git a/examples/components/gleam.toml b/examples/components/gleam.toml
index 131d773..3cac691 100644
--- a/examples/components/gleam.toml
+++ b/examples/components/gleam.toml
@@ -4,4 +4,4 @@ target = "javascript"
[dependencies]
gleam_stdlib = "~> 0.34"
-lustre = { path = "../../" } \ No newline at end of file
+lustre = { path = "../../" }
diff --git a/examples/components/manifest.toml b/examples/components/manifest.toml
index 715dadc..1c72364 100644
--- a/examples/components/manifest.toml
+++ b/examples/components/manifest.toml
@@ -2,8 +2,12 @@
# You typically do not need to edit this file
packages = [
+ { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" },
+ { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
+ { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" },
{ name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
- { name = "lustre", version = "3.0.12", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." },
+ { name = "lustre", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." },
+ { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
]
[requirements]
diff --git a/examples/components/src/components.gleam b/examples/components/src/components.gleam
index 85fc583..03400bf 100644
--- a/examples/components/src/components.gleam
+++ b/examples/components/src/components.gleam
@@ -16,23 +16,20 @@ import lustre/event
// MAIN ------------------------------------------------------------------------
pub fn main() {
- let assert Ok(_) =
+ let counter =
lustre.component(
- "custom-counter",
counter_init,
counter_update,
counter_view,
dict.from_list([
- #(
- "count",
- fn(attr) {
- dynamic.int(attr)
- |> result.map(GotCount)
- },
- ),
+ #("count", fn(attr) {
+ dynamic.int(attr)
+ |> result.map(GotCount)
+ }),
]),
)
+ let assert Ok(_) = lustre.register(counter, "custom-counter")
// A `simple` lustre application doesn't produce `Effect`s. These are best to
// start with if you're just getting started with lustre or you know you don't
// need the runtime to manage any side effects.
diff --git a/examples/counter/gleam.toml b/examples/counter/gleam.toml
index 2a5f0f5..e6bddb8 100644
--- a/examples/counter/gleam.toml
+++ b/examples/counter/gleam.toml
@@ -4,4 +4,4 @@ target = "javascript"
[dependencies]
gleam_stdlib = "~> 0.34"
-lustre = { path = "../../" } \ No newline at end of file
+lustre = { path = "../../" }
diff --git a/examples/counter/manifest.toml b/examples/counter/manifest.toml
index 715dadc..1c72364 100644
--- a/examples/counter/manifest.toml
+++ b/examples/counter/manifest.toml
@@ -2,8 +2,12 @@
# You typically do not need to edit this file
packages = [
+ { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" },
+ { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
+ { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" },
{ name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
- { name = "lustre", version = "3.0.12", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." },
+ { name = "lustre", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." },
+ { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
]
[requirements]
diff --git a/examples/events/manifest.toml b/examples/events/manifest.toml
index 715dadc..4116093 100644
--- a/examples/events/manifest.toml
+++ b/examples/events/manifest.toml
@@ -2,8 +2,12 @@
# You typically do not need to edit this file
packages = [
+ { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" },
+ { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
+ { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" },
{ name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
- { name = "lustre", version = "3.0.12", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." },
+ { name = "lustre", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." },
+ { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
]
[requirements]
diff --git a/examples/input/manifest.toml b/examples/input/manifest.toml
index 715dadc..16bdd27 100644
--- a/examples/input/manifest.toml
+++ b/examples/input/manifest.toml
@@ -2,8 +2,12 @@
# You typically do not need to edit this file
packages = [
+ { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" },
+ { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
+ { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" },
{ name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
- { name = "lustre", version = "3.0.12", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." },
+ { name = "lustre", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." },
+ { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
]
[requirements]
diff --git a/examples/nested/manifest.toml b/examples/nested/manifest.toml
index 715dadc..6593d5e 100644
--- a/examples/nested/manifest.toml
+++ b/examples/nested/manifest.toml
@@ -2,8 +2,12 @@
# You typically do not need to edit this file
packages = [
+ { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" },
+ { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
+ { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" },
{ name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
- { name = "lustre", version = "3.0.12", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." },
+ { name = "lustre", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." },
+ { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
]
[requirements]
diff --git a/examples/nested/src/nested.gleam b/examples/nested/src/nested.gleam
index 89e68f0..2e1b9ec 100644
--- a/examples/nested/src/nested.gleam
+++ b/examples/nested/src/nested.gleam
@@ -1,6 +1,6 @@
// IMPORTS ---------------------------------------------------------------------
-import examples/counter
+import nested/counter
import gleam/list
import gleam/map.{type Map}
import gleam/pair
diff --git a/examples/nested/src/nested/counter.gleam b/examples/nested/src/nested/counter.gleam
new file mode 100644
index 0000000..37af39a
--- /dev/null
+++ b/examples/nested/src/nested/counter.gleam
@@ -0,0 +1,53 @@
+// IMPORTS ---------------------------------------------------------------------
+
+import gleam/int
+import lustre
+import lustre/element.{type Element, text}
+import lustre/element/html.{button, div, p}
+import lustre/event
+
+// MAIN ------------------------------------------------------------------------
+
+pub fn main() {
+ // A `simple` lustre application doesn't produce `Effect`s. These are best to
+ // start with if you're just getting started with lustre or you know you don't
+ // need the runtime to manage any side effects.
+ let app = lustre.simple(init, update, view)
+ let assert Ok(_) = lustre.start(app, "[data-lustre-app]", Nil)
+}
+
+// MODEL -----------------------------------------------------------------------
+
+pub type Model =
+ Int
+
+pub fn init(_) -> Model {
+ 0
+}
+
+// UPDATE ----------------------------------------------------------------------
+
+pub opaque type Msg {
+ Incr
+ Decr
+ Reset
+}
+
+pub fn update(model: Model, msg: Msg) -> Model {
+ case msg {
+ Incr -> model + 1
+ Decr -> model - 1
+ Reset -> 0
+ }
+}
+
+// VIEW ------------------------------------------------------------------------
+
+pub fn view(model: Model) -> Element(Msg) {
+ div([], [
+ button([event.on_click(Incr)], [text("+")]),
+ button([event.on_click(Decr)], [text("-")]),
+ button([event.on_click(Reset)], [text("Reset")]),
+ p([], [text(int.to_string(model))]),
+ ])
+}
diff --git a/examples/server_demo/README.md b/examples/server_demo/README.md
new file mode 100644
index 0000000..c0d6a2a
--- /dev/null
+++ b/examples/server_demo/README.md
@@ -0,0 +1,22 @@
+# server_demo
+
+[![Package Version](https://img.shields.io/hexpm/v/server_demo)](https://hex.pm/packages/server_demo)
+[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/server_demo/)
+
+## Quick start
+
+```sh
+gleam run # Run the project
+gleam test # Run the tests
+gleam shell # Run an Erlang shell
+```
+
+## Installation
+
+If available on Hex this package can be added to your Gleam project:
+
+```sh
+gleam add server_demo
+```
+
+and its documentation can be found at <https://hexdocs.pm/server_demo>.
diff --git a/examples/server_demo/gleam.toml b/examples/server_demo/gleam.toml
new file mode 100644
index 0000000..81a3557
--- /dev/null
+++ b/examples/server_demo/gleam.toml
@@ -0,0 +1,13 @@
+name = "demo"
+version = "1.0.0"
+
+[dependencies]
+gleam_erlang = "~> 0.23"
+gleam_http = "~> 3.5"
+gleam_json = "~> 0.7"
+gleam_otp = "~> 0.8"
+gleam_stdlib = "~> 0.34"
+lustre = { path = "../../" }
+lustre_ui = "~> 0.2"
+mist = "~> 0.15"
+wisp = "~> 0.8"
diff --git a/examples/server_demo/manifest.toml b/examples/server_demo/manifest.toml
new file mode 100644
index 0000000..d78134c
--- /dev/null
+++ b/examples/server_demo/manifest.toml
@@ -0,0 +1,32 @@
+# This file was generated by Gleam
+# You typically do not need to edit this file
+
+packages = [
+ { name = "exception", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "984401CFC95BCA87C391E36194D2B9E5B946467D44893FADB1CA4ACD4B7A29CE" },
+ { name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" },
+ { name = "gleam_crypto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "DE1FC4E631CA374AB29CCAEAC043EE171B86114D7DC66DD483F0A93BF0C4C6FF" },
+ { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" },
+ { name = "gleam_http", version = "3.5.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "C2FC3322203B16F897C1818D9810F5DEFCE347F0751F3B44421E1261277A7373" },
+ { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
+ { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" },
+ { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
+ { name = "glisten", version = "0.9.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang", "gleam_otp"], otp_app = "glisten", source = "hex", outer_checksum = "C960B6CF25D4AABAB01211146E9B57E11827B9C49E4175217E0FB7EF5BCB0FF7" },
+ { name = "lustre", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." },
+ { name = "lustre_ui", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib", "lustre"], otp_app = "lustre_ui", source = "hex", outer_checksum = "F81BE5D20153CFFC717C2C4687A19375A8613528908AF7069DDA1B929C8398B1" },
+ { name = "marceau", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "1AAD727A30BE0F95562C3403BB9B27C823797AD90037714255EEBF617B1CDA81" },
+ { name = "mist", version = "0.15.0", build_tools = ["gleam"], requirements = ["gleam_otp", "gleam_erlang", "gleam_stdlib", "gleam_http", "glisten"], otp_app = "mist", source = "hex", outer_checksum = "49F51DDB64D7B2832F72727CC9721C478D6B524C96EA444C601A19D01E023C03" },
+ { name = "simplifile", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "359CD7006E2F69255025C858CCC6407C11A876EC179E6ED1E46809E8DC6B1AAD" },
+ { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
+ { name = "wisp", version = "0.10.0", build_tools = ["gleam"], requirements = ["marceau", "gleam_json", "simplifile", "gleam_http", "gleam_crypto", "mist", "gleam_stdlib", "exception", "gleam_erlang"], otp_app = "wisp", source = "hex", outer_checksum = "744FF91702078301BDF8FE76F26C14B658A7D151D867FA6995762318ED2536A0" },
+]
+
+[requirements]
+gleam_erlang = { version = "~> 0.23" }
+gleam_http = { version = "~> 3.5" }
+gleam_json = { version = "~> 0.7" }
+gleam_otp = { version = "~> 0.8" }
+gleam_stdlib = { version = "~> 0.34" }
+lustre = { path = "../../" }
+lustre_ui = { version = "~> 0.2" }
+mist = { version = "~> 0.15" }
+wisp = { version = "~> 0.8" }
diff --git a/examples/server_demo/src/demo.gleam b/examples/server_demo/src/demo.gleam
new file mode 100644
index 0000000..ebe858a
--- /dev/null
+++ b/examples/server_demo/src/demo.gleam
@@ -0,0 +1,22 @@
+// IMPORTS ---------------------------------------------------------------------
+
+import demo/socket
+import demo/web
+import gleam/erlang/process
+import mist
+
+// MAIN ------------------------------------------------------------------------
+
+pub fn main() {
+ let assert Ok(_) =
+ mist.new(fn(req) {
+ case req.path {
+ "/ws" -> socket.handle(req)
+ _ -> web.handle(req)
+ }
+ })
+ |> mist.port(8000)
+ |> mist.start_http
+
+ process.sleep_forever()
+}
diff --git a/examples/server_demo/src/demo/app.gleam b/examples/server_demo/src/demo/app.gleam
new file mode 100644
index 0000000..13d09a8
--- /dev/null
+++ b/examples/server_demo/src/demo/app.gleam
@@ -0,0 +1,88 @@
+// IMPORTS ---------------------------------------------------------------------
+
+import gleam/dict.{type Dict}
+import gleam/dynamic.{type Decoder}
+import gleam/int
+import gleam/json
+import gleam/result
+import lustre/attribute
+import lustre/effect.{type Effect}
+import lustre/element.{type Element}
+import lustre/element/html
+import lustre/event
+import lustre/server
+import lustre/ui
+
+// MODEL -----------------------------------------------------------------------
+
+pub type Model =
+ Int
+
+pub fn init(count: Int) -> #(Model, Effect(Msg)) {
+ #(count, effect.none())
+}
+
+// UPDATE ----------------------------------------------------------------------
+
+pub opaque type Msg {
+ Incr
+ Decr
+ Reset(Int)
+}
+
+pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
+ case msg {
+ Incr -> #(model + 1, effect.none())
+ Decr -> #(model - 1, effect.none())
+ Reset(count) -> #(
+ count,
+ effect.event(
+ "changed",
+ json.string("You reset the count to: " <> int.to_string(count)),
+ ),
+ )
+ }
+}
+
+pub fn on_attribute_change() -> Dict(String, Decoder(Msg)) {
+ dict.from_list([
+ #("count", fn(dyn) {
+ dyn
+ |> dynamic.int
+ |> result.map(Reset)
+ }),
+ ])
+}
+
+// VIEW ------------------------------------------------------------------------
+
+pub fn view(model: Model) -> Element(Msg) {
+ let count = int.to_string(model)
+
+ ui.centre(
+ [attribute.style([#("width", "100vw"), #("height", "100vh")])],
+ ui.sequence([], [
+ ui.button([event.on_click(Decr)], [element.text("-")]),
+ ui.centre([], html.span([], [element.text(count)])),
+ ui.button([event.on_click(Incr)], [element.text("+")]),
+ ]),
+ )
+ // ui.cluster([], [
+ // ui.input([event.on_input(Change), attribute.value(model.input)]),
+ // html.span([], [element.text(model.input)]),
+ // ]),
+ // ui.centre(
+ // [
+ // event.on("mousemove", on_mouse_move),
+ // server.include(["offsetX", "offsetY"]),
+ // attribute.style([
+ // #("aspect-ratio", "1 / 1 "),
+ // #("background-color", "var(--element-background)"),
+ // ]),
+ // ],
+ // html.div([], [
+ // html.p([], [element.text("x: " <> int.to_string(model.mouse.0))]),
+ // html.p([], [element.text("y: " <> int.to_string(model.mouse.1))]),
+ // ]),
+ // ),
+}
diff --git a/examples/server_demo/src/demo/socket.gleam b/examples/server_demo/src/demo/socket.gleam
new file mode 100644
index 0000000..bc43962
--- /dev/null
+++ b/examples/server_demo/src/demo/socket.gleam
@@ -0,0 +1,78 @@
+// IMPORTS ---------------------------------------------------------------------
+
+import demo/app
+import gleam/bit_array
+import gleam/erlang/process.{type Subject}
+import gleam/function.{identity}
+import gleam/http/request.{type Request as HttpRequest}
+import gleam/http/response.{type Response as HttpResponse}
+import gleam/json
+import gleam/option.{Some}
+import gleam/otp/actor
+import gleam/result
+import lustre.{type Action, type ServerComponent}
+import lustre/element.{type Patch}
+import lustre/server
+import mist.{
+ type Connection, type ResponseData, type WebsocketConnection,
+ type WebsocketMessage,
+}
+
+//
+
+pub fn handle(req: HttpRequest(Connection)) -> HttpResponse(ResponseData) {
+ mist.websocket(req, update, init, function.constant(Nil))
+}
+
+type Model(flags, model, msg) {
+ Model(self: Subject(Patch(msg)), app: Subject(Action(ServerComponent, msg)))
+}
+
+fn init(_) {
+ let assert Ok(app) =
+ lustre.component(app.init, app.update, app.view, app.on_attribute_change())
+ |> lustre.start_actor(0)
+ let self = process.new_subject()
+ let model = Model(self, app)
+ let selector = process.selecting(process.new_selector(), self, identity)
+
+ actor.send(app, lustre.add_renderer(process.self(), process.send(self, _)))
+ #(model, Some(selector))
+}
+
+fn update(
+ model: Model(flags, model, msg),
+ conn: WebsocketConnection,
+ msg: WebsocketMessage(Patch(a)),
+) {
+ case msg {
+ mist.Text(bits) -> {
+ let _ = {
+ use dyn <- json.decode_bits(bits)
+ use action <- result.try(server.decode_action(dyn))
+
+ actor.send(model.app, action)
+ Ok(Nil)
+ }
+
+ actor.continue(model)
+ }
+ mist.Binary(_) -> actor.continue(model)
+ mist.Closed -> {
+ actor.send(model.app, lustre.remove_renderer(process.self()))
+ actor.continue(model)
+ }
+ mist.Shutdown -> {
+ actor.send(model.app, lustre.shutdown())
+ actor.Stop(process.Normal)
+ }
+ mist.Custom(patch) -> {
+ element.encode_patch(patch)
+ |> json.to_string
+ |> bit_array.from_string
+ |> mist.send_text_frame(conn, _)
+
+ actor.continue(model)
+ }
+ }
+}
diff --git a/examples/server_demo/src/demo/web.gleam b/examples/server_demo/src/demo/web.gleam
new file mode 100644
index 0000000..b8c17e2
--- /dev/null
+++ b/examples/server_demo/src/demo/web.gleam
@@ -0,0 +1,58 @@
+// IMPORTS ---------------------------------------------------------------------
+
+import gleam/http/request.{type Request as HttpRequest}
+import gleam/http/response.{type Response as HttpResponse}
+import gleam/result
+import gleam/string_builder
+import lustre/attribute.{attribute}
+import lustre/element
+import lustre/element/html.{html}
+import lustre/server
+import lustre/ui/styles
+import mist.{type Connection, type ResponseData}
+import wisp.{type Request, type Response}
+
+//
+
+pub fn handle(req: HttpRequest(Connection)) -> HttpResponse(ResponseData) {
+ wisp.mist_handler(handler, "")(req)
+}
+
+fn handler(req: Request) -> Response {
+ use req <- wisp.handle_head(req)
+ use <- wisp.serve_static(req, under: "/static", from: static_directory())
+
+ html([attribute("lang", "en")], [
+ html.head([], [
+ html.meta([attribute("charset", "utf-8")]),
+ html.meta([
+ attribute("name", "viewport"),
+ attribute("content", "width=device-width, initial-scale=1"),
+ ]),
+ styles.elements(),
+ html.script(
+ [attribute("type", "module")],
+ "
+ import '/static/lustre-server-component.mjs'
+
+ document.addEventListener('DOMContentLoaded', () => {
+ document
+ .querySelector('lustre-server-component')
+ .addEventListener('alert', event => {
+ console.log(`The server count says: ${event.detail}`)
+ })
+ })
+ ",
+ ),
+ ]),
+ html.body([], [server.component([server.route("/ws")])]),
+ ])
+ |> element.to_string_builder
+ |> string_builder.prepend("<!DOCTYPE html>\n")
+ |> wisp.html_response(200)
+}
+
+fn static_directory() -> String {
+ wisp.priv_directory("lustre")
+ |> result.unwrap("")
+}
diff --git a/examples/svg/manifest.toml b/examples/svg/manifest.toml
index 715dadc..1c72364 100644
--- a/examples/svg/manifest.toml
+++ b/examples/svg/manifest.toml
@@ -2,8 +2,12 @@
# You typically do not need to edit this file
packages = [
+ { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" },
+ { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
+ { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" },
{ name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
- { name = "lustre", version = "3.0.12", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../.." },
+ { name = "lustre", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib"], source = "local", path = "../.." },
+ { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
]
[requirements]
diff --git a/gleam.toml b/gleam.toml
index babc74e..a0331e3 100644
--- a/gleam.toml
+++ b/gleam.toml
@@ -11,10 +11,16 @@ links = [
gleam = ">= 0.33.0"
internal_modules = [
+ "lustre/internals",
+ "lustre/internals/*",
+ "lustre/runtime",
"lustre/try",
]
[dependencies]
+gleam_erlang = "~> 0.23"
+gleam_json = "~> 0.7"
+gleam_otp = "~> 0.8"
gleam_stdlib = "~> 0.34"
gleam_community_ansi = "~> 1.3"
glint = "~> 0.14"
diff --git a/manifest.toml b/manifest.toml
index 0c1900d..93fc21a 100644
--- a/manifest.toml
+++ b/manifest.toml
@@ -5,13 +5,20 @@ packages = [
{ name = "argv", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "A6E9009E50BBE863EB37D963E4315398D41A3D87D0075480FC244125808F964A" },
{ name = "gleam_community_ansi", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "AB7C3CCC894653637E02DC455D5890C8CF3064E83E78CFE61145A4C458D02DE6" },
{ name = "gleam_community_colour", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "A49A5E3AE8B637A5ACBA80ECB9B1AFE89FD3D5351FF6410A42B84F666D40D7D5" },
+ { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" },
+ { name = "gleam_json", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB405BD93A8828BCD870463DE29375E7B2D252D9D124C109E5B618AAC00B86FC" },
+ { name = "gleam_otp", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang"], otp_app = "gleam_otp", source = "hex", outer_checksum = "5FADBBEC5ECF3F8B6BE91101D432758503192AE2ADBAD5602158977341489F71" },
{ name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" },
{ name = "glint", version = "0.14.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib", "gleam_community_colour", "snag"], otp_app = "glint", source = "hex", outer_checksum = "21AB16D5A50D4EF34DF935915FDBEE06B2DAEDEE3FCC8584C6E635A866566B38" },
{ name = "snag", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "54D32E16E33655346AA3E66CBA7E191DE0A8793D2C05284E3EFB90AD2CE92BCC" },
+ { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
]
[requirements]
argv = { version = "~> 1.0" }
gleam_community_ansi = { version = "~> 1.3" }
+gleam_erlang = { version = "~> 0.23" }
+gleam_json = { version = "~> 0.7" }
+gleam_otp = { version = "~> 0.8" }
gleam_stdlib = { version = "~> 0.34" }
glint = { version = "~> 0.14" }
diff --git a/priv/lustre-server-component.min.mjs b/priv/lustre-server-component.min.mjs
new file mode 100644
index 0000000..bc05808
--- /dev/null
+++ b/priv/lustre-server-component.min.mjs
@@ -0,0 +1 @@
+var f=class{withFields(e){let n=Object.keys(this).map(l=>l in e?e[l]:this[l]);return new this.constructor(...n)}},w=class{static fromArray(e,n){let l=n||new p;return e.reduceRight((s,i)=>new $(i,s),l)}[Symbol.iterator](){return new k(this)}toArray(){return[...this]}atLeastLength(e){for(let n of this){if(e<=0)return!0;e--}return e<=0}hasLength(e){for(let n of this){if(e<=0)return!1;e--}return e===0}countLength(){let e=0;for(let n of this)e++;return e}};var k=class{#t;constructor(e){this.#t=e}next(){if(this.#t instanceof p)return{done:!0};{let{head:e,tail:n}=this.#t;return this.#t=n,{value:e,done:!1}}}},p=class extends w{},$=class extends w{constructor(e,n){super(),this.head=e,this.tail=n}};var x=class t extends f{static isResult(e){return e instanceof t}},h=class extends x{constructor(e){super(),this[0]=e}isOk(){return!0}},c=class extends x{constructor(e){super(),this[0]=e}isOk(){return!1}};function d(t,e,n,l,s,i){let r=new globalThis.Error(s);r.gleam_error=t,r.module=e,r.line=n,r.fn=l;for(let a in i)r[a]=i[a];return r}var Bt=new DataView(new ArrayBuffer(8));var Q=5,A=Math.pow(2,Q),Dt=A-1,Rt=A/2,Ut=A/4;function O(t,e){if(t.isOk()){let n=t[0];return new h(e(n))}else{if(t.isOk())throw d("case_no_match","gleam/result",67,"map","No case clause matched",{values:[t]});{let n=t[0];return new c(n)}}}function o(t,e,n,l){if(e?.tag&&t?.nodeType===1){let s=e.tag.toUpperCase(),i=e.namespace||"http://www.w3.org/1999/xhtml";return t.nodeName===s&&t.namespaceURI==i?te(t,e,n,l):D(t,e,n,l)}return e?.tag?D(t,e,n,l):typeof e?.content=="string"?t?.nodeType===3?ne(t,e):re(t,e):document.createComment(["[internal lustre error] I couldn't work out how to render this element. This","function should only be called internally by lustre's runtime: if you think","this is an error, please open an issue at","https://github.com/hayleigh-dot-dev/gleam-lustre/issues/new"].join(" "))}function R(t,e,n){for(let l of e[0]){let s=l[0];if(s==="0")o(t,l[1],n,t.parentNode);else{let i=Array.from(s),r=i.slice(0,-1).join(""),a=i.slice(-1)[0],u=t.querySelector(`[data-lustre-key="${s}"]`)??t.querySelector(`[data-lustre-key="${r}"]`).childNodes[a];o(u,l[1],n,u.parentNode)}}for(let l of e[1]){let s=l[0],i=Array.from(s),r=i.slice(0,-1).join(""),a=i.slice(-1)[0];(t.querySelector(`[data-lustre-key="${s}"]`)??t.querySelector(`[data-lustre-key="${r}"]`).childNodes[a]).remove()}for(let l of e[2]){let s=l[0],i=s==="0"?t:t.querySelector(`[data-lustre-key="${s}"]`);i.$lustre??={__registered_events:new Set};for(let r of l[0])g(i,r.name,r.value,n);for(let r of l[1])if(i.$lustre.__registered_events.has(r)){let a=r.slice(2).toLowerCase();i.removeEventListener(a,i.$lustre[`${r}Handler`]),i.$lustre.__registered_events.delete(r),delete i.$lustre[r],delete i.$lustre[`${r}Handler`]}else i.removeAttribute(r)}return t}function D(t,e,n,l=null){let s=e.namespace?document.createElementNS(e.namespace,e.tag):document.createElement(e.tag);s.$lustre={__registered_events:new Set};let i="";for(let r of e.attrs)r[0]==="class"?g(s,r[0],`${s.className} ${r[1]}`):r[0]==="style"?g(s,r[0],`${s.style.cssText} ${r[1]}`):r[0]==="dangerous-unescaped-html"?i+=r[1]:r[0]!==""&&g(s,r[0],r[1],n);if(customElements.get(e.tag))s._slot=e.children;else if(e.tag==="slot"){let r=new p,a=l;for(;a;)if(a._slot){r=a._slot;break}else a=a.parentNode;for(let u of r)s.appendChild(o(null,u,n,s))}else if(i)s.innerHTML=i;else for(let r of e.children)s.appendChild(o(null,r,n,s));return t&&t.replaceWith(s),s}function te(t,e,n,l){let s=t.attributes,i=new Map;t.$lustre??={__registered_events:new Set};for(let r of e.attrs)r[0]==="class"&&i.has("class")?i.set(r[0],`${i.get("class")} ${r[1]}`):r[0]==="style"&&i.has("style")?i.set(r[0],`${i.get("style")} ${r[1]}`):r[0]==="dangerous-unescaped-html"&&i.has("dangerous-unescaped-html")?i.set(r[0],`${i.get("dangerous-unescaped-html")} ${r[1]}`):r[0]!==""&&i.set(r[0],r[1]);for(let{name:r,value:a}of s)if(!i.has(r))t.removeAttribute(r);else{let u=i.get(r);u!==a&&(g(t,r,u,n),i.delete(r))}for(let r of t.$lustre.__registered_events)if(!i.has(r)){let a=r.slice(2).toLowerCase();t.removeEventListener(a,t.$lustre[`${r}Handler`]),t.$lustre.__registered_events.delete(r),delete t.$lustre[r],delete t.$lustre[`${r}Handler`]}for(let[r,a]of i)g(t,r,a,n);if(customElements.get(e.tag))t._slot=e.children;else if(e.tag==="slot"){let r=t.firstChild,a=new p,u=l;for(;u;)if(u._slot){a=u._slot;break}else u=u.parentNode;for(;r;)Array.isArray(a)&&a.length?o(r,a.shift(),n,t):a.head&&(o(r,a.head,n,t),a=a.tail),r=r.nextSibling;for(let _ of a)t.appendChild(o(null,_,n,t))}else if(i.has("dangerous-unescaped-html"))t.innerHTML=i.get("dangerous-unescaped-html");else{let r=t.firstChild,a=e.children;for(;r;)if(Array.isArray(a)&&a.length){let u=r.nextSibling;o(r,a.shift(),n,t),r=u}else if(a.head){let u=r.nextSibling;o(r,a.head,n,t),a=a.tail,r=u}else{let u=r.nextSibling;r.remove(),r=u}for(let u of a)t.appendChild(o(null,u,n,t))}return t}function g(t,e,n,l){switch(typeof n){case(e.startsWith("data-lustre-on-")&&"string"):{if(!n){t.removeAttribute(e),t.removeEventListener(s,t.$lustre[`${e}Handler`]);break}if(t.hasAttribute(e))break;let s=e.slice(15).toLowerCase(),i=r=>l(se(r));t.$lustre[`${e}Handler`]&&t.removeEventListener(s,t.$lustre[`${e}Handler`]),t.addEventListener(s,i),t.$lustre[e]=n,t.$lustre[`${e}Handler`]=i,t.$lustre.__registered_events.add(e),t.setAttribute(e,n);break}case"string":t.getAttribute(e)!==n&&t.setAttribute(e,n),n===""&&t.removeAttribute(e),e==="value"&&t.value!==n&&(t.value=n);break;case(e.startsWith("on")&&"function"):{if(t.$lustre[e]===n)break;let s=e.slice(2).toLowerCase(),i=r=>O(n(r),l);t.$lustre[`${e}Handler`]&&t.removeEventListener(s,t.$lustre[`${e}Handler`]),t.addEventListener(s,i),t.$lustre[e]=n,t.$lustre[`${e}Handler`]=i,t.$lustre.__registered_events.add(e);break}default:t[e]=n}}function re(t,e){let n=document.createTextNode(e.content);return t&&t.replaceWith(n),n}function ne(t,e){let n=t.nodeValue,l=e.content;return l?(n!==l&&(t.nodeValue=l),t):(t?.remove(),null)}function se(t){let e=t.target,n=e.getAttribute(`data-lustre-on-${t.type}`),l=JSON.parse(e.getAttribute("data-lustre-data")||"{}"),s=JSON.parse(e.getAttribute("data-lustre-include")||"[]");switch(t.type){case"input":case"change":s.push("target.value");break}return{tag:n,data:s.reduce((i,r)=>{let a=r.split(".");for(let u=0,_=i,b=t;u<a.length;u++)u===a.length-1?_[a[u]]=b[a[u]]:(_[a[u]]??={},b=b[a[u]],_=_[a[u]]);return i},l)}}var S=class extends HTMLElement{static get observedAttributes(){return["route"]}#t=null;#r=null;#e=null;constructor(){super(),this.#t=new MutationObserver(e=>{let n=[];for(let l of e)if(l.type==="attributes"){let{attributeName:s,oldValue:i}=l,r=this.getAttribute(s);if(i!==r)try{n.push([s,JSON.parse(r)])}catch{n.push([s,r])}}n.length&&this.#e?.send(JSON.stringify([5,n]))})}connectedCallback(){this.#r=document.createElement("div"),this.appendChild(this.#r)}attributeChangedCallback(e,n,l){switch(e){case"route":if(!l)this.#e?.close(),this.#e=null;else if(n!==l){let s=this.getAttribute("id"),i=l+(s?`?id=${s}`:"");this.#e?.close(),this.#e=new WebSocket(`ws://${window.location.host}${i}`),this.#e.addEventListener("message",({data:r})=>{let[a,...u]=JSON.parse(r);switch(a){case 0:return this.diff(u);case 1:return this.emit(u);case 2:return this.init(u)}})}}}init([e,n]){let l=[];for(let s of e)s in this?l.push([s,this[s]]):this.hasAttribute(s)&&l.push([s,this.getAttribute(s)]),Object.defineProperty(this,s,{get(){return this[`_${s}`]??this.getAttribute(s)},set(i){let r=this[s];typeof i=="string"?this.setAttribute(s,i):this[`_${s}`]=i,r!==i&&this.#e?.send(JSON.stringify([5,[[s,i]]]))}});this.#t.observe(this,{attributeFilter:e,attributeOldValue:!0,attributes:!0,characterData:!1,characterDataOldValue:!1,childList:!1,subtree:!1}),this.morph(n),l.length&&this.#e?.send(JSON.stringify([5,l]))}morph(e){this.#r=o(this.#r,e,n=>{this.#e?.send(JSON.stringify([4,n.tag,n.data]))})}diff([e]){this.#r=R(this.#r,e,n=>{this.#e?.send(JSON.stringify([4,n.tag,n.data]))})}emit([e,n]){this.dispatchEvent(new CustomEvent(e,{detail:n}))}disconnectedCallback(){this.#e?.close()}};window.customElements.define("lustre-server-component",S);export{S as LustreServerComponent};
diff --git a/priv/lustre-server-component.mjs b/priv/lustre-server-component.mjs
new file mode 100644
index 0000000..8230074
--- /dev/null
+++ b/priv/lustre-server-component.mjs
@@ -0,0 +1,558 @@
+// build/dev/javascript/lustre/lustre/internals/constants.mjs
+var diff = 0;
+var emit = 1;
+var init = 2;
+var event = 4;
+var attrs = 5;
+
+// build/dev/javascript/prelude.mjs
+var CustomType = class {
+ withFields(fields) {
+ let properties = Object.keys(this).map(
+ (label) => label in fields ? fields[label] : this[label]
+ );
+ return new this.constructor(...properties);
+ }
+};
+var List = class {
+ static fromArray(array, tail) {
+ let t = tail || new Empty();
+ return array.reduceRight((xs, x) => new NonEmpty(x, xs), t);
+ }
+ [Symbol.iterator]() {
+ return new ListIterator(this);
+ }
+ toArray() {
+ return [...this];
+ }
+ atLeastLength(desired) {
+ for (let _ of this) {
+ if (desired <= 0)
+ return true;
+ desired--;
+ }
+ return desired <= 0;
+ }
+ hasLength(desired) {
+ for (let _ of this) {
+ if (desired <= 0)
+ return false;
+ desired--;
+ }
+ return desired === 0;
+ }
+ countLength() {
+ let length2 = 0;
+ for (let _ of this)
+ length2++;
+ return length2;
+ }
+};
+var ListIterator = class {
+ #current;
+ constructor(current) {
+ this.#current = current;
+ }
+ next() {
+ if (this.#current instanceof Empty) {
+ return { done: true };
+ } else {
+ let { head, tail } = this.#current;
+ this.#current = tail;
+ return { value: head, done: false };
+ }
+ }
+};
+var Empty = class extends List {
+};
+var NonEmpty = class extends List {
+ constructor(head, tail) {
+ super();
+ this.head = head;
+ this.tail = tail;
+ }
+};
+var Result = class _Result extends CustomType {
+ static isResult(data) {
+ return data instanceof _Result;
+ }
+};
+var Ok = class extends Result {
+ constructor(value) {
+ super();
+ this[0] = value;
+ }
+ isOk() {
+ return true;
+ }
+};
+var Error = class extends Result {
+ constructor(detail) {
+ super();
+ this[0] = detail;
+ }
+ isOk() {
+ return false;
+ }
+};
+function makeError(variant, module, line, fn, message, extra) {
+ let error = new globalThis.Error(message);
+ error.gleam_error = variant;
+ error.module = module;
+ error.line = line;
+ error.fn = fn;
+ for (let k in extra)
+ error[k] = extra[k];
+ return error;
+}
+
+// build/dev/javascript/gleam_stdlib/dict.mjs
+var tempDataView = new DataView(new ArrayBuffer(8));
+var SHIFT = 5;
+var BUCKET_SIZE = Math.pow(2, SHIFT);
+var MASK = BUCKET_SIZE - 1;
+var MAX_INDEX_NODE = BUCKET_SIZE / 2;
+var MIN_ARRAY_NODE = BUCKET_SIZE / 4;
+
+// build/dev/javascript/gleam_stdlib/gleam/result.mjs
+function map2(result, fun) {
+ if (result.isOk()) {
+ let x = result[0];
+ return new Ok(fun(x));
+ } else if (!result.isOk()) {
+ let e = result[0];
+ return new Error(e);
+ } else {
+ throw makeError(
+ "case_no_match",
+ "gleam/result",
+ 67,
+ "map",
+ "No case clause matched",
+ { values: [result] }
+ );
+ }
+}
+
+// build/dev/javascript/lustre/vdom.ffi.mjs
+function morph(prev, curr, dispatch, parent) {
+ if (curr?.tag && prev?.nodeType === 1) {
+ const nodeName = curr.tag.toUpperCase();
+ const ns = curr.namespace || "http://www.w3.org/1999/xhtml";
+ if (prev.nodeName === nodeName && prev.namespaceURI == ns) {
+ return morphElement(prev, curr, dispatch, parent);
+ } else {
+ return createElement(prev, curr, dispatch, parent);
+ }
+ }
+ if (curr?.tag) {
+ return createElement(prev, curr, dispatch, parent);
+ }
+ if (typeof curr?.content === "string") {
+ return prev?.nodeType === 3 ? morphText(prev, curr) : createText(prev, curr);
+ }
+ return document.createComment(
+ [
+ "[internal lustre error] I couldn't work out how to render this element. This",
+ "function should only be called internally by lustre's runtime: if you think",
+ "this is an error, please open an issue at",
+ "https://github.com/hayleigh-dot-dev/gleam-lustre/issues/new"
+ ].join(" ")
+ );
+}
+function patch(root, diff2, dispatch) {
+ for (const created of diff2[0]) {
+ const key = created[0];
+ if (key === "0") {
+ morph(root, created[1], dispatch, root.parentNode);
+ } else {
+ const segments = Array.from(key);
+ const parentKey = segments.slice(0, -1).join("");
+ const indexKey = segments.slice(-1)[0];
+ const prev = root.querySelector(`[data-lustre-key="${key}"]`) ?? root.querySelector(`[data-lustre-key="${parentKey}"]`).childNodes[indexKey];
+ morph(prev, created[1], dispatch, prev.parentNode);
+ }
+ }
+ for (const removed of diff2[1]) {
+ const key = removed[0];
+ const segments = Array.from(key);
+ const parentKey = segments.slice(0, -1).join("");
+ const indexKey = segments.slice(-1)[0];
+ const prev = root.querySelector(`[data-lustre-key="${key}"]`) ?? root.querySelector(`[data-lustre-key="${parentKey}"]`).childNodes[indexKey];
+ prev.remove();
+ }
+ for (const updated of diff2[2]) {
+ const key = updated[0];
+ const prev = key === "0" ? root : root.querySelector(`[data-lustre-key="${key}"]`);
+ prev.$lustre ??= { __registered_events: /* @__PURE__ */ new Set() };
+ for (const created of updated[0]) {
+ morphAttr(prev, created.name, created.value, dispatch);
+ }
+ for (const removed of updated[1]) {
+ if (prev.$lustre.__registered_events.has(removed)) {
+ const event2 = removed.slice(2).toLowerCase();
+ prev.removeEventListener(event2, prev.$lustre[`${removed}Handler`]);
+ prev.$lustre.__registered_events.delete(removed);
+ delete prev.$lustre[removed];
+ delete prev.$lustre[`${removed}Handler`];
+ } else {
+ prev.removeAttribute(removed);
+ }
+ }
+ }
+ return root;
+}
+function createElement(prev, curr, dispatch, parent = null) {
+ const el = curr.namespace ? document.createElementNS(curr.namespace, curr.tag) : document.createElement(curr.tag);
+ el.$lustre = {
+ __registered_events: /* @__PURE__ */ new Set()
+ };
+ let dangerousUnescapedHtml = "";
+ for (const attr of curr.attrs) {
+ if (attr[0] === "class") {
+ morphAttr(el, attr[0], `${el.className} ${attr[1]}`);
+ } else if (attr[0] === "style") {
+ morphAttr(el, attr[0], `${el.style.cssText} ${attr[1]}`);
+ } else if (attr[0] === "dangerous-unescaped-html") {
+ dangerousUnescapedHtml += attr[1];
+ } else if (attr[0] !== "") {
+ morphAttr(el, attr[0], attr[1], dispatch);
+ }
+ }
+ if (customElements.get(curr.tag)) {
+ el._slot = curr.children;
+ } else if (curr.tag === "slot") {
+ let children = new Empty();
+ let parentWithSlot = parent;
+ while (parentWithSlot) {
+ if (parentWithSlot._slot) {
+ children = parentWithSlot._slot;
+ break;
+ } else {
+ parentWithSlot = parentWithSlot.parentNode;
+ }
+ }
+ for (const child of children) {
+ el.appendChild(morph(null, child, dispatch, el));
+ }
+ } else if (dangerousUnescapedHtml) {
+ el.innerHTML = dangerousUnescapedHtml;
+ } else {
+ for (const child of curr.children) {
+ el.appendChild(morph(null, child, dispatch, el));
+ }
+ }
+ if (prev)
+ prev.replaceWith(el);
+ return el;
+}
+function morphElement(prev, curr, dispatch, parent) {
+ const prevAttrs = prev.attributes;
+ const currAttrs = /* @__PURE__ */ new Map();
+ prev.$lustre ??= { __registered_events: /* @__PURE__ */ new Set() };
+ for (const currAttr of curr.attrs) {
+ if (currAttr[0] === "class" && currAttrs.has("class")) {
+ currAttrs.set(currAttr[0], `${currAttrs.get("class")} ${currAttr[1]}`);
+ } else if (currAttr[0] === "style" && currAttrs.has("style")) {
+ currAttrs.set(currAttr[0], `${currAttrs.get("style")} ${currAttr[1]}`);
+ } else if (currAttr[0] === "dangerous-unescaped-html" && currAttrs.has("dangerous-unescaped-html")) {
+ currAttrs.set(
+ currAttr[0],
+ `${currAttrs.get("dangerous-unescaped-html")} ${currAttr[1]}`
+ );
+ } else if (currAttr[0] !== "") {
+ currAttrs.set(currAttr[0], currAttr[1]);
+ }
+ }
+ for (const { name, value: prevValue } of prevAttrs) {
+ if (!currAttrs.has(name)) {
+ prev.removeAttribute(name);
+ } else {
+ const value = currAttrs.get(name);
+ if (value !== prevValue) {
+ morphAttr(prev, name, value, dispatch);
+ currAttrs.delete(name);
+ }
+ }
+ }
+ for (const name of prev.$lustre.__registered_events) {
+ if (!currAttrs.has(name)) {
+ const event2 = name.slice(2).toLowerCase();
+ prev.removeEventListener(event2, prev.$lustre[`${name}Handler`]);
+ prev.$lustre.__registered_events.delete(name);
+ delete prev.$lustre[name];
+ delete prev.$lustre[`${name}Handler`];
+ }
+ }
+ for (const [name, value] of currAttrs) {
+ morphAttr(prev, name, value, dispatch);
+ }
+ if (customElements.get(curr.tag)) {
+ prev._slot = curr.children;
+ } else if (curr.tag === "slot") {
+ let prevChild = prev.firstChild;
+ let currChild = new Empty();
+ let parentWithSlot = parent;
+ while (parentWithSlot) {
+ if (parentWithSlot._slot) {
+ currChild = parentWithSlot._slot;
+ break;
+ } else {
+ parentWithSlot = parentWithSlot.parentNode;
+ }
+ }
+ while (prevChild) {
+ if (Array.isArray(currChild) && currChild.length) {
+ morph(prevChild, currChild.shift(), dispatch, prev);
+ } else if (currChild.head) {
+ morph(prevChild, currChild.head, dispatch, prev);
+ currChild = currChild.tail;
+ }
+ prevChild = prevChild.nextSibling;
+ }
+ for (const child of currChild) {
+ prev.appendChild(morph(null, child, dispatch, prev));
+ }
+ } else if (currAttrs.has("dangerous-unescaped-html")) {
+ prev.innerHTML = currAttrs.get("dangerous-unescaped-html");
+ } else {
+ let prevChild = prev.firstChild;
+ let currChild = curr.children;
+ while (prevChild) {
+ if (Array.isArray(currChild) && currChild.length) {
+ const next = prevChild.nextSibling;
+ morph(prevChild, currChild.shift(), dispatch, prev);
+ prevChild = next;
+ } else if (currChild.head) {
+ const next = prevChild.nextSibling;
+ morph(prevChild, currChild.head, dispatch, prev);
+ currChild = currChild.tail;
+ prevChild = next;
+ } else {
+ const next = prevChild.nextSibling;
+ prevChild.remove();
+ prevChild = next;
+ }
+ }
+ for (const child of currChild) {
+ prev.appendChild(morph(null, child, dispatch, prev));
+ }
+ }
+ return prev;
+}
+function morphAttr(el, name, value, dispatch) {
+ switch (typeof value) {
+ case (name.startsWith("data-lustre-on-") && "string"): {
+ if (!value) {
+ el.removeAttribute(name);
+ el.removeEventListener(event2, el.$lustre[`${name}Handler`]);
+ break;
+ }
+ if (el.hasAttribute(name))
+ break;
+ const event2 = name.slice(15).toLowerCase();
+ const handler = (e) => dispatch(serverEventHandler(e));
+ if (el.$lustre[`${name}Handler`]) {
+ el.removeEventListener(event2, el.$lustre[`${name}Handler`]);
+ }
+ el.addEventListener(event2, handler);
+ el.$lustre[name] = value;
+ el.$lustre[`${name}Handler`] = handler;
+ el.$lustre.__registered_events.add(name);
+ el.setAttribute(name, value);
+ break;
+ }
+ case "string":
+ if (el.getAttribute(name) !== value)
+ el.setAttribute(name, value);
+ if (value === "")
+ el.removeAttribute(name);
+ if (name === "value" && el.value !== value)
+ el.value = value;
+ break;
+ case (name.startsWith("on") && "function"): {
+ if (el.$lustre[name] === value)
+ break;
+ const event2 = name.slice(2).toLowerCase();
+ const handler = (e) => map2(value(e), dispatch);
+ if (el.$lustre[`${name}Handler`]) {
+ el.removeEventListener(event2, el.$lustre[`${name}Handler`]);
+ }
+ el.addEventListener(event2, handler);
+ el.$lustre[name] = value;
+ el.$lustre[`${name}Handler`] = handler;
+ el.$lustre.__registered_events.add(name);
+ break;
+ }
+ default:
+ el[name] = value;
+ }
+}
+function createText(prev, curr) {
+ const el = document.createTextNode(curr.content);
+ if (prev)
+ prev.replaceWith(el);
+ return el;
+}
+function morphText(prev, curr) {
+ const prevValue = prev.nodeValue;
+ const currValue = curr.content;
+ if (!currValue) {
+ prev?.remove();
+ return null;
+ }
+ if (prevValue !== currValue)
+ prev.nodeValue = currValue;
+ return prev;
+}
+function serverEventHandler(event2) {
+ const el = event2.target;
+ const tag = el.getAttribute(`data-lustre-on-${event2.type}`);
+ const data = JSON.parse(el.getAttribute("data-lustre-data") || "{}");
+ const include = JSON.parse(el.getAttribute("data-lustre-include") || "[]");
+ switch (event2.type) {
+ case "input":
+ case "change":
+ include.push("target.value");
+ break;
+ }
+ return {
+ tag,
+ data: include.reduce((data2, property) => {
+ const path = property.split(".");
+ for (let i = 0, o = data2, e = event2; i < path.length; i++) {
+ if (i === path.length - 1) {
+ o[path[i]] = e[path[i]];
+ } else {
+ o[path[i]] ??= {};
+ e = e[path[i]];
+ o = o[path[i]];
+ }
+ }
+ return data2;
+ }, data)
+ };
+}
+
+// src/server-component.mjs
+var LustreServerComponent = class extends HTMLElement {
+ static get observedAttributes() {
+ return ["route"];
+ }
+ #observer = null;
+ #root = null;
+ #socket = null;
+ constructor() {
+ super();
+ this.#observer = new MutationObserver((mutations) => {
+ const changed = [];
+ for (const mutation of mutations) {
+ if (mutation.type === "attributes") {
+ const { attributeName: name, oldValue: prev } = mutation;
+ const next = this.getAttribute(name);
+ if (prev !== next) {
+ try {
+ changed.push([name, JSON.parse(next)]);
+ } catch {
+ changed.push([name, next]);
+ }
+ }
+ }
+ }
+ if (changed.length) {
+ this.#socket?.send(JSON.stringify([attrs, changed]));
+ }
+ });
+ }
+ connectedCallback() {
+ this.#root = document.createElement("div");
+ this.appendChild(this.#root);
+ }
+ attributeChangedCallback(name, prev, next) {
+ switch (name) {
+ case "route": {
+ if (!next) {
+ this.#socket?.close();
+ this.#socket = null;
+ } else if (prev !== next) {
+ const id = this.getAttribute("id");
+ const route = next + (id ? `?id=${id}` : "");
+ this.#socket?.close();
+ this.#socket = new WebSocket(`ws://${window.location.host}${route}`);
+ this.#socket.addEventListener("message", ({ data }) => {
+ const [kind, ...payload] = JSON.parse(data);
+ switch (kind) {
+ case diff:
+ return this.diff(payload);
+ case emit:
+ return this.emit(payload);
+ case init:
+ return this.init(payload);
+ }
+ });
+ }
+ }
+ }
+ }
+ init([attrs2, vdom]) {
+ const initial = [];
+ for (const attr of attrs2) {
+ if (attr in this) {
+ initial.push([attr, this[attr]]);
+ } else if (this.hasAttribute(attr)) {
+ initial.push([attr, this.getAttribute(attr)]);
+ }
+ Object.defineProperty(this, attr, {
+ get() {
+ return this[`_${attr}`] ?? this.getAttribute(attr);
+ },
+ set(value) {
+ const prev = this[attr];
+ if (typeof value === "string") {
+ this.setAttribute(attr, value);
+ } else {
+ this[`_${attr}`] = value;
+ }
+ if (prev !== value) {
+ this.#socket?.send(
+ JSON.stringify([attrs, [[attr, value]]])
+ );
+ }
+ }
+ });
+ }
+ this.#observer.observe(this, {
+ attributeFilter: attrs2,
+ attributeOldValue: true,
+ attributes: true,
+ characterData: false,
+ characterDataOldValue: false,
+ childList: false,
+ subtree: false
+ });
+ this.morph(vdom);
+ if (initial.length) {
+ this.#socket?.send(JSON.stringify([attrs, initial]));
+ }
+ }
+ morph(vdom) {
+ this.#root = morph(this.#root, vdom, (msg) => {
+ this.#socket?.send(JSON.stringify([event, msg.tag, msg.data]));
+ });
+ }
+ diff([diff2]) {
+ this.#root = patch(this.#root, diff2, (msg) => {
+ this.#socket?.send(JSON.stringify([event, msg.tag, msg.data]));
+ });
+ }
+ emit([event2, data]) {
+ this.dispatchEvent(new CustomEvent(event2, { detail: data }));
+ }
+ disconnectedCallback() {
+ this.#socket?.close();
+ }
+};
+window.customElements.define("lustre-server-component", LustreServerComponent);
+export {
+ LustreServerComponent
+};
diff --git a/src/client-component.ffi.mjs b/src/client-component.ffi.mjs
new file mode 100644
index 0000000..0970e7f
--- /dev/null
+++ b/src/client-component.ffi.mjs
@@ -0,0 +1,74 @@
+import { Ok, Error, isEqual } from "./gleam.mjs";
+import { Dispatch, Shutdown } from "./lustre/runtime.mjs";
+import {
+ ComponentAlreadyRegistered,
+ BadComponentName,
+ NotABrowser,
+} from "./lustre.mjs";
+import { LustreClientApplication, is_browser } from "./client-runtime.ffi.mjs";
+
+export function register({ init, update, view, on_attribute_change }, name) {
+ if (!is_browser()) return new Error(new NotABrowser());
+ if (!name.includes("-")) return new Error(new BadComponentName(name));
+ if (window.customElements.get(name)) {
+ return new Error(new ComponentAlreadyRegistered(name));
+ }
+
+ window.customElements.define(
+ name,
+ class LustreClientComponent extends HTMLElement {
+ #root = document.createElement("div");
+ #application = null;
+
+ static get observedAttributes() {
+ return on_attribute_change.entries().map(([name, _]) => name);
+ }
+
+ constructor() {
+ super();
+ on_attribute_change.forEach((decoder, name) => {
+ Object.defineProperty(this, name, {
+ get() {
+ return this[`_${name}`] || this.getAttribute(name);
+ },
+
+ set(value) {
+ const prev = this[name];
+ const decoded = decoder(value);
+
+ if (decoded.isOk() && !isEqual(prev, value)) {
+ this.#application
+ ? this.#application.send(new Dispatch(decoded[0]))
+ : window.requestAnimationFrame(() =>
+ this.#application.send(new Dispatch(decoded[0]))
+ );
+ }
+
+ if (typeof value === "string") {
+ this.setAttribute(name, value);
+ } else {
+ this[`_${name}`] = value;
+ }
+ },
+ });
+ });
+ }
+
+ connectedCallback() {
+ this.#application = new LustreClientApplication(
+ init(),
+ update,
+ view,
+ this.#root
+ );
+ this.appendChild(this.#root);
+ }
+
+ disconnectedCallback() {
+ this.#application.send(new Shutdown());
+ }
+ }
+ );
+
+ return new Ok(null);
+}
diff --git a/src/client-runtime.ffi.mjs b/src/client-runtime.ffi.mjs
new file mode 100644
index 0000000..13e3906
--- /dev/null
+++ b/src/client-runtime.ffi.mjs
@@ -0,0 +1,133 @@
+import { ElementNotFound, NotABrowser } from "./lustre.mjs";
+import { Dispatch, Shutdown } from "./lustre/runtime.mjs";
+import { morph } from "./vdom.ffi.mjs";
+import { Ok, Error, isEqual } from "./gleam.mjs";
+
+export class LustreClientApplication {
+ #root = null;
+ #queue = [];
+ #effects = [];
+ #didUpdate = false;
+
+ #model = null;
+ #update = null;
+ #view = null;
+
+ static start(flags, selector, init, update, view) {
+ if (!is_browser()) return new Error(new NotABrowser());
+ const root =
+ selector instanceof HTMLElement
+ ? selector
+ : document.querySelector(selector);
+ if (!root) return new Error(new ElementNotFound());
+ const app = new LustreClientApplication(init(flags), update, view, root);
+
+ return new Ok((msg) => app.send(msg));
+ }
+
+ constructor([model, effects], update, view, root = document.body) {
+ this.#model = model;
+ this.#update = update;
+ this.#view = view;
+ this.#root = root;
+ this.#effects = effects.all.toArray();
+ this.#didUpdate = true;
+
+ window.requestAnimationFrame(() => this.#tick());
+ }
+
+ send(action) {
+ switch (true) {
+ case action instanceof Dispatch: {
+ this.#queue.push(action[0]);
+ this.#tick();
+
+ return;
+ }
+
+ case action instanceof Shutdown: {
+ this.#shutdown();
+ return;
+ }
+
+ default:
+ return;
+ }
+ }
+
+ emit(event, data) {
+ this.#root.dispatchEvent(
+ new CustomEvent(event, {
+ bubbles: true,
+ detail: data,
+ composed: true,
+ })
+ );
+ }
+
+ #tick() {
+ this.#flush_queue();
+
+ if (this.#didUpdate) {
+ const vdom = this.#view(this.#model);
+
+ this.#didUpdate = false;
+ this.#root = morph(this.#root, vdom, (msg) => {
+ this.send(new Dispatch(msg));
+ });
+ }
+ }
+
+ #flush_queue(iterations = 0) {
+ while (this.#queue.length) {
+ const [next, effects] = this.#update(this.#model, this.#queue.shift());
+
+ this.#model = next;
+ this.#didUpdate ||= isEqual(this.#model, next);
+ this.#effects = this.#effects.concat(effects.all.toArray());
+ }
+
+ while (this.#effects.length) {
+ this.#effects.shift()(
+ (msg) => this.send(new Dispatch(msg)),
+ (event, data) => this.emit(event, data)
+ );
+ }
+
+ if (this.#queue.length) {
+ if (iterations < 5) {
+ this.#flush_queue(++iterations);
+ } else {
+ window.requestAnimationFrame(() => this.#tick());
+ }
+ }
+ }
+
+ #shutdown() {
+ this.#root.remove();
+ this.#root = null;
+ this.#model = null;
+ this.#queue = [];
+ this.#effects = [];
+ this.#didUpdate = false;
+ this.#update = () => {};
+ this.#view = () => {};
+ }
+}
+
+export const start = (app, selector, flags) =>
+ LustreClientApplication.start(
+ flags,
+ selector,
+ app.init,
+ app.update,
+ app.view
+ );
+
+// UTILS -----------------------------------------------------------------------
+
+export const is_browser = () => window && window.document;
+export const is_registered = (name) =>
+ is_browser() && !!window.customElements.get(name);
+export const prevent_default = (event) => event.preventDefault();
+export const stop_propagation = (event) => event.stopPropagation();
diff --git a/src/lustre.ffi.mjs b/src/lustre.ffi.mjs
deleted file mode 100644
index 918f7e2..0000000
--- a/src/lustre.ffi.mjs
+++ /dev/null
@@ -1,224 +0,0 @@
-import {
- AppAlreadyStarted,
- AppNotYetStarted,
- BadComponentName,
- ComponentAlreadyRegistered,
- ElementNotFound,
- NotABrowser,
-} from "./lustre.mjs";
-import { from } from "./lustre/effect.mjs";
-import { morph } from "./runtime.ffi.mjs";
-import { Ok, Error, isEqual } from "./gleam.mjs";
-
-// RUNTIME ---------------------------------------------------------------------
-
-///
-///
-export class App {
- #root = null;
- #state = null;
- #queue = [];
- #effects = [];
- #didUpdate = false;
-
- #init = null;
- #update = null;
- #view = null;
-
- constructor(init, update, render) {
- this.#init = init;
- this.#update = update;
- this.#view = render;
- }
-
- start(selector, flags) {
- if (!is_browser()) return new Error(new NotABrowser());
- if (this.#root) return new Error(new AppAlreadyStarted());
-
- this.#root =
- selector instanceof HTMLElement
- ? selector
- : document.querySelector(selector);
-
- if (!this.#root) return new Error(new ElementNotFound());
-
- const [next, effects] = this.#init(flags);
-
- this.#state = next;
- this.#effects = effects.all.toArray();
- this.#didUpdate = true;
-
- window.requestAnimationFrame(() => this.#tick());
-
- return new Ok((msg) => this.dispatch(msg));
- }
-
- dispatch(msg) {
- this.#queue.push(msg);
- this.#tick();
- }
-
- emit(name, event = null) {
- this.#root.dispatchEvent(
- new CustomEvent(name, {
- bubbles: true,
- detail: event,
- composed: true,
- })
- );
- }
-
- destroy() {
- if (!this.#root) return new Error(new AppNotYetStarted());
-
- this.#root.remove();
- this.#root = null;
- this.#state = null;
- this.#queue = [];
- this.#effects = [];
- this.#didUpdate = false;
- this.#update = () => {};
- this.#view = () => {};
- }
-
- #tick() {
- this.#flush();
-
- if (this.#didUpdate) {
- const vdom = this.#view(this.#state);
-
- this.#root = morph(this.#root, vdom, (msg) => this.dispatch(msg));
- this.#didUpdate = false;
- }
- }
-
- #flush(times = 0) {
- if (!this.#root) return;
- if (this.#queue.length) {
- while (this.#queue.length) {
- const [next, effects] = this.#update(this.#state, this.#queue.shift());
- // If the user returned their model unchanged and not reconstructed then
- // we don't need to trigger a re-render.
- this.#didUpdate ||= this.#state !== next;
- this.#state = next;
- this.#effects = this.#effects.concat(effects.all.toArray());
- }
- }
-
- // Each update can produce effects which must now be executed.
- while (this.#effects.length)
- this.#effects.shift()(
- (msg) => this.dispatch(msg),
- (name, data) => this.emit(name, data)
- );
-
- // Synchronous effects will immediately queue a message to be processed. If
- // it is reasonable, we can process those updates too before proceeding to
- // the next render.
- if (this.#queue.length) {
- times >= 5 ? console.warn(tooManyUpdates) : this.#flush(++times);
- }
- }
-}
-
-export const setup = (init, update, render) => new App(init, update, render);
-export const start = (app, selector, flags) => app.start(selector, flags);
-export const destroy = (app) => app.destroy();
-
-// HTML EVENTS -----------------------------------------------------------------
-
-export const prevent_default = (e) => e.preventDefault?.();
-export const stop_propagation = (e) => e.stopPropagation?.();
-
-// CUSTOM ELEMENTS -------------------------------------------------------------
-
-export const setup_component = (
- name,
- init,
- update,
- render,
- on_attribute_change
-) => {
- if (!name.includes("-")) return new Error(new BadComponentName());
- if (!is_browser()) return new Error(new NotABrowser());
- if (customElements.get(name)) {
- return new Error(new ComponentAlreadyRegistered());
- }
-
- customElements.define(
- name,
- class extends HTMLElement {
- static get observedAttributes() {
- return on_attribute_change.entries().map(([name, _]) => name);
- }
-
- #container = document.createElement("div");
- #app = null;
- #dispatch = null;
-
- constructor() {
- super();
-
- this.#app = new App(init, update, render);
- // This is necessary for ✨ reasons ✨. Clearly there's a bug in the
- // implementation of either the `App` or the runtime but I con't work it
- // out.
- //
- // If we pass the container to the app directly then the component fails
- // to render anything to the ODM.
- this.#container.appendChild(document.createElement("div"));
-
- const dispatch = this.#app.start(this.#container.firstChild);
- this.#dispatch = dispatch[0];
-
- on_attribute_change.forEach((decoder, name) => {
- Object.defineProperty(this, name, {
- get: () => {
- return this[`_${name}`] || this.getAttribute(name);
- },
-
- set: (value) => {
- const prev = this[name];
- const decoded = decoder(value);
-
- // We need this equality check to prevent constantly dispatching
- // messages when the value is an object or array: it might not have
- // changed but its reference might have and we don't want to trigger
- // useless updates.
- if (decoded.isOk() && !isEqual(prev, value)) {
- this.#dispatch(decoded[0]);
- }
-
- if (typeof value === "string") {
- this.setAttribute(name, value);
- } else {
- this[`_${name}`] = value;
- }
- },
- });
- });
- }
-
- connectedCallback() {
- this.appendChild(this.#container.firstChild);
- }
-
- attributeChangedCallback(name, prev, next) {
- if (prev !== next) {
- this[name] = next;
- }
- }
-
- disconnectedCallback() {
- this.#app.destroy();
- }
- }
- );
-
- return new Ok(null);
-};
-
-// UTLS ------------------------------------------------------------------------
-
-export const is_browser = () => window && window.document;
-export const is_registered = (name) => !!customElements.get(name);
diff --git a/src/lustre.gleam b/src/lustre.gleam
index 0c3f2ec..74742e1 100644
--- a/src/lustre.gleam
+++ b/src/lustre.gleam
@@ -1,46 +1,191 @@
+//// Lustre is a framework for rendering Web applications and components using
+//// Gleam. This module contains the core API for constructing and communicating
+//// with the different kinds of Lustre application.
+////
+//// Lustre currently has two kinds of application:
+////
+//// 1. A client-side single-page application: think Elm or React or Vue. These
+//// are applications that run in the client's browser and are responsible for
+//// rendering the entire page.
+////
+//// 2. A client-side component: an encapsulated Lustre application that can be
+//// rendered inside another Lustre application as a Web Component. Communication
+//// happens via attributes and event listeners, like any other encapsulated
+//// HTML element.
+////
+//// 3. A Lustre Server Component. These are applications that run anywhere Gleam
+//// runs and communicate with any number of connected clients by sending them
+//// patches to apply to their DOM.
+////
+//// On the server, these applications can be communicated with by sending them
+//// messages directly. On the client communication happens the same way as
+//// client-side components: through attributes and event listeners.
+////
+//// No matter where a Lustre application runs, it will always follow the same
+//// Model-View-Update architecture. Popularised by Elm (where it is known as The
+//// Elm Architecture), this pattern has since made its way into many other
+//// languages and frameworks and has proven to be a robust and reliable way to
+//// build complex user interfaces.
+////
+//// There are three main building blocks to the Model-View-Update architecture:
+////
+//// - A `Model` that represents your application's state and an `init` function
+//// to create it.
+////
+//// - A `Msg` type that represents all the different ways the outside world can
+//// communicate with your application and an `update` function that modifies
+//// your model in response to those messages.
+////
+//// - A `view` function that renders your model to HTML, represented as an
+//// `Element`.
+////
+//// To see how those pieces fit together, here's a little diagram:
+////
+//// ```text
+//// +--------+
+//// | |
+//// | update |
+//// | |
+//// +--------+
+//// ^ |
+//// | |
+//// Msg | | #(Model, Effect(Msg))
+//// | |
+//// | v
+//// +------+ +------------------------+
+//// | | #(Model, Effect(Msg)) | |
+//// | init |------------------------>| Lustre Runtime |
+//// | | | |
+//// +------+ +------------------------+
+//// ^ |
+//// | |
+//// Msg | | Model
+//// | |
+//// | v
+//// +--------+
+//// | |
+//// | view |
+//// | |
+//// +--------+
+//// ```
+////
+//// ❓ Wondering what that [`Effect`](./effect#effect-type) is all about? Check
+//// out the documentation for that over in the [`effect`](./effect) module.
+////
+//// For many kinds of app, you can take these three building blocks and put
+//// together a Lustre application capable of running *anywhere*. We like to
+//// describe Lustre as a **universal framework**.
+////
//// To read the full documentation for this module, please visit
//// [https://lustre.build/api/lustre](https://lustre.build/api/lustre)
// IMPORTS ---------------------------------------------------------------------
-import gleam/dynamic.{type Decoder}
+import gleam/bool
import gleam/dict.{type Dict}
+import gleam/dynamic.{type Decoder, type Dynamic}
+import gleam/erlang/process.{type Subject}
+import gleam/otp/actor.{type StartError}
+import gleam/result
import lustre/effect.{type Effect}
-import lustre/element.{type Element}
+import lustre/element.{type Element, type Patch}
+import lustre/runtime
// TYPES -----------------------------------------------------------------------
-@target(javascript)
-///
-pub type App(flags, model, msg)
-
-@target(erlang)
-///
+/// Represents a constructed Lustre application that is ready to be started.
+/// Depending on the kind of application you've constructed you have a few
+/// options:
+///
+/// - Use [`start`](#start) to start a single-page-application in the browser.
+///
+/// - Use [`start_server_component`](#start_server_component) to start a Lustre
+/// Server Component anywhere Gleam will run: Erlang, Node, Deno, or in the
+/// browser.
+///
+/// - Use [`start_actor`](#start_actor) to start a Lustre Server Component only
+/// for the Erlang target. BEAM users should always prefer this over
+/// `start_server_component` so they can take advantage of OTP features.
+///
+/// - Use [`register`](#register) to register a component in the browser to be
+/// used as a Custom Element. This is useful even if you're not using Lustre
+/// to build a SPA.
+///
pub opaque type App(flags, model, msg) {
- App
+ App(
+ init: fn(flags) -> #(model, Effect(msg)),
+ update: fn(model, msg) -> #(model, Effect(msg)),
+ view: fn(model) -> Element(msg),
+ on_attribute_change: Dict(String, Decoder(msg)),
+ )
}
+/// The `Browser` runtime is the most typical kind of Lustre application: it's
+/// a single-page application that runs in the browser similar to React or Vue.
+///
+pub type ClientSpa
+
+/// A `ServerComponent` is a type of Lustre application that does not directly
+/// render anything to the DOM. Instead, it can run anywhere Gleam runs and
+/// operates in a "headless" mode where it computes diffs between renders and
+/// sends them to any number of connected listeners.
+///
+/// Lustre Server Components are not tied to any particular transport or network
+/// protocol, but they are most commonly used with WebSockets in a fashion similar
+/// to Phoenix LiveView.
+///
+pub type ServerComponent
+
+/// An action represents a message that can be sent to (some types of) a running
+/// Lustre application. Like the [`App`](#App) type, the `runtime` type parameter
+/// can be used to determine what kinds of application a particular action can be
+/// sent to.
+///
+///
+///
+pub type Action(runtime, msg) =
+ runtime.Action(runtime, msg)
+
+/// Starting a Lustre application might fail for a number of reasons. This error
+/// type enumerates all those reasons, even though some of them are only possible
+/// on certain targets.
+///
+/// This generally makes error handling simpler than having to worry about a bunch
+/// of different error types and potentially unifying them yourself.
+///
pub type Error {
- AppAlreadyStarted
- AppNotYetStarted
+ ActorError(StartError)
BadComponentName
ComponentAlreadyRegistered
ElementNotFound
NotABrowser
+ NotErlang
}
// CONSTRUCTORS ----------------------------------------------------------------
-///
-pub fn element(element: Element(msg)) -> App(Nil, Nil, msg) {
+/// An element is the simplest type of Lustre application. It renders its contents
+/// once and does not handle any messages or effects. Often this type of application
+/// is used for folks just getting started with Lustre on the frontend and want a
+/// quick way to get something on the screen.
+///
+/// Take a look at the [`simple`](#simple) application constructor if you want to
+/// build something interactive.
+///
+/// 💡 Just because an element doesn't have its own update loop, doesn't mean its
+/// content is always static! An element application may render a component or
+/// server component that has its own encapsulated update loop!
+///
+pub fn element(html: Element(msg)) -> App(Nil, Nil, msg) {
let init = fn(_) { #(Nil, effect.none()) }
let update = fn(_, _) { #(Nil, effect.none()) }
- let view = fn(_) { element }
+ let view = fn(_) { html }
application(init, update, view)
}
///
+///
pub fn simple(
init: fn(flags) -> model,
update: fn(model, msg) -> model,
@@ -53,67 +198,158 @@ pub fn simple(
}
///
-@external(javascript, "./lustre.ffi.mjs", "setup")
+///
pub fn application(
- _init: fn(flags) -> #(model, Effect(msg)),
- _update: fn(model, msg) -> #(model, Effect(msg)),
- _view: fn(model) -> Element(msg),
+ init: fn(flags) -> #(model, Effect(msg)),
+ update: fn(model, msg) -> #(model, Effect(msg)),
+ view: fn(model) -> Element(msg),
) -> App(flags, model, msg) {
- // Applications are not usable on the erlang target. For those users, `App`
- // is an opaque type (aka they can't see its structure) and functions like
- // `start` and `destroy` are no-ops.
- //
- // Because the constructor is marked as `@target(erlang)` for some reason we
- // can't simply refer to it here even though the compiler should know that the
- // body of this function can only be entered from erlang (because we have an
- // external def for javascript) but alas, it does not.
- //
- // So instead, we must do this awful hack and cast a `Nil` to the `App` type
- // to make everything happy. Theoeretically this is not going to be a problem
- // unless someone starts poking around with their own ffi and at that point
- // they deserve it.
- dynamic.unsafe_coerce(dynamic.from(Nil))
-}
-
-@external(javascript, "./lustre.ffi.mjs", "setup_component")
+ App(init, update, view, dict.new())
+}
+
+///
+///
pub fn component(
- _name: String,
- _init: fn() -> #(model, Effect(msg)),
- _update: fn(model, msg) -> #(model, Effect(msg)),
- _view: fn(model) -> Element(msg),
- _on_attribute_change: Dict(String, Decoder(msg)),
-) -> Result(Nil, Error) {
- Ok(Nil)
+ init: fn(flags) -> #(model, Effect(msg)),
+ update: fn(model, msg) -> #(model, Effect(msg)),
+ view: fn(model) -> Element(msg),
+ on_attribute_change: Dict(String, Decoder(msg)),
+) -> App(flags, model, msg) {
+ App(init, update, view, on_attribute_change)
}
// EFFECTS ---------------------------------------------------------------------
///
-@external(javascript, "./lustre.ffi.mjs", "start")
+///
pub fn start(
+ app: App(flags, model, msg),
+ onto selector: String,
+ with flags: flags,
+) -> Result(fn(Action(ClientSpa, msg)) -> Nil, Error) {
+ use <- bool.guard(!is_browser(), Error(NotABrowser))
+ do_start(app, selector, flags)
+}
+
+@external(javascript, "./client-runtime.ffi.mjs", "start")
+fn do_start(
_app: App(flags, model, msg),
_selector: String,
_flags: flags,
-) -> Result(fn(msg) -> Nil, Error) {
+) -> Result(fn(Action(ClientSpa, msg)) -> Nil, Error) {
+ // It should never be possible for the body of this function to execute on the
+ // Erlang target because the `is_browser` guard will prevent it. Instead of
+ // a panic, we still return a well-typed `Error` here in the case where someone
+ // mistakenly uses this function internally.
Error(NotABrowser)
}
///
-@external(javascript, "./lustre.ffi.mjs", "destroy")
-pub fn destroy(_app: App(flags, model, msg)) -> Result(Nil, Error) {
- Ok(Nil)
+///
+@external(javascript, "./server-runtime.ffi.mjs", "start")
+pub fn start_server_component(
+ app: App(flags, model, msg),
+ with flags: flags,
+) -> Result(fn(Action(ServerComponent, msg)) -> Nil, Error) {
+ use runtime <- result.map(start_actor(app, flags))
+ actor.send(runtime, _)
+}
+
+///
+///
+/// 🚨 This function is only meaningful on the Erlang target. Attempts to call
+/// it on the JavaScript will result in the `NotErlang` error. If you're running
+/// a Lustre Server Component on Node or Deno, use
+/// [`start_server_component`](#start_server_component) instead.
+///
+pub fn start_actor(
+ app: App(flags, model, msg),
+ with flags: flags,
+) -> Result(Subject(Action(ServerComponent, msg)), Error) {
+ do_start_actor(app, flags)
+}
+
+@target(javascript)
+fn do_start_actor(_, _) {
+ Error(NotErlang)
+}
+
+@target(erlang)
+fn do_start_actor(
+ app: App(flags, model, msg),
+ flags: flags,
+) -> Result(Subject(Action(ServerComponent, msg)), Error) {
+ app.init(flags)
+ |> runtime.start(app.update, app.view, app.on_attribute_change)
+ |> result.map_error(ActorError)
+}
+
+/// Register a Lustre application as a Web Component. This lets you render that
+/// application in another Lustre application's view or use it as a Custom Element
+/// outside of Lustre entirely.
+///
+/// 💡 The provided application can only have `Nil` flags, because there is no way
+/// to specify flags when the component is first rendered.
+///
+/// 💡 There are [some rules](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#valid_custom_element_names)
+/// for what names are valid for a Custom Element. The most important one is that
+/// the name *must* contain a hypen so that it can be distinguished from standard
+/// HTML elements.
+///
+/// 🚨 This function is only meaningful when running in the browser. For server
+/// contexts, you can render a Lustre Server Component using `start_server_component`
+/// or `start_actor` instead.
+///
+@external(javascript, "./client-component.ffi.mjs", "register")
+pub fn register(app: App(Nil, model, msg), name: String) -> Result(Nil, Error) {
+ Error(NotABrowser)
+}
+
+// ACTIONS ---------------------------------------------------------------------
+
+pub fn add_renderer(
+ id: any,
+ renderer: fn(Patch(msg)) -> Nil,
+) -> Action(ServerComponent, msg) {
+ runtime.AddRenderer(dynamic.from(id), renderer)
+}
+
+pub fn dispatch(msg: msg) -> Action(runtime, msg) {
+ runtime.Dispatch(msg)
+}
+
+pub fn event(name: String, data: Dynamic) -> Action(ServerComponent, msg) {
+ runtime.Event(name, data)
+}
+
+pub fn remove_renderer(id: any) -> Action(ServerComponent, msg) {
+ runtime.RemoveRenderer(dynamic.from(id))
+}
+
+pub fn shutdown() -> Action(runtime, msg) {
+ runtime.Shutdown
}
// UTILS -----------------------------------------------------------------------
-///
-@external(javascript, "./lustre.ffi.mjs", "is_browser")
+/// Gleam's conditional compilation makes it possible to have different implementations
+/// of a function for different targets, but it's not possible to know what runtime
+/// you're targetting at compile-time.
+///
+/// This is problematic if you're using Lustre Server Components with a JavaScript
+/// backend because you'll want to know whether you're currently running on your
+/// server or in the browser: this function tells you that!
+///
+@external(javascript, "./client-runtime.ffi.mjs", "is_browser")
pub fn is_browser() -> Bool {
False
}
-///
-@external(javascript, "./lustre.ffi.mjs", "is_registered")
-pub fn is_registered(_name: String) -> Bool {
+/// Check if the given component name has already been registered as a Custom
+/// Element. This is particularly useful in contexts where _other web components_
+/// may have been registered and you must avoid collisions.
+///
+@external(javascript, "./client-runtime.ffi.mjs", "is_registered")
+pub fn is_registered(name: String) -> Bool {
False
}
diff --git a/src/lustre/attribute.gleam b/src/lustre/attribute.gleam
index 24c26df..163ccf4 100644
--- a/src/lustre/attribute.gleam
+++ b/src/lustre/attribute.gleam
@@ -9,15 +9,13 @@ import gleam/int
import gleam/list
import gleam/result
import gleam/string
-import gleam/string_builder.{type StringBuilder}
+import lustre/internals/vdom.{Attribute, Event}
// TYPES -----------------------------------------------------------------------
///
-pub opaque type Attribute(msg) {
- Attribute(String, Dynamic, as_property: Bool)
- Event(String, fn(Dynamic) -> Result(msg, Nil))
-}
+pub type Attribute(msg) =
+ vdom.Attribute(msg)
// CONSTRUCTORS ----------------------------------------------------------------
@@ -54,79 +52,6 @@ pub fn map(attr: Attribute(a), f: fn(a) -> b) -> Attribute(b) {
}
}
-// CONVERSIONS -----------------------------------------------------------------
-
-///
-///
-pub fn to_string(attr: Attribute(msg)) -> String {
- case to_string_parts(attr) {
- Ok(#(key, val)) -> key <> "=\"" <> val <> "\""
- Error(_) -> ""
- }
-}
-
-///
-///
-pub fn to_string_parts(attr: Attribute(msg)) -> Result(#(String, String), Nil) {
- case attr {
- Attribute("", _, _) -> Error(Nil)
- Attribute("dangerous-unescaped-html", _, _) -> Error(Nil)
- Attribute(name, value, as_property) -> {
- case dynamic.classify(value) {
- "String" -> Ok(#(name, dynamic.unsafe_coerce(value)))
-
- // Boolean attributes are determined based on their presence, eg we don't
- // want to render `disabled="false"` if the value is `false` we simply
- // want to omit the attribute altogether.
- "Boolean" ->
- case dynamic.unsafe_coerce(value) {
- True -> Ok(#(name, name))
- False -> Error(Nil)
- }
-
- // For everything else, we care whether or not the attribute is actually
- // a property. Properties are *Javascript* values that aren't necessarily
- // reflected in the DOM.
- _ if as_property -> Error(Nil)
- _ -> Ok(#(name, string.inspect(value)))
- }
- }
- Event(on, _) -> Ok(#("data-lustre-on", on))
- }
-}
-
-///
-pub fn to_string_builder(attr: Attribute(msg)) -> StringBuilder {
- case attr {
- Attribute("", _, _) -> string_builder.new()
- Attribute("dangerous-unescaped-html", _, _) -> string_builder.new()
- Attribute(name, value, as_property) -> {
- case dynamic.classify(value) {
- "String" ->
- [name, "=\"", dynamic.unsafe_coerce(value), "\""]
- |> string_builder.from_strings
-
- // Boolean attributes are determined based on their presence, eg we don't
- // want to render `disabled="false"` if the value is `false` we simply
- // want to omit the attribute altogether.
- "Boolean" ->
- case dynamic.unsafe_coerce(value) {
- True -> string_builder.from_string(name)
- False -> string_builder.new()
- }
-
- _ if as_property -> string_builder.new()
- _ ->
- [name, "=\"", string.inspect(value), "\""]
- |> string_builder.from_strings
- }
- }
- Event(on, _) ->
- ["data-lustre-on:", on]
- |> string_builder.from_strings
- }
-}
-
// COMMON ATTRIBUTES -----------------------------------------------------------
///
@@ -175,8 +100,8 @@ pub fn type_(name: String) -> Attribute(msg) {
}
///
-pub fn value(val: Dynamic) -> Attribute(msg) {
- property("value", val)
+pub fn value(val: any) -> Attribute(msg) {
+ property("value", dynamic.from(val))
}
///
diff --git a/src/lustre/effect.gleam b/src/lustre/effect.gleam
index 19378da..fff3da6 100644
--- a/src/lustre/effect.gleam
+++ b/src/lustre/effect.gleam
@@ -3,7 +3,7 @@
// IMPORTS ---------------------------------------------------------------------
-import gleam/dynamic.{type Dynamic}
+import gleam/json.{type Json}
import gleam/list
import gleam/function
@@ -11,7 +11,7 @@ import gleam/function
///
pub opaque type Effect(msg) {
- Effect(all: List(fn(fn(msg) -> Nil, fn(String, Dynamic) -> Nil) -> Nil))
+ Effect(all: List(fn(fn(msg) -> Nil, fn(String, Json) -> Nil) -> Nil))
}
// CONSTRUCTORS ----------------------------------------------------------------
@@ -31,8 +31,8 @@ pub fn from(effect: fn(fn(msg) -> Nil) -> Nil) -> Effect(msg) {
/// of Lustre's components, but in rare cases it may be useful to emit custom
/// events from the DOM node that your Lustre application is mounted to.
///
-pub fn event(name: String, data: data) -> Effect(msg) {
- Effect([fn(_, emit) { emit(name, dynamic.from(data)) }])
+pub fn event(name: String, data: Json) -> Effect(msg) {
+ Effect([fn(_, emit) { emit(name, data) }])
}
/// Typically our app's `update` function needs to return a tuple of
@@ -46,6 +46,7 @@ pub fn none() -> Effect(msg) {
// MANIPULATIONS ---------------------------------------------------------------
///
+///
pub fn batch(effects: List(Effect(msg))) -> Effect(msg) {
Effect({
use b, Effect(a) <- list.fold(effects, [])
@@ -54,14 +55,29 @@ pub fn batch(effects: List(Effect(msg))) -> Effect(msg) {
}
///
+///
pub fn map(effect: Effect(a), f: fn(a) -> b) -> Effect(b) {
Effect({
- use effect <- list.map(effect.all)
+ list.map(effect.all, fn(effect) {
+ fn(dispatch, emit) {
+ let dispatch = function.compose(f, dispatch)
- fn(dispatch, emit) {
- let dispatch = function.compose(f, dispatch)
-
- effect(dispatch, emit)
- }
+ effect(dispatch, emit)
+ }
+ })
})
}
+
+/// Perform a side effect by supplying your own `dispatch` function. This is
+/// primarily used internally by the server runtime, but it is also useful for
+/// testing.
+///
+pub fn perform(
+ effect: Effect(a),
+ dispatch: fn(a) -> Nil,
+ emit: fn(String, Json) -> Nil,
+) -> Nil {
+ use eff <- list.each(effect.all)
+
+ eff(dispatch, emit)
+}
diff --git a/src/lustre/element.gleam b/src/lustre/element.gleam
index 9593970..930dead 100644
--- a/src/lustre/element.gleam
+++ b/src/lustre/element.gleam
@@ -3,25 +3,22 @@
// IMPORTS ---------------------------------------------------------------------
+import gleam/json.{type Json}
import gleam/list
import gleam/string
import gleam/string_builder.{type StringBuilder}
import lustre/attribute.{type Attribute, attribute}
+import lustre/internals/vdom.{Element, Text}
+import lustre/internals/patch
// TYPES -----------------------------------------------------------------------
///
-pub opaque type Element(msg) {
- Text(content: String)
- Element(
- namespace: String,
- tag: String,
- attrs: List(Attribute(msg)),
- children: List(Element(msg)),
- self_closing: Bool,
- void: Bool,
- )
-}
+pub type Element(msg) =
+ vdom.Element(msg)
+
+pub type Patch(msg) =
+ patch.Patch(msg)
// CONSTRUCTORS ----------------------------------------------------------------
@@ -158,140 +155,17 @@ pub fn map(element: Element(a), f: fn(a) -> b) -> Element(b) {
///
pub fn to_string(element: Element(msg)) -> String {
- to_string_builder_helper(element, False)
- |> string_builder.to_string
+ vdom.element_to_string(element)
}
pub fn to_string_builder(element: Element(msg)) -> StringBuilder {
- to_string_builder_helper(element, False)
+ vdom.element_to_string_builder(element)
}
-fn to_string_builder_helper(
- element: Element(msg),
- raw_text: Bool,
-) -> StringBuilder {
- case element {
- Text("") -> string_builder.new()
- Text(content) if raw_text -> string_builder.from_string(content)
- Text(content) -> string_builder.from_string(escape("", content))
-
- Element(namespace, tag, attrs, _, self_closing, _) if self_closing -> {
- let html = string_builder.from_string("<" <> tag)
- let #(attrs, _) =
- attrs_to_string_builder(case namespace {
- "" -> attrs
- _ -> [attribute("xmlns", namespace), ..attrs]
- })
-
- html
- |> string_builder.append_builder(attrs)
- |> string_builder.append("/>")
- }
-
- Element(namespace, tag, attrs, _, _, void) if void -> {
- let html = string_builder.from_string("<" <> tag)
- let #(attrs, _) =
- attrs_to_string_builder(case namespace {
- "" -> attrs
- _ -> [attribute("xmlns", namespace), ..attrs]
- })
-
- html
- |> string_builder.append_builder(attrs)
- |> string_builder.append(">")
- }
-
- // Style and script tags are special beacuse they need to contain unescape
- // text content and not escaped HTML content.
- Element("", "style" as tag, attrs, children, False, False)
- | Element("", "script" as tag, attrs, children, False, False) -> {
- let html = string_builder.from_string("<" <> tag)
- let #(attrs, _) = attrs_to_string_builder(attrs)
-
- html
- |> string_builder.append_builder(attrs)
- |> string_builder.append(">")
- |> children_to_string_builder(children, True)
- |> string_builder.append("</" <> tag <> ">")
- }
-
- Element(namespace, tag, attrs, children, _, _) -> {
- let html = string_builder.from_string("<" <> tag)
- let #(attrs, inner_html) =
- attrs_to_string_builder(case namespace {
- "" -> attrs
- _ -> [attribute("xmlns", namespace), ..attrs]
- })
-
- case inner_html {
- "" ->
- html
- |> string_builder.append_builder(attrs)
- |> string_builder.append(">")
- |> children_to_string_builder(children, raw_text)
- |> string_builder.append("</" <> tag <> ">")
- _ ->
- html
- |> string_builder.append_builder(attrs)
- |> string_builder.append(">" <> inner_html <> "</" <> tag <> ">")
- }
- }
- }
-}
-
-fn attrs_to_string_builder(
- attrs: List(Attribute(msg)),
-) -> #(StringBuilder, String) {
- let #(html, class, style, inner_html) = {
- let init = #(string_builder.new(), "", "", "")
- use #(html, class, style, inner_html), attr <- list.fold(attrs, init)
-
- case attribute.to_string_parts(attr) {
- Ok(#("dangerous-unescaped-html", val)) -> #(
- html,
- class,
- style,
- inner_html
- <> val,
- )
- Ok(#("class", val)) if class == "" -> #(html, val, style, inner_html)
- Ok(#("class", val)) -> #(html, class <> " " <> val, style, inner_html)
- Ok(#("style", val)) if style == "" -> #(html, class, val, inner_html)
- Ok(#("style", val)) -> #(html, class, style <> " " <> val, inner_html)
- Ok(#(key, val)) -> #(
- string_builder.append(html, " " <> key <> "=\"" <> val <> "\""),
- class,
- style,
- inner_html,
- )
- Error(_) -> #(html, class, style, inner_html)
- }
- }
-
- #(
- case class, style {
- "", "" -> html
- _, "" -> string_builder.append(html, " class=\"" <> class <> "\"")
- "", _ -> string_builder.append(html, " style=\"" <> style <> "\"")
- _, _ ->
- string_builder.append(
- html,
- " class=\""
- <> class
- <> "\" style=\""
- <> style
- <> "\"",
- )
- },
- inner_html,
- )
+pub fn encode(element: Element(msg)) -> Json {
+ vdom.element_to_json(element)
}
-fn children_to_string_builder(
- html: StringBuilder,
- children: List(Element(msg)),
- raw_text: Bool,
-) -> StringBuilder {
- use html, child <- list.fold(children, html)
- string_builder.append_builder(html, to_string_builder_helper(child, raw_text))
+pub fn encode_patch(patch: Patch(msg)) -> Json {
+ patch.patch_to_json(patch)
}
diff --git a/src/lustre/event.gleam b/src/lustre/event.gleam
index 3c101d1..64628b0 100644
--- a/src/lustre/event.gleam
+++ b/src/lustre/event.gleam
@@ -4,6 +4,7 @@
// IMPORTS ---------------------------------------------------------------------
import gleam/dynamic.{type DecodeError, type Dynamic}
+import gleam/json.{type Json}
import gleam/result
import lustre/attribute.{type Attribute}
import lustre/effect.{type Effect}
@@ -16,7 +17,7 @@ type Decoded(a) =
// EFFECTS ---------------------------------------------------------------------
///
-pub fn emit(event: String, data: any) -> Effect(msg) {
+pub fn emit(event: String, data: Json) -> Effect(msg) {
effect.event(event, data)
}
@@ -169,12 +170,12 @@ pub fn mouse_position(event: Dynamic) -> Decoded(#(Float, Float)) {
// UTILS -----------------------------------------------------------------------
-@external(javascript, "../lustre.ffi.mjs", "prevent_default")
+@external(javascript, "../client-runtime.ffi.mjs", "prevent_default")
pub fn prevent_default(_event: Dynamic) -> Nil {
Nil
}
-@external(javascript, "../lustre.ffi.mjs", "stop_propagation")
+@external(javascript, "../client-runtime.ffi.mjs", "stop_propagation")
pub fn stop_propagation(_event: Dynamic) -> Nil {
Nil
}
diff --git a/src/lustre/internals/constants.gleam b/src/lustre/internals/constants.gleam
new file mode 100644
index 0000000..a51a200
--- /dev/null
+++ b/src/lustre/internals/constants.gleam
@@ -0,0 +1,26 @@
+// CONSTANTS -------------------------------------------------------------------
+//
+// These constants are used to identify different JSON payloads from the server
+// component runtime. We do this because payloads are sent as arrays to cut down
+// on the size of the payload. The first element of the array is always a tag
+// that tells us how to interpret the rest of the array.
+
+/// Represents the `Diff` variant of the `Patch` type.
+///
+pub const diff: Int = 0
+
+/// Represents the `Emit` variant of the `Patch` type.
+///
+pub const emit: Int = 1
+
+/// Represents the `Init` variant of the `Patch` type.
+///
+pub const init: Int = 2
+
+/// Represents the `Event` variant of the `Action` type.
+///
+pub const event: Int = 4
+
+/// Represents the `Attr` variant of the `Patch` type.
+///
+pub const attrs: Int = 5
diff --git a/src/lustre/internals/patch.gleam b/src/lustre/internals/patch.gleam
new file mode 100644
index 0000000..7a2073a
--- /dev/null
+++ b/src/lustre/internals/patch.gleam
@@ -0,0 +1,374 @@
+// IMPORTS ---------------------------------------------------------------------
+
+import gleam/bool
+import gleam/dict.{type Dict}
+import gleam/dynamic.{type Dynamic}
+import gleam/int
+import gleam/json.{type Json}
+import gleam/list
+import gleam/option.{type Option, None, Some}
+import gleam/set.{type Set}
+import gleam/string
+import lustre/internals/constants
+import lustre/internals/vdom.{
+ type Attribute, type Element, Attribute, Element, Event, Text,
+}
+
+// TYPES -----------------------------------------------------------------------
+
+pub type Patch(msg) {
+ Diff(ElementDiff(msg))
+ Emit(String, Json)
+ Init(List(String), Element(msg))
+}
+
+pub type ElementDiff(msg) {
+ ElementDiff(
+ created: Dict(String, Element(msg)),
+ removed: Set(String),
+ updated: Dict(String, AttributeDiff(msg)),
+ handlers: Dict(String, fn(Dynamic) -> Result(msg, Nil)),
+ )
+}
+
+pub type AttributeDiff(msg) {
+ AttributeDiff(
+ created: Set(Attribute(msg)),
+ removed: Set(String),
+ handlers: Dict(String, fn(Dynamic) -> Result(msg, Nil)),
+ )
+}
+
+// COMPUTING DIFFS -------------------------------------------------------------
+
+pub fn elements(old: Element(msg), new: Element(msg)) -> ElementDiff(msg) {
+ do_elements(
+ ElementDiff(dict.new(), set.new(), dict.new(), dict.new()),
+ Some(old),
+ Some(new),
+ "0",
+ )
+}
+
+fn do_elements(
+ diff: ElementDiff(msg),
+ old: Option(Element(msg)),
+ new: Option(Element(msg)),
+ key: String,
+) -> ElementDiff(msg) {
+ case old, new {
+ None, None -> diff
+ Some(_), None -> ElementDiff(..diff, removed: set.insert(diff.removed, key))
+ None, Some(new) ->
+ ElementDiff(
+ ..diff,
+ created: dict.insert(diff.created, key, new),
+ handlers: fold_event_handlers(diff.handlers, new, key),
+ )
+
+ Some(old), Some(new) -> {
+ case old, new {
+ Text(old), Text(new) if old == new -> diff
+ // We have two text nodes but their text content is not the same. We could
+ // be *really* granular here and compute a diff of the text content itself
+ // but we're not going to gain much from that.
+ Text(_), Text(_) ->
+ ElementDiff(..diff, created: dict.insert(diff.created, key, new))
+
+ // We previously had an element node but now we have a text node. All we
+ // need to do is mark the new one as created and it will replace the old
+ // element during patching.
+ Element(_, _, _, _, _, _), Text(_) ->
+ ElementDiff(..diff, created: dict.insert(diff.created, key, new))
+
+ Text(_), Element(_, _, _, _, _, _) as new ->
+ ElementDiff(
+ ..diff,
+ created: dict.insert(diff.created, key, new),
+ handlers: fold_event_handlers(diff.handlers, new, key),
+ )
+
+ // For two elements to be diffed rather than replaced, it is necessary
+ // for both their namespaces and their tags to be the same. If that is
+ // the case, we can dif their attributes to see what (if anything) has
+ // changed, and then recursively diff their children.
+ Element(old_ns, old_tag, old_attrs, old_children, _, _), Element(
+ new_ns,
+ new_tag,
+ new_attrs,
+ new_children,
+ _,
+ _,
+ ) if old_ns == new_ns && old_tag == new_tag -> {
+ let attribute_diff = attributes(old_attrs, new_attrs)
+ let handlers = {
+ use handlers, name, handler <- dict.fold(
+ attribute_diff.handlers,
+ diff.handlers,
+ )
+
+ let name = string.drop_left(name, 2)
+ dict.insert(handlers, key <> "-" <> name, handler)
+ }
+ let diff =
+ ElementDiff(
+ ..diff,
+ updated: case is_empty_attribute_diff(attribute_diff) {
+ True -> diff.updated
+ False -> dict.insert(diff.updated, key, attribute_diff)
+ },
+ handlers: handlers,
+ )
+
+ // This local `zip` function takes two lists of potentially different
+ // sizes and zips them together, padding the shorter list with `None`.
+ let children = zip(old_children, new_children)
+ use diff, #(old, new), pos <- list.index_fold(children, diff)
+ let key = key <> int.to_string(pos)
+
+ do_elements(diff, old, new, key)
+ }
+
+ // When we have two elements, but their namespaces or their tags differ,
+ // there is nothing to diff. We mark the new element as created and
+ // extract any event handlers.
+ Element(_, _, _, _, _, _), Element(_, _, _, _, _, _) as new ->
+ ElementDiff(
+ ..diff,
+ created: dict.insert(diff.created, key, new),
+ handlers: fold_event_handlers(diff.handlers, new, key),
+ )
+ }
+ }
+ }
+}
+
+pub fn attributes(
+ old: List(Attribute(msg)),
+ new: List(Attribute(msg)),
+) -> AttributeDiff(msg) {
+ let old = attribute_dict(old)
+ let new = attribute_dict(new)
+ let init = AttributeDiff(set.new(), set.new(), dict.new())
+
+ let #(diff, new) = {
+ use #(diff, new), key, attr <- dict.fold(old, #(init, new))
+ let new_attr = dict.get(new, key)
+ let diff = do_attribute(diff, key, Ok(attr), new_attr)
+ let new = dict.delete(new, key)
+
+ #(diff, new)
+ }
+
+ // Once we've diffed all the old attributes, all that's left is any remaining
+ // new attributes to add.
+ use diff, key, attr <- dict.fold(new, diff)
+ do_attribute(diff, key, Error(Nil), Ok(attr))
+}
+
+fn do_attribute(
+ diff: AttributeDiff(msg),
+ key: String,
+ old: Result(Attribute(msg), Nil),
+ new: Result(Attribute(msg), Nil),
+) -> AttributeDiff(msg) {
+ case old, new {
+ Error(_), Error(_) -> diff
+ Ok(old), Ok(Event(name, handler) as new) if old == new ->
+ AttributeDiff(..diff, handlers: dict.insert(diff.handlers, name, handler))
+ Ok(old), Ok(new) if old == new -> diff
+ Ok(_), Error(_) ->
+ AttributeDiff(..diff, removed: set.insert(diff.removed, key))
+
+ // It's not until JSON encoding that these event handlers will be converted
+ // to normal attributes. That's intentional in case we want to do anything
+ // with this diff _besides_ serialise it in the future.
+ _, Ok(Event(name, handler) as new) ->
+ AttributeDiff(
+ ..diff,
+ created: set.insert(diff.created, new),
+ handlers: dict.insert(diff.handlers, name, handler),
+ )
+
+ _, Ok(new) -> AttributeDiff(..diff, created: set.insert(diff.created, new))
+ }
+}
+
+// CONVERSIONS -----------------------------------------------------------------
+
+pub fn patch_to_json(patch: Patch(msg)) -> Json {
+ case patch {
+ Diff(diff) ->
+ json.preprocessed_array([
+ json.int(constants.diff),
+ element_diff_to_json(diff),
+ ])
+ Emit(name, event) ->
+ json.preprocessed_array([
+ json.int(constants.emit),
+ json.string(name),
+ event,
+ ])
+ Init(attrs, element) ->
+ json.preprocessed_array([
+ json.int(constants.init),
+ json.array(attrs, json.string),
+ vdom.element_to_json(element),
+ ])
+ }
+}
+
+pub fn element_diff_to_json(diff: ElementDiff(msg)) -> Json {
+ json.preprocessed_array([
+ json.preprocessed_array({
+ use array, key, element <- dict.fold(diff.created, [])
+ let json =
+ json.preprocessed_array([
+ json.string(key),
+ vdom.element_to_json(element),
+ ])
+
+ [json, ..array]
+ }),
+ json.preprocessed_array({
+ use array, key <- set.fold(diff.removed, [])
+ let json = json.preprocessed_array([json.string(key)])
+
+ [json, ..array]
+ }),
+ json.preprocessed_array({
+ use array, key, diff <- dict.fold(diff.updated, [])
+ use <- bool.guard(is_empty_attribute_diff(diff), array)
+
+ let json =
+ json.preprocessed_array([
+ json.string(key),
+ attribute_diff_to_json(diff, key),
+ ])
+
+ [json, ..array]
+ }),
+ ])
+}
+
+pub fn attribute_diff_to_json(diff: AttributeDiff(msg), key: String) -> Json {
+ json.preprocessed_array([
+ json.preprocessed_array({
+ use array, attr <- set.fold(diff.created, [])
+ case vdom.attribute_to_json(attr, key) {
+ Ok(json) -> [json, ..array]
+ Error(_) -> array
+ }
+ }),
+ json.preprocessed_array({
+ use array, key <- set.fold(diff.removed, [])
+ [json.string(key), ..array]
+ }),
+ ])
+}
+
+// UTILS -----------------------------------------------------------------------
+
+fn zip(xs: List(a), ys: List(a)) -> List(#(Option(a), Option(a))) {
+ case xs, ys {
+ [], [] -> []
+ [x, ..xs], [y, ..ys] -> [#(Some(x), Some(y)), ..zip(xs, ys)]
+ [x, ..xs], [] -> [#(Some(x), None), ..zip(xs, [])]
+ [], [y, ..ys] -> [#(None, Some(y)), ..zip([], ys)]
+ }
+}
+
+// For diffing attributes, it is much easier if we have a `Dict` to work with
+// rather than two lists. This function takes an attribute list and converts it
+// to a dictionary. Repeated attribute keys are *replaced* as the dict is built,
+// with the exception of `class` and `style` attributes which are *merged*.
+//
+// This special merging behaviour is necessary to preserve the runtime semantics
+// of Lustre's client patching.
+fn attribute_dict(
+ attributes: List(Attribute(msg)),
+) -> Dict(String, Attribute(msg)) {
+ use dict, attr <- list.fold(attributes, dict.new())
+
+ case attr {
+ Attribute("class", value, _) ->
+ case dict.get(dict, "class") {
+ Ok(Attribute(_, classes, _)) -> {
+ let classes =
+ dynamic.from(
+ dynamic.unsafe_coerce(classes)
+ <> " "
+ <> dynamic.unsafe_coerce(value),
+ )
+ dict.insert(dict, "class", Attribute("class", classes, False))
+ }
+
+ Ok(_) | Error(_) -> dict.insert(dict, "class", attr)
+ }
+
+ Attribute("style", value, _) ->
+ case dict.get(dict, "style") {
+ Ok(Attribute(_, styles, _)) -> {
+ let styles =
+ dynamic.from(list.append(
+ dynamic.unsafe_coerce(styles),
+ dynamic.unsafe_coerce(value),
+ ))
+ dict.insert(dict, "style", Attribute("style", styles, False))
+ }
+ Ok(_) | Error(_) -> dict.insert(dict, "class", attr)
+ }
+
+ Attribute(key, _, _) -> dict.insert(dict, key, attr)
+ Event(key, _) -> dict.insert(dict, key, attr)
+ }
+}
+
+fn event_handler(
+ attribute: Attribute(msg),
+) -> Result(#(String, fn(Dynamic) -> Result(msg, Nil)), Nil) {
+ case attribute {
+ Attribute(_, _, _) -> Error(Nil)
+ Event(name, handler) -> {
+ let name = string.drop_left(name, 2)
+
+ Ok(#(name, handler))
+ }
+ }
+}
+
+fn fold_event_handlers(
+ handlers: Dict(String, fn(Dynamic) -> Result(msg, Nil)),
+ element: Element(msg),
+ key: String,
+) -> Dict(String, fn(Dynamic) -> Result(msg, Nil)) {
+ case element {
+ Text(_) -> handlers
+ Element(_, _, attrs, children, _, _) -> {
+ let handlers =
+ list.fold(attrs, handlers, fn(handlers, attr) {
+ case event_handler(attr) {
+ Ok(#(name, handler)) -> {
+ let name = string.drop_left(name, 2)
+ dict.insert(handlers, key <> "-" <> name, handler)
+ }
+ Error(_) -> handlers
+ }
+ })
+ use handlers, child, index <- list.index_fold(children, handlers)
+ let key = key <> int.to_string(index)
+
+ fold_event_handlers(handlers, child, key)
+ }
+ }
+}
+
+pub fn is_empty_element_diff(diff: ElementDiff(msg)) -> Bool {
+ diff.created == dict.new()
+ && diff.removed == set.new()
+ && diff.updated == dict.new()
+}
+
+fn is_empty_attribute_diff(diff: AttributeDiff(msg)) -> Bool {
+ diff.created == set.new() && diff.removed == set.new()
+}
diff --git a/src/lustre/internals/vdom.gleam b/src/lustre/internals/vdom.gleam
new file mode 100644
index 0000000..6ba06f5
--- /dev/null
+++ b/src/lustre/internals/vdom.gleam
@@ -0,0 +1,353 @@
+// IMPORTS ---------------------------------------------------------------------
+
+import gleam/dict.{type Dict}
+import gleam/dynamic.{type Dynamic}
+import gleam/int
+import gleam/json.{type Json}
+import gleam/list
+import gleam/string
+import gleam/string_builder.{type StringBuilder}
+
+// TYPES -----------------------------------------------------------------------
+
+pub type Element(msg) {
+ Text(content: String)
+ Element(
+ namespace: String,
+ tag: String,
+ attrs: List(Attribute(msg)),
+ children: List(Element(msg)),
+ self_closing: Bool,
+ void: Bool,
+ )
+}
+
+pub type Attribute(msg) {
+ Attribute(String, Dynamic, as_property: Bool)
+ Event(String, fn(Dynamic) -> Result(msg, Nil))
+}
+
+// QUERIES ---------------------------------------------------------------------
+
+pub fn handlers(
+ element: Element(msg),
+) -> Dict(String, fn(Dynamic) -> Result(msg, Nil)) {
+ do_handlers(element, dict.new(), "0")
+}
+
+fn do_handlers(
+ element: Element(msg),
+ handlers: Dict(String, fn(Dynamic) -> Result(msg, Nil)),
+ key: String,
+) -> Dict(String, fn(Dynamic) -> Result(msg, Nil)) {
+ case element {
+ Text(_) -> handlers
+ Element(_, _, attrs, children, _, _) -> {
+ let handlers =
+ list.fold(attrs, handlers, fn(handlers, attr) {
+ case attribute_to_event_handler(attr) {
+ Ok(#(name, handler)) ->
+ dict.insert(handlers, key <> "-" <> name, handler)
+ Error(_) -> handlers
+ }
+ })
+
+ use handlers, child, index <- list.index_fold(children, handlers)
+ let key = key <> int.to_string(index)
+ do_handlers(child, handlers, key)
+ }
+ }
+}
+
+// CONVERSIONS: JSON -----------------------------------------------------------
+
+pub fn element_to_json(element: Element(msg)) -> Json {
+ do_element_to_json(element, "0")
+}
+
+fn do_element_to_json(element: Element(msg), key: String) -> Json {
+ case element {
+ Text(content) -> json.object([#("content", json.string(content))])
+
+ Element(namespace, tag, attrs, children, self_closing, void) -> {
+ let attrs =
+ json.preprocessed_array({
+ attrs
+ |> list.prepend(Attribute("data-lustre-key", dynamic.from(key), False))
+ |> list.filter_map(attribute_to_json(_, key))
+ })
+ let children =
+ json.preprocessed_array({
+ use child, index <- list.index_map(children)
+ let key = key <> int.to_string(index)
+ do_element_to_json(child, key)
+ })
+
+ json.object([
+ #("namespace", json.string(namespace)),
+ #("tag", json.string(tag)),
+ #("attrs", attrs),
+ #("children", children),
+ #("self_closing", json.bool(self_closing)),
+ #("void", json.bool(void)),
+ ])
+ }
+ }
+}
+
+pub fn attribute_to_json(
+ attribute: Attribute(msg),
+ key: String,
+) -> Result(Json, Nil) {
+ case attribute {
+ Attribute(_, _, True) -> Error(Nil)
+ Attribute(name, value, as_property: False) -> {
+ case dynamic.classify(value) {
+ "String" ->
+ Ok(
+ json.object([
+ #("0", json.string(name)),
+ #("1", json.string(dynamic.unsafe_coerce(value))),
+ ]),
+ )
+
+ "Boolean" ->
+ Ok(
+ json.object([
+ #("0", json.string(name)),
+ #("1", json.bool(dynamic.unsafe_coerce(value))),
+ ]),
+ )
+
+ "Int" ->
+ Ok(
+ json.object([
+ #("0", json.string(name)),
+ #("1", json.int(dynamic.unsafe_coerce(value))),
+ ]),
+ )
+
+ "Float" ->
+ Ok(
+ json.object([
+ #("0", json.string(name)),
+ #("1", json.float(dynamic.unsafe_coerce(value))),
+ ]),
+ )
+
+ _ -> Error(Nil)
+ }
+ }
+
+ Event(name, _) -> {
+ let name = string.drop_left(name, 2)
+
+ Ok(
+ json.object([
+ #("0", json.string("data-lustre-on-" <> name)),
+ #("1", json.string(key <> "-" <> name)),
+ ]),
+ )
+ }
+ }
+}
+
+// CONVERSIONS: STRING ---------------------------------------------------------
+
+pub fn element_to_string(element: Element(msg)) -> String {
+ element
+ |> do_element_to_string_builder(False)
+ |> string_builder.to_string
+}
+
+pub fn element_to_string_builder(element: Element(msg)) -> StringBuilder {
+ do_element_to_string_builder(element, False)
+}
+
+fn do_element_to_string_builder(
+ element: Element(msg),
+ raw_text: Bool,
+) -> StringBuilder {
+ case element {
+ Text("") -> string_builder.new()
+ Text(content) if raw_text -> string_builder.from_string(content)
+ Text(content) -> string_builder.from_string(escape("", content))
+
+ Element(namespace, tag, attrs, _, self_closing, _) if self_closing -> {
+ let html = string_builder.from_string("<" <> tag)
+ let #(attrs, _) =
+ attributes_to_string_builder(case namespace {
+ "" -> attrs
+ _ -> [Attribute("xmlns", dynamic.from(namespace), False), ..attrs]
+ })
+
+ html
+ |> string_builder.append_builder(attrs)
+ |> string_builder.append("/>")
+ }
+
+ Element(namespace, tag, attrs, _, _, void) if void -> {
+ let html = string_builder.from_string("<" <> tag)
+ let #(attrs, _) =
+ attributes_to_string_builder(case namespace {
+ "" -> attrs
+ _ -> [Attribute("xmlns", dynamic.from(namespace), False), ..attrs]
+ })
+
+ html
+ |> string_builder.append_builder(attrs)
+ |> string_builder.append(">")
+ }
+
+ // Style and script tags are special beacuse they need to contain unescape
+ // text content and not escaped HTML content.
+ Element("", "style" as tag, attrs, children, False, False)
+ | Element("", "script" as tag, attrs, children, False, False) -> {
+ let html = string_builder.from_string("<" <> tag)
+ let #(attrs, _) = attributes_to_string_builder(attrs)
+
+ html
+ |> string_builder.append_builder(attrs)
+ |> string_builder.append(">")
+ |> children_to_string_builder(children, True)
+ |> string_builder.append("</" <> tag <> ">")
+ }
+
+ Element(namespace, tag, attrs, children, _, _) -> {
+ let html = string_builder.from_string("<" <> tag)
+ let #(attrs, inner_html) =
+ attributes_to_string_builder(case namespace {
+ "" -> attrs
+ _ -> [Attribute("xmlns", dynamic.from(namespace), False), ..attrs]
+ })
+
+ case inner_html {
+ "" ->
+ html
+ |> string_builder.append_builder(attrs)
+ |> string_builder.append(">")
+ |> children_to_string_builder(children, raw_text)
+ |> string_builder.append("</" <> tag <> ">")
+ _ ->
+ html
+ |> string_builder.append_builder(attrs)
+ |> string_builder.append(">" <> inner_html <> "</" <> tag <> ">")
+ }
+ }
+ }
+}
+
+fn children_to_string_builder(
+ html: StringBuilder,
+ children: List(Element(msg)),
+ raw_text: Bool,
+) -> StringBuilder {
+ use html, child <- list.fold(children, html)
+
+ child
+ |> do_element_to_string_builder(raw_text)
+ |> string_builder.append_builder(html, _)
+}
+
+fn attributes_to_string_builder(
+ attrs: List(Attribute(msg)),
+) -> #(StringBuilder, String) {
+ let #(html, class, style, inner_html) = {
+ let init = #(string_builder.new(), "", "", "")
+ use #(html, class, style, inner_html), attr <- list.fold(attrs, init)
+
+ case attribute_to_string_parts(attr) {
+ Ok(#("dangerous-unescaped-html", val)) -> #(
+ html,
+ class,
+ style,
+ inner_html
+ <> val,
+ )
+ Ok(#("class", val)) if class == "" -> #(html, val, style, inner_html)
+ Ok(#("class", val)) -> #(html, class <> " " <> val, style, inner_html)
+ Ok(#("style", val)) if style == "" -> #(html, class, val, inner_html)
+ Ok(#("style", val)) -> #(html, class, style <> " " <> val, inner_html)
+ Ok(#(key, val)) -> #(
+ string_builder.append(html, " " <> key <> "=\"" <> val <> "\""),
+ class,
+ style,
+ inner_html,
+ )
+ Error(_) -> #(html, class, style, inner_html)
+ }
+ }
+
+ #(
+ case class, style {
+ "", "" -> html
+ _, "" -> string_builder.append(html, " class=\"" <> class <> "\"")
+ "", _ -> string_builder.append(html, " style=\"" <> style <> "\"")
+ _, _ ->
+ string_builder.append(
+ html,
+ " class=\"" <> class <> "\" style=\"" <> style <> "\"",
+ )
+ },
+ inner_html,
+ )
+}
+
+// UTILS -----------------------------------------------------------------------
+
+fn escape(escaped: String, content: String) -> String {
+ case content {
+ "<" <> rest -> escape(escaped <> "&lt;", rest)
+ ">" <> rest -> escape(escaped <> "&gt;", rest)
+ "&" <> rest -> escape(escaped <> "&amp;", rest)
+ "\"" <> rest -> escape(escaped <> "&quot;", rest)
+ "'" <> rest -> escape(escaped <> "&#39;", rest)
+ _ ->
+ case string.pop_grapheme(content) {
+ Ok(#(x, xs)) -> escape(escaped <> x, xs)
+ Error(_) -> escaped
+ }
+ }
+}
+
+fn attribute_to_string_parts(
+ attr: Attribute(msg),
+) -> Result(#(String, String), Nil) {
+ case attr {
+ Attribute("", _, _) -> Error(Nil)
+ Attribute("dangerous-unescaped-html", _, _) -> Error(Nil)
+ Attribute(name, value, as_property) -> {
+ case dynamic.classify(value) {
+ "String" -> Ok(#(name, dynamic.unsafe_coerce(value)))
+
+ // Boolean attributes are determined based on their presence, eg we don't
+ // want to render `disabled="false"` if the value is `false` we simply
+ // want to omit the attribute altogether.
+ "Boolean" ->
+ case dynamic.unsafe_coerce(value) {
+ True -> Ok(#(name, name))
+ False -> Error(Nil)
+ }
+
+ // For everything else, we care whether or not the attribute is actually
+ // a property. Properties are *Javascript* values that aren't necessarily
+ // reflected in the DOM.
+ _ if as_property -> Error(Nil)
+ _ -> Ok(#(name, string.inspect(value)))
+ }
+ }
+ _ -> Error(Nil)
+ }
+}
+
+pub fn attribute_to_event_handler(
+ attribute: Attribute(msg),
+) -> Result(#(String, fn(Dynamic) -> Result(msg, Nil)), Nil) {
+ case attribute {
+ Attribute(_, _, _) -> Error(Nil)
+ Event(name, handler) -> {
+ let name = string.drop_left(name, 2)
+ Ok(#(name, handler))
+ }
+ }
+}
diff --git a/src/lustre/runtime.gleam b/src/lustre/runtime.gleam
new file mode 100644
index 0000000..71e9205
--- /dev/null
+++ b/src/lustre/runtime.gleam
@@ -0,0 +1,244 @@
+// IMPORTS ---------------------------------------------------------------------
+
+import gleam/dict.{type Dict}
+import gleam/dynamic.{type Decoder, type Dynamic}
+import gleam/erlang/process.{type Selector, type Subject}
+import gleam/function.{identity}
+import gleam/list
+import gleam/json.{type Json}
+import gleam/option.{Some}
+import gleam/otp/actor.{type Next, type StartError, Spec}
+import gleam/result
+import lustre/effect.{type Effect}
+import lustre/element.{type Element, type Patch}
+import lustre/internals/patch.{Diff, Init}
+import lustre/internals/vdom
+
+// TYPES -----------------------------------------------------------------------
+
+///
+///
+type State(runtime, model, msg) {
+ State(
+ self: Subject(Action(runtime, msg)),
+ model: model,
+ update: fn(model, msg) -> #(model, Effect(msg)),
+ view: fn(model) -> Element(msg),
+ html: Element(msg),
+ renderers: Dict(Dynamic, fn(Patch(msg)) -> Nil),
+ handlers: Dict(String, fn(Dynamic) -> Result(msg, Nil)),
+ on_attribute_change: Dict(String, Decoder(msg)),
+ )
+}
+
+///
+///
+pub type Action(runtime, msg) {
+ AddRenderer(Dynamic, fn(Patch(msg)) -> Nil)
+ Attrs(List(#(String, Dynamic)))
+ Batch(List(msg), Effect(msg))
+ Dispatch(msg)
+ Emit(String, Json)
+ Event(String, Dynamic)
+ RemoveRenderer(Dynamic)
+ SetSelector(Selector(Action(runtime, msg)))
+ Shutdown
+}
+
+// ACTOR -----------------------------------------------------------------------
+
+@target(erlang)
+///
+///
+pub fn start(
+ init: #(model, Effect(msg)),
+ update: fn(model, msg) -> #(model, Effect(msg)),
+ view: fn(model) -> Element(msg),
+ on_attribute_change: Dict(String, Decoder(msg)),
+) -> Result(Subject(Action(runtime, msg)), StartError) {
+ let timeout = 1000
+ let init = fn() {
+ let self = process.new_subject()
+ let html = view(init.0)
+ let handlers = vdom.handlers(html)
+ let state =
+ State(
+ self,
+ init.0,
+ update,
+ view,
+ html,
+ dict.new(),
+ handlers,
+ on_attribute_change,
+ )
+ let selector = process.selecting(process.new_selector(), self, identity)
+
+ run_effects(init.1, self)
+ actor.Ready(state, selector)
+ }
+
+ actor.start_spec(Spec(init, timeout, loop))
+}
+
+@target(erlang)
+fn loop(
+ message: Action(runtime, msg),
+ state: State(runtime, model, msg),
+) -> Next(Action(runtime, msg), State(runtime, model, msg)) {
+ case message {
+ Attrs(attrs) -> {
+ list.filter_map(attrs, fn(attr) {
+ case dict.get(state.on_attribute_change, attr.0) {
+ Error(_) -> Error(Nil)
+ Ok(decoder) ->
+ decoder(attr.1)
+ |> result.replace_error(Nil)
+ }
+ })
+ |> Batch(effect.none())
+ |> loop(state)
+ }
+
+ AddRenderer(id, renderer) -> {
+ let renderers = dict.insert(state.renderers, id, renderer)
+ let next = State(..state, renderers: renderers)
+
+ renderer(Init(dict.keys(state.on_attribute_change), state.html))
+ actor.continue(next)
+ }
+
+ Batch([], _) -> actor.continue(state)
+ Batch([msg], other_effects) -> {
+ let #(model, effects) = state.update(state.model, msg)
+ let html = state.view(model)
+ let diff = patch.elements(state.html, html)
+ let next =
+ State(..state, model: model, html: html, handlers: diff.handlers)
+
+ run_effects(effect.batch([effects, other_effects]), state.self)
+
+ case patch.is_empty_element_diff(diff) {
+ True -> Nil
+ False -> run_renderers(state.renderers, Diff(diff))
+ }
+
+ actor.continue(next)
+ }
+ Batch([msg, ..rest], other_effects) -> {
+ let #(model, effects) = state.update(state.model, msg)
+ let html = state.view(model)
+ let diff = patch.elements(state.html, html)
+ let next =
+ State(..state, model: model, html: html, handlers: diff.handlers)
+
+ loop(Batch(rest, effect.batch([effects, other_effects])), next)
+ }
+
+ Dispatch(msg) -> {
+ let #(model, effects) = state.update(state.model, msg)
+ let html = state.view(model)
+ let diff = patch.elements(state.html, html)
+ let next =
+ State(..state, model: model, html: html, handlers: diff.handlers)
+
+ run_effects(effects, state.self)
+
+ case patch.is_empty_element_diff(diff) {
+ True -> Nil
+ False -> run_renderers(state.renderers, Diff(diff))
+ }
+
+ actor.continue(next)
+ }
+
+ Emit(name, event) -> {
+ let patch = patch.Emit(name, event)
+
+ run_renderers(state.renderers, patch)
+ actor.continue(state)
+ }
+
+ Event(name, event) -> {
+ case dict.get(state.handlers, name) {
+ Error(_) -> actor.continue(state)
+ Ok(handler) -> {
+ handler(event)
+ |> result.map(Dispatch)
+ |> result.map(actor.send(state.self, _))
+ |> result.unwrap(Nil)
+
+ actor.continue(state)
+ }
+ }
+ }
+
+ RemoveRenderer(id) -> {
+ let renderers = dict.delete(state.renderers, id)
+ let next = State(..state, renderers: renderers)
+
+ actor.continue(next)
+ }
+
+ SetSelector(selector) -> actor.Continue(state, Some(selector))
+ Shutdown -> actor.Stop(process.Killed)
+ }
+}
+
+// UTILS -----------------------------------------------------------------------
+
+@target(erlang)
+fn run_renderers(
+ renderers: Dict(any, fn(Patch(msg)) -> Nil),
+ patch: Patch(msg),
+) -> Nil {
+ use _, _, renderer <- dict.fold(renderers, Nil)
+ renderer(patch)
+}
+
+@target(erlang)
+fn run_effects(effects: Effect(msg), self: Subject(Action(runtime, msg))) -> Nil {
+ let dispatch = fn(msg) { actor.send(self, Dispatch(msg)) }
+ let emit = fn(name, event) { actor.send(self, Emit(name, event)) }
+
+ effect.perform(effects, dispatch, emit)
+}
+
+// Empty implementations of every function in this module are required because we
+// need to be able to build the codebase *locally* with the JavaScript target to
+// bundle the server component runtime.
+//
+// For *consumers* of Lustre this is not a problem, Gleam will see this module is
+// never included in any path reachable from JavaScript but when we're *inside the
+// package* Gleam has no idea that is the case.
+
+@target(javascript)
+pub fn start(
+ init: #(model, Effect(msg)),
+ update: fn(model, msg) -> #(model, Effect(msg)),
+ view: fn(model) -> Element(msg),
+ on_attribute_change: Dict(String, Decoder(msg)),
+) -> Result(Subject(Action(runtime, msg)), StartError) {
+ panic
+}
+
+@target(javascript)
+fn loop(
+ message: Action(runtime, msg),
+ state: State(runtime, model, msg),
+) -> Next(Action(runtime, msg), State(runtime, model, msg)) {
+ panic
+}
+
+@target(javascript)
+fn run_renderers(
+ renderers: Dict(any, fn(Patch(msg)) -> Nil),
+ patch: Patch(msg),
+) -> Nil {
+ panic
+}
+
+@target(javascript)
+fn run_effects(effects: Effect(msg), self: Subject(Action(runtime, msg))) -> Nil {
+ panic
+}
diff --git a/src/lustre/server.gleam b/src/lustre/server.gleam
new file mode 100644
index 0000000..5e20fd6
--- /dev/null
+++ b/src/lustre/server.gleam
@@ -0,0 +1,183 @@
+// IMPORTS ---------------------------------------------------------------------
+
+import gleam/bool
+import gleam/dynamic.{type DecodeError, type Dynamic, DecodeError}
+import gleam/erlang/process.{type Selector}
+import gleam/int
+import gleam/json.{type Json}
+import gleam/result
+import lustre/attribute.{type Attribute, attribute}
+import lustre/effect.{type Effect}
+import lustre/element.{type Element, element}
+import lustre/internals/constants
+import lustre/runtime.{type Action, Attrs, Event, SetSelector}
+
+// ELEMENTS --------------------------------------------------------------------
+
+/// A simple wrapper to render a `<lustre-server-component>` element.
+///
+pub fn component(attrs: List(Attribute(msg))) -> Element(msg) {
+ element("lustre-server-component", attrs, [])
+}
+
+// ATTRIBUTES ------------------------------------------------------------------
+
+/// The `route` attribute should always be included on a [`component`](#component)
+/// to tell the client runtime what path to initiate the WebSocket connection on.
+///
+///
+///
+pub fn route(path: String) -> Attribute(msg) {
+ attribute("route", path)
+}
+
+/// Ocassionally you may want to attach custom data to an event sent to the server.
+/// This could be used to include a hash of the current build to detect if the
+/// event was sent from a stale client.
+///
+/// ```gleam
+///
+/// ```
+///
+pub fn data(json: Json) -> Attribute(msg) {
+ json
+ |> json.to_string
+ |> attribute("data-lustre-data", _)
+}
+
+/// Properties of the JavaScript event object are typically not serialisable.
+/// This means if we want to pass them to the server we need to copy them into
+/// a new object first.
+///
+/// This attribute tells Lustre what properties to include. Properties can come
+/// from nested objects by using dot notation. For example, you could include the
+/// `id` of the target `element` by passing `["target.id"]`.
+///
+/// ```gleam
+/// import gleam/dynamic
+/// import gleam/result.{try}
+/// import lustre/element.{type Element}
+/// import lustre/element/html
+/// import lustre/event
+/// import lustre/server
+///
+/// pub fn custom_button(on_click: fn(String) -> msg) -> Element(msg) {
+/// let handler = fn(event) {
+/// use target <- try(dynamic.field("target", dynamic.dynamic)(event))
+/// use id <- try(dynamic.field("id", dynamic.string)(target))
+///
+/// Ok(on_click(id))
+/// }
+///
+/// html.button([event.on_click(handler), server.include(["target.id"])], [
+/// element.text("Click me!")
+/// ])
+/// }
+/// ```
+///
+pub fn include(properties: List(String)) -> Attribute(msg) {
+ properties
+ |> json.array(json.string)
+ |> json.to_string
+ |> attribute("data-lustre-include", _)
+}
+
+// EFFECTS ---------------------------------------------------------------------
+
+///
+///
+pub fn emit(event: String, data: Json) -> Effect(msg) {
+ effect.event(event, data)
+}
+
+@target(erlang)
+///
+///
+pub fn selector(sel: Selector(Action(runtime, msg))) -> Effect(msg) {
+ use _ <- effect.from
+ let self = process.new_subject()
+
+ process.send(self, SetSelector(sel))
+}
+
+// DECODERS --------------------------------------------------------------------
+
+pub fn decode_action(
+ dyn: Dynamic,
+) -> Result(Action(runtime, msg), List(DecodeError)) {
+ dynamic.any([decode_event, decode_attrs])(dyn)
+}
+
+///
+///
+fn decode_event(
+ dyn: Dynamic,
+) -> Result(Action(runtime, msg), List(DecodeError)) {
+ use #(kind, name, data) <- result.try(dynamic.tuple3(
+ dynamic.int,
+ dynamic.dynamic,
+ dynamic.dynamic,
+ )(dyn))
+ use <- bool.guard(
+ kind != constants.event,
+ Error([
+ DecodeError(
+ path: ["0"],
+ found: int.to_string(kind),
+ expected: int.to_string(constants.event),
+ ),
+ ]),
+ )
+ use name <- result.try(dynamic.string(name))
+
+ Ok(Event(name, data))
+}
+
+fn decode_attrs(
+ dyn: Dynamic,
+) -> Result(Action(runtime, msg), List(DecodeError)) {
+ use list <- result.try(dynamic.list(dynamic.dynamic)(dyn))
+ case list {
+ [kind, attrs] -> {
+ use kind <- result.try(dynamic.int(kind))
+ use <- bool.guard(
+ kind != constants.attrs,
+ Error([
+ DecodeError(
+ path: ["0"],
+ found: int.to_string(kind),
+ expected: int.to_string(constants.attrs),
+ ),
+ ]),
+ )
+ use attrs <- result.try(dynamic.list(decode_attr)(attrs))
+ Ok(Attrs(attrs))
+ }
+ _ ->
+ Error([
+ DecodeError(
+ path: [],
+ found: dynamic.classify(dyn),
+ expected: "a tuple of 2 elements",
+ ),
+ ])
+ }
+}
+
+fn decode_attr(dyn: Dynamic) -> Result(#(String, Dynamic), List(DecodeError)) {
+ use list <- result.try(dynamic.list(dynamic.dynamic)(dyn))
+ case list {
+ [key, value] -> {
+ use key <- result.try(dynamic.string(key))
+ Ok(#(key, value))
+ }
+ _ ->
+ Error([
+ DecodeError(
+ path: [],
+ found: dynamic.classify(dyn),
+ expected: "a tuple of 2 elements",
+ ),
+ ])
+ }
+}
diff --git a/src/runtime.ffi.mjs b/src/runtime.ffi.mjs
index c3cec68..f7b711b 100644
--- a/src/runtime.ffi.mjs
+++ b/src/runtime.ffi.mjs
@@ -227,6 +227,32 @@ function morphElement(prev, curr, dispatch, parent) {
function morphAttr(el, name, value, dispatch) {
switch (typeof value) {
+ case name.startsWith("data-lustre-on-") && "string": {
+ if (!value) {
+ el.removeAttribute(name);
+ el.removeEventListener(event, el.$lustre[`${name}Handler`]);
+
+ break;
+ }
+ if (el.hasAttribute(name)) break;
+
+ const event = name.slice(15).toLowerCase();
+ const handler = (e) => dispatch(serverEventHandler(e));
+
+ if (el.$lustre[`${name}Handler`]) {
+ el.removeEventListener(event, el.$lustre[`${name}Handler`]);
+ }
+
+ el.addEventListener(event, handler);
+
+ el.$lustre[name] = value;
+ el.$lustre[`${name}Handler`] = handler;
+ el.$lustre.__registered_events.add(name);
+ el.setAttribute(name, value);
+
+ break;
+ }
+
case "string":
if (el.getAttribute(name) !== value) el.setAttribute(name, value);
if (value === "") el.removeAttribute(name);
@@ -281,3 +307,38 @@ function morphText(prev, curr) {
return prev;
}
+
+// UTILS -----------------------------------------------------------------------
+
+function serverEventHandler(event) {
+ const el = event.target;
+ const tag = el.getAttribute(`data-lustre-on-${event.type}`);
+ const data = JSON.parse(el.getAttribute("data-lustre-data") || "{}");
+ const include = JSON.parse(el.getAttribute("data-lustre-include") || "[]");
+
+ switch (event.type) {
+ case "input":
+ case "change":
+ include.push("target.value");
+ break;
+ }
+
+ return {
+ tag,
+ data: include.reduce((data, property) => {
+ const path = property.split(".");
+
+ for (let i = 0, o = data, e = event; i < path.length; i++) {
+ if (i === path.length - 1) {
+ o[path[i]] = e[path[i]];
+ } else {
+ o[path[i]] ??= {};
+ e = e[path[i]];
+ o = o[path[i]];
+ }
+ }
+
+ return data;
+ }, data),
+ };
+}
diff --git a/src/server-component.mjs b/src/server-component.mjs
new file mode 100644
index 0000000..b7aec2c
--- /dev/null
+++ b/src/server-component.mjs
@@ -0,0 +1,155 @@
+// Note that this path is relative to the built Gleam project, not the source files
+// in `src/`. This particular module is not used by the Lustre package itself, but
+// is instead bundled and made available to package users in the `priv/` directory.
+//
+// It makes obvious sense to co-locate the source with the rest of the package
+// source code, but if we use relative imports here the bundle will fail because
+// `vdom.ffi.mjs` is importing things from the Gleam standard library and expects
+// to be placed in the `build/dev/javascript/lustre/` directory.
+//
+import * as Constants from "../build/dev/javascript/lustre/lustre/internals/constants.mjs";
+import { patch, morph } from "../build/dev/javascript/lustre/vdom.ffi.mjs";
+
+export class LustreServerComponent extends HTMLElement {
+ static get observedAttributes() {
+ return ["route"];
+ }
+
+ #observer = null;
+ #root = null;
+ #socket = null;
+
+ constructor() {
+ super();
+
+ this.#observer = new MutationObserver((mutations) => {
+ const changed = [];
+
+ for (const mutation of mutations) {
+ if (mutation.type === "attributes") {
+ const { attributeName: name, oldValue: prev } = mutation;
+ const next = this.getAttribute(name);
+
+ if (prev !== next) {
+ try {
+ changed.push([name, JSON.parse(next)]);
+ } catch {
+ changed.push([name, next]);
+ }
+ }
+ }
+ }
+
+ if (changed.length) {
+ this.#socket?.send(JSON.stringify([Constants.attrs, changed]));
+ }
+ });
+ }
+
+ connectedCallback() {
+ this.#root = document.createElement("div");
+ this.appendChild(this.#root);
+ }
+
+ attributeChangedCallback(name, prev, next) {
+ switch (name) {
+ case "route": {
+ if (!next) {
+ this.#socket?.close();
+ this.#socket = null;
+ } else if (prev !== next) {
+ const id = this.getAttribute("id");
+ const route = next + (id ? `?id=${id}` : "");
+
+ this.#socket?.close();
+ this.#socket = new WebSocket(`ws://${window.location.host}${route}`);
+ this.#socket.addEventListener("message", ({ data }) => {
+ const [kind, ...payload] = JSON.parse(data);
+
+ switch (kind) {
+ case Constants.diff:
+ return this.diff(payload);
+
+ case Constants.emit:
+ return this.emit(payload);
+
+ case Constants.init:
+ return this.init(payload);
+ }
+ });
+ }
+ }
+ }
+ }
+
+ init([attrs, vdom]) {
+ const initial = [];
+
+ for (const attr of attrs) {
+ if (attr in this) {
+ initial.push([attr, this[attr]]);
+ } else if (this.hasAttribute(attr)) {
+ initial.push([attr, this.getAttribute(attr)]);
+ }
+
+ Object.defineProperty(this, attr, {
+ get() {
+ return this[`_${attr}`] ?? this.getAttribute(attr);
+ },
+ set(value) {
+ const prev = this[attr];
+
+ if (typeof value === "string") {
+ this.setAttribute(attr, value);
+ } else {
+ this[`_${attr}`] = value;
+ }
+
+ if (prev !== value) {
+ this.#socket?.send(
+ JSON.stringify([Constants.attrs, [[attr, value]]])
+ );
+ }
+ },
+ });
+ }
+
+ this.#observer.observe(this, {
+ attributeFilter: attrs,
+ attributeOldValue: true,
+ attributes: true,
+ characterData: false,
+ characterDataOldValue: false,
+ childList: false,
+ subtree: false,
+ });
+
+ this.morph(vdom);
+
+ if (initial.length) {
+ this.#socket?.send(JSON.stringify([Constants.attrs, initial]));
+ }
+ }
+
+ morph(vdom) {
+ this.#root = morph(this.#root, vdom, (msg) => {
+ this.#socket?.send(JSON.stringify([Constants.event, msg.tag, msg.data]));
+ });
+ }
+
+ diff([diff]) {
+ this.#root = patch(this.#root, diff, (msg) => {
+ this.#socket?.send(JSON.stringify([Constants.event, msg.tag, msg.data]));
+ });
+ }
+
+ emit([event, data]) {
+ this.dispatchEvent(new CustomEvent(event, { detail: data }));
+ }
+
+ disconnectedCallback() {
+ this.#socket?.close();
+ }
+}
+
+window.customElements.define("lustre-server-component", LustreServerComponent);
diff --git a/src/server-runtime.ffi.mjs b/src/server-runtime.ffi.mjs
new file mode 100644
index 0000000..e82e82d
--- /dev/null
+++ b/src/server-runtime.ffi.mjs
@@ -0,0 +1,143 @@
+import { Ok, isEqual } from "./gleam.mjs";
+import {
+ AddRenderer,
+ Dispatch,
+ Event,
+ RemoveRenderer,
+ Shutdown,
+} from "./lustre/runtime.mjs";
+
+export class LustreServerApplication {
+ #queue = [];
+ #effects = [];
+ #didUpdate = false;
+
+ #vdom = null;
+ #handlers = new Map();
+ #renderers = new Set();
+
+ #model = null;
+ #update = null;
+ #view = null;
+
+ static start(flags, init, update, view) {
+ const app = new LustreServerApplication(init(flags), update, view, root);
+
+ return new Ok((msg) => app.send(msg));
+ }
+
+ // PUBLIC METHODS ------------------------------------------------------------
+
+ constructor([model, effects], update, view) {
+ this.#model = model;
+ this.#update = update;
+ this.#view = view;
+ this.#vdom = this.#view(this.#model);
+ this.#effects = effects.all.toArray();
+ this.#didUpdate = true;
+
+ globalThis.queueMicrotask(() => this.#tick());
+ }
+
+ send(action) {
+ switch (true) {
+ case action instanceof AddRenderer: {
+ this.#renderers.add(action[0]);
+ return;
+ }
+
+ case action instanceof Dispatch: {
+ this.#queue.push(action[0]);
+ this.#tick();
+
+ return;
+ }
+
+ case action instanceof Event: {
+ const [event, data] = action;
+
+ if (this.#handlers.has(event)) {
+ const msg = this.#handlers.get(event)(data);
+
+ if (msg.isOk()) {
+ this.#queue.push(msg[0]);
+ this.#tick();
+ }
+ }
+ }
+
+ case action instanceof RemoveRenderer: {
+ this.#renderers.delete(action[0]);
+ return;
+ }
+
+ case action instanceof Shutdown: {
+ this.#shutdown();
+ return;
+ }
+
+ default:
+ return;
+ }
+ }
+
+ // PRIVATE METHODS -----------------------------------------------------------
+
+ #tick() {
+ this.#flush_queue();
+
+ if (this.#didUpdate) {
+ this.#vdom = this.#view(this.#model);
+
+ for (const renderer of this.#renderers) {
+ renderer.render(this.#vdom);
+ }
+ }
+ }
+
+ #flush_queue(iterations = 0) {
+ while (this.#queue.length) {
+ const [next, effects] = this.#update(this.#model, this.#queue.shift());
+
+ this.#model = next;
+ this.#didUpdate ||= !isEqual(this.#model, next);
+ this.#effects = this.#effects.concat(effects.all.toArray());
+ }
+
+ while (this.#effects.length) {
+ this.#effects.shift()(
+ (msg) => this.send(new Dispatch(msg)),
+ (event, data) => this.emit(event, data)
+ );
+ }
+
+ if (this.#queue.length) {
+ if (iterations < 5) {
+ this.#flush_queue(++iterations);
+ } else {
+ window.requestAnimationFrame(() => this.#tick());
+ }
+ }
+ }
+
+ #shutdown() {
+ this.#model = null;
+ this.#queue = [];
+ this.#effects = [];
+ this.#didUpdate = false;
+ this.#update = () => {};
+ this.#view = () => {};
+ this.#vdom = null;
+ this.#handlers = new Map();
+ this.#renderers = new Set();
+ }
+}
+
+export const start = (app, selector, flags) =>
+ LustreClientApplication.start(
+ flags,
+ selector,
+ app.init,
+ app.update,
+ app.view
+ );
diff --git a/src/vdom.ffi.mjs b/src/vdom.ffi.mjs
new file mode 100644
index 0000000..ec5a226
--- /dev/null
+++ b/src/vdom.ffi.mjs
@@ -0,0 +1,407 @@
+import { Empty } from "./gleam.mjs";
+import { map as result_map } from "../gleam_stdlib/gleam/result.mjs";
+
+export function morph(prev, curr, dispatch, parent) {
+ // The current node is an `Element` and the previous DOM node is also a DOM
+ // element.
+ if (curr?.tag && prev?.nodeType === 1) {
+ const nodeName = curr.tag.toUpperCase();
+ const ns = curr.namespace || "http://www.w3.org/1999/xhtml";
+
+ // If the current node and the existing DOM node have the same tag and
+ // namespace, we can morph them together: keeping the DOM node intact and just
+ // updating its attributes and children.
+ if (prev.nodeName === nodeName && prev.namespaceURI == ns) {
+ return morphElement(prev, curr, dispatch, parent);
+ }
+ // Otherwise, we need to replace the DOM node with a new one. The `createElement`
+ // function will handle replacing the existing DOM node for us.
+ else {
+ return createElement(prev, curr, dispatch, parent);
+ }
+ }
+
+ // The current node is an `Element` but the previous DOM node either did not
+ // exist or it is not a DOM element (eg it might be a text or comment node).
+ if (curr?.tag) {
+ return createElement(prev, curr, dispatch, parent);
+ }
+
+ // The current node is a `Text`.
+ if (typeof curr?.content === "string") {
+ return prev?.nodeType === 3
+ ? morphText(prev, curr)
+ : createText(prev, curr);
+ }
+
+ // If someone was naughty and tried to pass in something other than a Lustre
+ // element (or if there is an actual bug with the runtime!) we'll render a
+ // comment and ask them to report the issue.
+ return document.createComment(
+ [
+ "[internal lustre error] I couldn't work out how to render this element. This",
+ "function should only be called internally by lustre's runtime: if you think",
+ "this is an error, please open an issue at",
+ "https://github.com/hayleigh-dot-dev/gleam-lustre/issues/new",
+ ].join(" ")
+ );
+}
+
+export function patch(root, diff, dispatch) {
+ for (const created of diff[0]) {
+ const key = created[0];
+
+ if (key === "0") {
+ morph(root, created[1], dispatch, root.parentNode);
+ } else {
+ const segments = Array.from(key);
+ const parentKey = segments.slice(0, -1).join("");
+ const indexKey = segments.slice(-1)[0];
+ const prev =
+ root.querySelector(`[data-lustre-key="${key}"]`) ??
+ root.querySelector(`[data-lustre-key="${parentKey}"]`).childNodes[
+ indexKey
+ ];
+
+ morph(prev, created[1], dispatch, prev.parentNode);
+ }
+ }
+
+ for (const removed of diff[1]) {
+ const key = removed[0];
+ const segments = Array.from(key);
+ const parentKey = segments.slice(0, -1).join("");
+ const indexKey = segments.slice(-1)[0];
+ const prev =
+ root.querySelector(`[data-lustre-key="${key}"]`) ??
+ root.querySelector(`[data-lustre-key="${parentKey}"]`).childNodes[
+ indexKey
+ ];
+
+ prev.remove();
+ }
+
+ for (const updated of diff[2]) {
+ const key = updated[0];
+ const prev =
+ key === "0" ? root : root.querySelector(`[data-lustre-key="${key}"]`);
+
+ prev.$lustre ??= { __registered_events: new Set() };
+
+ for (const created of updated[0]) {
+ morphAttr(prev, created.name, created.value, dispatch);
+ }
+
+ for (const removed of updated[1]) {
+ if (prev.$lustre.__registered_events.has(removed)) {
+ const event = removed.slice(2).toLowerCase();
+
+ prev.removeEventListener(event, prev.$lustre[`${removed}Handler`]);
+ prev.$lustre.__registered_events.delete(removed);
+
+ delete prev.$lustre[removed];
+ delete prev.$lustre[`${removed}Handler`];
+ } else {
+ prev.removeAttribute(removed);
+ }
+ }
+ }
+
+ return root;
+}
+
+// ELEMENTS --------------------------------------------------------------------
+
+function createElement(prev, curr, dispatch, parent = null) {
+ const el = curr.namespace
+ ? document.createElementNS(curr.namespace, curr.tag)
+ : document.createElement(curr.tag);
+
+ el.$lustre = {
+ __registered_events: new Set(),
+ };
+
+ let dangerousUnescapedHtml = "";
+
+ for (const attr of curr.attrs) {
+ if (attr[0] === "class") {
+ morphAttr(el, attr[0], `${el.className} ${attr[1]}`);
+ } else if (attr[0] === "style") {
+ morphAttr(el, attr[0], `${el.style.cssText} ${attr[1]}`);
+ } else if (attr[0] === "dangerous-unescaped-html") {
+ dangerousUnescapedHtml += attr[1];
+ } else if (attr[0] !== "") {
+ morphAttr(el, attr[0], attr[1], dispatch);
+ }
+ }
+
+ if (customElements.get(curr.tag)) {
+ el._slot = curr.children;
+ } else if (curr.tag === "slot") {
+ let children = new Empty();
+ let parentWithSlot = parent;
+
+ while (parentWithSlot) {
+ if (parentWithSlot._slot) {
+ children = parentWithSlot._slot;
+ break;
+ } else {
+ parentWithSlot = parentWithSlot.parentNode;
+ }
+ }
+
+ for (const child of children) {
+ el.appendChild(morph(null, child, dispatch, el));
+ }
+ } else if (dangerousUnescapedHtml) {
+ el.innerHTML = dangerousUnescapedHtml;
+ } else {
+ for (const child of curr.children) {
+ el.appendChild(morph(null, child, dispatch, el));
+ }
+ }
+
+ if (prev) prev.replaceWith(el);
+
+ return el;
+}
+
+function morphElement(prev, curr, dispatch, parent) {
+ const prevAttrs = prev.attributes;
+ const currAttrs = new Map();
+
+ // This can happen if we're morphing an existing DOM element that *wasn't*
+ // initially created by lustre.
+ prev.$lustre ??= { __registered_events: new Set() };
+
+ // We're going to convert the Gleam List of attributes into a JavaScript Map
+ // so its easier to lookup specific attributes.
+ for (const currAttr of curr.attrs) {
+ if (currAttr[0] === "class" && currAttrs.has("class")) {
+ currAttrs.set(currAttr[0], `${currAttrs.get("class")} ${currAttr[1]}`);
+ } else if (currAttr[0] === "style" && currAttrs.has("style")) {
+ currAttrs.set(currAttr[0], `${currAttrs.get("style")} ${currAttr[1]}`);
+ } else if (
+ currAttr[0] === "dangerous-unescaped-html" &&
+ currAttrs.has("dangerous-unescaped-html")
+ ) {
+ currAttrs.set(
+ currAttr[0],
+ `${currAttrs.get("dangerous-unescaped-html")} ${currAttr[1]}`
+ );
+ } else if (currAttr[0] !== "") {
+ currAttrs.set(currAttr[0], currAttr[1]);
+ }
+ }
+
+ // TODO: Event listeners aren't currently removed when they are removed from
+ // the attributes list. This is a bug!
+ for (const { name, value: prevValue } of prevAttrs) {
+ if (!currAttrs.has(name)) {
+ prev.removeAttribute(name);
+ } else {
+ const value = currAttrs.get(name);
+
+ if (value !== prevValue) {
+ morphAttr(prev, name, value, dispatch);
+ currAttrs.delete(name);
+ }
+ }
+ }
+
+ for (const name of prev.$lustre.__registered_events) {
+ if (!currAttrs.has(name)) {
+ const event = name.slice(2).toLowerCase();
+
+ prev.removeEventListener(event, prev.$lustre[`${name}Handler`]);
+ prev.$lustre.__registered_events.delete(name);
+
+ delete prev.$lustre[name];
+ delete prev.$lustre[`${name}Handler`];
+ }
+ }
+
+ for (const [name, value] of currAttrs) {
+ morphAttr(prev, name, value, dispatch);
+ }
+
+ if (customElements.get(curr.tag)) {
+ prev._slot = curr.children;
+ } else if (curr.tag === "slot") {
+ let prevChild = prev.firstChild;
+ let currChild = new Empty();
+ let parentWithSlot = parent;
+
+ while (parentWithSlot) {
+ if (parentWithSlot._slot) {
+ currChild = parentWithSlot._slot;
+ break;
+ } else {
+ parentWithSlot = parentWithSlot.parentNode;
+ }
+ }
+
+ while (prevChild) {
+ if (Array.isArray(currChild) && currChild.length) {
+ morph(prevChild, currChild.shift(), dispatch, prev);
+ } else if (currChild.head) {
+ morph(prevChild, currChild.head, dispatch, prev);
+ currChild = currChild.tail;
+ }
+
+ prevChild = prevChild.nextSibling;
+ }
+
+ for (const child of currChild) {
+ prev.appendChild(morph(null, child, dispatch, prev));
+ }
+ } else if (currAttrs.has("dangerous-unescaped-html")) {
+ prev.innerHTML = currAttrs.get("dangerous-unescaped-html");
+ } else {
+ let prevChild = prev.firstChild;
+ let currChild = curr.children;
+
+ while (prevChild) {
+ if (Array.isArray(currChild) && currChild.length) {
+ const next = prevChild.nextSibling;
+ morph(prevChild, currChild.shift(), dispatch, prev);
+ prevChild = next;
+ } else if (currChild.head) {
+ const next = prevChild.nextSibling;
+ morph(prevChild, currChild.head, dispatch, prev);
+ currChild = currChild.tail;
+ prevChild = next;
+ } else {
+ const next = prevChild.nextSibling;
+ prevChild.remove();
+ prevChild = next;
+ }
+ }
+
+ for (const child of currChild) {
+ prev.appendChild(morph(null, child, dispatch, prev));
+ }
+ }
+
+ return prev;
+}
+
+// ATTRIBUTES ------------------------------------------------------------------
+
+function morphAttr(el, name, value, dispatch) {
+ switch (typeof value) {
+ case name.startsWith("data-lustre-on-") && "string": {
+ if (!value) {
+ el.removeAttribute(name);
+ el.removeEventListener(event, el.$lustre[`${name}Handler`]);
+
+ break;
+ }
+ if (el.hasAttribute(name)) break;
+
+ const event = name.slice(15).toLowerCase();
+ const handler = (e) => dispatch(serverEventHandler(e));
+
+ if (el.$lustre[`${name}Handler`]) {
+ el.removeEventListener(event, el.$lustre[`${name}Handler`]);
+ }
+
+ el.addEventListener(event, handler);
+
+ el.$lustre[name] = value;
+ el.$lustre[`${name}Handler`] = handler;
+ el.$lustre.__registered_events.add(name);
+ el.setAttribute(name, value);
+
+ break;
+ }
+
+ case "string":
+ if (el.getAttribute(name) !== value) el.setAttribute(name, value);
+ if (value === "") el.removeAttribute(name);
+ if (name === "value" && el.value !== value) el.value = value;
+ break;
+
+ // Event listeners need to be handled slightly differently because we need
+ // to be able to support custom events. We
+ case name.startsWith("on") && "function": {
+ if (el.$lustre[name] === value) break;
+
+ const event = name.slice(2).toLowerCase();
+ const handler = (e) => result_map(value(e), dispatch);
+
+ if (el.$lustre[`${name}Handler`]) {
+ el.removeEventListener(event, el.$lustre[`${name}Handler`]);
+ }
+
+ el.addEventListener(event, handler);
+
+ el.$lustre[name] = value;
+ el.$lustre[`${name}Handler`] = handler;
+ el.$lustre.__registered_events.add(name);
+
+ break;
+ }
+
+ default:
+ el[name] = value;
+ }
+}
+
+// TEXT ------------------------------------------------------------------------
+
+function createText(prev, curr) {
+ const el = document.createTextNode(curr.content);
+
+ if (prev) prev.replaceWith(el);
+ return el;
+}
+
+function morphText(prev, curr) {
+ const prevValue = prev.nodeValue;
+ const currValue = curr.content;
+
+ if (!currValue) {
+ prev?.remove();
+ return null;
+ }
+
+ if (prevValue !== currValue) prev.nodeValue = currValue;
+
+ return prev;
+}
+
+// UTILS -----------------------------------------------------------------------
+
+function serverEventHandler(event) {
+ const el = event.target;
+ const tag = el.getAttribute(`data-lustre-on-${event.type}`);
+ const data = JSON.parse(el.getAttribute("data-lustre-data") || "{}");
+ const include = JSON.parse(el.getAttribute("data-lustre-include") || "[]");
+
+ switch (event.type) {
+ case "input":
+ case "change":
+ include.push("target.value");
+ break;
+ }
+
+ return {
+ tag,
+ data: include.reduce((data, property) => {
+ const path = property.split(".");
+
+ for (let i = 0, o = data, e = event; i < path.length; i++) {
+ if (i === path.length - 1) {
+ o[path[i]] = e[path[i]];
+ } else {
+ o[path[i]] ??= {};
+ e = e[path[i]];
+ o = o[path[i]];
+ }
+ }
+
+ return data;
+ }, data),
+ };
+}
diff --git a/test/lustre_test.gleam b/test/lustre_test.gleam
deleted file mode 100644
index 4ead3b1..0000000
--- a/test/lustre_test.gleam
+++ /dev/null
@@ -1,3 +0,0 @@
-pub fn main() {
- Nil
-}