From 9919bc2702c89168d1805eaa0db9e4baff091260 Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Sat, 19 Aug 2023 22:21:19 +0100 Subject: :truck: Shift things around to accomodate a monorepo. --- lib/src/lustre.ffi.mjs | 206 +++++++ lib/src/lustre.gleam | 254 ++++++++ lib/src/lustre/attribute.gleam | 408 +++++++++++++ lib/src/lustre/effect.gleam | 67 +++ lib/src/lustre/element.gleam | 126 ++++ lib/src/lustre/element/html.gleam | 1197 +++++++++++++++++++++++++++++++++++++ lib/src/lustre/element/svg.gleam | 351 +++++++++++ lib/src/lustre/event.gleam | 184 ++++++ lib/src/runtime.ffi.mjs | 230 +++++++ 9 files changed, 3023 insertions(+) create mode 100644 lib/src/lustre.ffi.mjs create mode 100644 lib/src/lustre.gleam create mode 100644 lib/src/lustre/attribute.gleam create mode 100644 lib/src/lustre/effect.gleam create mode 100644 lib/src/lustre/element.gleam create mode 100644 lib/src/lustre/element/html.gleam create mode 100644 lib/src/lustre/element/svg.gleam create mode 100644 lib/src/lustre/event.gleam create mode 100644 lib/src/runtime.ffi.mjs (limited to 'lib/src') diff --git a/lib/src/lustre.ffi.mjs b/lib/src/lustre.ffi.mjs new file mode 100644 index 0000000..b99f6e2 --- /dev/null +++ b/lib/src/lustre.ffi.mjs @@ -0,0 +1,206 @@ +import { ElementNotFound, ComponentAlreadyRegistered } from "./lustre.mjs"; +import { from } from "./lustre/effect.mjs"; +import { map } from "./lustre/element.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 = "body") { + if (this.#root) return this; + + try { + const el = + selector instanceof HTMLElement + ? selector + : document.querySelector(selector); + const [next, effects] = this.#init(); + + this.#root = el; + this.#state = next; + this.#effects = effects[0].toArray(); + this.#didUpdate = true; + + window.requestAnimationFrame(() => this.#tick()); + + return new Ok((msg) => this.dispatch(msg)); + } catch (_) { + return new Error(new ElementNotFound()); + } + } + + 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() { + this.#root.remove(); + this.#state = null; + this.#queue = []; + this.#effects = []; + this.#didUpdate = false; + this.#update = () => {}; + this.#view = () => {}; + } + + #render() { + const node = this.#view(this.#state); + const vdom = map(node, (msg) => this.dispatch(msg)); + + morph(this.#root, vdom); + } + + #tick() { + this.#flush(); + this.#didUpdate && this.#render(); + this.#didUpdate = false; + } + + #flush(times = 0) { + if (this.#queue.length) { + while (this.#queue.length) { + const [next, effects] = this.#update(this.#state, this.#queue.shift()); + + this.#state = next; + this.#effects = this.#effects.concat(effects[0].toArray()); + } + this.#didUpdate = true; + } + + // Each update can produce effects which must now be executed. + while (this.#effects[0]) + 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) => app.start(selector); + +export const emit = (name, data) => + // Normal `Effect`s constructed in Gleam from `effect.from` don't get told + // about the second argument, but it's there 👀. + from((_, emit) => { + emit(name, data); + }); + +// 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 (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); + const dispatch = this.#app.start(this.#container); + 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, decoded[0])) { + this.#dispatch(decoded[0]); + } + + if (typeof value === "string") { + this.setAttribute(name, value); + } else { + this[`_${name}`] = value; + } + }, + }); + }); + } + + connectedCallback() { + this.appendChild(this.#container); + } + + attributeChangedCallback(name, prev, next) { + if (prev !== next) { + this[name] = next; + } + } + + disconnectedCallback() { + this.#app.destroy(); + } + } + ); + return new Ok(null); +}; diff --git a/lib/src/lustre.gleam b/lib/src/lustre.gleam new file mode 100644 index 0000000..673f982 --- /dev/null +++ b/lib/src/lustre.gleam @@ -0,0 +1,254 @@ +//// Lustre is a declarative framework for building Web apps in Gleam. + +// IMPORTS --------------------------------------------------------------------- + +import gleam/dynamic.{Decoder} +import gleam/map.{Map} +import lustre/effect.{Effect} +import lustre/element.{Element} + +// TYPES ----------------------------------------------------------------------- + +/// An `App` describes a Lustre application: what state it holds and what kind +/// of actions get dispatched to update that model. The only useful thing you can +/// do with an `App` is pass it to [`start`](#start). +/// +/// You can construct an `App` from the two constructors exposed in this module: +/// [`basic`](#basic) and [`application`](#application). Although you can't do +/// anything but [`start`](#start) them, the constructors are separated in case +/// you want to set up an application but defer starting it until some later point +/// in time. +/// +/// ```text +/// +--------+ +/// | | +/// | update | +/// | | +/// +--------+ +/// ^ | +/// | | +/// Msg | | #(Model, Effect(Msg)) +/// | | +/// | v +/// +------+ +------------------------+ +/// | | #(Model, Effect(Msg)) | | +/// | init |------------------------>| Lustre Runtime | +/// | | | | +/// +------+ +------------------------+ +/// ^ | +/// | | +/// Msg | | Model +/// | | +/// | v +/// +--------+ +/// | | +/// | render | +/// | | +/// +--------+ +/// ``` +/// +pub type App(model, msg) + +pub type Error { + ElementNotFound + ComponentAlreadyRegistered +} + +// These types aren't exposed, but they're just here to try and shrink the type +// annotations for `App` and `application` a little bit. When generating docs, +// Gleam automatically expands type aliases so this is purely for the benefit of +// those reading the source. +// + +type Update(model, msg) = + fn(model, msg) -> #(model, Effect(msg)) + +type Render(model, msg) = + fn(model) -> Element(msg) + +// CONSTRUCTORS ---------------------------------------------------------------- + +@target(javascript) +/// Create a basic lustre app that just renders some element on the page. +/// Note that this doesn't mean the content is static! With `element.stateful` +/// you can still create components with local state. +/// +/// Basic lustre apps don't have any *global* application state and so the +/// plumbing is a lot simpler. If you find yourself passing lots of state around, +/// you might want to consider using [`simple`](#simple) or [`application`](#application) +/// instead. +/// +/// ```gleam +/// import lustre +/// import lustre/element +/// +/// pub fn main () { +/// let app = lustre.element( +/// element.h1([], [ +/// element.text("Hello, world!") +/// ]) +/// ) +/// +/// assert Ok(_) = lustre.start(app, "#root") +/// } +/// ``` +/// +pub fn element(element: Element(msg)) -> App(Nil, msg) { + let init = fn() { #(Nil, effect.none()) } + let update = fn(_, _) { #(Nil, effect.none()) } + let render = fn(_) { element } + + application(init, update, render) +} + +@target(javascript) +/// If you start off with a simple `[element`](#element) app, you may find +/// yourself leaning on [`stateful`](./lustrel/element.html#stateful) elements +/// to manage model used throughout your app. If that's the case or if you know +/// you need some global model from the get-go, you might want to construct a +/// [`simple`](#simple) app instead. +/// +/// This is one app constructor that allows your HTML elements to dispatch actions +/// to update your program model. +/// +/// ```gleam +/// import gleam/int +/// import lustre +/// import lustre/element +/// import lustre/event +/// +/// type Msg { +/// Decr +/// Incr +/// } +/// +/// pub fn main () { +/// let init = 0 +/// +/// let update = fn (model, msg) { +/// case msg { +/// Decr -> model - 1 +/// Incr -> model + 1 +/// } +/// } +/// +/// let render = fn (model) { +/// element.div([], [ +/// element.button([ event.on_click(Decr) ], [ +/// element.text("-") +/// ]), +/// +/// element.text(int.to_string(model)), +/// +/// element.button([ event.on_click(Incr) ], [ +/// element.text("+") +/// ]) +/// ]) +/// } +/// +/// let app = lustre.simple(init, update, render) +/// assert Ok(_) = lustre.start(app, "#root") +/// } +/// ``` +/// +pub fn simple( + init: fn() -> model, + update: fn(model, msg) -> model, + render: fn(model) -> Element(msg), +) -> App(model, msg) { + let init = fn() { #(init(), effect.none()) } + let update = fn(model, msg) { #(update(model, msg), effect.none()) } + + application(init, update, render) +} + +@target(javascript) +/// An evolution of a [`simple`](#simple) app that allows you to return a +/// [`Effect`](./lustre/effect.html#Effect) from your `init` and `update`s. Commands give +/// us a way to perform side effects like sending an HTTP request or running a +/// timer and then dispatch actions back to the runtime to trigger an `update`. +/// +///``` +/// import lustre +/// import lustre/effect +/// import lustre/element +/// +/// pub fn main () { +/// let init = #(0, tick()) +/// +/// let update = fn (model, msg) { +/// case msg { +/// Tick -> #(model + 1, tick()) +/// } +/// } +/// +/// let render = fn (model) { +/// element.div([], [ +/// element.text("Time elapsed: ") +/// element.text(int.to_string(model)) +/// ]) +/// } +/// +/// let app = lustre.simple(init, update, render) +/// assert Ok(_) = lustre.start(app, "#root") +/// } +/// +/// fn tick () -> Effect(Msg) { +/// effect.from(fn (dispatch) { +/// setInterval(fn () { +/// dispatch(Tick) +/// }, 1000) +/// }) +/// } +/// +/// external fn set_timeout (f: fn () -> a, delay: Int) -> Nil +/// = "" "window.setTimeout" +///``` +@external(javascript, "./lustre.ffi.mjs", "setup") +pub fn application(init: fn() -> #(model, Effect(msg)), update: Update( + model, + msg, + ), render: Render(model, msg)) -> App(model, msg) + +@target(javascript) +@external(javascript, "./lustre.ffi.mjs", "setup_component") +pub fn component(name: String, init: fn() -> #(model, Effect(msg)), update: Update( + model, + msg, + ), render: Render(model, msg), on_attribute_change: Map(String, Decoder(msg))) -> Result( + Nil, + Error, +) + +// EFFECTS --------------------------------------------------------------------- + +@target(javascript) +/// Once you have created a app with either `basic` or `application`, you +/// need to actually start it! This function will mount your app to the DOM +/// node that matches the query selector you provide. +/// +/// If everything mounted OK, we'll get back a dispatch function that you can +/// call to send actions to your app and trigger an update. +/// +///``` +/// import lustre +/// +/// pub fn main () { +/// let app = lustre.appliation(init, update, render) +/// assert Ok(dispatch) = lustre.start(app, "#root") +/// +/// dispatch(Incr) +/// dispatch(Incr) +/// dispatch(Incr) +/// } +///``` +/// +/// This may not seem super useful at first, but by returning this dispatch +/// function from your `main` (or elsewhere) you can get events into your Lustre +/// app from the outside world. +/// +@external(javascript, "./lustre.ffi.mjs", "start") +pub fn start(app: App(model, msg), selector: String) -> Result( + fn(msg) -> Nil, + Error, +) diff --git a/lib/src/lustre/attribute.gleam b/lib/src/lustre/attribute.gleam new file mode 100644 index 0000000..459a86e --- /dev/null +++ b/lib/src/lustre/attribute.gleam @@ -0,0 +1,408 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/dynamic.{Dynamic} +import gleam/int +import gleam/list +import gleam/option.{Option} +import gleam/string +import gleam/string_builder.{StringBuilder} + +// TYPES ----------------------------------------------------------------------- + +/// Attributes are attached to specific elements. They're either key/value pairs +/// or event handlers. +/// +pub opaque type Attribute(msg) { + Attribute(String, Dynamic) + Event(String, fn(Dynamic) -> Option(msg)) +} + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// +/// Lustre does some work internally to convert common Gleam values into ones that +/// make sense for JavaScript. Here are the types that are converted: +/// +/// - `List(a)` -> `Array(a)` +/// - `Some(a)` -> `a` +/// - `None` -> `undefined` +/// +pub fn attribute(name: String, value: String) -> Attribute(msg) { + escape("", value) + |> dynamic.from + |> Attribute(name, _) +} + +/// +pub fn property(name: String, value: any) -> Attribute(msg) { + Attribute(name, dynamic.from(value)) +} + +fn escape(escaped: String, content: String) -> String { + case string.pop_grapheme(content) { + Ok(#("<", xs)) -> escape(escaped <> "<", xs) + Ok(#(">", xs)) -> escape(escaped <> ">", xs) + Ok(#("&", xs)) -> escape(escaped <> "&", xs) + Ok(#("\"", xs)) -> escape(escaped <> """, xs) + Ok(#("'", xs)) -> escape(escaped <> "'", xs) + Ok(#(x, xs)) -> escape(escaped <> x, xs) + Error(_) -> escaped <> content + } +} + +/// Attach custom event handlers to an element. A number of helper functions exist +/// in this module to cover the most common events and use-cases, so you should +/// check those out first. +/// +/// If you need to handle an event that isn't covered by the helper functions, +/// then you can use `on` to attach a custom event handler. The callback is given +/// the event object as a `Dynamic`. +/// +/// As a simple example, you can implement `on_click` like so: +/// +/// ```gleam +/// import gleam/option.{Some} +/// import lustre/attribute.{Attribute} +/// import lustre/event +/// +/// pub fn on_click(msg: msg) -> Attribute(msg) { +/// use _ <- event.on("click") +/// Some(msg) +/// } +/// ``` +/// +/// By using `gleam/dynamic` you can decode the event object and pull out all sorts +/// of useful data. This is how `on_input` is implemented: +/// +/// ```gleam +/// import gleam/dynamic +/// import gleam/option.{None, Some} +/// import gleam/result +/// import lustre/attribute.{Attribute} +/// import lustre/event +/// +/// pub fn on_input(msg: fn(String) -> msg) -> Attribute(msg) { +/// use event, dispatch <- on("input") +/// let decode = dynamic.field("target", dynamic.field("value", dynamic.string)) +/// +/// case decode(event) { +/// Ok(value) -> Some(msg(value)) +/// Error(_) -> None +/// } +/// } +/// ``` +/// +/// You can take a look at the MDN reference for events +/// [here](https://developer.mozilla.org/en-US/docs/Web/API/Event) to see what +/// you can decode. +/// +/// Unlike the helpers in the rest of this module, it is possible to simply ignore +/// the dispatch function and not dispatch a message at all. In fact, we saw this +/// with the `on_input` example above: if we can't decode the event object, we +/// simply return `None` and emit nothing. +/// +/// Beyond ignoring errors, this can be used to perform side effects we don't need +/// to observe in our main application loop, such as logging... +/// +/// ```gleam +/// import gleam/io +/// import gleam/option.{None} +/// import lustre/attribute.{Attribute} +/// import lustre/event +/// +/// pub fn log_on_click(msg: String) -> Attribute(msg) { +/// use _ <- event.on("click") +/// io.println(msg) +/// None +/// } +/// ``` +/// +pub fn on(name: String, handler: fn(Dynamic) -> Option(msg)) -> Attribute(msg) { + Event("on" <> name, handler) +} + +// MANIPULATIONS --------------------------------------------------------------- + +/// +/// +pub fn map(attr: Attribute(a), f: fn(a) -> b) -> Attribute(b) { + case attr { + Attribute(name, value) -> Attribute(name, value) + Event(on, handler) -> Event(on, fn(e) { option.map(handler(e), f) }) + } +} + +// CONVERSIONS ----------------------------------------------------------------- + +/// +/// +pub fn to_string(attr: Attribute(msg)) -> String { + case attr { + Attribute(name, value) -> { + case dynamic.classify(value) { + "String" -> 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 -> name + False -> "" + } + + // For everything else we'll just make a best-effort serialisation. + _ -> name <> "=\"" <> string.inspect(value) <> "\"" + } + } + Event(on, _) -> "data-lustre-on:" <> on + } +} + +/// +/// +pub fn to_string_builder(attr: Attribute(msg)) -> StringBuilder { + case attr { + Attribute(name, value) -> { + 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() + } + + // For everything else we'll just make a best-effort serialisation. + _ -> + [name, "=\"", string.inspect(value), "\""] + |> string_builder.from_strings + } + } + Event(on, _) -> + ["data-lustre-on:", on] + |> string_builder.from_strings + } +} + +// COMMON ATTRIBUTES ----------------------------------------------------------- + +/// +pub fn style(properties: List(#(String, String))) -> Attribute(msg) { + attribute( + "style", + { + use styles, #(name, value) <- list.fold(properties, "") + styles <> name <> ":" <> value <> ";" + }, + ) +} + +/// +pub fn class(name: String) -> Attribute(msg) { + attribute("class", name) +} + +/// +pub fn classes(names: List(#(String, Bool))) -> Attribute(msg) { + attribute( + "class", + names + |> list.filter_map(fn(class) { + case class.1 { + True -> Ok(class.0) + False -> Error(Nil) + } + }) + |> string.join(" "), + ) +} + +/// +pub fn id(name: String) -> Attribute(msg) { + attribute("id", name) +} + +// INPUTS ---------------------------------------------------------------------- + +/// +pub fn type_(name: String) -> Attribute(msg) { + attribute("type", name) +} + +/// +pub fn value(val: Dynamic) -> Attribute(msg) { + property("value", val) +} + +/// +pub fn checked(is_checked: Bool) -> Attribute(msg) { + property("checked", is_checked) +} + +/// +pub fn placeholder(text: String) -> Attribute(msg) { + attribute("placeholder", text) +} + +/// +pub fn selected(is_selected: Bool) -> Attribute(msg) { + property("selected", is_selected) +} + +// INPUT HELPERS --------------------------------------------------------------- + +/// +pub fn accept(types: List(String)) -> Attribute(msg) { + attribute("accept", string.join(types, " ")) +} + +/// +pub fn accept_charset(types: List(String)) -> Attribute(msg) { + attribute("acceptCharset", string.join(types, " ")) +} + +/// +pub fn msg(uri: String) -> Attribute(msg) { + attribute("msg", uri) +} + +/// +pub fn autocomplete(name: String) -> Attribute(msg) { + attribute("autocomplete", name) +} + +/// +pub fn autofocus(should_autofocus: Bool) -> Attribute(msg) { + property("autoFocus", should_autofocus) +} + +/// +pub fn disabled(is_disabled: Bool) -> Attribute(msg) { + property("disabled", is_disabled) +} + +/// +pub fn name(name: String) -> Attribute(msg) { + attribute("name", name) +} + +/// +pub fn pattern(regex: String) -> Attribute(msg) { + attribute("pattern", regex) +} + +/// +pub fn readonly(is_readonly: Bool) -> Attribute(msg) { + property("readonly", is_readonly) +} + +/// +pub fn required(is_required: Bool) -> Attribute(msg) { + property("required", is_required) +} + +/// +pub fn for(id: String) -> Attribute(msg) { + attribute("for", id) +} + +// INPUT RANGES ---------------------------------------------------------------- + +/// +pub fn max(val: String) -> Attribute(msg) { + attribute("max", val) +} + +/// +pub fn min(val: String) -> Attribute(msg) { + attribute("min", val) +} + +/// +pub fn step(val: String) -> Attribute(msg) { + attribute("step", val) +} + +// INPUT TEXT AREAS ------------------------------------------------------------ + +/// +pub fn cols(val: Int) -> Attribute(msg) { + attribute("cols", int.to_string(val)) +} + +/// +pub fn rows(val: Int) -> Attribute(msg) { + attribute("rows", int.to_string(val)) +} + +/// +pub fn wrap(mode: String) -> Attribute(msg) { + attribute("wrap", mode) +} + +// LINKS AND AREAS ------------------------------------------------------------- + +/// +pub fn href(uri: String) -> Attribute(msg) { + attribute("href", uri) +} + +/// +pub fn target(target: String) -> Attribute(msg) { + attribute("target", target) +} + +/// +pub fn download(filename: String) -> Attribute(msg) { + attribute("download", filename) +} + +/// +pub fn rel(relationship: String) -> Attribute(msg) { + attribute("rel", relationship) +} + +// EMBEDDED CONTENT ------------------------------------------------------------ + +/// +pub fn src(uri: String) -> Attribute(msg) { + attribute("src", uri) +} + +/// +pub fn height(val: Int) -> Attribute(msg) { + property("height", int.to_string(val)) +} + +/// +pub fn width(val: Int) -> Attribute(msg) { + property("width", int.to_string(val)) +} + +/// +pub fn alt(text: String) -> Attribute(msg) { + attribute("alt", text) +} + +// AUDIO AND VIDEO ------------------------------------------------------------- + +/// +pub fn autoplay(should_autoplay: Bool) -> Attribute(msg) { + property("autoplay", should_autoplay) +} + +/// +pub fn controls(visible: Bool) -> Attribute(msg) { + property("controls", visible) +} + +/// +pub fn loop(should_loop: Bool) -> Attribute(msg) { + property("loop", should_loop) +} diff --git a/lib/src/lustre/effect.gleam b/lib/src/lustre/effect.gleam new file mode 100644 index 0000000..19f54b0 --- /dev/null +++ b/lib/src/lustre/effect.gleam @@ -0,0 +1,67 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/list + +// TYPES ----------------------------------------------------------------------- + +/// A `Effect` represents some side effect we want the Lustre runtime to perform. +/// It is parameterised by our app's `action` type because some effects need to +/// get information back into your program. +/// +pub opaque type Effect(action) { + Effect(List(fn(fn(action) -> Nil) -> Nil)) +} + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// Create a `Effect` from some custom side effect. This is mostly useful for +/// package authors, or for integrating other libraries into your Lustre app. +/// +/// We pass in a function that recieves a `dispatch` callback that can be used +/// to send messages to the Lustre runtime. We could, for example, create a `tick` +/// command that uses the `setTimeout` JavaScript API to send a message to the +/// runtime every second: +/// +/// ```gleam +/// import lustre/effect.{Effect} +/// +/// external fn set_interval(callback: fn() -> any, interval: Int) = +/// "" "window.setInterval" +/// +/// pub fn every_second(msg: msg) -> Effect(msg) { +/// use dispatch <- effect.from +/// +/// set_interval(fn() { dispatch(msg) }, 1000) +/// } +/// ``` +/// +pub fn from(effect: fn(fn(action) -> Nil) -> Nil) -> Effect(action) { + Effect([effect]) +} + +/// Typically our app's `update` function needs to return a tuple of +/// `#(model, Effect(action))`. When we don't need to perform any side effects we +/// can just return `none()`! +/// +pub fn none() -> Effect(action) { + Effect([]) +} + +// MANIPULATIONS --------------------------------------------------------------- + +/// +/// +pub fn batch(cmds: List(Effect(action))) -> Effect(action) { + Effect({ + use b, Effect(a) <- list.fold(cmds, []) + list.append(b, a) + }) +} + +pub fn map(effect: Effect(a), f: fn(a) -> b) -> Effect(b) { + let Effect(l) = effect + Effect(list.map( + l, + fn(effect) { fn(dispatch) { effect(fn(a) { dispatch(f(a)) }) } }, + )) +} diff --git a/lib/src/lustre/element.gleam b/lib/src/lustre/element.gleam new file mode 100644 index 0000000..4e8abee --- /dev/null +++ b/lib/src/lustre/element.gleam @@ -0,0 +1,126 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/list +import gleam/string +import gleam/string_builder.{StringBuilder} +import lustre/attribute.{Attribute} + +// TYPES ----------------------------------------------------------------------- + +/// +/// +pub opaque type Element(msg) { + Text(String) + Element(String, List(Attribute(msg)), List(Element(msg))) + ElementNs(String, List(Attribute(msg)), List(Element(msg)), String) +} + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// +/// +pub fn element( + tag: String, + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + Element(tag, attrs, children) +} + +/// +/// +pub fn namespaced( + namespace: String, + tag: String, + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + ElementNs(tag, attrs, children, namespace) +} + +/// +/// +pub fn text(content: String) -> Element(msg) { + Text(content) +} + +fn escape(escaped: String, content: String) -> String { + case string.pop_grapheme(content) { + Ok(#("<", xs)) -> escape(escaped <> "<", xs) + Ok(#(">", xs)) -> escape(escaped <> ">", xs) + Ok(#("&", xs)) -> escape(escaped <> "&", xs) + Ok(#("\"", xs)) -> escape(escaped <> """, xs) + Ok(#("'", xs)) -> escape(escaped <> "'", xs) + Ok(#(x, xs)) -> escape(escaped <> x, xs) + Error(_) -> escaped <> content + } +} + +// MANIPULATIONS --------------------------------------------------------------- + +/// +/// +pub fn map(element: Element(a), f: fn(a) -> b) -> Element(b) { + case element { + Text(content) -> Text(content) + Element(tag, attrs, children) -> + Element( + tag, + list.map(attrs, attribute.map(_, f)), + list.map(children, map(_, f)), + ) + ElementNs(tag, attrs, children, namespace) -> + ElementNs( + tag, + list.map(attrs, attribute.map(_, f)), + list.map(children, map(_, f)), + namespace, + ) + } +} + +// CONVERSIONS ----------------------------------------------------------------- + +/// +/// +pub fn to_string(element: Element(msg)) -> String { + to_string_builder(element) + |> string_builder.to_string +} + +/// +/// +pub fn to_string_builder(element: Element(msg)) -> StringBuilder { + case element { + Text(content) -> string_builder.from_string(escape("", content)) + Element(tag, attrs, children) -> + string_builder.from_string("<" <> tag) + |> attrs_to_string_builder(attrs) + |> string_builder.append(">") + |> children_to_string_builder(children) + |> string_builder.append(" tag <> ">") + ElementNs(tag, attrs, children, namespace) -> + string_builder.from_string("<" <> tag) + |> attrs_to_string_builder(attrs) + |> string_builder.append(" xmlns=\"" <> namespace <> "\"") + |> string_builder.append(">") + |> children_to_string_builder(children) + |> string_builder.append(" tag <> ">") + } +} + +fn attrs_to_string_builder( + html: StringBuilder, + attrs: List(Attribute(msg)), +) -> StringBuilder { + use html, attr <- list.fold(attrs, html) + string_builder.append_builder(html, attribute.to_string_builder(attr)) +} + +fn children_to_string_builder( + html: StringBuilder, + children: List(Element(msg)), +) -> StringBuilder { + use html, child <- list.fold(children, html) + string_builder.append_builder(html, to_string_builder(child)) +} diff --git a/lib/src/lustre/element/html.gleam b/lib/src/lustre/element/html.gleam new file mode 100644 index 0000000..9eb4f5e --- /dev/null +++ b/lib/src/lustre/element/html.gleam @@ -0,0 +1,1197 @@ +// IMPORTS --------------------------------------------------------------------- + +import lustre/element.{Element, element, namespaced, text} +import lustre/attribute.{Attribute} + +// The doc comments (and order) for functions in this module are taken from the +// MDN Element reference: +// +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element +// + +// HTML ELEMENTS: MAIN ROOT ---------------------------------------------------- + +/// Represents the root (top-level element) of an HTML document, so it is also +/// referred to as the root element. All other elements must be descendants of +/// this element. +/// +pub fn html( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("html", attrs, children) +} + +// HTML ELEMENTS: DOCUMENT METADATA -------------------------------------------- + +/// Specifies the base URL to use for all relative URLs in a document. There can +/// be only one such element in a document. +/// +pub fn base(attrs: List(Attribute(msg))) -> Element(msg) { + element("base", attrs, []) +} + +/// Contains machine-readable information (metadata) about the document, like its +/// title, scripts, and style sheets. +/// +pub fn head(attrs: List(Attribute(msg))) -> Element(msg) { + element("head", attrs, []) +} + +/// Specifies relationships between the current document and an external resource. +/// This element is most commonly used to link to CSS but is also used to establish +/// site icons (both "favicon" style icons and icons for the home screen and apps +/// on mobile devices) among other things. +/// +pub fn link(attrs: List(Attribute(msg))) -> Element(msg) { + element("link", attrs, []) +} + +/// Represents metadata that cannot be represented by other HTML meta-related +/// elements, like , ,