From 24f6962aa457d32319756f6217aafde7b0a9c752 Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Tue, 23 Jan 2024 00:09:45 +0000 Subject: =?UTF-8?q?=E2=9C=A8=20Add=20universal=20components=20that=20can?= =?UTF-8?q?=20run=20on=20the=20server=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :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. --- examples/components/gleam.toml | 2 +- examples/components/manifest.toml | 6 +- examples/components/src/components.gleam | 15 +- examples/counter/gleam.toml | 2 +- examples/counter/manifest.toml | 6 +- examples/events/manifest.toml | 6 +- examples/input/manifest.toml | 6 +- examples/nested/manifest.toml | 6 +- examples/nested/src/nested.gleam | 2 +- examples/nested/src/nested/counter.gleam | 53 +++ examples/server_demo/README.md | 22 ++ examples/server_demo/gleam.toml | 13 + examples/server_demo/manifest.toml | 32 ++ examples/server_demo/src/demo.gleam | 22 ++ examples/server_demo/src/demo/app.gleam | 88 +++++ examples/server_demo/src/demo/socket.gleam | 78 ++++ examples/server_demo/src/demo/web.gleam | 58 +++ examples/svg/manifest.toml | 6 +- gleam.toml | 6 + manifest.toml | 7 + priv/lustre-server-component.min.mjs | 1 + priv/lustre-server-component.mjs | 558 +++++++++++++++++++++++++++++ src/client-component.ffi.mjs | 74 ++++ src/client-runtime.ffi.mjs | 133 +++++++ src/lustre.ffi.mjs | 224 ------------ src/lustre.gleam | 340 +++++++++++++++--- src/lustre/attribute.gleam | 85 +---- src/lustre/effect.gleam | 36 +- src/lustre/element.gleam | 154 +------- src/lustre/event.gleam | 7 +- src/lustre/internals/constants.gleam | 26 ++ src/lustre/internals/patch.gleam | 374 +++++++++++++++++++ src/lustre/internals/vdom.gleam | 353 ++++++++++++++++++ src/lustre/runtime.gleam | 244 +++++++++++++ src/lustre/server.gleam | 183 ++++++++++ src/runtime.ffi.mjs | 61 ++++ src/server-component.mjs | 155 ++++++++ src/server-runtime.ffi.mjs | 143 ++++++++ src/vdom.ffi.mjs | 407 +++++++++++++++++++++ test/lustre_test.gleam | 3 - 40 files changed, 3467 insertions(+), 530 deletions(-) create mode 100644 examples/nested/src/nested/counter.gleam create mode 100644 examples/server_demo/README.md create mode 100644 examples/server_demo/gleam.toml create mode 100644 examples/server_demo/manifest.toml create mode 100644 examples/server_demo/src/demo.gleam create mode 100644 examples/server_demo/src/demo/app.gleam create mode 100644 examples/server_demo/src/demo/socket.gleam create mode 100644 examples/server_demo/src/demo/web.gleam create mode 100644 priv/lustre-server-component.min.mjs create mode 100644 priv/lustre-server-component.mjs create mode 100644 src/client-component.ffi.mjs create mode 100644 src/client-runtime.ffi.mjs delete mode 100644 src/lustre.ffi.mjs create mode 100644 src/lustre/internals/constants.gleam create mode 100644 src/lustre/internals/patch.gleam create mode 100644 src/lustre/internals/vdom.gleam create mode 100644 src/lustre/runtime.gleam create mode 100644 src/lustre/server.gleam create mode 100644 src/server-component.mjs create mode 100644 src/server-runtime.ffi.mjs create mode 100644 src/vdom.ffi.mjs delete mode 100644 test/lustre_test.gleam 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 . 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("\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{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 <> "<", rest) + ">" <> rest -> escape(escaped <> ">", rest) + "&" <> rest -> escape(escaped <> "&", rest) + "\"" <> rest -> escape(escaped <> """, rest) + "'" <> rest -> escape(escaped <> "'", 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 `` 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 -} -- cgit v1.2.3