diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2024-01-23 00:09:45 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-01-23 00:09:45 +0000 |
commit | 24f6962aa457d32319756f6217aafde7b0a9c752 (patch) | |
tree | 42119d9b073f56eabe9dda4ae2065ef4b2086e6a /src | |
parent | 45e671ac32de95ae1a0a9f9e98da8645d01af3cf (diff) | |
download | lustre-24f6962aa457d32319756f6217aafde7b0a9c752.tar.gz lustre-24f6962aa457d32319756f6217aafde7b0a9c752.zip |
✨ Add universal components that can run on the server (#39)
* :heavy_plus_sign: Add gleam_erlang gleam_otp and gleam_json dependencies.
* :sparkles: Add json encoders for elememnts and attributes.
* :sparkles: Add the ability to perform an effect with a custom dispatch function.
* :construction: Experiment with a server-side component runtime.
* :construction: Expose special server click events.
* :construction: Experiment with a server-side component runtime.
* :construction: Experiment with a server-side component runtime.
* :construction: Experiment with a server-side component runtime.
* :construction: Create a basic server component client bundle.
* :construction: Create a basic server component demo.
* :bug: Fixed a bug where the runtime stopped performing patches.
* :refactor: Roll back introduction of shadow dom.
* :recycle: Refactor to Custom Element-based approach to encapsulating server components.
* :truck: Move some things around.
* :sparkles: Add a minified version of the server component runtime.
* :wrench: Add lustre/server/* to internal modules.
* :recycle: on_attribute_change and on_client_event handlers are now functions not dicts.
* :recycle: Refactor server component event handling to no longer need explicit tags.
* :fire: Remove unnecessary attempt to stringify events.
* :memo: Start documeint lustre/server functions.
* :construction: Experiment with a js implementation of the server component backend runtime.
* :recycle: Experiment with an API that makes heavier use of conditional complilation.
* :recycle: Big refactor to unify server components, client components, and client apps.
* :bug: Fixed some bugs with client runtimes.
* :recycle: Update examples to new lustre api/
* :truck: Move server demo into examples/ folder/
* :wrench: Add lustre/runtime to internal modules.
* :construction: Experiment with a diffing implementation.
* :wrench: Hide internal modules from docs.
* :heavy_plus_sign: Update deps to latest versions.
* :recycle: Move diffing and vdom code into separate internal modules.
* :sparkles: Bring server components to feature parity with client components.
* :recycle: Update server component demo.
* :bug: Fix bug where attribute changes weren't properly broadcast.
* :fire: Remove unused 'Patch' type.
* :recycle: Stub out empty js implementations so we can build for js.
* :memo: Docs for the docs gods.
* :recycle: Rename lustre.server_component to lustre.component.
Diffstat (limited to 'src')
-rw-r--r-- | src/client-component.ffi.mjs | 74 | ||||
-rw-r--r-- | src/client-runtime.ffi.mjs | 133 | ||||
-rw-r--r-- | src/lustre.ffi.mjs | 224 | ||||
-rw-r--r-- | src/lustre.gleam | 340 | ||||
-rw-r--r-- | src/lustre/attribute.gleam | 85 | ||||
-rw-r--r-- | src/lustre/effect.gleam | 36 | ||||
-rw-r--r-- | src/lustre/element.gleam | 154 | ||||
-rw-r--r-- | src/lustre/event.gleam | 7 | ||||
-rw-r--r-- | src/lustre/internals/constants.gleam | 26 | ||||
-rw-r--r-- | src/lustre/internals/patch.gleam | 374 | ||||
-rw-r--r-- | src/lustre/internals/vdom.gleam | 353 | ||||
-rw-r--r-- | src/lustre/runtime.gleam | 244 | ||||
-rw-r--r-- | src/lustre/server.gleam | 183 | ||||
-rw-r--r-- | src/runtime.ffi.mjs | 61 | ||||
-rw-r--r-- | src/server-component.mjs | 155 | ||||
-rw-r--r-- | src/server-runtime.ffi.mjs | 143 | ||||
-rw-r--r-- | src/vdom.ffi.mjs | 407 |
17 files changed, 2490 insertions, 509 deletions
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 `<lustre-server-component>` element. +/// +pub fn component(attrs: List(Attribute(msg))) -> Element(msg) { + element("lustre-server-component", attrs, []) +} + +// ATTRIBUTES ------------------------------------------------------------------ + +/// The `route` attribute should always be included on a [`component`](#component) +/// to tell the client runtime what path to initiate the WebSocket connection on. +/// +/// +/// +pub fn route(path: String) -> Attribute(msg) { + attribute("route", path) +} + +/// Ocassionally you may want to attach custom data to an event sent to the server. +/// This could be used to include a hash of the current build to detect if the +/// event was sent from a stale client. +/// +/// ```gleam +/// +/// ``` +/// +pub fn data(json: Json) -> Attribute(msg) { + json + |> json.to_string + |> attribute("data-lustre-data", _) +} + +/// Properties of the JavaScript event object are typically not serialisable. +/// This means if we want to pass them to the server we need to copy them into +/// a new object first. +/// +/// This attribute tells Lustre what properties to include. Properties can come +/// from nested objects by using dot notation. For example, you could include the +/// `id` of the target `element` by passing `["target.id"]`. +/// +/// ```gleam +/// import gleam/dynamic +/// import gleam/result.{try} +/// import lustre/element.{type Element} +/// import lustre/element/html +/// import lustre/event +/// import lustre/server +/// +/// pub fn custom_button(on_click: fn(String) -> msg) -> Element(msg) { +/// let handler = fn(event) { +/// use target <- try(dynamic.field("target", dynamic.dynamic)(event)) +/// use id <- try(dynamic.field("id", dynamic.string)(target)) +/// +/// Ok(on_click(id)) +/// } +/// +/// html.button([event.on_click(handler), server.include(["target.id"])], [ +/// element.text("Click me!") +/// ]) +/// } +/// ``` +/// +pub fn include(properties: List(String)) -> Attribute(msg) { + properties + |> json.array(json.string) + |> json.to_string + |> attribute("data-lustre-include", _) +} + +// EFFECTS --------------------------------------------------------------------- + +/// +/// +pub fn emit(event: String, data: Json) -> Effect(msg) { + effect.event(event, data) +} + +@target(erlang) +/// +/// +pub fn selector(sel: Selector(Action(runtime, msg))) -> Effect(msg) { + use _ <- effect.from + let self = process.new_subject() + + process.send(self, SetSelector(sel)) +} + +// DECODERS -------------------------------------------------------------------- + +pub fn decode_action( + dyn: Dynamic, +) -> Result(Action(runtime, msg), List(DecodeError)) { + dynamic.any([decode_event, decode_attrs])(dyn) +} + +/// +/// +fn decode_event( + dyn: Dynamic, +) -> Result(Action(runtime, msg), List(DecodeError)) { + use #(kind, name, data) <- result.try(dynamic.tuple3( + dynamic.int, + dynamic.dynamic, + dynamic.dynamic, + )(dyn)) + use <- bool.guard( + kind != constants.event, + Error([ + DecodeError( + path: ["0"], + found: int.to_string(kind), + expected: int.to_string(constants.event), + ), + ]), + ) + use name <- result.try(dynamic.string(name)) + + Ok(Event(name, data)) +} + +fn decode_attrs( + dyn: Dynamic, +) -> Result(Action(runtime, msg), List(DecodeError)) { + use list <- result.try(dynamic.list(dynamic.dynamic)(dyn)) + case list { + [kind, attrs] -> { + use kind <- result.try(dynamic.int(kind)) + use <- bool.guard( + kind != constants.attrs, + Error([ + DecodeError( + path: ["0"], + found: int.to_string(kind), + expected: int.to_string(constants.attrs), + ), + ]), + ) + use attrs <- result.try(dynamic.list(decode_attr)(attrs)) + Ok(Attrs(attrs)) + } + _ -> + Error([ + DecodeError( + path: [], + found: dynamic.classify(dyn), + expected: "a tuple of 2 elements", + ), + ]) + } +} + +fn decode_attr(dyn: Dynamic) -> Result(#(String, Dynamic), List(DecodeError)) { + use list <- result.try(dynamic.list(dynamic.dynamic)(dyn)) + case list { + [key, value] -> { + use key <- result.try(dynamic.string(key)) + Ok(#(key, value)) + } + _ -> + Error([ + DecodeError( + path: [], + found: dynamic.classify(dyn), + expected: "a tuple of 2 elements", + ), + ]) + } +} diff --git a/src/runtime.ffi.mjs b/src/runtime.ffi.mjs index c3cec68..f7b711b 100644 --- a/src/runtime.ffi.mjs +++ b/src/runtime.ffi.mjs @@ -227,6 +227,32 @@ function morphElement(prev, curr, dispatch, parent) { function morphAttr(el, name, value, dispatch) { switch (typeof value) { + case name.startsWith("data-lustre-on-") && "string": { + if (!value) { + el.removeAttribute(name); + el.removeEventListener(event, el.$lustre[`${name}Handler`]); + + break; + } + if (el.hasAttribute(name)) break; + + const event = name.slice(15).toLowerCase(); + const handler = (e) => dispatch(serverEventHandler(e)); + + if (el.$lustre[`${name}Handler`]) { + el.removeEventListener(event, el.$lustre[`${name}Handler`]); + } + + el.addEventListener(event, handler); + + el.$lustre[name] = value; + el.$lustre[`${name}Handler`] = handler; + el.$lustre.__registered_events.add(name); + el.setAttribute(name, value); + + break; + } + case "string": if (el.getAttribute(name) !== value) el.setAttribute(name, value); if (value === "") el.removeAttribute(name); @@ -281,3 +307,38 @@ function morphText(prev, curr) { return prev; } + +// UTILS ----------------------------------------------------------------------- + +function serverEventHandler(event) { + const el = event.target; + const tag = el.getAttribute(`data-lustre-on-${event.type}`); + const data = JSON.parse(el.getAttribute("data-lustre-data") || "{}"); + const include = JSON.parse(el.getAttribute("data-lustre-include") || "[]"); + + switch (event.type) { + case "input": + case "change": + include.push("target.value"); + break; + } + + return { + tag, + data: include.reduce((data, property) => { + const path = property.split("."); + + for (let i = 0, o = data, e = event; i < path.length; i++) { + if (i === path.length - 1) { + o[path[i]] = e[path[i]]; + } else { + o[path[i]] ??= {}; + e = e[path[i]]; + o = o[path[i]]; + } + } + + return data; + }, data), + }; +} diff --git a/src/server-component.mjs b/src/server-component.mjs new file mode 100644 index 0000000..b7aec2c --- /dev/null +++ b/src/server-component.mjs @@ -0,0 +1,155 @@ +// Note that this path is relative to the built Gleam project, not the source files +// in `src/`. This particular module is not used by the Lustre package itself, but +// is instead bundled and made available to package users in the `priv/` directory. +// +// It makes obvious sense to co-locate the source with the rest of the package +// source code, but if we use relative imports here the bundle will fail because +// `vdom.ffi.mjs` is importing things from the Gleam standard library and expects +// to be placed in the `build/dev/javascript/lustre/` directory. +// +import * as Constants from "../build/dev/javascript/lustre/lustre/internals/constants.mjs"; +import { patch, morph } from "../build/dev/javascript/lustre/vdom.ffi.mjs"; + +export class LustreServerComponent extends HTMLElement { + static get observedAttributes() { + return ["route"]; + } + + #observer = null; + #root = null; + #socket = null; + + constructor() { + super(); + + this.#observer = new MutationObserver((mutations) => { + const changed = []; + + for (const mutation of mutations) { + if (mutation.type === "attributes") { + const { attributeName: name, oldValue: prev } = mutation; + const next = this.getAttribute(name); + + if (prev !== next) { + try { + changed.push([name, JSON.parse(next)]); + } catch { + changed.push([name, next]); + } + } + } + } + + if (changed.length) { + this.#socket?.send(JSON.stringify([Constants.attrs, changed])); + } + }); + } + + connectedCallback() { + this.#root = document.createElement("div"); + this.appendChild(this.#root); + } + + attributeChangedCallback(name, prev, next) { + switch (name) { + case "route": { + if (!next) { + this.#socket?.close(); + this.#socket = null; + } else if (prev !== next) { + const id = this.getAttribute("id"); + const route = next + (id ? `?id=${id}` : ""); + + this.#socket?.close(); + this.#socket = new WebSocket(`ws://${window.location.host}${route}`); + this.#socket.addEventListener("message", ({ data }) => { + const [kind, ...payload] = JSON.parse(data); + + switch (kind) { + case Constants.diff: + return this.diff(payload); + + case Constants.emit: + return this.emit(payload); + + case Constants.init: + return this.init(payload); + } + }); + } + } + } + } + + init([attrs, vdom]) { + const initial = []; + + for (const attr of attrs) { + if (attr in this) { + initial.push([attr, this[attr]]); + } else if (this.hasAttribute(attr)) { + initial.push([attr, this.getAttribute(attr)]); + } + + Object.defineProperty(this, attr, { + get() { + return this[`_${attr}`] ?? this.getAttribute(attr); + }, + set(value) { + const prev = this[attr]; + + if (typeof value === "string") { + this.setAttribute(attr, value); + } else { + this[`_${attr}`] = value; + } + + if (prev !== value) { + this.#socket?.send( + JSON.stringify([Constants.attrs, [[attr, value]]]) + ); + } + }, + }); + } + + this.#observer.observe(this, { + attributeFilter: attrs, + attributeOldValue: true, + attributes: true, + characterData: false, + characterDataOldValue: false, + childList: false, + subtree: false, + }); + + this.morph(vdom); + + if (initial.length) { + this.#socket?.send(JSON.stringify([Constants.attrs, initial])); + } + } + + morph(vdom) { + this.#root = morph(this.#root, vdom, (msg) => { + this.#socket?.send(JSON.stringify([Constants.event, msg.tag, msg.data])); + }); + } + + diff([diff]) { + this.#root = patch(this.#root, diff, (msg) => { + this.#socket?.send(JSON.stringify([Constants.event, msg.tag, msg.data])); + }); + } + + emit([event, data]) { + this.dispatchEvent(new CustomEvent(event, { detail: data })); + } + + disconnectedCallback() { + this.#socket?.close(); + } +} + +window.customElements.define("lustre-server-component", LustreServerComponent); diff --git a/src/server-runtime.ffi.mjs b/src/server-runtime.ffi.mjs new file mode 100644 index 0000000..e82e82d --- /dev/null +++ b/src/server-runtime.ffi.mjs @@ -0,0 +1,143 @@ +import { Ok, isEqual } from "./gleam.mjs"; +import { + AddRenderer, + Dispatch, + Event, + RemoveRenderer, + Shutdown, +} from "./lustre/runtime.mjs"; + +export class LustreServerApplication { + #queue = []; + #effects = []; + #didUpdate = false; + + #vdom = null; + #handlers = new Map(); + #renderers = new Set(); + + #model = null; + #update = null; + #view = null; + + static start(flags, init, update, view) { + const app = new LustreServerApplication(init(flags), update, view, root); + + return new Ok((msg) => app.send(msg)); + } + + // PUBLIC METHODS ------------------------------------------------------------ + + constructor([model, effects], update, view) { + this.#model = model; + this.#update = update; + this.#view = view; + this.#vdom = this.#view(this.#model); + this.#effects = effects.all.toArray(); + this.#didUpdate = true; + + globalThis.queueMicrotask(() => this.#tick()); + } + + send(action) { + switch (true) { + case action instanceof AddRenderer: { + this.#renderers.add(action[0]); + return; + } + + case action instanceof Dispatch: { + this.#queue.push(action[0]); + this.#tick(); + + return; + } + + case action instanceof Event: { + const [event, data] = action; + + if (this.#handlers.has(event)) { + const msg = this.#handlers.get(event)(data); + + if (msg.isOk()) { + this.#queue.push(msg[0]); + this.#tick(); + } + } + } + + case action instanceof RemoveRenderer: { + this.#renderers.delete(action[0]); + return; + } + + case action instanceof Shutdown: { + this.#shutdown(); + return; + } + + default: + return; + } + } + + // PRIVATE METHODS ----------------------------------------------------------- + + #tick() { + this.#flush_queue(); + + if (this.#didUpdate) { + this.#vdom = this.#view(this.#model); + + for (const renderer of this.#renderers) { + renderer.render(this.#vdom); + } + } + } + + #flush_queue(iterations = 0) { + while (this.#queue.length) { + const [next, effects] = this.#update(this.#model, this.#queue.shift()); + + this.#model = next; + this.#didUpdate ||= !isEqual(this.#model, next); + this.#effects = this.#effects.concat(effects.all.toArray()); + } + + while (this.#effects.length) { + this.#effects.shift()( + (msg) => this.send(new Dispatch(msg)), + (event, data) => this.emit(event, data) + ); + } + + if (this.#queue.length) { + if (iterations < 5) { + this.#flush_queue(++iterations); + } else { + window.requestAnimationFrame(() => this.#tick()); + } + } + } + + #shutdown() { + this.#model = null; + this.#queue = []; + this.#effects = []; + this.#didUpdate = false; + this.#update = () => {}; + this.#view = () => {}; + this.#vdom = null; + this.#handlers = new Map(); + this.#renderers = new Set(); + } +} + +export const start = (app, selector, flags) => + LustreClientApplication.start( + flags, + selector, + app.init, + app.update, + app.view + ); diff --git a/src/vdom.ffi.mjs b/src/vdom.ffi.mjs new file mode 100644 index 0000000..ec5a226 --- /dev/null +++ b/src/vdom.ffi.mjs @@ -0,0 +1,407 @@ +import { Empty } from "./gleam.mjs"; +import { map as result_map } from "../gleam_stdlib/gleam/result.mjs"; + +export function morph(prev, curr, dispatch, parent) { + // The current node is an `Element` and the previous DOM node is also a DOM + // element. + if (curr?.tag && prev?.nodeType === 1) { + const nodeName = curr.tag.toUpperCase(); + const ns = curr.namespace || "http://www.w3.org/1999/xhtml"; + + // If the current node and the existing DOM node have the same tag and + // namespace, we can morph them together: keeping the DOM node intact and just + // updating its attributes and children. + if (prev.nodeName === nodeName && prev.namespaceURI == ns) { + return morphElement(prev, curr, dispatch, parent); + } + // Otherwise, we need to replace the DOM node with a new one. The `createElement` + // function will handle replacing the existing DOM node for us. + else { + return createElement(prev, curr, dispatch, parent); + } + } + + // The current node is an `Element` but the previous DOM node either did not + // exist or it is not a DOM element (eg it might be a text or comment node). + if (curr?.tag) { + return createElement(prev, curr, dispatch, parent); + } + + // The current node is a `Text`. + if (typeof curr?.content === "string") { + return prev?.nodeType === 3 + ? morphText(prev, curr) + : createText(prev, curr); + } + + // If someone was naughty and tried to pass in something other than a Lustre + // element (or if there is an actual bug with the runtime!) we'll render a + // comment and ask them to report the issue. + return document.createComment( + [ + "[internal lustre error] I couldn't work out how to render this element. This", + "function should only be called internally by lustre's runtime: if you think", + "this is an error, please open an issue at", + "https://github.com/hayleigh-dot-dev/gleam-lustre/issues/new", + ].join(" ") + ); +} + +export function patch(root, diff, dispatch) { + for (const created of diff[0]) { + const key = created[0]; + + if (key === "0") { + morph(root, created[1], dispatch, root.parentNode); + } else { + const segments = Array.from(key); + const parentKey = segments.slice(0, -1).join(""); + const indexKey = segments.slice(-1)[0]; + const prev = + root.querySelector(`[data-lustre-key="${key}"]`) ?? + root.querySelector(`[data-lustre-key="${parentKey}"]`).childNodes[ + indexKey + ]; + + morph(prev, created[1], dispatch, prev.parentNode); + } + } + + for (const removed of diff[1]) { + const key = removed[0]; + const segments = Array.from(key); + const parentKey = segments.slice(0, -1).join(""); + const indexKey = segments.slice(-1)[0]; + const prev = + root.querySelector(`[data-lustre-key="${key}"]`) ?? + root.querySelector(`[data-lustre-key="${parentKey}"]`).childNodes[ + indexKey + ]; + + prev.remove(); + } + + for (const updated of diff[2]) { + const key = updated[0]; + const prev = + key === "0" ? root : root.querySelector(`[data-lustre-key="${key}"]`); + + prev.$lustre ??= { __registered_events: new Set() }; + + for (const created of updated[0]) { + morphAttr(prev, created.name, created.value, dispatch); + } + + for (const removed of updated[1]) { + if (prev.$lustre.__registered_events.has(removed)) { + const event = removed.slice(2).toLowerCase(); + + prev.removeEventListener(event, prev.$lustre[`${removed}Handler`]); + prev.$lustre.__registered_events.delete(removed); + + delete prev.$lustre[removed]; + delete prev.$lustre[`${removed}Handler`]; + } else { + prev.removeAttribute(removed); + } + } + } + + return root; +} + +// ELEMENTS -------------------------------------------------------------------- + +function createElement(prev, curr, dispatch, parent = null) { + const el = curr.namespace + ? document.createElementNS(curr.namespace, curr.tag) + : document.createElement(curr.tag); + + el.$lustre = { + __registered_events: new Set(), + }; + + let dangerousUnescapedHtml = ""; + + for (const attr of curr.attrs) { + if (attr[0] === "class") { + morphAttr(el, attr[0], `${el.className} ${attr[1]}`); + } else if (attr[0] === "style") { + morphAttr(el, attr[0], `${el.style.cssText} ${attr[1]}`); + } else if (attr[0] === "dangerous-unescaped-html") { + dangerousUnescapedHtml += attr[1]; + } else if (attr[0] !== "") { + morphAttr(el, attr[0], attr[1], dispatch); + } + } + + if (customElements.get(curr.tag)) { + el._slot = curr.children; + } else if (curr.tag === "slot") { + let children = new Empty(); + let parentWithSlot = parent; + + while (parentWithSlot) { + if (parentWithSlot._slot) { + children = parentWithSlot._slot; + break; + } else { + parentWithSlot = parentWithSlot.parentNode; + } + } + + for (const child of children) { + el.appendChild(morph(null, child, dispatch, el)); + } + } else if (dangerousUnescapedHtml) { + el.innerHTML = dangerousUnescapedHtml; + } else { + for (const child of curr.children) { + el.appendChild(morph(null, child, dispatch, el)); + } + } + + if (prev) prev.replaceWith(el); + + return el; +} + +function morphElement(prev, curr, dispatch, parent) { + const prevAttrs = prev.attributes; + const currAttrs = new Map(); + + // This can happen if we're morphing an existing DOM element that *wasn't* + // initially created by lustre. + prev.$lustre ??= { __registered_events: new Set() }; + + // We're going to convert the Gleam List of attributes into a JavaScript Map + // so its easier to lookup specific attributes. + for (const currAttr of curr.attrs) { + if (currAttr[0] === "class" && currAttrs.has("class")) { + currAttrs.set(currAttr[0], `${currAttrs.get("class")} ${currAttr[1]}`); + } else if (currAttr[0] === "style" && currAttrs.has("style")) { + currAttrs.set(currAttr[0], `${currAttrs.get("style")} ${currAttr[1]}`); + } else if ( + currAttr[0] === "dangerous-unescaped-html" && + currAttrs.has("dangerous-unescaped-html") + ) { + currAttrs.set( + currAttr[0], + `${currAttrs.get("dangerous-unescaped-html")} ${currAttr[1]}` + ); + } else if (currAttr[0] !== "") { + currAttrs.set(currAttr[0], currAttr[1]); + } + } + + // TODO: Event listeners aren't currently removed when they are removed from + // the attributes list. This is a bug! + for (const { name, value: prevValue } of prevAttrs) { + if (!currAttrs.has(name)) { + prev.removeAttribute(name); + } else { + const value = currAttrs.get(name); + + if (value !== prevValue) { + morphAttr(prev, name, value, dispatch); + currAttrs.delete(name); + } + } + } + + for (const name of prev.$lustre.__registered_events) { + if (!currAttrs.has(name)) { + const event = name.slice(2).toLowerCase(); + + prev.removeEventListener(event, prev.$lustre[`${name}Handler`]); + prev.$lustre.__registered_events.delete(name); + + delete prev.$lustre[name]; + delete prev.$lustre[`${name}Handler`]; + } + } + + for (const [name, value] of currAttrs) { + morphAttr(prev, name, value, dispatch); + } + + if (customElements.get(curr.tag)) { + prev._slot = curr.children; + } else if (curr.tag === "slot") { + let prevChild = prev.firstChild; + let currChild = new Empty(); + let parentWithSlot = parent; + + while (parentWithSlot) { + if (parentWithSlot._slot) { + currChild = parentWithSlot._slot; + break; + } else { + parentWithSlot = parentWithSlot.parentNode; + } + } + + while (prevChild) { + if (Array.isArray(currChild) && currChild.length) { + morph(prevChild, currChild.shift(), dispatch, prev); + } else if (currChild.head) { + morph(prevChild, currChild.head, dispatch, prev); + currChild = currChild.tail; + } + + prevChild = prevChild.nextSibling; + } + + for (const child of currChild) { + prev.appendChild(morph(null, child, dispatch, prev)); + } + } else if (currAttrs.has("dangerous-unescaped-html")) { + prev.innerHTML = currAttrs.get("dangerous-unescaped-html"); + } else { + let prevChild = prev.firstChild; + let currChild = curr.children; + + while (prevChild) { + if (Array.isArray(currChild) && currChild.length) { + const next = prevChild.nextSibling; + morph(prevChild, currChild.shift(), dispatch, prev); + prevChild = next; + } else if (currChild.head) { + const next = prevChild.nextSibling; + morph(prevChild, currChild.head, dispatch, prev); + currChild = currChild.tail; + prevChild = next; + } else { + const next = prevChild.nextSibling; + prevChild.remove(); + prevChild = next; + } + } + + for (const child of currChild) { + prev.appendChild(morph(null, child, dispatch, prev)); + } + } + + return prev; +} + +// ATTRIBUTES ------------------------------------------------------------------ + +function morphAttr(el, name, value, dispatch) { + switch (typeof value) { + case name.startsWith("data-lustre-on-") && "string": { + if (!value) { + el.removeAttribute(name); + el.removeEventListener(event, el.$lustre[`${name}Handler`]); + + break; + } + if (el.hasAttribute(name)) break; + + const event = name.slice(15).toLowerCase(); + const handler = (e) => dispatch(serverEventHandler(e)); + + if (el.$lustre[`${name}Handler`]) { + el.removeEventListener(event, el.$lustre[`${name}Handler`]); + } + + el.addEventListener(event, handler); + + el.$lustre[name] = value; + el.$lustre[`${name}Handler`] = handler; + el.$lustre.__registered_events.add(name); + el.setAttribute(name, value); + + break; + } + + case "string": + if (el.getAttribute(name) !== value) el.setAttribute(name, value); + if (value === "") el.removeAttribute(name); + if (name === "value" && el.value !== value) el.value = value; + break; + + // Event listeners need to be handled slightly differently because we need + // to be able to support custom events. We + case name.startsWith("on") && "function": { + if (el.$lustre[name] === value) break; + + const event = name.slice(2).toLowerCase(); + const handler = (e) => result_map(value(e), dispatch); + + if (el.$lustre[`${name}Handler`]) { + el.removeEventListener(event, el.$lustre[`${name}Handler`]); + } + + el.addEventListener(event, handler); + + el.$lustre[name] = value; + el.$lustre[`${name}Handler`] = handler; + el.$lustre.__registered_events.add(name); + + break; + } + + default: + el[name] = value; + } +} + +// TEXT ------------------------------------------------------------------------ + +function createText(prev, curr) { + const el = document.createTextNode(curr.content); + + if (prev) prev.replaceWith(el); + return el; +} + +function morphText(prev, curr) { + const prevValue = prev.nodeValue; + const currValue = curr.content; + + if (!currValue) { + prev?.remove(); + return null; + } + + if (prevValue !== currValue) prev.nodeValue = currValue; + + return prev; +} + +// UTILS ----------------------------------------------------------------------- + +function serverEventHandler(event) { + const el = event.target; + const tag = el.getAttribute(`data-lustre-on-${event.type}`); + const data = JSON.parse(el.getAttribute("data-lustre-data") || "{}"); + const include = JSON.parse(el.getAttribute("data-lustre-include") || "[]"); + + switch (event.type) { + case "input": + case "change": + include.push("target.value"); + break; + } + + return { + tag, + data: include.reduce((data, property) => { + const path = property.split("."); + + for (let i = 0, o = data, e = event; i < path.length; i++) { + if (i === path.length - 1) { + o[path[i]] = e[path[i]]; + } else { + o[path[i]] ??= {}; + e = e[path[i]]; + o = o[path[i]]; + } + } + + return data; + }, data), + }; +} |